From 3a54e96d8bdb8aa6cd74b304c5258fe46b21a0bf Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Fri, 19 Jun 2026 20:03:11 -0400 Subject: [PATCH] Implement AspectContainer and FlowGrid --- Package.swift | 8 + Sources/CAspectContainer/aspect_container.c | 251 ++++++++++ Sources/CAspectContainer/flow_grid.c | 431 ++++++++++++++++++ .../include/aspect_container.h | 31 ++ Sources/CAspectContainer/include/flow_grid.h | 41 ++ .../Components/AspectContainer.swift | 139 ++++++ Sources/LuminateUI/Components/FlowGrid.swift | 232 ++++++++++ .../Components/HomePosterCell.swift | 49 +- Sources/LuminateUI/Components/ItemGrid.swift | 13 +- .../LuminateUI/Components/LibraryGrid.swift | 11 +- Sources/LuminateUI/Components/MediaRow.swift | 1 + 11 files changed, 1160 insertions(+), 47 deletions(-) create mode 100644 Sources/CAspectContainer/aspect_container.c create mode 100644 Sources/CAspectContainer/flow_grid.c create mode 100644 Sources/CAspectContainer/include/aspect_container.h create mode 100644 Sources/CAspectContainer/include/flow_grid.h create mode 100644 Sources/LuminateUI/Components/AspectContainer.swift create mode 100644 Sources/LuminateUI/Components/FlowGrid.swift diff --git a/Package.swift b/Package.swift index 600f172..9022b6f 100644 --- a/Package.swift +++ b/Package.swift @@ -52,9 +52,17 @@ let package = Package( .product(name: "Logging", package: "swift-log"), ] ), + .target( + name: "CAspectContainer", + dependencies: [ + .product(name: "CAdw", package: "adwaita-swift") + ] + ), + .target( name: "LuminateUI", dependencies: [ + "CAspectContainer", "LuminateCore", "LuminateDI", "LuminateObservationMacros", diff --git a/Sources/CAspectContainer/aspect_container.c b/Sources/CAspectContainer/aspect_container.c new file mode 100644 index 0000000..1dd4c7d --- /dev/null +++ b/Sources/CAspectContainer/aspect_container.c @@ -0,0 +1,251 @@ +#include "aspect_container.h" + +struct _AspectContainer { + GtkWidget parent_instance; + float aspect_ratio; + int max_width; +}; + +enum { + PROP_0, + PROP_ASPECT_RATIO, + PROP_MAX_WIDTH, + LAST_PROP +}; + +static GParamSpec *props[LAST_PROP]; + +G_DEFINE_TYPE(AspectContainer, aspect_container, GTK_TYPE_WIDGET) + +static void +aspect_container_set_property(GObject *object, guint prop_id, + const GValue *value, GParamSpec *pspec) +{ + AspectContainer *self = ASPECT_CONTAINER(object); + switch (prop_id) { + case PROP_ASPECT_RATIO: + aspect_container_set_aspect_ratio(self, g_value_get_float(value)); + break; + case PROP_MAX_WIDTH: + aspect_container_set_max_width(self, g_value_get_int(value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); + break; + } +} + +static void +aspect_container_get_property(GObject *object, guint prop_id, + GValue *value, GParamSpec *pspec) +{ + AspectContainer *self = ASPECT_CONTAINER(object); + switch (prop_id) { + case PROP_ASPECT_RATIO: + g_value_set_float(value, self->aspect_ratio); + break; + case PROP_MAX_WIDTH: + g_value_set_int(value, self->max_width); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); + break; + } +} + +static void +aspect_container_dispose(GObject *object) +{ + GtkWidget *widget = GTK_WIDGET(object); + GtkWidget *child; + + while ((child = gtk_widget_get_first_child(widget))) + gtk_widget_unparent(child); + + G_OBJECT_CLASS(aspect_container_parent_class)->dispose(object); +} + +static GtkSizeRequestMode +aspect_container_get_request_mode(GtkWidget *widget) +{ + (void)widget; + return GTK_SIZE_REQUEST_HEIGHT_FOR_WIDTH; +} + +static void +aspect_container_size_allocate(GtkWidget *widget, + int width, int height, int baseline) +{ + GtkWidget *child = gtk_widget_get_first_child(widget); + if (!child) + return; + + GtkAllocation alloc = { + .x = 0, + .y = 0, + .width = width, + .height = height, + }; + gtk_widget_size_allocate(child, &alloc, baseline); +} + +static void +aspect_container_measure(GtkWidget *widget, + GtkOrientation orientation, + int for_size, + int *minimum, + int *natural, + int *minimum_baseline, + int *natural_baseline) +{ + AspectContainer *self = ASPECT_CONTAINER(widget); + + if (minimum_baseline) + *minimum_baseline = -1; + if (natural_baseline) + *natural_baseline = -1; + + GtkWidget *child = gtk_widget_get_first_child(widget); + gboolean has_child = child && gtk_widget_get_visible(child); + + if (orientation == GTK_ORIENTATION_HORIZONTAL) { + if (has_child) + gtk_widget_measure(child, GTK_ORIENTATION_HORIZONTAL, -1, + NULL, NULL, NULL, NULL); + + int w = self->max_width > 0 ? self->max_width : 0; + if (minimum) *minimum = w; + if (natural) *natural = w; + } else { + if (for_size >= 0) { + int w = self->max_width > 0 && for_size > self->max_width + ? self->max_width : for_size; + int h_min = (int)(w * self->aspect_ratio); + int h_nat = (int)(w * self->aspect_ratio); + if (minimum) *minimum = h_min; + if (natural) *natural = h_nat; + } else if (has_child) { + int child_min_w = 0, child_nat_w = 0; + gtk_widget_measure(child, GTK_ORIENTATION_HORIZONTAL, -1, + &child_min_w, &child_nat_w, NULL, NULL); + + int min_w = child_min_w; + int nat_w = child_nat_w >= child_min_w ? child_nat_w : child_min_w; + + int req_w = 0, req_h = 0; + gtk_widget_get_size_request(widget, &req_w, &req_h); + if (req_w > min_w) min_w = req_w; + if (req_w > nat_w) nat_w = req_w; + + if (self->max_width > 0) { + if (min_w > self->max_width) min_w = self->max_width; + if (nat_w > self->max_width) nat_w = self->max_width; + } + + int h_min = (int)(min_w * self->aspect_ratio); + int h_nat = (int)(nat_w * self->aspect_ratio); + if (minimum) *minimum = h_min; + if (natural) *natural = h_nat; + } else { + if (minimum) *minimum = 0; + if (natural) *natural = 0; + } + } +} + +static void +aspect_container_compute_expand(GtkWidget *widget, + gboolean *hexpand_p, + gboolean *vexpand_p) +{ + GtkWidget *child = gtk_widget_get_first_child(widget); + if (child) { + *hexpand_p = gtk_widget_compute_expand(child, GTK_ORIENTATION_HORIZONTAL); + *vexpand_p = gtk_widget_compute_expand(child, GTK_ORIENTATION_VERTICAL); + } else { + *hexpand_p = FALSE; + *vexpand_p = FALSE; + } +} + +static void +aspect_container_class_init(AspectContainerClass *klass) +{ + GObjectClass *gobject_class = G_OBJECT_CLASS(klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS(klass); + + gobject_class->set_property = aspect_container_set_property; + gobject_class->get_property = aspect_container_get_property; + gobject_class->dispose = aspect_container_dispose; + + widget_class->get_request_mode = aspect_container_get_request_mode; + widget_class->size_allocate = aspect_container_size_allocate; + widget_class->measure = aspect_container_measure; + widget_class->compute_expand = aspect_container_compute_expand; + + props[PROP_ASPECT_RATIO] = g_param_spec_float( + "aspect-ratio", NULL, NULL, + 0.0, G_MAXFLOAT, 1.0, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY); + + props[PROP_MAX_WIDTH] = g_param_spec_int( + "max-width", NULL, NULL, + 0, G_MAXINT, 0, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY); + + g_object_class_install_properties(gobject_class, LAST_PROP, props); + + gtk_widget_class_set_css_name(widget_class, "aspectcontainer"); + gtk_widget_class_set_accessible_role(widget_class, GTK_ACCESSIBLE_ROLE_GENERIC); +} + +static void +aspect_container_init(AspectContainer *self) +{ + self->aspect_ratio = 1.0; + self->max_width = 0; +} + +GtkWidget * +aspect_container_new(float aspect_ratio) +{ + return g_object_new(ASPECT_TYPE_CONTAINER, + "aspect-ratio", aspect_ratio, + NULL); +} + +void +aspect_container_set_aspect_ratio(AspectContainer *self, float ratio) +{ + g_return_if_fail(ASPECT_IS_CONTAINER(self)); + if (self->aspect_ratio == ratio) + return; + self->aspect_ratio = ratio; + g_object_notify_by_pspec(G_OBJECT(self), props[PROP_ASPECT_RATIO]); + gtk_widget_queue_resize(GTK_WIDGET(self)); +} + +float +aspect_container_get_aspect_ratio(AspectContainer *self) +{ + g_return_val_if_fail(ASPECT_IS_CONTAINER(self), 1.0); + return self->aspect_ratio; +} + +void +aspect_container_set_max_width(AspectContainer *self, int max_width) +{ + g_return_if_fail(ASPECT_IS_CONTAINER(self)); + if (self->max_width == max_width) + return; + self->max_width = max_width; + g_object_notify_by_pspec(G_OBJECT(self), props[PROP_MAX_WIDTH]); + gtk_widget_queue_resize(GTK_WIDGET(self)); +} + +int +aspect_container_get_max_width(AspectContainer *self) +{ + g_return_val_if_fail(ASPECT_IS_CONTAINER(self), 0); + return self->max_width; +} diff --git a/Sources/CAspectContainer/flow_grid.c b/Sources/CAspectContainer/flow_grid.c new file mode 100644 index 0000000..aeb0c2d --- /dev/null +++ b/Sources/CAspectContainer/flow_grid.c @@ -0,0 +1,431 @@ +#include "flow_grid.h" + +#include + +struct _FlowGrid { + GtkWidget parent_instance; + int minimum_size; + int column_spacing; + int row_spacing; + FlowGridJustify justify; +}; + +enum { + PROP_0, + PROP_MINIMUM_SIZE, + PROP_COLUMN_SPACING, + PROP_ROW_SPACING, + PROP_JUSTIFY, + LAST_PROP +}; + +static GParamSpec *props[LAST_PROP]; + +G_DEFINE_TYPE(FlowGrid, flow_grid, GTK_TYPE_WIDGET) + +static void +flow_grid_set_property(GObject *object, guint prop_id, + const GValue *value, GParamSpec *pspec) +{ + FlowGrid *self = FLOW_GRID(object); + switch (prop_id) { + case PROP_MINIMUM_SIZE: + flow_grid_set_minimum_size(self, g_value_get_int(value)); + break; + case PROP_COLUMN_SPACING: + flow_grid_set_column_spacing(self, g_value_get_int(value)); + break; + case PROP_ROW_SPACING: + flow_grid_set_row_spacing(self, g_value_get_int(value)); + break; + case PROP_JUSTIFY: + flow_grid_set_justify(self, (FlowGridJustify)g_value_get_int(value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); + break; + } +} + +static void +flow_grid_get_property(GObject *object, guint prop_id, + GValue *value, GParamSpec *pspec) +{ + FlowGrid *self = FLOW_GRID(object); + switch (prop_id) { + case PROP_MINIMUM_SIZE: + g_value_set_int(value, self->minimum_size); + break; + case PROP_COLUMN_SPACING: + g_value_set_int(value, self->column_spacing); + break; + case PROP_ROW_SPACING: + g_value_set_int(value, self->row_spacing); + break; + case PROP_JUSTIFY: + g_value_set_int(value, self->justify); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); + break; + } +} + +static void +flow_grid_dispose(GObject *object) +{ + GtkWidget *widget = GTK_WIDGET(object); + GtkWidget *child; + while ((child = gtk_widget_get_first_child(widget))) + gtk_widget_unparent(child); + G_OBJECT_CLASS(flow_grid_parent_class)->dispose(object); +} + +static int +flow_grid_count_visible_children(GtkWidget *widget) +{ + int count = 0; + GtkWidget *child; + for (child = gtk_widget_get_first_child(widget); + child != NULL; + child = gtk_widget_get_next_sibling(child)) { + if (gtk_widget_should_layout(child)) + count++; + } + return count; +} + +static void +flow_grid_collect_visible_children(GtkWidget *widget, GtkWidget **children, int max) +{ + int i = 0; + GtkWidget *child; + for (child = gtk_widget_get_first_child(widget); + child != NULL && i < max; + child = gtk_widget_get_next_sibling(child)) { + if (gtk_widget_should_layout(child)) + children[i++] = child; + } +} + +static void +flow_grid_compute_layout(GtkWidget *widget, int for_size, + int *out_num_columns, int *out_num_rows, + double *out_column_width) +{ + FlowGrid *self = FLOW_GRID(widget); + int n_children = flow_grid_count_visible_children(widget); + + if (n_children == 0) { + *out_num_columns = 1; + *out_num_rows = 0; + *out_column_width = (double)self->minimum_size; + return; + } + + int num_columns = (for_size + self->column_spacing) + / (self->minimum_size + self->column_spacing); + num_columns = num_columns < 1 ? 1 : num_columns; + num_columns = num_columns > n_children ? n_children : num_columns; + + double column_width; + if (n_children == num_columns) + column_width = (double)self->minimum_size; + else { + column_width = (for_size - self->column_spacing * (num_columns - 1)) + / (double)num_columns; + if (column_width < self->minimum_size) + column_width = self->minimum_size; + } + + int num_rows = (int)ceil((double)n_children / num_columns); + + *out_num_columns = num_columns; + *out_num_rows = num_rows; + *out_column_width = column_width; +} + +static GtkSizeRequestMode +flow_grid_get_request_mode(GtkWidget *widget) +{ + return GTK_SIZE_REQUEST_HEIGHT_FOR_WIDTH; +} + +static void +flow_grid_measure(GtkWidget *widget, + GtkOrientation orientation, + int for_size, + int *minimum, + int *natural, + int *minimum_baseline, + int *natural_baseline) +{ + FlowGrid *self = FLOW_GRID(widget); + + if (minimum_baseline) + *minimum_baseline = -1; + if (natural_baseline) + *natural_baseline = -1; + + if (for_size <= 0) { + if (minimum) *minimum = 0; + if (natural) *natural = 0; + return; + } + + int n_children = flow_grid_count_visible_children(widget); + if (n_children == 0) { + if (minimum) *minimum = 0; + if (natural) *natural = 0; + return; + } + + GtkWidget **children = g_newa(GtkWidget*, n_children); + flow_grid_collect_visible_children(widget, children, n_children); + + int num_columns, num_rows; + double column_width; + flow_grid_compute_layout(widget, for_size, &num_columns, &num_rows, &column_width); + + int *min_row_heights = g_newa(int, num_rows); + int *nat_row_heights = g_newa(int, num_rows); + memset(min_row_heights, 0, num_rows * sizeof(int)); + memset(nat_row_heights, 0, num_rows * sizeof(int)); + + for (int i = 0; i < n_children; i++) { + int cw = (int)(column_width + 0.5); + if (cw < 0) cw = -1; + + int child_min = 0, child_nat = 0; + gtk_widget_measure(children[i], orientation, cw, + &child_min, &child_nat, NULL, NULL); + int row = i / num_columns; + if (child_min > min_row_heights[row]) + min_row_heights[row] = child_min; + if (child_nat > nat_row_heights[row]) + nat_row_heights[row] = child_nat; + } + + int spacing = self->row_spacing * (num_rows - 1); + int total_min = 0, total_nat = 0; + for (int r = 0; r < num_rows; r++) { + total_min += min_row_heights[r]; + total_nat += nat_row_heights[r]; + } + total_min += spacing; + total_nat += spacing; + + if (minimum) *minimum = total_min; + if (natural) *natural = total_nat > total_min ? total_nat : total_min; +} + +static void +flow_grid_size_allocate(GtkWidget *widget, + int width, int height, int baseline) +{ + FlowGrid *self = FLOW_GRID(widget); + + int n_children = flow_grid_count_visible_children(widget); + if (n_children == 0) + return; + + GtkWidget **children = g_newa(GtkWidget*, n_children); + flow_grid_collect_visible_children(widget, children, n_children); + + int num_columns, num_rows; + double column_width; + flow_grid_compute_layout(widget, width, &num_columns, &num_rows, &column_width); + + int *row_heights = g_newa(int, num_rows); + memset(row_heights, 0, num_rows * sizeof(int)); + + for (int i = 0; i < n_children; i++) { + int cw = (int)(column_width + 0.5); + if (cw < 0) cw = -1; + + int child_min = 0; + gtk_widget_measure(children[i], GTK_ORIENTATION_VERTICAL, cw, + &child_min, NULL, NULL, NULL); + int row = i / num_columns; + if (child_min > row_heights[row]) + row_heights[row] = child_min; + } + + for (int i = 0; i < n_children; i++) { + int idx = i; + int column = idx % num_columns; + int row = idx / num_columns; + + int x_offset = 0; + if (row + 1 == num_rows) { + int n_in_last_row = n_children % num_columns; + if (n_in_last_row == 0) + n_in_last_row = num_columns; + double row_width = column_width * n_in_last_row + + self->column_spacing * (n_in_last_row - 1); + double empty_space = width - row_width; + switch (self->justify) { + case FLOW_GRID_JUSTIFY_CENTER: + x_offset = (int)(empty_space / 2.0 + 0.5); + break; + case FLOW_GRID_JUSTIFY_END: + x_offset = (int)(empty_space + 0.5); + break; + default: + x_offset = 0; + break; + } + } + + int y_offset = 0; + for (int r = 0; r < row; r++) + y_offset += row_heights[r]; + + int x = x_offset + + (int)(column * column_width + 0.5) + + column * self->column_spacing; + int y = y_offset + row * self->row_spacing; + + int child_w = (int)(column_width + 0.5); + int child_h = row_heights[row]; + + GtkAllocation alloc = { + .x = x, + .y = y, + .width = child_w, + .height = child_h, + }; + gtk_widget_size_allocate(children[i], &alloc, -1); + } +} + +static void +flow_grid_class_init(FlowGridClass *klass) +{ + GObjectClass *gobject_class = G_OBJECT_CLASS(klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS(klass); + + gobject_class->set_property = flow_grid_set_property; + gobject_class->get_property = flow_grid_get_property; + gobject_class->dispose = flow_grid_dispose; + + widget_class->get_request_mode = flow_grid_get_request_mode; + widget_class->measure = flow_grid_measure; + widget_class->size_allocate = flow_grid_size_allocate; + + props[PROP_MINIMUM_SIZE] = g_param_spec_int( + "minimum-size", NULL, NULL, + 1, G_MAXINT, 200, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY); + + props[PROP_COLUMN_SPACING] = g_param_spec_int( + "column-spacing", NULL, NULL, + 0, G_MAXINT, 0, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY); + + props[PROP_ROW_SPACING] = g_param_spec_int( + "row-spacing", NULL, NULL, + 0, G_MAXINT, 0, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY); + + props[PROP_JUSTIFY] = g_param_spec_int( + "justify", NULL, NULL, + 0, 2, 0, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY); + + g_object_class_install_properties(gobject_class, LAST_PROP, props); + + gtk_widget_class_set_css_name(widget_class, "flowgrid"); + gtk_widget_class_set_accessible_role(widget_class, GTK_ACCESSIBLE_ROLE_GROUP); +} + +static void +flow_grid_init(FlowGrid *self) +{ + self->minimum_size = 200; + self->column_spacing = 0; + self->row_spacing = 0; + self->justify = FLOW_GRID_JUSTIFY_START; +} + +GtkWidget * +flow_grid_new(int minimum_size, int column_spacing, int row_spacing) +{ + return g_object_new(FLOW_TYPE_GRID, + "minimum-size", minimum_size, + "column-spacing", column_spacing, + "row-spacing", row_spacing, + NULL); +} + +void +flow_grid_set_minimum_size(FlowGrid *self, int size) +{ + g_return_if_fail(FLOW_IS_GRID(self)); + if (self->minimum_size == size) + return; + self->minimum_size = size; + g_object_notify_by_pspec(G_OBJECT(self), props[PROP_MINIMUM_SIZE]); + gtk_widget_queue_resize(GTK_WIDGET(self)); +} + +int +flow_grid_get_minimum_size(FlowGrid *self) +{ + g_return_val_if_fail(FLOW_IS_GRID(self), 200); + return self->minimum_size; +} + +void +flow_grid_set_column_spacing(FlowGrid *self, int spacing) +{ + g_return_if_fail(FLOW_IS_GRID(self)); + if (self->column_spacing == spacing) + return; + self->column_spacing = spacing; + g_object_notify_by_pspec(G_OBJECT(self), props[PROP_COLUMN_SPACING]); + gtk_widget_queue_resize(GTK_WIDGET(self)); +} + +int +flow_grid_get_column_spacing(FlowGrid *self) +{ + g_return_val_if_fail(FLOW_IS_GRID(self), 0); + return self->column_spacing; +} + +void +flow_grid_set_row_spacing(FlowGrid *self, int spacing) +{ + g_return_if_fail(FLOW_IS_GRID(self)); + if (self->row_spacing == spacing) + return; + self->row_spacing = spacing; + g_object_notify_by_pspec(G_OBJECT(self), props[PROP_ROW_SPACING]); + gtk_widget_queue_resize(GTK_WIDGET(self)); +} + +int +flow_grid_get_row_spacing(FlowGrid *self) +{ + g_return_val_if_fail(FLOW_IS_GRID(self), 0); + return self->row_spacing; +} + +void +flow_grid_set_justify(FlowGrid *self, FlowGridJustify justify) +{ + g_return_if_fail(FLOW_IS_GRID(self)); + if (self->justify == justify) + return; + self->justify = justify; + g_object_notify_by_pspec(G_OBJECT(self), props[PROP_JUSTIFY]); + gtk_widget_queue_resize(GTK_WIDGET(self)); +} + +FlowGridJustify +flow_grid_get_justify(FlowGrid *self) +{ + g_return_val_if_fail(FLOW_IS_GRID(self), FLOW_GRID_JUSTIFY_START); + return self->justify; +} diff --git a/Sources/CAspectContainer/include/aspect_container.h b/Sources/CAspectContainer/include/aspect_container.h new file mode 100644 index 0000000..302eeaf --- /dev/null +++ b/Sources/CAspectContainer/include/aspect_container.h @@ -0,0 +1,31 @@ +#ifndef ASPECT_CONTAINER_H +#define ASPECT_CONTAINER_H + +#include + +G_BEGIN_DECLS + +#define ASPECT_TYPE_CONTAINER (aspect_container_get_type()) +#define ASPECT_CONTAINER(obj) (G_TYPE_CHECK_INSTANCE_CAST((obj), ASPECT_TYPE_CONTAINER, AspectContainer)) +#define ASPECT_IS_CONTAINER(obj) (G_TYPE_CHECK_INSTANCE_TYPE((obj), ASPECT_TYPE_CONTAINER)) + +typedef struct _AspectContainer AspectContainer; +typedef struct _AspectContainerClass AspectContainerClass; + +struct _AspectContainerClass { + GtkWidgetClass parent_class; +}; + +GType aspect_container_get_type (void); +GtkWidget *aspect_container_new (float aspect_ratio); + +void aspect_container_set_aspect_ratio (AspectContainer *self, + float ratio); +float aspect_container_get_aspect_ratio (AspectContainer *self); +void aspect_container_set_max_width (AspectContainer *self, + int max_width); +int aspect_container_get_max_width (AspectContainer *self); + +G_END_DECLS + +#endif /* ASPECT_CONTAINER_H */ diff --git a/Sources/CAspectContainer/include/flow_grid.h b/Sources/CAspectContainer/include/flow_grid.h new file mode 100644 index 0000000..8929154 --- /dev/null +++ b/Sources/CAspectContainer/include/flow_grid.h @@ -0,0 +1,41 @@ +#ifndef FLOW_GRID_H +#define FLOW_GRID_H + +#include + +G_BEGIN_DECLS + +#define FLOW_TYPE_GRID (flow_grid_get_type()) +#define FLOW_GRID(obj) (G_TYPE_CHECK_INSTANCE_CAST((obj), FLOW_TYPE_GRID, FlowGrid)) +#define FLOW_IS_GRID(obj) (G_TYPE_CHECK_INSTANCE_TYPE((obj), FLOW_TYPE_GRID)) + +typedef struct _FlowGrid FlowGrid; +typedef struct _FlowGridClass FlowGridClass; + +struct _FlowGridClass { + GtkWidgetClass parent_class; +}; + +typedef enum { + FLOW_GRID_JUSTIFY_START, + FLOW_GRID_JUSTIFY_CENTER, + FLOW_GRID_JUSTIFY_END, +} FlowGridJustify; + +GType flow_grid_get_type (void); +GtkWidget *flow_grid_new (int minimum_size, + int column_spacing, + int row_spacing); + +void flow_grid_set_minimum_size (FlowGrid *self, int size); +int flow_grid_get_minimum_size (FlowGrid *self); +void flow_grid_set_column_spacing (FlowGrid *self, int spacing); +int flow_grid_get_column_spacing (FlowGrid *self); +void flow_grid_set_row_spacing (FlowGrid *self, int spacing); +int flow_grid_get_row_spacing (FlowGrid *self); +void flow_grid_set_justify (FlowGrid *self, FlowGridJustify justify); +FlowGridJustify flow_grid_get_justify (FlowGrid *self); + +G_END_DECLS + +#endif /* FLOW_GRID_H */ diff --git a/Sources/LuminateUI/Components/AspectContainer.swift b/Sources/LuminateUI/Components/AspectContainer.swift new file mode 100644 index 0000000..73a22c4 --- /dev/null +++ b/Sources/LuminateUI/Components/AspectContainer.swift @@ -0,0 +1,139 @@ +// +// AspectContainer.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Adwaita +import CAspectContainer + +/// A single‑child container that preserves a fixed aspect ratio. +/// +/// Unlike GTK's built‑in `GtkAspectFrame`, this container overrides +/// `measure()` to report the correct height (`width × ratio`) during +/// the layout pass. This makes it work correctly inside `ScrollView`, +/// `AdwCarousel`, and other flexible layouts where the parent needs +/// to know the child's preferred size. +/// +/// ```swift +/// AspectContainer(aspectRatio: 16.0 / 9.0) { +/// .child { +/// Picture() +/// .data(imageData) +/// .halign(.fill) +/// .hexpand() +/// } +/// .maxWidth(800) +/// } +/// ``` +/// +/// Ported 1:1 from the Gelata project's `AspectRatioContainer` Rust widget. +public struct AspectContainer: Widget { + + #if exposeGeneratedAppearUpdateFunctions + public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #else + var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #endif + + /// The child widget. + var child: Body? + /// The desired width‑to‑height ratio. + var aspectRatio: Float + /// An optional maximum width in pixels (0 = no limit). + var maxWidth: Int? + + /// Create an aspect‑ratio container. + /// - Parameter aspectRatio: Desired `width / height` ratio. + public init(aspectRatio: Float) { + self.aspectRatio = aspectRatio + } + + // MARK: - Widget + + public func container(data: WidgetData, type: Data.Type) -> ViewStorage + where Data: ViewRenderData { + let storage = ViewStorage(aspect_container_new(aspectRatio)?.opaque()) + for function in appearFunctions { + function(storage, data) + } + if let childStorage = child?.storage(data: data, type: type) { + storage.content["child"] = [childStorage] + gtk_widget_set_parent( + childStorage.opaquePointer?.cast(), + storage.opaquePointer?.cast()) + } + return storage + } + + public func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + storage.modify { widget in + if let childStorage = storage.content["child"]?.first { + child?.updateStorage( + childStorage, + data: data, + updateProperties: updateProperties, + type: type + ) + } + if let maxWidth, + updateProperties, + (storage.previousState as? Self)?.maxWidth != maxWidth + { + aspect_container_set_max_width(widget, maxWidth.cInt) + } + if updateProperties, + (storage.previousState as? Self)?.aspectRatio != aspectRatio + { + aspect_container_set_aspect_ratio(widget, aspectRatio) + } + } + for function in updateFunctions { + function(storage, data, updateProperties) + } + if updateProperties { + storage.previousState = self + } + } + +} + +// MARK: - Modifiers + +extension AspectContainer { + + /// Set the child widget. + public func child(@ViewBuilder _ child: () -> Body) -> Self { + modify { $0.child = child() } + } + + /// Set an optional maximum width in pixels. + /// When set to a positive value, the container's width is clamped + /// before computing the height from the aspect ratio. + public func maxWidth(_ maxWidth: Int?) -> Self { + modify { $0.maxWidth = maxWidth } + } + +} diff --git a/Sources/LuminateUI/Components/FlowGrid.swift b/Sources/LuminateUI/Components/FlowGrid.swift new file mode 100644 index 0000000..a28b79e --- /dev/null +++ b/Sources/LuminateUI/Components/FlowGrid.swift @@ -0,0 +1,232 @@ +// +// FlowGrid.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Adwaita +import CAspectContainer + +/// Controls how the last row is aligned when it doesn't fill the width. +public enum FlowGridJustifySetting: Int { + + /// Last row items are aligned to the start. + case start + /// Last row items are centered. + case center + /// Last row items are aligned to the end. + case end + + var cValue: FlowGridJustify { + switch self { + case .start: FLOW_GRID_JUSTIFY_START + case .center: FLOW_GRID_JUSTIFY_CENTER + case .end: FLOW_GRID_JUSTIFY_END + } + } + +} + +/// A reflowing grid that arranges children in columns. +/// +/// Unlike `GtkFlowBox`, this widget re‑measures children during every layout +/// pass using the actual column width. This avoids stale size caches and +/// ensures the `AspectContainer` receives the correct `for_size` for its +/// height computation. +/// +/// ```swift +/// FlowGrid(items, id: \.id) { item in +/// HomePosterCell(item: item, navigation: $navigation) +/// } +/// .columnSpacing(16) +/// .rowSpacing(16) +/// .halign(.fill) +/// ``` +public struct FlowGrid: Widget where Identifier: Hashable { + + #if exposeGeneratedAppearUpdateFunctions + public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #else + var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #endif + + /// The minimum column width. + var minimumSize: Int = 200 + /// The spacing between columns. + var columnSpacing: Int = 0 + /// The spacing between rows. + var rowSpacing: Int = 0 + /// How the last row is aligned. + var justify: FlowGridJustifySetting = .start + + /// The elements to display. + var elements: [Element] + /// The content builder. + var content: (Element) -> Body + /// The identifier key path. + var id: KeyPath + + /// Initialize `FlowGrid`. + public init( + _ elements: [Element], + id: KeyPath, + @ViewBuilder content: @escaping (Element) -> Body + ) { + self.elements = elements + self.content = content + self.id = id + } + + // MARK: - Widget + + public func container(data: WidgetData, type: Data.Type) -> ViewStorage + where Data: ViewRenderData { + let storage = ViewStorage( + flow_grid_new(minimumSize.cInt, columnSpacing.cInt, rowSpacing.cInt)?.opaque() + ) + for function in appearFunctions { + function(storage, data) + } + return storage + } + + public func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + storage.modify { widget in + + // --- Properties --- + if updateProperties, + (storage.previousState as? Self)?.minimumSize != minimumSize + { + flow_grid_set_minimum_size(widget, minimumSize.cInt) + } + if updateProperties, + (storage.previousState as? Self)?.columnSpacing != columnSpacing + { + flow_grid_set_column_spacing(widget, columnSpacing.cInt) + } + if updateProperties, + (storage.previousState as? Self)?.rowSpacing != rowSpacing + { + flow_grid_set_row_spacing(widget, rowSpacing.cInt) + } + if updateProperties, + (storage.previousState as? Self)?.justify != justify + { + flow_grid_set_justify(widget, justify.cValue) + } + + // --- Children --- + var contentStorage: [ViewStorage] = storage.content[.mainContent] ?? [] + let oldElements = storage.fields["element"] as? [Element] ?? [] + + var oldByID: [Identifier: ViewStorage] = [:] + for (i, oldElement) in oldElements.enumerated() where i < contentStorage.count { + oldByID[oldElement[keyPath: id]] = contentStorage[i] + } + + var newContentStorage: [ViewStorage] = [] + var lastChild: OpaquePointer? + + for element in elements { + let elementID = element[keyPath: id] + if let existingStorage = oldByID.removeValue(forKey: elementID) { + newContentStorage.append(existingStorage) + lastChild = existingStorage.opaquePointer + } else { + let child = content(element).storage(data: data, type: type) + gtk_widget_set_parent(child.opaquePointer?.cast(), widget?.cast()) + newContentStorage.append(child) + lastChild = child.opaquePointer + } + } + + for (_, staleStorage) in oldByID { + gtk_widget_unparent(staleStorage.opaquePointer?.cast()) + } + + storage.fields["element"] = elements + storage.content[.mainContent] = newContentStorage + + for (index, element) in elements.enumerated() { + content(element).updateStorage( + newContentStorage[index], + data: data, + updateProperties: updateProperties, + type: type + ) + } + } + + for function in updateFunctions { + function(storage, data, updateProperties) + } + if updateProperties { + storage.previousState = self + } + } + +} + +// MARK: - Modifiers + +extension FlowGrid { + + /// The minimum column width in pixels. + public func minimumSize(_ minimumSize: Int) -> Self { + modify { $0.minimumSize = minimumSize } + } + + /// The spacing between columns in pixels. + public func columnSpacing(_ columnSpacing: Int) -> Self { + modify { $0.columnSpacing = columnSpacing } + } + + /// The spacing between rows in pixels. + public func rowSpacing(_ rowSpacing: Int) -> Self { + modify { $0.rowSpacing = rowSpacing } + } + + /// How the last row is aligned. + public func justify(_ justify: FlowGridJustifySetting) -> Self { + modify { $0.justify = justify } + } + +} + +// MARK: - Convenience Initializer for Identifiable Elements + +extension FlowGrid where Element: Identifiable, Identifier == Element.ID { + + /// Initialize `FlowGrid` with identifiable elements. + public init( + _ elements: [Element], + @ViewBuilder content: @escaping (Element) -> Body + ) { + self.elements = elements + self.content = content + self.id = \.id + } + +} diff --git a/Sources/LuminateUI/Components/HomePosterCell.swift b/Sources/LuminateUI/Components/HomePosterCell.swift index 78d043e..17c5933 100644 --- a/Sources/LuminateUI/Components/HomePosterCell.swift +++ b/Sources/LuminateUI/Components/HomePosterCell.swift @@ -27,7 +27,7 @@ import LuminateDI struct HomePosterCell: View { let item: Components.Schemas.BaseItemDto - let width: Int = 200 + let minWidth: Int = 200 @Binding var navigation: NavigationStack @Injected(\.client) var client @Injected(\.imageService) var imageService @@ -55,7 +55,7 @@ struct HomePosterCell: View { loadImage() } } - .frame(minWidth: width + (Constants.padding * 2)) + .frame(minWidth: minWidth + (Constants.padding * 2)) .halign(.fill) .valign(.start) .hexpand(false) @@ -65,48 +65,27 @@ struct HomePosterCell: View { @ViewBuilder private var imageSection: Body { - AspectFrame(ratio: 1.5) + AspectContainer(aspectRatio: 1.5) .child { image .halign(.fill) .hexpand() + .overflow(.hidden) + .card() } - .obeyChild(false) - .xalign(0.5) - .yalign(0.5) - .halign(.fill) - .hexpand() - .overflow(.hidden) - .card() - // image - // .halign(.fill) - // .hexpand() - // .frame(minHeight: Int(Double(width) * 1.5)) - // .overflow(.hidden) - // .card() + .frame(minWidth: minWidth) } @ViewBuilder private var image: Body { if let imageData { - // Picture() - // .contentFit(.cover) - // .data(imageData) - // .valign(.fill) - // .halign(.fill) - // .vexpand() - // .hexpand() - // .transition(.crossfade) - Overlay() - .overlay { - Picture() - .contentFit(.cover) - .data(imageData) - .valign(.fill) - .halign(.fill) - .vexpand() - .hexpand() - } + Picture() + .contentFit(.cover) + .data(imageData) + .valign(.fill) + .halign(.fill) + .vexpand() + .hexpand() .transition(.crossfade) } else { Spinner() @@ -150,7 +129,7 @@ struct HomePosterCell: View { itemId: itemId, imageType: .primary, tag: tag, - maxWidth: 200 + maxWidth: 400 ) else { return } let data = try? await imageService.loadImage(url: url) diff --git a/Sources/LuminateUI/Components/ItemGrid.swift b/Sources/LuminateUI/Components/ItemGrid.swift index 6ed389f..ace8961 100644 --- a/Sources/LuminateUI/Components/ItemGrid.swift +++ b/Sources/LuminateUI/Components/ItemGrid.swift @@ -57,15 +57,16 @@ public struct ItemGrid: View { } if isLoading { Spinner() + .transition(.crossfade) } else { - WrapBox(items, id: \.id) { item in + FlowGrid(items, id: \.id) { item in HomePosterCell(item: item, navigation: $navigation) } - .lineSpacing(16) - .childSpacing(16) - .justify(JustifyMode.none) - .justifyLastLine(false) - .halign(.start) + .columnSpacing(16) + .rowSpacing(16) + .minimumSize(216) + .halign(.fill) + .transition(.crossfade) } } .onAppear { diff --git a/Sources/LuminateUI/Components/LibraryGrid.swift b/Sources/LuminateUI/Components/LibraryGrid.swift index e04af70..196a20d 100644 --- a/Sources/LuminateUI/Components/LibraryGrid.swift +++ b/Sources/LuminateUI/Components/LibraryGrid.swift @@ -45,14 +45,13 @@ public struct LibraryGrid: View { .title3() .halign(.start) .padding(10, .horizontal) - WrapBox(libraries, id: \.id) { item in + FlowGrid(libraries, id: \.id) { item in HomePosterCell(item: item, navigation: $navigation) } - .lineSpacing(16) - .childSpacing(16) - .justify(.fill) - .justifyLastLine(false) - .halign(.start) + .columnSpacing(16) + .rowSpacing(16) + .minimumSize(216) + .halign(.fill) } } } diff --git a/Sources/LuminateUI/Components/MediaRow.swift b/Sources/LuminateUI/Components/MediaRow.swift index 53d94c4..8062fee 100644 --- a/Sources/LuminateUI/Components/MediaRow.swift +++ b/Sources/LuminateUI/Components/MediaRow.swift @@ -65,6 +65,7 @@ public struct MediaRow: View { .padding(16, .trailing) } } + // .propagateNaturalHeight() .vscrollbarPolicy(.never) .hscrollbarPolicy(.external) .style("undershoot-start")