diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 90fcfd8..0000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM swift:noble - -RUN touch /var/mail/ubuntu && \ - chown ubuntu /var/mail/ubuntu && \ - userdel -r ubuntu && \ - apt-get update && \ - apt-get install -y libadwaita-1.0 libadwaita-1-dev diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 42f32e1..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "Swift", - "build": { - "dockerfile": "Dockerfile" - }, - "features": { - "ghcr.io/devcontainers/features/common-utils:2": { - "installZsh": "false", - "username": "vscode", - "userUid": "1000", - "userGid": "1000", - "upgradePackages": "false" - }, - "ghcr.io/devcontainers/features/git:1": { - "version": "os-provided", - "ppa": "false" - }, - "ghcr.io/devcontainers-contrib/features/curl-apt-get:1": {} - }, - "runArgs": [ - "--cap-add=SYS_PTRACE", - "--security-opt", - "seccomp=unconfined" - ], - // Configure tool-specific properties. - "customizations": { - // Configure properties specific to VS Code. - "vscode": { - // Set *default* container specific settings.json values on container create. - "settings": { - "lldb.library": "/usr/lib/liblldb.so" - }, - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "sswg.swift-lang" - ] - } - }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "vscode" -} diff --git a/Package.swift b/Package.swift index cf7b7ed..957d766 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"), .package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.16.0"), .package(url: "https://github.com/apple/swift-syntax", from: "603.0.0"), + .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), ], targets: [ .target( @@ -48,11 +49,20 @@ let package = Package( dependencies: [ "LuminateCore", .product(name: "Adwaita", package: "adwaita-swift"), + .product(name: "Logging", package: "swift-log"), ] ), + .target( + name: "CGtkWidgets", + dependencies: [ + .product(name: "CAdw", package: "adwaita-swift") + ] + ), + .target( name: "LuminateUI", dependencies: [ + "CGtkWidgets", "LuminateCore", "LuminateDI", "LuminateObservationMacros", @@ -79,11 +89,21 @@ let package = Package( "LuminateObservationMacros", .product(name: "Adwaita", package: "adwaita-swift"), .product(name: "Localized", package: "localized"), + .product(name: "Logging", package: "swift-log"), ], path: "Sources/Luminate", resources: [.process("Localized.yml")], plugins: [.plugin(name: "GenerateLocalized", package: "localized")] ), + + .testTarget( + name: "LuminateTests", + dependencies: [ + "CGtkWidgets", + .product(name: "CAdw", package: "adwaita-swift"), + .product(name: "Adwaita", package: "adwaita-swift"), + ] + ), ], swiftLanguageModes: [.v5] ) diff --git a/Sources/CGtkWidgets/aspect_container.c b/Sources/CGtkWidgets/aspect_container.c new file mode 100644 index 0000000..1dd4c7d --- /dev/null +++ b/Sources/CGtkWidgets/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/CGtkWidgets/flow_grid.c b/Sources/CGtkWidgets/flow_grid.c new file mode 100644 index 0000000..aeb0c2d --- /dev/null +++ b/Sources/CGtkWidgets/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/CGtkWidgets/include/aspect_container.h b/Sources/CGtkWidgets/include/aspect_container.h new file mode 100644 index 0000000..302eeaf --- /dev/null +++ b/Sources/CGtkWidgets/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/CGtkWidgets/include/flow_grid.h b/Sources/CGtkWidgets/include/flow_grid.h new file mode 100644 index 0000000..8929154 --- /dev/null +++ b/Sources/CGtkWidgets/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/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 60e530a..2b5af0f 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -21,6 +21,7 @@ import Adwaita import Foundation +import Logging import LuminateCore import LuminateDI import LuminatePlayer @@ -35,12 +36,22 @@ struct Luminate: App { @State private var isLaunchLoading = true init() { + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + #if DEBUG + handler.logLevel = .debug + #else + handler.logLevel = .info + #endif + return handler + } ObservationRegistrar.onChange = { StateManager.updateViews() } if let store = try? SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) { DIContainer.shared.register(\.persistence, value: store) } DIContainer.shared.register(\.imageService, value: ImageService()) - DIContainer.shared.register(\.pageAnimationTracker, value: PageAnimationTracker()) + DIContainer.shared.register(\.viewUpdateScheduler, value: ViewUpdateScheduler()) + DIContainer.shared.register(\.logger, value: Logger(label: "dev.bscubed.Luminate")) } var scene: Scene { @@ -74,6 +85,9 @@ struct Luminate: App { } .keyboardShortcut("r".ctrl()) { _ in } + #if DEBUG + .devel() + #endif } private func loadSavedSession() { @@ -107,19 +121,27 @@ struct ContentView: View { var client: JellyfinClient var userId: String @State var stack: NavigationStack = .init() - @Injected(\.pageAnimationTracker) var pageAnimationTracker - var view: Body { NavigationView($stack, "Luminate") { page in switch page { - case .folder(let title, let items): - LibraryPage(title: title, items: items, navigation: $stack) + case .items(let title, let items, let type): + ItemPage(title: title, items: items, type: type, navigation: $stack) .topToolbar { ToolbarView() } - .navigationTitle(title) - case .library(let item): - Text("REPLACE ME") + .navigationTitle(page.description) + case .item(let item, let type): + ItemPage(item: item, type: type, navigation: $stack) + .topToolbar { + ToolbarView() + } + .navigationTitle(page.description) + case .movieDetail(let item): + MovieDetailView(for: item) + .topToolbar { + ToolbarView() + } + .navigationTitle(page.description) } } initialView: { HomeView(navigation: $stack) @@ -128,11 +150,5 @@ struct ContentView: View { } .navigationTitle("Luminate") } - .pushed { - pageAnimationTracker.markPush() - } - .popped { - pageAnimationTracker.markPush() - } } } diff --git a/Sources/Luminate/Pages/HomeView.swift b/Sources/Luminate/Pages/HomeView.swift index dbd6645..c8b10f7 100644 --- a/Sources/Luminate/Pages/HomeView.swift +++ b/Sources/Luminate/Pages/HomeView.swift @@ -33,10 +33,10 @@ public struct HomeView: View { @Injected(\.client) var client @Injected(\.userId) var userId @Binding var navigation: NavigationStack - @State private var resumeItems: [Components.Schemas.BaseItemDto] = [] - @State private var nextUpItems: [Components.Schemas.BaseItemDto] = [] - @State private var latestItems: [Components.Schemas.BaseItemDto] = [] - @State private var libraries: [Components.Schemas.BaseItemDto] = [] + @State private var resumeItems: [BaseItemDto] = [] + @State private var nextUpItems: [BaseItemDto] = [] + @State private var latestItems: [BaseItemDto] = [] + @State private var libraries: [BaseItemDto] = [] @State private var isLoading = true @State private var isLoadingData = false @@ -55,6 +55,12 @@ public struct HomeView: View { .frame(minWidth: 64) .frame(maxWidth: 64) } else { + ItemGrid( + items: libraries, + type: .library, + navigation: $navigation + ) + .padding(32, .bottom) if !resumeItems.isEmpty { let title = "Continue Watching" MediaRow( @@ -62,7 +68,7 @@ public struct HomeView: View { items: resumeItems, navigation: $navigation, onSeeAll: { - navigation.push(.folder(title: title, items: resumeItems)) + navigation.push(.items(title: title, items: resumeItems)) } ) .padding(32, .bottom) @@ -74,7 +80,7 @@ public struct HomeView: View { items: nextUpItems, navigation: $navigation, onSeeAll: { - navigation.push(.folder(title: title, items: nextUpItems)) + navigation.push(.items(title: title, items: nextUpItems)) } ) .padding(32, .bottom) @@ -86,20 +92,15 @@ public struct HomeView: View { items: latestItems, navigation: $navigation, onSeeAll: { - navigation.push(.folder(title: title, items: latestItems)) + navigation.push(.items(title: title, items: latestItems)) } ) .padding(32, .bottom) } - LibraryGrid( - libraries: libraries, - navigation: $navigation - ) - .padding(32, .bottom) } } .padding(8, .horizontal) - .padding(32, .bottom) + .padding(32, .vertical) } } .hscrollbarPolicy(.never) @@ -113,14 +114,17 @@ public struct HomeView: View { Task { async let resume = client.getItems( userId: userId, + fields: [.primaryImageAspectRatio], filters: [.isResumable], sortBy: [.datePlayed], sortOrder: [.descending], limit: 20, recursive: true ) - async let nextUp = client.getNextUp(userId: userId, limit: 20) - async let latest = client.getLatestMedia(userId: userId, limit: 20) + async let nextUp = client.getNextUp( + userId: userId, limit: 20, fields: [.primaryImageAspectRatio]) + async let latest = client.getLatestMedia( + userId: userId, fields: [.primaryImageAspectRatio], limit: 20) async let views = client.getUserViews(userId: userId) do { let (resume, nextUp, latest, views) = try await (resume, nextUp, latest, views) diff --git a/Sources/Luminate/Pages/ItemPage.swift b/Sources/Luminate/Pages/ItemPage.swift new file mode 100644 index 0000000..1b2e975 --- /dev/null +++ b/Sources/Luminate/Pages/ItemPage.swift @@ -0,0 +1,108 @@ +// +// ItemPage.swift +// Luminate +// +// Created by Brendan Szymanski on 6/16/25. +// +// 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 LuminateCore +import LuminateDI +import LuminateUI + +public struct ItemPage: View { + + private var title: String? + private var parentId: String? + private var type: DisplayType + + @Binding var navigation: NavigationStack + + @State private var items: [BaseItemDto] = [] + @State private var isLoading = false + + @Injected(\.client) var client + @Injected(\.userId) var userId + + nonisolated public init( + title: String, items: [BaseItemDto], + type: DisplayType = .mixed, + navigation: Binding> + ) { + self.title = title + self._items = .init(wrappedValue: items) + self.parentId = nil + self.type = type + _navigation = navigation + } + + nonisolated public init( + item: BaseItemDto, + type: DisplayType = .mixed, + navigation: Binding> + ) { + self.title = item.name + self.parentId = item.id + self.type = type + _isLoading = .init(wrappedValue: item.id != nil) + _navigation = navigation + } + + public var view: Body { + ScrollView { + Clamp() + .maximumSize(1550) + .tighteningThreshold(550) + .child { + if isLoading, parentId != nil { + Spinner() + } else { + ItemGrid( + items: items, + type: type, + navigation: $navigation, + title: title + ) + .padding(32, .vertical) + } + } + } + .hscrollbarPolicy(.never) + .propagateNaturalHeight() + .onAppear { + loadIfNeeded() + } + } + + private func loadIfNeeded() { + guard let parentId else { return } + Task { + let result = try? await client.getItems( + userId: userId, + parentId: parentId, + fields: [.primaryImageAspectRatio], + sortBy: [.sortName], + sortOrder: [.ascending] + ) + items = result?.items ?? [] + isLoading = false + } + } +} diff --git a/Sources/Luminate/Pages/LibraryPage.swift b/Sources/Luminate/Pages/LibraryPage.swift deleted file mode 100644 index 4dbc03d..0000000 --- a/Sources/Luminate/Pages/LibraryPage.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// LibraryPage.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 LuminateCore -import LuminateUI - -public struct LibraryPage: View { - - nonisolated public init( - title: String, items: [Components.Schemas.BaseItemDto], - navigation: Binding> - ) { - self.title = title - self.items = items - _navigation = navigation - } - - private var items: [Components.Schemas.BaseItemDto] - private var title: String - @Binding var navigation: NavigationStack - - public var view: Body { - ScrollView { - Clamp() - .maximumSize(1550) - .tighteningThreshold(550) - .child { - LibraryGrid( - libraries: items, - navigation: $navigation, - title: title - ) - .padding(32, .bottom) - } - } - .hscrollbarPolicy(.never) - .propagateNaturalHeight() - } -} diff --git a/Sources/LuminateCore/BaseItemDto+Display.swift b/Sources/LuminateCore/BaseItemDto+Display.swift index 401a343..f1d7ee3 100644 --- a/Sources/LuminateCore/BaseItemDto+Display.swift +++ b/Sources/LuminateCore/BaseItemDto+Display.swift @@ -21,11 +21,11 @@ import Foundation -extension Components.Schemas.BaseItemPerson: Identifiable {} +extension BaseItemPerson: Identifiable {} -extension Components.Schemas.BaseItemDto: Identifiable {} +extension BaseItemDto: Identifiable {} -extension Components.Schemas.SearchHint { +extension SearchHint { public var runtimeString: String { guard let ticks = runTimeTicks else { return "" } let totalSeconds = Int(ticks / 10_000_000) @@ -36,7 +36,7 @@ extension Components.Schemas.SearchHint { } } -extension Components.Schemas.BaseItemDto { +extension BaseItemDto { public var runtimeString: String { guard let ticks = runTimeTicks else { return "" } let totalSeconds = Int(ticks / 10_000_000) @@ -46,16 +46,61 @@ extension Components.Schemas.BaseItemDto { return "\(minutes)m" } - public var yearString: String { - guard let year = productionYear else { return "" } - return "\(year)" + public var yearString: String? { + productionYear.map(String.init) + } + + public var episodePlacementString: String? { + guard let seasonNumber = parentIndexNumber else { return nil } + guard let episodeNumber = indexNumber else { return nil } + guard let episodeName = name else { return nil } + return "S\(seasonNumber):E\(episodeNumber) - \(episodeName)" } public var primaryImageTag: String? { imageTags?.additionalProperties["Primary"] } + public var seriesRunYears: String? { + guard isShow else { return nil } + guard let startYear = yearString else { return nil } + guard status == "Ended" else { return "\(startYear) - Present" } + guard let endDate else { return nil } + let endYear = String(Calendar.current.component(.year, from: endDate)) + return startYear == endYear ? startYear : "\(startYear) - \(endYear)" + } + public var backdropImageTag: String? { backdropImageTags?.first } + + public var type: BaseItemKind? { + _type?.value1 + } + + public var isShow: Bool { + _type?.value1 == .series + } + + public var displayType: DisplayType { + return switch type { + case .season: .season + case .episode: .episode + case .movie: .movie + case .person: .person + case .series: .series + default: .mixed + } + } + + public var childDisplayType: DisplayType? { + return switch type { + case .season: .episode + case .episode: .none + case .movie: .none + case .person: .mixed + case .series: .season + default: .mixed + } + } } diff --git a/Sources/LuminateCore/DisplayType.swift b/Sources/LuminateCore/DisplayType.swift new file mode 100644 index 0000000..3b8e60c --- /dev/null +++ b/Sources/LuminateCore/DisplayType.swift @@ -0,0 +1,75 @@ +// +// MediaRow.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 Foundation + +public enum DisplayType { + case mixed + case season + case episode + case movie + case person + case library + case series + + public var aspectRatio: Float { + return switch self { + case .mixed: 1.5 + case .season: 1.5 + case .episode: 0.5625 + case .movie: 1.5 + case .person: 1 + case .library: 0.5625 + case .series: 1.5 + } + } + + public func itemId(for item: BaseItemDto) -> String? { + return switch self { + case .mixed: item.seriesId ?? item.id + default: item.id + } + } + + public func title(for item: BaseItemDto) -> String? { + return switch self { + case .mixed: item.seriesName ?? item.name + case .library: .none + default: item.name + } + } + + public func subtitle(for item: BaseItemDto) -> String? { + return switch self { + case .library: .none + case .mixed: item.episodePlacementString ?? item.seriesRunYears ?? item.yearString + default: item.yearString + } + } + + public func itemWidth(isMobile: Bool = false) -> Int { + switch self { + case .episode: return isMobile ? 150 : 300 + case .library: return isMobile ? 125 : 250 + default: return isMobile ? 100 : 200 + } + } +} diff --git a/Sources/LuminateCore/ImageService.swift b/Sources/LuminateCore/ImageService.swift index 4b1ab39..c8ca15f 100644 --- a/Sources/LuminateCore/ImageService.swift +++ b/Sources/LuminateCore/ImageService.swift @@ -20,6 +20,7 @@ // import Foundation +import Logging #if canImport(FoundationNetworking) import FoundationNetworking @@ -28,36 +29,65 @@ import Foundation public actor ImageService { private let cacheDir: URL private let memoryCache = NSCache() + private var activeDownloads = 0 + private var imagesDownloaded = 0 + private let maxConcurrent: Int + private var pendingContinuations: [CheckedContinuation] = [] - public init(cacheDir: URL? = nil) { + private var logger: Logger = .init(label: "dev.bscubed.Luminate") + + public init(cacheDir: URL? = nil, maxConcurrent: Int = 6) { let defaultCache = FileManager.default.urls( for: .cachesDirectory, in: .userDomainMask ).first!.appendingPathComponent("luminate/images") self.cacheDir = cacheDir ?? defaultCache + self.maxConcurrent = maxConcurrent try? FileManager.default.createDirectory( at: self.cacheDir, withIntermediateDirectories: true) + + logger.debug("New ImageService created") } public func loadImage(url: URL) async throws -> Data { let key = url.absoluteString as NSString if let cached = memoryCache.object(forKey: key) { + logger.debug("Hit memory cache for \(key)") return cached as Data } - let diskKey = url.absoluteString.data(using: .utf8)!.base64EncodedString() - .replacingOccurrences(of: "/", with: "_") - let diskURL = cacheDir.appendingPathComponent(diskKey) + let diskURL = diskCacheURL(for: url) if let data = try? Data(contentsOf: diskURL) { memoryCache.setObject(data as NSData, forKey: key) + logger.debug("Hit disk cache for \(key)") return data } + + if activeDownloads >= maxConcurrent { + await withCheckedContinuation { (continuation: CheckedContinuation) in + logger.debug("Idk what this is doing tbh") + pendingContinuations.append(continuation) + } + } + + activeDownloads += 1 + defer { + activeDownloads -= 1 + if !pendingContinuations.isEmpty { + let next = pendingContinuations.removeFirst() + next.resume() + } + } + + logger.debug("Started downloading \(url)") let (data, _) = try await URLSession.shared.data(from: url) let nsData = data as NSData memoryCache.setObject(nsData, forKey: key) try? data.write(to: diskURL) + logger.debug("Finished downloading \(url)") return data } public func prefetch(urls: [URL]) async { + logger.debug("Prefetching \(urls)") await withTaskGroup(of: Void.self) { group in for url in urls { group.addTask { _ = try? await self.loadImage(url: url) } @@ -66,8 +96,15 @@ public actor ImageService { } public func clearCache() { + logger.debug("Clearing all cache...") memoryCache.removeAllObjects() try? FileManager.default.removeItem(at: cacheDir) try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) } + + private func diskCacheURL(for url: URL) -> URL { + let diskKey = url.absoluteString.data(using: .utf8)!.base64EncodedString() + .replacingOccurrences(of: "/", with: "_") + return cacheDir.appendingPathComponent(diskKey) + } } diff --git a/Sources/LuminateCore/JellyfinClient.swift b/Sources/LuminateCore/JellyfinClient.swift index df02956..97e8f55 100644 --- a/Sources/LuminateCore/JellyfinClient.swift +++ b/Sources/LuminateCore/JellyfinClient.swift @@ -143,7 +143,7 @@ public actor JellyfinClient { } public func authenticate(username: String, password: String) async throws - -> Components.Schemas.AuthenticationResult + -> AuthenticationResult { let response = try await client.authenticateUserByName( Operations.AuthenticateUserByName.Input( @@ -187,17 +187,17 @@ public actor JellyfinClient { public func getItems( userId: String, parentId: String? = nil, - includeItemTypes: [Components.Schemas.BaseItemKind]? = nil, - fields: [Components.Schemas.ItemFields]? = nil, - filters: [Components.Schemas.ItemFilter]? = nil, - sortBy: [Components.Schemas.ItemSortBy]? = nil, - sortOrder: [Components.Schemas.SortOrder]? = nil, + includeItemTypes: [BaseItemKind]? = nil, + fields: [ItemFields]? = nil, + filters: [ItemFilter]? = nil, + sortBy: [ItemSortBy]? = nil, + sortOrder: [SortOrder]? = nil, searchTerm: String? = nil, startIndex: Int32? = nil, limit: Int32? = nil, recursive: Bool? = nil, isFavorite: Bool? = nil - ) async throws -> Components.Schemas.BaseItemDtoQueryResult { + ) async throws -> BaseItemDtoQueryResult { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetItems.Input.Query(userId: userId) query.parentId = parentId @@ -234,7 +234,7 @@ public actor JellyfinClient { } public func getItem(itemId: String, userId: String? = nil) async throws - -> Components.Schemas.BaseItemDto + -> BaseItemDto { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.getItem( @@ -264,7 +264,7 @@ public actor JellyfinClient { } public func getUserViews(userId: String) async throws - -> Components.Schemas.BaseItemDtoQueryResult + -> BaseItemDtoQueryResult { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.getUserViews( @@ -296,12 +296,12 @@ public actor JellyfinClient { userId: String, startIndex: Int32? = nil, limit: Int32? = nil, - fields: [Components.Schemas.ItemFields]? = nil, + fields: [ItemFields]? = nil, seriesId: String? = nil, parentId: String? = nil, enableResumable: Bool? = nil, enableRewatching: Bool? = nil - ) async throws -> Components.Schemas.BaseItemDtoQueryResult { + ) async throws -> BaseItemDtoQueryResult { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetNextUp.Input.Query(userId: userId) query.startIndex = startIndex @@ -336,13 +336,13 @@ public actor JellyfinClient { public func getSeasons( seriesId: String, userId: String, - fields: [Components.Schemas.ItemFields]? = nil, + fields: [ItemFields]? = nil, isSpecialSeason: Bool? = nil, enableImages: Bool? = nil, imageTypeLimit: Int32? = nil, - enableImageTypes: [Components.Schemas.ImageType]? = nil, + enableImageTypes: [ImageType]? = nil, enableUserData: Bool? = nil - ) async throws -> Components.Schemas.BaseItemDtoQueryResult { + ) async throws -> BaseItemDtoQueryResult { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetSeasons.Input.Query(userId: userId) query.fields = fields @@ -384,14 +384,14 @@ public actor JellyfinClient { userId: String, seasonId: String? = nil, season: Int32? = nil, - fields: [Components.Schemas.ItemFields]? = nil, + fields: [ItemFields]? = nil, startIndex: Int32? = nil, limit: Int32? = nil, enableImages: Bool? = nil, imageTypeLimit: Int32? = nil, - enableImageTypes: [Components.Schemas.ImageType]? = nil, + enableImageTypes: [ImageType]? = nil, enableUserData: Bool? = nil - ) async throws -> Components.Schemas.BaseItemDtoQueryResult { + ) async throws -> BaseItemDtoQueryResult { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetEpisodes.Input.Query(userId: userId) query.seasonId = seasonId @@ -436,9 +436,9 @@ public actor JellyfinClient { userId: String? = nil, startIndex: Int32? = nil, limit: Int32? = nil, - includeItemTypes: [Components.Schemas.BaseItemKind]? = nil, + includeItemTypes: [BaseItemKind]? = nil, parentId: String? = nil - ) async throws -> Components.Schemas.SearchHintResult { + ) async throws -> SearchHintResult { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetSearchHints.Input.Query(searchTerm: searchTerm) query.userId = userId @@ -470,7 +470,7 @@ public actor JellyfinClient { } public func markPlayedItem(itemId: String, userId: String, datePlayed: Date? = nil) async throws - -> Components.Schemas.UserItemDataDto + -> UserItemDataDto { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.markPlayedItem( @@ -502,7 +502,7 @@ public actor JellyfinClient { } public func markUnplayedItem(itemId: String, userId: String) async throws - -> Components.Schemas.UserItemDataDto + -> UserItemDataDto { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.markUnplayedItem( @@ -534,7 +534,7 @@ public actor JellyfinClient { } public func markFavoriteItem(itemId: String, userId: String) async throws - -> Components.Schemas.UserItemDataDto + -> UserItemDataDto { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.markFavoriteItem( @@ -564,7 +564,7 @@ public actor JellyfinClient { } public func unmarkFavoriteItem(itemId: String, userId: String) async throws - -> Components.Schemas.UserItemDataDto + -> UserItemDataDto { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.unmarkFavoriteItem( @@ -594,7 +594,7 @@ public actor JellyfinClient { } public func getPlaybackInfo(itemId: String, userId: String) async throws - -> Components.Schemas.PlaybackInfoResponse + -> PlaybackInfoResponse { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.getPlaybackInfo( @@ -625,7 +625,7 @@ public actor JellyfinClient { } } - public func reportPlaybackStart(info: Components.Schemas.PlaybackStartInfo) async throws { + public func reportPlaybackStart(info: PlaybackStartInfo) async throws { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.reportPlaybackStart( Operations.ReportPlaybackStart.Input( @@ -645,7 +645,7 @@ public actor JellyfinClient { } } - public func reportPlaybackProgress(info: Components.Schemas.PlaybackProgressInfo) async throws { + public func reportPlaybackProgress(info: PlaybackProgressInfo) async throws { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.reportPlaybackProgress( Operations.ReportPlaybackProgress.Input( @@ -665,7 +665,7 @@ public actor JellyfinClient { } } - public func reportPlaybackStopped(info: Components.Schemas.PlaybackStopInfo) async throws { + public func reportPlaybackStopped(info: PlaybackStopInfo) async throws { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.reportPlaybackStopped( Operations.ReportPlaybackStopped.Input( @@ -688,15 +688,15 @@ public actor JellyfinClient { public func getLatestMedia( userId: String, parentId: String? = nil, - fields: [Components.Schemas.ItemFields]? = nil, - includeItemTypes: [Components.Schemas.BaseItemKind]? = nil, + fields: [ItemFields]? = nil, + includeItemTypes: [BaseItemKind]? = nil, limit: Int32? = nil, enableImages: Bool? = nil, imageTypeLimit: Int32? = nil, - enableImageTypes: [Components.Schemas.ImageType]? = nil, + enableImageTypes: [ImageType]? = nil, enableUserData: Bool? = nil, groupItems: Bool? = nil - ) async throws -> [Components.Schemas.BaseItemDto] { + ) async throws -> [BaseItemDto] { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetLatestMedia.Input.Query(userId: userId) query.parentId = parentId @@ -731,8 +731,8 @@ public actor JellyfinClient { } } - public func imageURL( - itemId: String, imageType: Components.Schemas.ImageType, tag: String? = nil, + nonisolated public func imageURL( + itemId: String, imageType: ImageType, tag: String? = nil, maxWidth: Int32? = nil, quality: Int32? = 90 ) -> URL? { guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { @@ -747,7 +747,7 @@ public actor JellyfinClient { return components.url } - public func userImageURL(userId: String, tag: String? = nil) -> URL? { + nonisolated public func userImageURL(userId: String, tag: String? = nil) -> URL? { guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { return nil } diff --git a/Sources/LuminateCore/LuminateCore.swift b/Sources/LuminateCore/LuminateCore.swift index 2e0e4c4..d1f7e71 100644 --- a/Sources/LuminateCore/LuminateCore.swift +++ b/Sources/LuminateCore/LuminateCore.swift @@ -23,6 +23,24 @@ @_exported import OpenAPIRuntime @_exported import OpenAPIURLSession +public typealias AuthenticationResult = Components.Schemas.AuthenticationResult +public typealias BaseItemDto = Components.Schemas.BaseItemDto +public typealias BaseItemDtoQueryResult = Components.Schemas.BaseItemDtoQueryResult +public typealias BaseItemKind = Components.Schemas.BaseItemKind +public typealias BaseItemPerson = Components.Schemas.BaseItemPerson +public typealias ImageType = Components.Schemas.ImageType +public typealias ItemFields = Components.Schemas.ItemFields +public typealias ItemFilter = Components.Schemas.ItemFilter +public typealias ItemSortBy = Components.Schemas.ItemSortBy +public typealias PlaybackInfoResponse = Components.Schemas.PlaybackInfoResponse +public typealias PlaybackProgressInfo = Components.Schemas.PlaybackProgressInfo +public typealias PlaybackStartInfo = Components.Schemas.PlaybackStartInfo +public typealias PlaybackStopInfo = Components.Schemas.PlaybackStopInfo +public typealias SearchHint = Components.Schemas.SearchHint +public typealias SearchHintResult = Components.Schemas.SearchHintResult +public typealias SortOrder = Components.Schemas.SortOrder +public typealias UserItemDataDto = Components.Schemas.UserItemDataDto + public enum LuminateCore { public static let version = "0.1.0" } diff --git a/Sources/LuminateCore/Page.swift b/Sources/LuminateCore/Page.swift index 70e470c..c696422 100644 --- a/Sources/LuminateCore/Page.swift +++ b/Sources/LuminateCore/Page.swift @@ -20,15 +20,15 @@ // public enum Page: CustomStringConvertible { - case library(item: Components.Schemas.BaseItemDto) - case folder(title: String, items: [Components.Schemas.BaseItemDto]) + case item(item: BaseItemDto, type: DisplayType = .mixed) + case items(title: String, items: [BaseItemDto], type: DisplayType = .mixed) + case movieDetail(item: BaseItemDto) public var description: String { - switch self { - case .library(let item): - return item.name ?? "Library" - case .folder(let title, _): - return title + return switch self { + case .item(let item, _): item.name ?? "Library" + case .items(let title, _, _): title + case .movieDetail(let item): item.name ?? "Luminate" } } } diff --git a/Sources/LuminateCore/SQLiteStore.swift b/Sources/LuminateCore/SQLiteStore.swift index ae22a4d..901fbbf 100644 --- a/Sources/LuminateCore/SQLiteStore.swift +++ b/Sources/LuminateCore/SQLiteStore.swift @@ -34,10 +34,10 @@ public actor SQLiteStore: PersistenceService { db = try Connection(dbURL.path) db.busyTimeout = 5 try db.execute("PRAGMA journal_mode = WAL") - try migrate() + try Self.migrate(db) } - private func migrate() throws { + private static func migrate(_ db: Connection) throws { let version = db.userVersion switch version { case 0: diff --git a/Sources/LuminateCore/PageAnimationTracking.swift b/Sources/LuminateCore/ViewUpdateScheduling.swift similarity index 85% rename from Sources/LuminateCore/PageAnimationTracking.swift rename to Sources/LuminateCore/ViewUpdateScheduling.swift index 864f048..181030e 100644 --- a/Sources/LuminateCore/PageAnimationTracking.swift +++ b/Sources/LuminateCore/ViewUpdateScheduling.swift @@ -1,5 +1,5 @@ // -// PageAnimationTracking.swift +// ViewUpdateScheduling.swift // // Copyright 2026 Brendan Szymanski // @@ -21,7 +21,6 @@ import Foundation -public protocol PageAnimationTracking: AnyObject { - var isAnimating: Bool { get } - func markPush() +public protocol ViewUpdateScheduling: AnyObject { + func scheduleFlush() } diff --git a/Sources/LuminateDI/DIContainer.swift b/Sources/LuminateDI/DIContainer.swift index c67b6b5..7770649 100644 --- a/Sources/LuminateDI/DIContainer.swift +++ b/Sources/LuminateDI/DIContainer.swift @@ -21,13 +21,21 @@ import Adwaita import Foundation +import Synchronization public final class DIContainer: @unchecked Sendable { public static let shared = DIContainer() - public private(set) var values = InjectionValues() - private var observers: [AnyKeyPath: [UUID: @Sendable () -> Void]] = [:] - private let lock = NSLock() + + private struct State { + var values = InjectionValues() + var observers: [AnyKeyPath: [UUID: @Sendable () -> Void]] = [:] + } + private let state = Mutex(.init()) + + public var values: InjectionValues { + state.withLock { $0.values } + } private init() {} @@ -35,8 +43,8 @@ public final class DIContainer: @unchecked Sendable { _ keyPath: WritableKeyPath, value: T ) { - lock.withLock { - values[keyPath: keyPath] = value + state.withLock { + $0.values[keyPath: keyPath] = value } notifyObservers(for: keyPath) } @@ -44,8 +52,8 @@ public final class DIContainer: @unchecked Sendable { public func resolve( _ keyPath: KeyPath ) -> T { - lock.withLock { - guard let value = values[keyPath: keyPath] else { + state.withLock { + guard let value = $0.values[keyPath: keyPath] else { fatalError( "DIContainer: No value registered for \(keyPath). " + "Call DIContainer.shared.register(\\.key, value:) during app startup." @@ -61,31 +69,30 @@ public final class DIContainer: @unchecked Sendable { handler: @escaping @Sendable () -> Void ) -> UUID { let id = UUID() - lock.withLock { - observers[keyPath, default: [:]][id] = handler + state.withLock { + $0.observers[keyPath, default: [:]][id] = handler } return id } func removeObserver(_ id: UUID) { - lock.withLock { - for keyPath in observers.keys { - observers[keyPath]?.removeValue(forKey: id) + state.withLock { + for keyPath in $0.observers.keys { + $0.observers[keyPath]?.removeValue(forKey: id) } } } private func notifyObservers(for keyPath: AnyKeyPath) { - let handlers: [@Sendable () -> Void] = lock.withLock { - Array((observers[keyPath] ?? [:]).values) + let handlers: [@Sendable () -> Void] = state.withLock { + Array(($0.observers[keyPath] ?? [:]).values) } handlers.forEach { $0() } } public func reset() { - lock.withLock { - values = InjectionValues() - observers.removeAll() + state.withLock { + $0 = State() } } } diff --git a/Sources/LuminateDI/InjectionValues.swift b/Sources/LuminateDI/InjectionValues.swift index 7adf183..3dfbb4a 100644 --- a/Sources/LuminateDI/InjectionValues.swift +++ b/Sources/LuminateDI/InjectionValues.swift @@ -20,6 +20,7 @@ // import Foundation +import Logging import LuminateCore public struct InjectionValues { @@ -29,7 +30,8 @@ public struct InjectionValues { public var imageService: ImageService? public var webSocketClient: WebSocketClient? public var persistence: PersistenceService? - public var pageAnimationTracker: (any PageAnimationTracking)? + public var viewUpdateScheduler: (any ViewUpdateScheduling)? + public var logger: Logger? public init() {} } diff --git a/Sources/LuminatePlayer/PlayerView.swift b/Sources/LuminatePlayer/PlayerView.swift index 78351ff..5213fa1 100644 --- a/Sources/LuminatePlayer/PlayerView.swift +++ b/Sources/LuminatePlayer/PlayerView.swift @@ -25,7 +25,7 @@ import LuminateCore public struct PlayerView: View { - public var item: Components.Schemas.BaseItemDto + public var item: BaseItemDto public var client: JellyfinClient public var userId: String public var mediaSourceId: String @@ -38,7 +38,7 @@ public struct PlayerView: View { public var onClose: () -> Void public init( - item: Components.Schemas.BaseItemDto, + item: BaseItemDto, client: JellyfinClient, userId: String, mediaSourceId: String, diff --git a/Sources/LuminateUI/Components/AspectContainer.swift b/Sources/LuminateUI/Components/AspectContainer.swift new file mode 100644 index 0000000..9567357 --- /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 CGtkWidgets + +/// 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/EpisodeList.swift b/Sources/LuminateUI/Components/EpisodeList.swift index bcd74e0..e52232a 100644 --- a/Sources/LuminateUI/Components/EpisodeList.swift +++ b/Sources/LuminateUI/Components/EpisodeList.swift @@ -22,6 +22,7 @@ import Adwaita import Foundation import LuminateCore +import LuminateDI struct EpisodeList: View { @@ -29,7 +30,7 @@ struct EpisodeList: View { var seasonId: String var client: JellyfinClient var userId: String - @State private var episodes: [Components.Schemas.BaseItemDto] = [] + @State private var episodes: [BaseItemDto] = [] var view: Body { VStack { @@ -59,8 +60,10 @@ struct EpisodeList: View { struct EpisodeRow: View { - var episode: Components.Schemas.BaseItemDto + var episode: BaseItemDto var client: JellyfinClient + @Injected(\.imageService) var imageService + @Injected(\.viewUpdateScheduler) var viewUpdateScheduler @State private var imageData: Data? var view: Body { @@ -105,15 +108,16 @@ struct EpisodeRow: View { } Task { guard - let url = await client.imageURL( + let url = client.imageURL( itemId: itemId, imageType: .primary, tag: tag, maxWidth: 200 ) else { return } - let service = ImageService() - imageData = try? await service.loadImage(url: url) + let data = try? await imageService.loadImage(url: url) + _imageData.rawValue = data + viewUpdateScheduler.scheduleFlush() } } } diff --git a/Sources/LuminateUI/Components/FlowGrid.swift b/Sources/LuminateUI/Components/FlowGrid.swift new file mode 100644 index 0000000..8c336f0 --- /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 CGtkWidgets + +/// 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 5c50675..d19e5ac 100644 --- a/Sources/LuminateUI/Components/HomePosterCell.swift +++ b/Sources/LuminateUI/Components/HomePosterCell.swift @@ -26,78 +26,141 @@ import LuminateDI struct HomePosterCell: View { - var item: Components.Schemas.BaseItemDto - @Injected(\.client) var client - @Injected(\.pageAnimationTracker) var pageAnimationTracker + let itemId: String? + let title: String? + let subtitle: String? + let width: Int + let imageAspectRatio: Float + let onClick: (() -> Void)? + + @Injected(\.client) private var client + @Injected(\.imageService) private var imageService + @Injected(\.viewUpdateScheduler) private var viewUpdateScheduler + @State private var imageData: Data? + private struct Constants { + static let padding = 8 + } + + init( + itemId: String? = nil, + title: String? = nil, + subtitle: String? = nil, + width: Int = 200, + imageAspectRatio: Float = 1.5, + onClick: @escaping () -> Void + ) { + self.itemId = itemId + self.title = title + self.subtitle = subtitle + self.width = width + self.imageAspectRatio = imageAspectRatio + self.onClick = onClick + } + var view: Body { - VStack { - if let data = imageData { - Picture() - .contentFit(.cover) - .data(data) - .frame(minWidth: 200, minHeight: 300) - .frame(maxWidth: 200) - .frame(maxHeight: 300) - } else { - Box(spacing: 0) {} - .frame(minWidth: 200, minHeight: 300) - .frame(maxWidth: 200) - .frame(maxHeight: 300) - .card() - } - VStack(spacing: 0) { - if let title = item.name, !title.isEmpty { - Text(item.name ?? "") - .ellipsize() - .heading() - .halign(.center) - .frame(maxWidth: 200) - } - let subtitle = item.yearString - if !subtitle.isEmpty { - Text(subtitle) - .ellipsize() - .caption() - .dimLabel() - .halign(.center) - .frame(maxWidth: 200) + Bin { + VStack(spacing: showTextSection ? 6 : 0) { + imageSection + if showTextSection { + textSection } } - .padding(6, .vertical) - .padding(12, .horizontal) + .padding(padding) } + .onClick(handler: onClick ?? {}) .onAppear { Idle { loadImage() } } - .overflow(.hidden) + .frame(minWidth: width) + .halign(.fill) + .valign(.start) + .hexpand(false) + .style("activatable") .card() } - private func loadImage() { - guard let tag = item.primaryImageTag, - let itemId = item.seriesId ?? item.id - else { return } - Task { - guard - let url = await client.imageURL( - itemId: itemId, - imageType: .primary, - tag: tag, - maxWidth: 400 - ) - else { return } - let service = ImageService() - let data = try? await service.loadImage(url: url) - - if pageAnimationTracker.isAnimating { - _imageData.rawValue = data - } else { - imageData = data + @ViewBuilder + private var imageSection: Body { + AspectContainer(aspectRatio: imageAspectRatio) + .child { + image + .halign(.fill) + .hexpand() + .overflow(.hidden) + .card() } + .frame(minWidth: width - (padding * 2)) + } + + @ViewBuilder + private var image: Body { + if let imageData { + Picture() + .contentFit(.cover) + .data(imageData) + .valign(.fill) + .halign(.fill) + .vexpand() + .hexpand() + .transition(.crossfade) + } else { + Spinner() + .frame(minWidth: 64, minHeight: 64) + .transition(.crossfade) + } + } + + @ViewBuilder + private var textSection: Body { + Bin { + VStack(spacing: 2) { + if let title, !title.isEmpty { + Text(title) + .maxWidthChars(0) + .ellipsize() + .heading() + } + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .maxWidthChars(0) + .ellipsize() + .caption() + .dimLabel() + } + } + .valign(.center) + } + .frame(minHeight: 40) + .hexpand() + } + + private var showTextSection: Bool { + (title?.isEmpty == false) && (subtitle?.isEmpty == false) + } + + private var padding: Int { + showTextSection ? Constants.padding : 0 + } + + private func loadImage() { + guard let itemId else { return } + guard + let url = client.imageURL( + itemId: itemId, + imageType: .primary, + maxWidth: width.cInt * 2 + ) + else { return } + + Task { + let data = try? await imageService.loadImage(url: url) + + _imageData.rawValue = data + viewUpdateScheduler.scheduleFlush() } } } diff --git a/Sources/LuminateUI/Components/ItemGrid.swift b/Sources/LuminateUI/Components/ItemGrid.swift index 3aed5b6..a05d28d 100644 --- a/Sources/LuminateUI/Components/ItemGrid.swift +++ b/Sources/LuminateUI/Components/ItemGrid.swift @@ -20,78 +20,55 @@ // import Adwaita +import Foundation import LuminateCore public struct ItemGrid: View { - var client: JellyfinClient - var userId: String - var parentId: String? - var includeItemTypes: [Components.Schemas.BaseItemKind]? - var title: String? - @State private var items: [Components.Schemas.BaseItemDto] = [] - @State private var isLoading = false - private let pageSize: Int32 = 50 + public var items: [BaseItemDto] + public var type: DisplayType + public var title: String? + + @Binding public var navigation: NavigationStack public init( - client: JellyfinClient, - userId: String, - parentId: String? = nil, - includeItemTypes: [Components.Schemas.BaseItemKind]? = nil, + items: [BaseItemDto], + type: DisplayType = .mixed, + navigation: Binding>, title: String? = nil ) { - self.client = client - self.userId = userId - self.parentId = parentId - self.includeItemTypes = includeItemTypes + self.items = items + self.type = type + _navigation = navigation self.title = title } public var view: Body { - VStack { - if let title { - Text(title) - .title2() - .halign(.start) - .padding() - } - if isLoading { - Spinner() - } else { - ScrollView { - FlowBox(items) { item in - PosterCell(item: item, client: client) + VStack(spacing: 16) { + Text(title ?? "Libraries") + .title3() + .halign(.start) + .padding(10, .horizontal) + FlowGrid(items, id: \.id) { item in + HomePosterCell( + itemId: type.itemId(for: item), + title: type.title(for: item), + subtitle: type.subtitle(for: item), + width: type.itemWidth(), + imageAspectRatio: type.aspectRatio + ) { + if let childDisplayType = item.childDisplayType { + navigation.push(.item(item: item, type: childDisplayType)) + } + if item.type == .movie { + navigation.push(.movieDetail(item: item)) } } } - } - .onAppear { - loadItems() - } - } - - private func loadItems() { - isLoading = true - Task { - do { - let result = try await client.getItems( - userId: userId, - parentId: parentId, - includeItemTypes: includeItemTypes, - fields: [.overview, .genres, .people, .mediaSources], - sortBy: [.sortName], - sortOrder: [.ascending], - startIndex: 0, - limit: pageSize, - recursive: true - ) - await MainActor.run { - items = result.items ?? [] - isLoading = false - } - } catch { - await MainActor.run { isLoading = false } - } + .columnSpacing(16) + .rowSpacing(16) + .minimumSize(type.itemWidth()) + .halign(.fill) } } } diff --git a/Sources/LuminateUI/Components/LibraryGrid.swift b/Sources/LuminateUI/Components/LibraryGrid.swift deleted file mode 100644 index af42d9f..0000000 --- a/Sources/LuminateUI/Components/LibraryGrid.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// LibraryGrid.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 Foundation -import LuminateCore - -public struct LibraryGrid: View { - - public var libraries: [Components.Schemas.BaseItemDto] - @Binding public var navigation: NavigationStack - public var title: String? - - public init( - libraries: [Components.Schemas.BaseItemDto], - navigation: Binding>, - title: String? = nil - ) { - self.libraries = libraries - _navigation = navigation - self.title = title - } - - public var view: Body { - VStack(spacing: 16) { - Text(title ?? "Libraries") - .title3() - .halign(.start) - .padding(10, .horizontal) - FlowBox(libraries) { library in - HomePosterCell(item: library) - } - .columnSpacing(16) - .rowSpacing(16) - } - } -} diff --git a/Sources/LuminateUI/Components/MediaRow.swift b/Sources/LuminateUI/Components/MediaRow.swift index 9025003..e6b319d 100644 --- a/Sources/LuminateUI/Components/MediaRow.swift +++ b/Sources/LuminateUI/Components/MediaRow.swift @@ -22,22 +22,29 @@ import Adwaita import Foundation import LuminateCore +import LuminateDI public struct MediaRow: View { public var title: String - public var items: [Components.Schemas.BaseItemDto] - @Binding public var navigation: NavigationStack + public var items: [BaseItemDto] + public var type: DisplayType public var onSeeAll: (() -> Void)? + @Binding public var navigation: NavigationStack + + @Injected(\.client) private var client + public init( title: String, - items: [Components.Schemas.BaseItemDto], + items: [BaseItemDto], + type: DisplayType = .mixed, navigation: Binding>, - onSeeAll: (() -> Void)? = nil + onSeeAll: @escaping () -> Void ) { self.title = title self.items = items + self.type = type _navigation = navigation self.onSeeAll = onSeeAll } @@ -47,26 +54,41 @@ public struct MediaRow: View { HStack { Text(title) .title3() - - /// Poor man's `Spacer()` - Bin() - .hexpand() + .halign(.start) if let onSeeAll { Button("See All") { onSeeAll() } + .halign(.end) + .hexpand() } } + .halign(.fill) ScrollView { ForEach(items, horizontal: true) { item in - HomePosterCell(item: item) - .padding(16, .trailing) + HomePosterCell( + itemId: type.itemId(for: item), + title: type.title(for: item), + subtitle: type.subtitle(for: item), + width: type.itemWidth(), + imageAspectRatio: type.aspectRatio + ) { + if let childDisplayType = item.childDisplayType { + navigation.push(.item(item: item, type: childDisplayType)) + } + if item.type == .movie { + navigation.push(.movieDetail(item: item)) + } + } + .padding(16, .trailing) } } .vscrollbarPolicy(.never) .hscrollbarPolicy(.external) + .style("undershoot-start") + .style("undershoot-end") } } } diff --git a/Sources/LuminateUI/Components/MovieDetailView.swift b/Sources/LuminateUI/Components/MovieDetailView.swift index 8ec79ea..f86a5ad 100644 --- a/Sources/LuminateUI/Components/MovieDetailView.swift +++ b/Sources/LuminateUI/Components/MovieDetailView.swift @@ -22,26 +22,26 @@ import Adwaita import Foundation import LuminateCore +import LuminateDI -struct MovieDetailView: View { +public struct MovieDetailView: View { - var item: Components.Schemas.BaseItemDto - var client: JellyfinClient - var userId: String + var item: BaseItemDto + @Injected(\.client) private var client + @Injected(\.userId) private var userId + @Injected(\.imageService) private var imageService @State private var isFavorite: Bool @State private var isPlayed: Bool - @State private var similarItems: [Components.Schemas.BaseItemDto] = [] + @State private var similarItems: [BaseItemDto] = [] @State private var backdropData: Data? - init(item: Components.Schemas.BaseItemDto, client: JellyfinClient, userId: String) { + public init(for item: BaseItemDto) { self.item = item - self.client = client - self.userId = userId _isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false) _isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false) } - var view: Body { + public var view: Body { ScrollView { VStack { if let data = backdropData { @@ -52,7 +52,7 @@ struct MovieDetailView: View { .hexpand(true) } HStack { - PosterCell(item: item, client: client) + PosterCell(item: item) .frame(minWidth: 200) .frame(maxWidth: 200) VStack { @@ -111,7 +111,7 @@ struct MovieDetailView: View { ScrollView { HStack { ForEach(similarItems) { sim in - PosterCell(item: sim, client: client) + PosterCell(item: sim) } } } @@ -131,12 +131,11 @@ struct MovieDetailView: View { guard let tag = item.backdropImageTag, let itemId = item.id else { return } Task { guard - let url = await client.imageURL( + let url = client.imageURL( itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920 ) else { return } - let service = ImageService() - backdropData = try? await service.loadImage(url: url) + backdropData = try? await imageService.loadImage(url: url) } } @@ -150,29 +149,29 @@ struct MovieDetailView: View { limit: 10, recursive: true ) - await MainActor.run { similarItems = result?.items ?? [] } + similarItems = result?.items ?? [] } } private func toggleFavorite() { Task { if isFavorite { - try? await client.unmarkFavoriteItem(itemId: item.id ?? "", userId: userId) + _ = try? await client.unmarkFavoriteItem(itemId: item.id ?? "", userId: userId) } else { - try? await client.markFavoriteItem(itemId: item.id ?? "", userId: userId) + _ = try? await client.markFavoriteItem(itemId: item.id ?? "", userId: userId) } - await MainActor.run { isFavorite.toggle() } + isFavorite.toggle() } } private func togglePlayed() { Task { if isPlayed { - try? await client.markUnplayedItem(itemId: item.id ?? "", userId: userId) + _ = try? await client.markUnplayedItem(itemId: item.id ?? "", userId: userId) } else { - try? await client.markPlayedItem(itemId: item.id ?? "", userId: userId) + _ = try? await client.markPlayedItem(itemId: item.id ?? "", userId: userId) } - await MainActor.run { isPlayed.toggle() } + isPlayed.toggle() } } } diff --git a/Sources/LuminateUI/Components/PersonCell.swift b/Sources/LuminateUI/Components/PersonCell.swift index 4270dfb..d92c7ae 100644 --- a/Sources/LuminateUI/Components/PersonCell.swift +++ b/Sources/LuminateUI/Components/PersonCell.swift @@ -23,7 +23,7 @@ import Adwaita import LuminateCore struct PersonCell: View { - var person: Components.Schemas.BaseItemPerson + var person: BaseItemPerson var view: Body { VStack { Avatar(showInitials: false, size: 60) diff --git a/Sources/LuminateUI/Components/PosterCell.swift b/Sources/LuminateUI/Components/PosterCell.swift index df25294..bb5c331 100644 --- a/Sources/LuminateUI/Components/PosterCell.swift +++ b/Sources/LuminateUI/Components/PosterCell.swift @@ -26,9 +26,10 @@ import LuminateDI struct PosterCell: View { - var item: Components.Schemas.BaseItemDto - var client: JellyfinClient - @Injected(\.pageAnimationTracker) var pageAnimationTracker + var item: BaseItemDto + @Injected(\.client) private var client: JellyfinClient + @Injected(\.imageService) private var imageService + @Injected(\.viewUpdateScheduler) private var viewUpdateScheduler @State private var imageData: Data? var view: Body { @@ -68,14 +69,10 @@ struct PosterCell: View { maxWidth: 300 ) guard let url else { return } - let service = ImageService() - let data = try? await service.loadImage(url: url) + let data = try? await imageService.loadImage(url: url) - if pageAnimationTracker.isAnimating { - _imageData.rawValue = data - } else { - imageData = data - } + _imageData.rawValue = data + viewUpdateScheduler.scheduleFlush() } } } diff --git a/Sources/LuminateUI/Components/SearchView.swift b/Sources/LuminateUI/Components/SearchView.swift index 039a420..b06e1e6 100644 --- a/Sources/LuminateUI/Components/SearchView.swift +++ b/Sources/LuminateUI/Components/SearchView.swift @@ -29,7 +29,7 @@ struct SearchView: View { var client: JellyfinClient var userId: String @State private var searchText = "" - @State private var results: [Components.Schemas.SearchHint] = [] + @State private var results: [SearchHint] = [] @State private var isSearching = false var view: Body { @@ -73,15 +73,16 @@ struct SearchView: View { } } -extension Components.Schemas.SearchHint: Identifiable { +extension SearchHint: Identifiable { public var id: String { id ?? itemId ?? String(describing: self) } } struct SearchResultRow: View { - var hint: Components.Schemas.SearchHint + var hint: SearchHint var client: JellyfinClient - @Injected(\.pageAnimationTracker) var pageAnimationTracker + @Injected(\.viewUpdateScheduler) var viewUpdateScheduler + @Injected(\.imageService) var imageService @State private var imageData: Data? var view: Body { @@ -138,14 +139,10 @@ struct SearchResultRow: View { itemId: itemId, imageType: .primary, tag: tag, maxWidth: 160 ) else { return } - let service = ImageService() - let data = try? await service.loadImage(url: url) + let data = try? await imageService.loadImage(url: url) - if pageAnimationTracker.isAnimating { - _imageData.rawValue = data - } else { - imageData = data - } + _imageData.rawValue = data + viewUpdateScheduler.scheduleFlush() } } } diff --git a/Sources/LuminateUI/Components/TVShowView.swift b/Sources/LuminateUI/Components/TVShowView.swift index 68b7284..6b37a5b 100644 --- a/Sources/LuminateUI/Components/TVShowView.swift +++ b/Sources/LuminateUI/Components/TVShowView.swift @@ -22,13 +22,15 @@ import Adwaita import Foundation import LuminateCore +import LuminateDI struct TVShowView: View { - var item: Components.Schemas.BaseItemDto + var item: BaseItemDto var client: JellyfinClient var userId: String - @State private var seasons: [Components.Schemas.BaseItemDto] = [] + @Injected(\.imageService) var imageService + @State private var seasons: [BaseItemDto] = [] @State private var selectedSeasonId: String? @State private var backdropData: Data? @@ -43,7 +45,7 @@ struct TVShowView: View { .hexpand(true) } HStack { - PosterCell(item: item, client: client) + PosterCell(item: item) .frame(minWidth: 200) .frame(maxWidth: 200) VStack { @@ -106,8 +108,7 @@ struct TVShowView: View { itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920 ) else { return } - let service = ImageService() - backdropData = try? await service.loadImage(url: url) + backdropData = try? await imageService.loadImage(url: url) } } diff --git a/Sources/LuminateUI/Components/WrapBox.swift b/Sources/LuminateUI/Components/WrapBox.swift new file mode 100644 index 0000000..fffca03 --- /dev/null +++ b/Sources/LuminateUI/Components/WrapBox.swift @@ -0,0 +1,430 @@ +// +// WrapBox.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 CAdw + +// MARK: - Swift Enum Wrappers for C Enums + +/// Controls how children are justified within each line. +public enum JustifyMode { + + /// No justification. + case none + /// Children are stretched to fill the line. + case fill + /// Extra space is distributed evenly between children. + case spread + + var cValue: AdwJustifyMode { + switch self { + case .none: ADW_JUSTIFY_NONE + case .fill: ADW_JUSTIFY_FILL + case .spread: ADW_JUSTIFY_SPREAD + } + } + +} + +/// Controls the packing direction. +public enum PackDirection { + + /// Children are packed from start to end. + case startToEnd + /// Children are packed from end to start. + case endToStart + + var cValue: AdwPackDirection { + switch self { + case .startToEnd: ADW_PACK_START_TO_END + case .endToStart: ADW_PACK_END_TO_START + } + } + +} + +/// Controls the wrapping policy. +public enum WrapPolicy { + + /// Wrapping occurs at the minimum size. + case minimum + /// Wrapping occurs at the natural size. + case natural + + var cValue: AdwWrapPolicy { + switch self { + case .minimum: ADW_WRAP_MINIMUM + case .natural: ADW_WRAP_NATURAL + } + } + +} + +/// Units for spacing and length properties. +public enum LengthUnit { + + /// Pixels. + case px + /// Points. + case pt + /// Scale-independent pixels. + case sp + + var cValue: AdwLengthUnit { + switch self { + case .px: ADW_LENGTH_UNIT_PX + case .pt: ADW_LENGTH_UNIT_PT + case .sp: ADW_LENGTH_UNIT_SP + } + } + +} + +// MARK: - WrapBox Widget + +/// A responsive wrapping container that arranges children in a reflowing grid. +/// +/// `WrapBox` places its children in a horizontal flow, wrapping to the next +/// line when the available width is exhausted. It is backed by `AdwWrapBox` from libadwaita. +/// +/// Use inside a `ScrollView` to allow shrinking in both axes. +/// +/// ```swift +/// ScrollView { +/// WrapBox(items) { item in +/// ItemCell(item: item) +/// } +/// .childSpacing(12) +/// .lineSpacing(12) +/// .lineHomogeneous(true) +/// } +/// ``` +public struct WrapBox: AdwaitaWidget where Identifier: Hashable { + + #if exposeGeneratedAppearUpdateFunctions + /// Additional update functions for type extensions. + public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + /// Additional appear functions for type extensions. + public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #else + /// Additional update functions for type extensions. + var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + /// Additional appear functions for type extensions. + var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #endif + + /// The amount of space between children. + var childSpacing: Int? + /// The unit for `childSpacing`. + var childSpacingUnit: LengthUnit? + /// The packing direction. + var packDirection: PackDirection? + /// The alignment of children within each line (0.0 to 1.0). + var align: Float? + /// The justification mode. + var justify: JustifyMode? + /// Whether to justify the last line. + var justifyLastLine: Bool? + /// The amount of space between lines. + var lineSpacing: Int? + /// The unit for `lineSpacing`. + var lineSpacingUnit: LengthUnit? + /// Whether all lines should be the same size. + var lineHomogeneous: Bool? + /// The natural length of each line. + var naturalLineLength: Int? + /// The unit for `naturalLineLength`. + var naturalLineLengthUnit: LengthUnit? + /// Whether to reverse the wrapping direction. + var wrapReverse: Bool? + /// The wrapping policy. + var wrapPolicy: WrapPolicy? + + /// The dynamic widget elements. + var elements: [Element] + /// The dynamic widget content. + var content: (Element) -> Body + /// The dynamic widget identifier key path. + var id: KeyPath + + /// Initialize `WrapBox`. + /// - Parameters: + /// - elements: The elements to display. + /// - id: The key path to the element's identifier. + /// - content: A view builder for rendering each element. + public init( + _ elements: [Element], + id: KeyPath, + @ViewBuilder content: @escaping (Element) -> Body + ) { + self.elements = elements + self.content = content + self.id = id + } + + // MARK: - AdwaitaWidget + + public func container(data: WidgetData, type: Data.Type) -> ViewStorage + where Data: ViewRenderData { + let storage = ViewStorage(adw_wrap_box_new()?.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 + + // --- Apply property changes --- + if let childSpacing, updateProperties, + (storage.previousState as? Self)?.childSpacing != childSpacing + { + adw_wrap_box_set_child_spacing(widget, childSpacing.cInt) + } + if let childSpacingUnit, updateProperties, + (storage.previousState as? Self)?.childSpacingUnit != childSpacingUnit + { + adw_wrap_box_set_child_spacing_unit(widget, childSpacingUnit.cValue) + } + if let packDirection, updateProperties, + (storage.previousState as? Self)?.packDirection != packDirection + { + adw_wrap_box_set_pack_direction(widget, packDirection.cValue) + } + if let align, updateProperties, + (storage.previousState as? Self)?.align != align + { + adw_wrap_box_set_align(widget, align) + } + if let justify, updateProperties, + (storage.previousState as? Self)?.justify != justify + { + adw_wrap_box_set_justify(widget, justify.cValue) + } + if let justifyLastLine, updateProperties, + (storage.previousState as? Self)?.justifyLastLine != justifyLastLine + { + adw_wrap_box_set_justify_last_line(widget, justifyLastLine.cBool) + } + if let lineSpacing, updateProperties, + (storage.previousState as? Self)?.lineSpacing != lineSpacing + { + adw_wrap_box_set_line_spacing(widget, lineSpacing.cInt) + } + if let lineSpacingUnit, updateProperties, + (storage.previousState as? Self)?.lineSpacingUnit != lineSpacingUnit + { + adw_wrap_box_set_line_spacing_unit(widget, lineSpacingUnit.cValue) + } + if let lineHomogeneous, updateProperties, + (storage.previousState as? Self)?.lineHomogeneous != lineHomogeneous + { + adw_wrap_box_set_line_homogeneous(widget, lineHomogeneous.cBool) + } + if let naturalLineLength, updateProperties, + (storage.previousState as? Self)?.naturalLineLength != naturalLineLength + { + adw_wrap_box_set_natural_line_length(widget, naturalLineLength.cInt) + } + if let naturalLineLengthUnit, updateProperties, + (storage.previousState as? Self)?.naturalLineLengthUnit != naturalLineLengthUnit + { + adw_wrap_box_set_natural_line_length_unit(widget, naturalLineLengthUnit.cValue) + } + if let wrapReverse, updateProperties, + (storage.previousState as? Self)?.wrapReverse != wrapReverse + { + adw_wrap_box_set_wrap_reverse(widget, wrapReverse.cBool) + } + if let wrapPolicy, updateProperties, + (storage.previousState as? Self)?.wrapPolicy != wrapPolicy + { + adw_wrap_box_set_wrap_policy(widget, wrapPolicy.cValue) + } + + // --- Child management --- + 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) + if let lastPtr = lastChild { + adw_wrap_box_reorder_child_after( + widget, + existingStorage.opaquePointer?.cast(), + lastPtr.cast() + ) + } else { + adw_wrap_box_reorder_child_after( + widget, + existingStorage.opaquePointer?.cast(), + nil + ) + } + lastChild = existingStorage.opaquePointer + } else { + let child = content(element).storage(data: data, type: type) + if let lastPtr = lastChild { + adw_wrap_box_insert_child_after( + widget, + child.opaquePointer?.cast(), + lastPtr.cast() + ) + } else { + adw_wrap_box_prepend(widget, child.opaquePointer?.cast()) + } + newContentStorage.append(child) + lastChild = child.opaquePointer + } + } + + for (_, staleStorage) in oldByID { + adw_wrap_box_remove(widget, 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: - Modifier Methods + +extension WrapBox { + + /// The amount of space between children. + public func childSpacing(_ childSpacing: Int?) -> Self { + modify { $0.childSpacing = childSpacing } + } + + /// The unit for `childSpacing`. + public func childSpacingUnit(_ childSpacingUnit: LengthUnit?) -> Self { + modify { $0.childSpacingUnit = childSpacingUnit } + } + + /// The packing direction. + public func packDirection(_ packDirection: PackDirection?) -> Self { + modify { $0.packDirection = packDirection } + } + + /// The alignment of children within each line (0.0 to 1.0). + public func align(_ align: Float?) -> Self { + modify { $0.align = align } + } + + /// The justification mode. + public func justify(_ justify: JustifyMode?) -> Self { + modify { $0.justify = justify } + } + + /// Whether to justify the last line. + public func justifyLastLine(_ justifyLastLine: Bool? = true) -> Self { + modify { $0.justifyLastLine = justifyLastLine } + } + + /// The amount of space between lines. + public func lineSpacing(_ lineSpacing: Int?) -> Self { + modify { $0.lineSpacing = lineSpacing } + } + + /// The unit for `lineSpacing`. + public func lineSpacingUnit(_ lineSpacingUnit: LengthUnit?) -> Self { + modify { $0.lineSpacingUnit = lineSpacingUnit } + } + + /// Whether all lines should be the same size. + public func lineHomogeneous(_ lineHomogeneous: Bool? = true) -> Self { + modify { $0.lineHomogeneous = lineHomogeneous } + } + + /// The natural length of each line. + public func naturalLineLength(_ naturalLineLength: Int?) -> Self { + modify { $0.naturalLineLength = naturalLineLength } + } + + /// The unit for `naturalLineLength`. + public func naturalLineLengthUnit(_ naturalLineLengthUnit: LengthUnit?) -> Self { + modify { $0.naturalLineLengthUnit = naturalLineLengthUnit } + } + + /// Whether to reverse the wrapping direction. + public func wrapReverse(_ wrapReverse: Bool? = true) -> Self { + modify { $0.wrapReverse = wrapReverse } + } + + /// The wrapping policy. + public func wrapPolicy(_ wrapPolicy: WrapPolicy?) -> Self { + modify { $0.wrapPolicy = wrapPolicy } + } + +} + +// MARK: - Convenience Initializer for Identifiable Elements + +extension WrapBox where Element: Identifiable, Identifier == Element.ID { + + /// Initialize `WrapBox` with identifiable elements. + /// - Parameters: + /// - elements: The identifiable elements to display. + /// - content: A view builder for rendering each element. + public init( + _ elements: [Element], + @ViewBuilder content: @escaping (Element) -> Body + ) { + self.elements = elements + self.content = content + self.id = \.id + } + +} diff --git a/Sources/LuminateUI/Utilities/Bin+Extensions.swift b/Sources/LuminateUI/Utilities/Bin+Extensions.swift new file mode 100644 index 0000000..46c517f --- /dev/null +++ b/Sources/LuminateUI/Utilities/Bin+Extensions.swift @@ -0,0 +1,29 @@ +// +// AnyView+Overflow.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 + +extension Bin { + public init(@ViewBuilder content: @escaping () -> Body) { + self.init() + self = self.child(content) + } +} diff --git a/Sources/LuminateUI/Utilities/Button+Content.swift b/Sources/LuminateUI/Utilities/Button+Content.swift new file mode 100644 index 0000000..1432b18 --- /dev/null +++ b/Sources/LuminateUI/Utilities/Button+Content.swift @@ -0,0 +1,32 @@ +// +// HomePosterCell.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 + +extension Button { + + public init(@ViewBuilder child: @escaping () -> Body, handler: @escaping () -> Void) { + self.init("", handler: handler) + self = self.label(nil) + .child(child) + .hasFrame(false) + } +} diff --git a/Sources/LuminateUI/Utilities/PageAnimationTracker.swift b/Sources/LuminateUI/Utilities/ViewUpdateScheduler.swift similarity index 66% rename from Sources/LuminateUI/Utilities/PageAnimationTracker.swift rename to Sources/LuminateUI/Utilities/ViewUpdateScheduler.swift index 805c6e1..aa544e3 100644 --- a/Sources/LuminateUI/Utilities/PageAnimationTracker.swift +++ b/Sources/LuminateUI/Utilities/ViewUpdateScheduler.swift @@ -1,5 +1,5 @@ // -// PageAnimationTracker.swift +// ViewUpdateScheduler.swift // // Copyright 2026 Brendan Szymanski // @@ -22,22 +22,23 @@ import Adwaita import Foundation import LuminateCore +import Synchronization -public class PageAnimationTracker: PageAnimationTracking { - public var isAnimating = false - private var pushGeneration = 0 +public class ViewUpdateScheduler: ViewUpdateScheduling { + private let isPending = Mutex(false) public init() {} - public func markPush() { - isAnimating = true - pushGeneration += 1 - let captured = pushGeneration - Idle(delay: 250) { [weak self] in - guard let self, self.pushGeneration == captured else { return false } - self.isAnimating = false + public func scheduleFlush() { + let shouldSchedule = isPending.withLock { + if $0 { return false } + $0 = true + return true + } + guard shouldSchedule else { return } + Idle { [weak self] in StateManager.updateViews() - return false + self?.isPending.withLock { $0 = false } } } } diff --git a/Tests/LuminateTests/WidgetTests.swift b/Tests/LuminateTests/WidgetTests.swift new file mode 100644 index 0000000..71ffda3 --- /dev/null +++ b/Tests/LuminateTests/WidgetTests.swift @@ -0,0 +1,187 @@ +import Adwaita +import CAdw +import CGtkWidgets +import Testing + +// MARK: - Helpers + +/// Initialize GTK once per test suite. +func ensureGTKInit() { + struct Once { + static let initialized: Bool = { + gtk_init() + return true + }() + } + _ = Once.initialized +} + +/// Create a simple test widget that reports a fixed size. +func makeFixedChild(width: Int, height: Int) -> UnsafeMutablePointer? { + let box = gtk_box_new(.GTK_ORIENTATION_VERTICAL, 0) + gtk_widget_set_size_request(box, Int32(width), Int32(height)) + gtk_widget_realize(box) + return box +} + +/// Measure a widget and return (minimum, natural). +func measureWidget( + _ widget: UnsafeMutablePointer?, + orientation: GtkOrientation, + forSize: Int = -1 +) -> (minimum: Int, natural: Int) { + var min: Int32 = 0 + var nat: Int32 = 0 + gtk_widget_measure(widget, orientation, Int32(forSize), &min, &nat, nil, nil) + return (Int(min), Int(nat)) +} + +// MARK: - AspectContainer Tests + +@Suite("Widget Tests", .serialized) +struct WidgetTests { + + init() { + ensureGTKInit() + } + + @Test("Reports height = width × ratio when for_size is known") + func measureWithKnownWidth() { + let container = aspect_container_new(2.0)! + let child = makeFixedChild(width: 100, height: 50) + gtk_widget_set_parent(child, container) + + let (minH, natH) = measureWidget( + container, orientation: .GTK_ORIENTATION_VERTICAL, forSize: 100) + #expect(minH == 200, "height = 100 × 2.0 = 200") + #expect(natH == 200, "natural height = 200") + } + + @Test("Reports height = child_min_w × ratio when for_size is unknown") + func measureFallback() { + let container = aspect_container_new(1.5)! + let child = makeFixedChild(width: 100, height: 100) + gtk_widget_set_parent(child, container) + gtk_widget_set_size_request(container, 100, -1) + + let (minH, natH) = measureWidget( + container, orientation: .GTK_ORIENTATION_VERTICAL, forSize: -1) + #expect(minH == 150, "height = 100 × 1.5 = 150 (minimum)") + #expect(natH == 150, "natural = 150") + } + + @Test("max_width caps reported width") + func maxWidthCapsWidth() { + let container = aspect_container_new(1.5)! + let child = makeFixedChild(width: 300, height: 300) + gtk_widget_set_parent(child, container) + aspect_container_set_max_width(container.opaque(), 200) + + let (minW, natW) = measureWidget( + container, orientation: .GTK_ORIENTATION_HORIZONTAL, forSize: -1) + #expect(minW == 200, "capped at 200") + #expect(natW == 200, "capped at 200") + } + + @Test("max_width affects height calculation") + func maxWidthAffectsHeight() { + let container = aspect_container_new(2.0)! + let child = makeFixedChild(width: 300, height: 150) + gtk_widget_set_parent(child, container) + aspect_container_set_max_width(container.opaque(), 100) + + let (minH, natH) = measureWidget( + container, orientation: .GTK_ORIENTATION_VERTICAL, forSize: -1) + #expect(minH == 200, "height = max_width × ratio = 200") + #expect(natH == 200, "natural = 200") + } + + @Test("Changing aspect ratio triggers resize") + func changeAspectRatio() { + let container = aspect_container_new(1.0)! + let child = makeFixedChild(width: 100, height: 100) + gtk_widget_set_parent(child, container) + aspect_container_set_aspect_ratio(container.opaque(), 0.5) + + let (minH, natH) = measureWidget( + container, orientation: .GTK_ORIENTATION_VERTICAL, forSize: 100) + #expect(minH == 50, "height = 100 × 0.5 = 50") + #expect(natH == 50, "natural = 50") + } + + @Test("Horizontal measure returns 0 without max_width") + func horizontalMeasureNoMaxWidth() { + let container = aspect_container_new(1.5)! + let child = makeFixedChild(width: 300, height: 200) + gtk_widget_set_parent(child, container) + + let (minW, natW) = measureWidget( + container, orientation: .GTK_ORIENTATION_HORIZONTAL, forSize: -1) + #expect(minW == 0, "no max_width → reports 0") + #expect(natW == 0, "no max_width → reports 0") + } + + // MARK: - AspectContainer Tests + + /// Helper: create a FlowGrid with `n` children, each having a given size. + func makeFlowGrid( + minimumSize: Int = 100, + columnSpacing: Int = 0, + rowSpacing: Int = 0, + childSize: Int = 100, + count: Int = 4 + ) -> UnsafeMutablePointer? { + let grid = flow_grid_new(Int32(minimumSize), Int32(columnSpacing), Int32(rowSpacing)) + for _ in 0..