Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
scroll_wrap.c
Go to the documentation of this file.
1/*
2 * This file is part of Ansel,
3 * Copyright (C) 2026 Aurélien PIERRE.
4 *
5 * Ansel is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * Ansel is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with Ansel. If not, see <http://www.gnu.org/licenses/>.
17 */
18
19#include "widgets/scroll_wrap.h"
20
21#include "system/macros.h" // IS_NULL_PTR
22#include "system/mem_alloc.h"
26#include <glib/gi18n.h>
27#include "widgets/container.h"
28
29static const char *const DT_GUI_WIDGET_AUTO_HEIGHT_KEY = "dt-gui-widget-auto-height";
30
31// ---- Resizable drawing area (the histogram-scope paradigm, made reusable) -------------------
32// A fixed-pixel-height GtkDrawingArea (or any widget sized by height-request) made vertically
33// resizable by the shared grip primitive, with the height persisted to config. Unlike the
34// scroll-wrap helper above, the content draws itself and is not scrolled; we only manage its
35// height-request, so the drawing code keeps reading the live allocation as usual.
36
37static const char *const DT_UI_RESIZABLE_AREA_KEY = "dt-ui-resizable-area";
38
40{
41 char *config_str; // conf key persisting the user-chosen height (px); owned
42 int min_height; // minimum height floor, device pixels
43 int last_height; // last applied height, shared with the drag handle
45
46/* The tallest run of whole rows that fits in `limit`, walked in display order.
47 *
48 * Rounding to a multiple of a nominal row height cannot answer this once a row is not that
49 * height: a separator is three pixels, so a list of four rows plus a separator rounds to five
50 * nominal rows and cuts the fifth in half -- which is the very thing the rounding exists to
51 * prevent. Returns TRUE once the limit is reached, so the recursion into expanded children can
52 * stop the walk. */
53static gboolean _treeview_fit_rows(GtkTreeView *treeview, GtkTreeModel *model, GtkTreeIter *parent,
54 const gint nominal, const gint limit, gint *used)
55{
56 if(!GTK_IS_TREE_MODEL(model)) return TRUE;
57
59 gboolean valid = parent ? gtk_tree_model_iter_children(model, &iter, parent)
61
62 while(valid)
63 {
65
66 GdkRectangle rect = { 0 };
67 if(path) gtk_tree_view_get_background_area(treeview, path, NULL, &rect);
68 const gint row = rect.height > 0 ? rect.height : nominal;
69
70 if(*used + row > limit)
71 {
72 if(path) gtk_tree_path_free(path);
73 return TRUE;
74 }
75 *used += row;
76
78 && _treeview_fit_rows(treeview, model, &iter, nominal, limit, used))
79 {
81 return TRUE;
82 }
83
84 if(path) gtk_tree_path_free(path);
86 }
87
88 return FALSE;
89}
90
92{
93 gint height = DT_PIXEL_APPLY_DPI(10);
94
95 if(GTK_IS_TREE_VIEW(w))
96 {
97 gint row_height = 0;
98
100 for(int c = 0; c < num_columns; c++)
101 {
102 gint cell_height = 0;
106 }
108 gtk_widget_style_get_property(w, "vertical-separator", &separation);
109
111 }
112 else if(GTK_IS_TEXT_VIEW(w))
113 {
116 g_object_unref(layout);
117 }
118 else
119 {
121 if(child)
122 {
124 }
125 }
126
127 return height;
128}
129
130// find the scrolled window parent of a treeview, if any
132{
133 if(!GTK_IS_WIDGET(w)) return NULL;
134
135 GtkWidget *parent = w;
136 while(parent)
137 {
138 if(GTK_IS_SCROLLED_WINDOW(parent)) break;
139 parent = gtk_widget_get_parent(parent);
140 }
141
142 return GTK_IS_SCROLLED_WINDOW(parent) ? parent : NULL;
143}
144
145// Counts only visible items (those whose parents are expanded)
146/* The height the visible rows actually occupy.
147 *
148 * Each row is measured rather than counted, because not every row is one nominal row tall: a
149 * GtkTreeView separator is drawn as a few pixels, and counting it as a row would claim a whole
150 * one of window height for it. gtk_tree_view_get_background_area() answers 0 for a view that is
151 * not realised yet, and those rows fall back to the nominal height -- which is what this did for
152 * every row before. */
154 const gint nominal)
155{
156 if(!GTK_IS_TREE_MODEL(model)) return 0;
157
159 gboolean valid = parent ? gtk_tree_model_iter_children(model, &iter, parent)
161 int height = 0;
162
163 while(valid)
164 {
166
167 GdkRectangle rect = { 0 };
168 if(path) gtk_tree_view_get_background_area(treeview, path, NULL, &rect);
169 height += rect.height > 0 ? rect.height : nominal;
170
171 // If this item is expanded, recursively measure its visible children
174
175 if(path) gtk_tree_path_free(path);
176
178 }
179
180 return height;
181}
182
184{
185 if(!GTK_IS_TEXT_VIEW(textview)) return 0;
186
188 if(!GTK_IS_TEXT_BUFFER(buffer)) return 0;
189
190 // For text views, use the number of logical lines in the buffer.
191 return MAX(0, gtk_text_buffer_get_line_count(buffer));
192}
193
195{
196 if(IS_NULL_PTR(state)) return;
197
198 GtkTreeModel *model = state->model;
199 if(model)
200 {
201 if(state->model_row_inserted) g_signal_handler_disconnect(model, state->model_row_inserted);
202 if(state->model_row_deleted) g_signal_handler_disconnect(model, state->model_row_deleted);
203 if(state->model_row_changed) g_signal_handler_disconnect(model, state->model_row_changed);
204 if(state->model_rows_reordered) g_signal_handler_disconnect(model, state->model_rows_reordered);
205 g_object_remove_weak_pointer(G_OBJECT(model), (gpointer *)&state->model);
206 }
207
208 if(GTK_IS_TREE_VIEW(treeview))
209 {
210 if(state->model_row_expanded) g_signal_handler_disconnect(treeview, state->model_row_expanded);
211 if(state->model_row_collapsed) g_signal_handler_disconnect(treeview, state->model_row_collapsed);
212 }
213
214 state->model = NULL;
215 state->model_row_inserted = 0;
216 state->model_row_deleted = 0;
217 state->model_row_changed = 0;
218 state->model_rows_reordered = 0;
219 state->model_row_expanded = 0;
220 state->model_row_collapsed = 0;
221}
222
224{
225 if(IS_NULL_PTR(state)) return;
226
227 GtkTextBuffer *buffer = state->buffer;
228 if(buffer)
229 {
230 if(state->buffer_changed) g_signal_handler_disconnect(buffer, state->buffer_changed);
231 g_object_remove_weak_pointer(G_OBJECT(buffer), (gpointer *)&state->buffer);
232 }
233
234 state->buffer = NULL;
235 state->buffer_changed = 0;
236}
237
250
262{
264 if(IS_NULL_PTR(state)) return;
265
267 if(!GTK_IS_SCROLLED_WINDOW(sw)) return;
268
269 const gint max_height = _resizable_scroll_max_height();
270 const gint min_size = MAX(1, state->min_size);
271 int stored = 0;
272 const gboolean has_conf = dt_widget_stored_int(state->config_str, &stored);
273
274 gint height;
275 gint increment = 0;
276
277 if(state->mode == DT_UI_RESIZE_STATIC)
278 {
279 // Fixed height: the user's persisted size, or min_size as the default. Independent of content,
280 // so a content refresh (e.g. on hovering another thumbnail) never shifts the layout.
281 height = CLAMP(has_conf ? stored : min_size, min_size, max_height);
282 }
283 else
284 {
285 // Dynamic: fit to content, capped by the user's persisted height (or the window ceiling).
286 const gboolean row_based = GTK_IS_TREE_VIEW(w) || GTK_IS_TEXT_VIEW(w);
287 increment = row_based ? _get_container_row_heigth(w) : 0;
288
289 gint content = 0;
290 if(GTK_IS_TREE_VIEW(w))
291 {
293 NULL, increment);
294 content = MAX(increment, measured);
295 }
296 else if(GTK_IS_TEXT_VIEW(w))
297 {
298 const int rows = _textview_count_visible_rows(w);
299 content = MAX(1, rows) * increment;
300 }
301 else
302 {
304 }
305
306 /* One blank row past the last one, when the caller asked for it: a list filled edge to edge
307 * cannot be told from one with more rows hidden below, the blank row says it ends there. It
308 * only counts while everything fits -- a capped list snaps to its rows below and scrolls. */
309 if(row_based && state->trailing_row) content += increment;
310
311 const gint cap = has_conf ? CLAMP(stored, min_size, max_height) : max_height;
312 height = CLAMP(MIN(content, cap), min_size, max_height);
313
314 /* Snap to whole rows to avoid clipped half-rows -- but only when the area is showing less
315 * than it holds, which is the only case where a row can be cut. When everything fits, the
316 * measured height is already exact, and rounding it up would add most of a row of dead space
317 * for any content that is not a whole number of them, such as a list carrying a separator. */
318 if(increment > 0 && height < content)
319 {
320 if(GTK_IS_TREE_VIEW(w))
321 {
322 gint used = 0;
324 height, &used);
325 if(used > 0) height = used;
326 }
327 else
328 {
329 // A textview's lines are all the one height, so a multiple of it is a row boundary.
330 height += increment - 1;
331 height -= height % increment;
332 }
333 }
334 }
335 state->last_height = height;
336
337 // The request covers the scrolled window's whole CSS box, and the viewport only gets what is
338 // left of it: padding AND border are both taken out first. Counting one and not the other
339 // leaves the content exactly that many pixels short of fitting, and an AUTOMATIC policy
340 // answers a two-pixel shortfall with a full scrollbar -- on an area whose whole purpose is to
341 // be sized to fit its content. The .dt_recessed_scroll treeviews carry both (2px padding over
342 // a 1px border), so this is not a hypothetical.
344 const GtkStateFlags sw_state = gtk_widget_get_state_flags(sw);
345 GtkBorder padding;
346 GtkBorder border;
349
350 gint old_height = 0;
352 const gint new_height = height + padding.top + padding.bottom + border.top + border.bottom
353 + (GTK_IS_TEXT_VIEW(w) ? 2 : 0);
356}
357
358static void _widget_auto_update(GtkWidget *widget)
359{
361}
362
363static gboolean _resizable_scroll_draw(GtkWidget *w, cairo_t *cr, gpointer user_data)
364{
366 return FALSE;
367}
368
369static void _resizable_scroll_realize(GtkWidget *w, gpointer user_data)
370{
372}
373
374// Drag handle accessors: bare (pre-padding) target height, kept consistent with the sizing rule.
375static int _resizable_scroll_handle_get_size(gpointer user_data)
376{
377 GtkWidget *w = GTK_WIDGET(user_data);
379 return state ? state->last_height : 0;
380}
381
382static int _resizable_scroll_handle_resize(int requested_size, gboolean finished, gpointer user_data)
383{
384 GtkWidget *w = GTK_WIDGET(user_data);
386 if(IS_NULL_PTR(state) || IS_NULL_PTR(state->config_str)) return requested_size;
387
388 const gint value = CLAMP(requested_size, MAX(1, state->min_size), _resizable_scroll_max_height());
389 dt_widget_store_int(state->config_str, value);
391 return state->last_height;
392}
393
395 gpointer user_data)
396{
397 (void)model;
398 (void)path;
399 (void)iter;
401}
402
403static void _widget_auto_model_row_deleted(GtkTreeModel *model, GtkTreePath *path, gpointer user_data)
404{
405 (void)model;
406 (void)path;
408}
409
411 gpointer user_data)
412{
413 (void)model;
414 (void)path;
415 (void)iter;
417}
418
420 gint *new_order, gpointer user_data)
421{
422 (void)model;
423 (void)path;
424 (void)iter;
427}
428
430 gpointer user_data)
431{
432 (void)tree_view;
434 (void)path;
436}
437
439 gpointer user_data)
440{
441 (void)tree_view;
443 (void)path;
444
445 // Recalculate tree view height after loading the data
447}
448
449static void _widget_auto_text_buffer_changed(GtkTextBuffer *buffer, gpointer user_data)
450{
451 (void)buffer;
453}
454
456{
457 if(!GTK_IS_TREE_VIEW(treeview)) return;
458
460 if(IS_NULL_PTR(state)) return;
461
463 if(model == state->model) return;
464
466 if(!GTK_IS_TREE_MODEL(model)) return;
467
468 state->model = model;
469 g_object_add_weak_pointer(G_OBJECT(model), (gpointer *)&state->model);
470 state->model_row_inserted = g_signal_connect(model, "row-inserted",
472 state->model_row_deleted = g_signal_connect(model, "row-deleted",
474 state->model_row_changed = g_signal_connect(model, "row-changed",
476 state->model_rows_reordered = g_signal_connect(model, "rows-reordered",
478 state->model_row_expanded = g_signal_connect(treeview, "row-expanded",
480 state->model_row_collapsed = g_signal_connect(treeview, "row-collapsed",
482}
483
485{
486 if(!GTK_IS_TEXT_VIEW(textview)) return;
487
489 if(IS_NULL_PTR(state)) return;
490
492 if(buffer == state->buffer) return;
493
495 if(!GTK_IS_TEXT_BUFFER(buffer)) return;
496
497 state->buffer = buffer;
498 g_object_add_weak_pointer(G_OBJECT(buffer), (gpointer *)&state->buffer);
499 state->buffer_changed = g_signal_connect(buffer, "changed",
501}
502
503static void _widget_auto_on_model_changed(GObject *treeview, GParamSpec *pspec, gpointer user_data)
504{
505 (void)pspec;
506 (void)user_data;
509}
510
511static void _widget_auto_on_buffer_changed(GObject *textview, GParamSpec *pspec, gpointer user_data)
512{
513 (void)pspec;
514 (void)user_data;
517}
518
528
545GtkWidget *dt_ui_scroll_wrap(GtkWidget *w, gint min_size, const char *config_str, dt_ui_resize_mode_t mode)
546{
549 if(GTK_IS_TREE_VIEW(w)) dt_gui_add_class(sw, "dt_recessed_scroll");
551
552 // Per-widget sizing state, freed with w.
554 state->config_str = g_strdup(config_str);
555 state->min_size = MAX(1, DT_PIXEL_APPLY_DPI(min_size));
556 state->mode = mode;
558
559 if(mode == DT_UI_RESIZE_DYNAMIC)
560 {
561 // Sizing triggers. Lists/textviews recompute from their model/buffer change signals (cheap and
562 // exact) plus a one-shot on realize, when row heights become accurate -- we deliberately avoid a
563 // per-draw recount, which would re-walk a large model on every redraw. Generic content has no
564 // such signals, so it recomputes on draw using its (GTK-cached) preferred height.
565 if(GTK_IS_TREE_VIEW(w))
566 {
569 }
570 else if(GTK_IS_TEXT_VIEW(w))
571 {
574 }
575 else
576 {
578 }
581 }
582 else
583 {
584 // Static: height is content-independent, so we only size once after realization (and on drag).
586 }
587
588 // Drag grip floating on the scrolled window's bottom edge (overlay): it takes no layout space,
589 // so the wrapper leaves no margin-like gap and stays aligned with neighbouring widgets. The grip
590 // is centered on the bottom border via CSS and is invisible until hovered.
592 _("Drag to resize"),
595
596 GtkWidget *over = gtk_overlay_new();
599
601 return over;
602}
603
608{
609 if(!GTK_IS_CONTAINER(wrapper)) return NULL;
611 GtkWidget *sw = NULL;
612 for(GList *l = children; l; l = g_list_next(l))
613 {
614 if(GTK_IS_SCROLLED_WINDOW(l->data))
615 {
616 sw = GTK_WIDGET(l->data);
617 break;
618 }
619 }
621 return sw;
622}
623
633
634static void _resizable_area_free(gpointer data)
635{
637 if(IS_NULL_PTR(state)) return;
638 g_free(state->config_str);
639 dt_free(state);
640}
641
642static int _resizable_area_get_size(gpointer user_data)
643{
644 GtkWidget *area = GTK_WIDGET(user_data);
646 if(state) return state->last_height;
648}
649
650static int _resizable_area_resize(int requested_size, gboolean finished, gpointer user_data)
651{
652 GtkWidget *area = GTK_WIDGET(user_data);
654 if(IS_NULL_PTR(state)) return requested_size;
655
656 const int height = CLAMP(requested_size, MAX(1, state->min_height), _resizable_scroll_max_height());
657 state->last_height = height;
659 if(finished) dt_widget_store_int(state->config_str, height);
660 return height;
661}
662
663GtkWidget *dt_ui_resizable_drawing_area(GtkWidget *area, char *config_str, int default_height, int min_height)
664{
666 state->config_str = g_strdup(config_str);
667 state->min_height = MAX(1, DT_PIXEL_APPLY_DPI(min_height));
668
670 dt_widget_stored_int(config_str, &height);
672 state->last_height = height;
675
676 // Drag grip floating on the area's bottom edge (an overlay, not a packed sibling), so it takes
677 // no layout space -- the area stays flush with neighbouring widgets, no margin-like gap. It sits
678 // over the graph's bottom inset/axis margin (graphs reserve one), invisible until hovered.
680 _("Drag to resize"),
682
683 GtkWidget *over = gtk_overlay_new();
684 gtk_container_add(GTK_CONTAINER(over), area);
686 return over;
687}
688
689// clang-format off
690// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
691// vim: shiftwidth=2 expandtab tabstop=2 cindent
692// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
693// clang-format on
#define TRUE
Definition ashift_lsd.c:162
#define FALSE
Definition ashift_lsd.c:158
typedef void((*dt_cache_allocate_t)(void *userdata, dt_cache_entry_t *entry))
struct _GtkWidget GtkWidget
GtkWidget, opaque, spelled exactly as GTK spells it.
Definition colorspaces.h:98
static const int row
GtkWidget * dt_gui_container_first_child(GtkContainer *container)
Definition container.c:43
const char * model
#define IS_NULL_PTR(p)
C is way too permissive with !=, == and if(var) checks, which can mean too many things depending on w...
Definition macros.h:96
#define dt_free(ptr)
g_free() ptr and set it to NULL, skipping both if it is already NULL.
Definition mem_alloc.h:171
uint32_t height
Definition mipmap_cache.c:1
GtkWidget * dtgtk_resize_handle_new(GtkOrientation orientation, gboolean invert, const char *tooltip, dtgtk_resize_handle_get_size_f get_size, dtgtk_resize_handle_resize_f resize, gpointer user_data)
Create a themed handle widget driving one-dimensional resize gestures.
static void _widget_auto_model_rows_reordered(GtkTreeModel *model, GtkTreePath *path, GtkTreeIter *iter, gint *new_order, gpointer user_data)
void dt_ui_scroll_wrap_reserve_trailing_row(GtkWidget *content_widget)
Make a DT_UI_RESIZE_DYNAMIC list or text view ask for one blank row past its content.
static int _resizable_scroll_handle_resize(int requested_size, gboolean finished, gpointer user_data)
static void _widget_auto_on_buffer_changed(GObject *textview, GParamSpec *pspec, gpointer user_data)
static int _textview_count_visible_rows(GtkWidget *textview)
static int _resizable_area_resize(int requested_size, gboolean finished, gpointer user_data)
static void _widget_auto_connect_model(GtkWidget *treeview)
static void _widget_auto_model_row_expanded(GtkTreeView *tree_view, GtkTreeIter *expanded_iter, GtkTreePath *path, gpointer user_data)
static void _widget_auto_model_row_changed(GtkTreeModel *model, GtkTreePath *path, GtkTreeIter *iter, gpointer user_data)
GtkWidget * dt_ui_resizable_drawing_area(GtkWidget *area, char *config_str, int default_height, int min_height)
Make a self-drawing widget (typically a GtkDrawingArea graph or scope) vertically resizable.
static int _resizable_area_get_size(gpointer user_data)
static gint _get_container_row_heigth(GtkWidget *w)
Definition scroll_wrap.c:91
static void _widget_auto_connect_buffer(GtkWidget *textview)
static void _resizable_scroll_realize(GtkWidget *w, gpointer user_data)
static int _treeview_visible_height(GtkTreeView *treeview, GtkTreeModel *model, GtkTreeIter *parent, const gint nominal)
static void _widget_auto_height_free(gpointer data)
static void _widget_auto_model_row_collapsed(GtkTreeView *tree_view, GtkTreeIter *collapsed_iter, GtkTreePath *path, gpointer user_data)
static int _resizable_scroll_handle_get_size(gpointer user_data)
static void _widget_auto_model_row_deleted(GtkTreeModel *model, GtkTreePath *path, gpointer user_data)
static gboolean _treeview_fit_rows(GtkTreeView *treeview, GtkTreeModel *model, GtkTreeIter *parent, const gint nominal, const gint limit, gint *used)
Definition scroll_wrap.c:53
static void _widget_auto_disconnect_buffer(dt_gui_widget_auto_height_t *state)
static GtkWidget * _search_parent_scrolled_window(GtkWidget *w)
GtkWidget * dt_ui_scroll_wrap_get_scrolled_window(GtkWidget *wrapper)
Return the inner scrolled window of a dt_ui_scroll_wrap() wrapper, or NULL.
static void _widget_auto_text_buffer_changed(GtkTextBuffer *buffer, gpointer user_data)
static gint _resizable_scroll_max_height(void)
Window-height ceiling shared by the auto-size rule and the drag handle.
static void _resizable_area_free(gpointer data)
static const char *const DT_GUI_WIDGET_AUTO_HEIGHT_KEY
Definition scroll_wrap.c:29
static void _widget_auto_model_row_inserted(GtkTreeModel *model, GtkTreePath *path, GtkTreeIter *iter, gpointer user_data)
GtkWidget * dt_ui_scroll_wrap(GtkWidget *w, gint min_size, const char *config_str, dt_ui_resize_mode_t mode)
Wrap a scrollable content widget in a recessed, vertically resizable scrolled window.
static void _widget_auto_on_model_changed(GObject *treeview, GParamSpec *pspec, gpointer user_data)
static void _resizable_scroll_apply(GtkWidget *w)
The single sizing rule for every dt_ui_scroll_wrap area.
static void _widget_auto_update(GtkWidget *widget)
static const char *const DT_UI_RESIZABLE_AREA_KEY
Definition scroll_wrap.c:37
static void _widget_auto_disconnect_model(dt_gui_widget_auto_height_t *state, GtkWidget *treeview)
static gboolean _resizable_scroll_draw(GtkWidget *w, cairo_t *cr, gpointer user_data)
dt_ui_resize_mode_t
Definition scroll_wrap.h:39
@ DT_UI_RESIZE_STATIC
Definition scroll_wrap.h:46
@ DT_UI_RESIZE_DYNAMIC
Definition scroll_wrap.h:42
static const dt_aligned_pixel_simd_t value
Definition simd.h:144
const float uint32_t state[4]
#define MIN(a, b)
Definition thinplate.c:32
#define MAX(a, b)
Definition thinplate.c:29
GtkWidget * dt_widget_root_window(void)
gboolean dt_widget_stored_int(const char *key, int *value)
void dt_widget_store_int(const char *key, int value)
#define DT_PIXEL_APPLY_DPI(value)
void dt_gui_add_class(GtkWidget *widget, const gchar *class_name)