From beeb0c07248cc90b62efe12c7fdf7511b28ec4f2 Mon Sep 17 00:00:00 2001 From: Lephenixnoir Date: Thu, 1 Dec 2022 10:37:34 +0100 Subject: [PATCH] jlist, jscrolledlist: add versatile paintable list widgets --- CMakeLists.txt | 2 + include/justui/defs.h | 6 + include/justui/jframe.h | 32 +++- include/justui/jlist.h | 102 +++++++++++++ include/justui/jscrolledlist.h | 33 ++++ src/jframe.c | 105 ++++++++++--- src/jlist.c | 272 +++++++++++++++++++++++++++++++++ src/jscrolledlist.c | 68 +++++++++ 8 files changed, 597 insertions(+), 23 deletions(-) create mode 100644 include/justui/jlist.h create mode 100644 include/justui/jscrolledlist.h create mode 100644 src/jlist.c create mode 100644 src/jscrolledlist.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 72f0515..69157c8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,8 @@ add_library(${NAME} STATIC src/jfkeys.c src/jfileselect.c src/jframe.c + src/jlist.c + src/jscrolledlist.c src/vec.c src/keymap.c ${ASSETS_${FXSDK_PLATFORM}} diff --git a/include/justui/defs.h b/include/justui/defs.h index 645acc2..3851d68 100644 --- a/include/justui/defs.h +++ b/include/justui/defs.h @@ -21,6 +21,12 @@ typedef struct { uint8_t left; } jdirs; +/* jrect: Small rectangle */ +typedef struct { + int16_t x, y; + int16_t w, h; +} jrect; + /* jalign: Alignment options with both horizontal and vertical names */ typedef enum { /* Horizontal names */ diff --git a/include/justui/jframe.h b/include/justui/jframe.h index e8d0aa7..323e1fb 100644 --- a/include/justui/jframe.h +++ b/include/justui/jframe.h @@ -39,6 +39,9 @@ typedef struct { /* If floating_scrollbars == false, spacing between scrollbars and child */ uint8_t scrollbar_spacing; + /* Visibility margin (see jframe_scroll_to_region()) */ + uint8_t visibility_margin_x, visibility_margin_y; + /* Whether scrollbars are shown */ bool scrollbar_x, scrollbar_y; /* Current scroll offsets */ @@ -56,10 +59,29 @@ typedef struct { jframe *jframe_create(void *parent); /* Trivial properties */ -void jframe_set_align(jframe *j, jalign halign, jalign valign); -void jframe_set_scrollbars_always_visible(jframe *j, bool always_visible); -void jframe_set_floating_scrollbars(jframe *j, bool floating_scrollbars); -void jframe_set_keyboard_control(jframe *j, bool keyboard_control); -void jframe_set_match_size(jframe *j, bool match_width, bool match_height); +void jframe_set_align(jframe *f, jalign halign, jalign valign); +void jframe_set_scrollbars_always_visible(jframe *f, bool always_visible); +void jframe_set_floating_scrollbars(jframe *f, bool floating_scrollbars); +void jframe_set_keyboard_control(jframe *f, bool keyboard_control); +void jframe_set_match_size(jframe *f, bool match_width, bool match_height); +void jframe_set_visibility_margin(jframe *f, int margin_x, int margin_y); + +/* jframe_scroll_to_region(): Scroll a region of the child into view + + This functions scrolls the frame to ensure that the specified region of the + child widget is visible within the frame (minus the visibility margin). + + The purpose of the visibility margin is to avoid aligning important regions + of the child widget along the edges of the frame unless we reach the edge of + the child widget. For example, with a scrolling list, we want the selected + item to be somewhat off the edge of the frame so that items around it are + visible. Showing the selected item right on the edge of the frame suggests + to the user that there are no items beyond it. + + If either dimension of the provided region is larger than the content size + of the frame minus the visibility margin, the center of the region will be + shown at the center of the view along that direciton. Otherwise, the view + will scroll the minimum amount possible to bring the region into view. */ +void jframe_scroll_to_region(jframe *f, jrect region); #endif /* _J_JFRAME */ diff --git a/include/justui/jlist.h b/include/justui/jlist.h new file mode 100644 index 0000000..ca39216 --- /dev/null +++ b/include/justui/jlist.h @@ -0,0 +1,102 @@ +//--- +// JustUI.jlist: List widget with arbitrary, selectable children +//--- + +#ifndef _J_JLIST +#define _J_JLIST + +#include +#include + +typedef enum { + /* Selected item is indicated by inverting its rendered area */ + JLIST_SELECTION_INVERT = 0, + /* Selected item is indicated by applying a background color */ + JLIST_SELECTION_BACKGROUND = 1, + +} jlist_selection_style; + +typedef struct { + /* Delegate widget */ + jwidget *delegate; + /* Whether item can be selected */ + bool selectable; + /* Whether item can be triggered */ + bool triggerable; + + /* The following fields are only applicable if there is no delegate. */ + + /* Item's natural with and height, in pixels */ + int16_t natural_width, natural_height; + +} jlist_item_info; + +struct jlist; + +typedef void (*jlist_item_info_function)(struct jlist *list, int index, + jlist_item_info *info); + +typedef void (*jlist_item_paint_function)(int x, int y, int w, int h, + struct jlist *list, int index, bool selected); + +/* jlist: List widget with arbitrary, selectable children + + This widget is used to make lists of selectable elements. The elements are + backed by a model which is essentially associating an index in the list to + some piece of user data. + + Elements can either be manually-rendered like jpainted, or be delegated to + full widgets (eg. for editing in a list). + + In terms of layout, jlist is a raw vertical list of all of its items, with + no spacing. Generally it is desirable to put it in a jframe to make it + scroll; otherwise, it has rather unpredictable dimensions. */ +typedef struct jlist { + jwidget widget; + + /* Number of items */ + int item_count; + /* Per-widget information */ + jlist_item_info *items; + /* Item information and paint functions */ + jlist_item_info_function info_function; + jlist_item_paint_function paint_function; + + /* Currently selected item, -1 if none */ + int cursor; + +} jlist; + +/* Events */ +extern uint16_t JLIST_ITEM_TRIGGERED; +extern uint16_t JLIST_SELECTION_MOVED; +extern uint16_t JLIST_MODEL_UPDATED; + +/* jlist_create(): Create a new (empty) jlist. */ +jlist *jlist_create(void *parent, jlist_item_info_function info_function, + jlist_item_paint_function paint_function); + +/* jlist_update_model(): Update jlists's information about the model + The new model size is passed as parameter. The model is refreshed by + repeatedly calling the info function. */ +void jlist_update_model(jlist *l, int item_count); + +/* jlist_clear(): Remove all items */ +void jlist_clear(jlist *l); + +/* jlist_select(): Move selection to a selectable item */ +void jlist_select(jlist *l, int item); + +/* jlist_selected_item(): Get currently selected item (-1 if none) */ +int jlist_selected_item(jlist *l); + +/* jlist_selected_region(): Get the currently selected region of the widget + + The region is returned as a jrect within the widget's coordinates. This is + useful when the list is inside a frame, to scroll the frame to a suitable + position after the list's selection moved. See jscrolledlist. + + The returned region is undefined if there is no selected item. */ +jrect jlist_selected_region(jlist *l); + +#endif /* _J_JLIST */ diff --git a/include/justui/jscrolledlist.h b/include/justui/jscrolledlist.h new file mode 100644 index 0000000..a4a1d9f --- /dev/null +++ b/include/justui/jscrolledlist.h @@ -0,0 +1,33 @@ +//--- +// JustUI.jscrolledlist: A jlist inside a jframe +//--- + +#ifndef _J_JSCROLLEDLIST +#define _J_JSCROLLEDLIST + +#include +#include +#include + +/* jscrolledlist: A jlist inside a jframe + + jlist as a variabled-size widget which is intended to be used inside a + scrolling view like a jframe. However this still requires the jframe to + scroll when the list cursor moves and when the model is refreshed. + + This utility widget does this wrapping. It does not have any specific + functions, and instead returns the list and frame as its `->list` and + `->frame` members. */ +typedef struct { + jwidget widget; + jframe *frame; + jlist *list; + +} jscrolledlist; + +/* jscrolledlist_create(): Create a scrolled list */ +jscrolledlist *jscrolledlist_create(void *parent, + jlist_item_info_function info_function, + jlist_item_paint_function paint_function); + +#endif /* _J_JSCROLLEDLIST */ diff --git a/src/jframe.c b/src/jframe.c index 8dd5896..8c1d335 100644 --- a/src/jframe.c +++ b/src/jframe.c @@ -34,9 +34,13 @@ jframe *jframe_create(void *parent) #ifdef FX9860G f->scrollbar_width = 1; f->scrollbar_spacing = 1; + f->visibility_margin_x = 4; + f->visibility_margin_y = 4; #else f->scrollbar_width = 2; f->scrollbar_spacing = 2; + f->visibility_margin_x = 8; + f->visibility_margin_y = 8; #endif f->scroll_x = 0; @@ -47,11 +51,11 @@ jframe *jframe_create(void *parent) return f; } -static jwidget *frame_child(jframe *j) +static jwidget *frame_child(jframe *f) { - if(j->widget.child_count == 0) + if(f->widget.child_count == 0) return NULL; - return j->widget.children[0]; + return f->widget.children[0]; } static int frame_scrollbar_space_size(jframe *f) @@ -83,37 +87,102 @@ static int aligned_start_within(int B, int S, jalign align) // Getters and setters //--- -void jframe_set_align(jframe *j, jalign halign, jalign valign) +void jframe_set_align(jframe *f, jalign halign, jalign valign) { - if(j->halign == halign && j->valign == valign) + if(f->halign == halign && f->valign == valign) return; - j->halign = halign; - j->valign = valign; - j->widget.update = 1; + f->halign = halign; + f->valign = valign; + f->widget.update = 1; } -void jframe_set_scrollbars_always_visible(jframe *j, bool always_visible) +void jframe_set_scrollbars_always_visible(jframe *f, bool always_visible) { - if(j->scrollbars_always_visible == always_visible) + if(f->scrollbars_always_visible == always_visible) return; - j->scrollbars_always_visible = always_visible; - j->widget.dirty = 1; + f->scrollbars_always_visible = always_visible; + f->widget.dirty = 1; } -void jframe_set_floating_scrollbars(jframe *j, bool floating_scrollbars) +void jframe_set_floating_scrollbars(jframe *f, bool floating_scrollbars) { - if(j->floating_scrollbars == floating_scrollbars) + if(f->floating_scrollbars == floating_scrollbars) return; - j->floating_scrollbars = floating_scrollbars; - j->widget.dirty = 1; + f->floating_scrollbars = floating_scrollbars; + f->widget.dirty = 1; } -void jframe_set_keyboard_control(jframe *j, bool keyboard_control) +void jframe_set_keyboard_control(jframe *f, bool keyboard_control) { - j->keyboard_control = keyboard_control; + f->keyboard_control = keyboard_control; +} + +void jframe_set_match_size(jframe *f, bool match_width, bool match_height) +{ + if(f->match_width == match_width && f->match_height == match_height) + return; + + f->match_width = match_width; + f->match_height = match_height; + f->widget.dirty = 1; +} + +void jframe_set_visibility_margin(jframe *f, int margin_x, int margin_y) +{ + f->visibility_margin_x = margin_x; + f->visibility_margin_y = margin_y; +} + +//--- +// Scrolling +//--- + +void jframe_scroll_to_region(jframe *f, jrect region) +{ + jwidget *child = frame_child(f); + if(!child) + return; + + int x1 = region.x, x2 = x1 + region.w; + int y1 = region.y, y2 = y1 + region.h; + + /* Clipping */ + x1 = max(x1, 0); + y1 = max(y1, 0); + x2 = min(x2, jwidget_full_width(child)); + y2 = min(y2, jwidget_full_height(child)); + + /* Viewport region + TODO: Handle oversized visibility margin properly */ + int vp_x1 = f->visibility_margin_x; + int vp_x2 = jwidget_content_width(f) - f->visibility_margin_x; + int vp_y1 = f->visibility_margin_y; + int vp_y2 = jwidget_content_height(f) - f->visibility_margin_y; + + /* If the requested region doesn't fit in the viewport, center on it */ + if(x2 - x1 > vp_x2 - vp_x1) + f->scroll_x = (x1 + x2) / 2 - (vp_x1 + vp_x2) / 2; + /* The visible region for some scroll_x is + scroll_x + vp_x1 ... scroll_x + vp_x2 + The minimum/maximum value for scroll_x are when + x2 - scroll_x = vp_x2 (region x2 touches viewport x2) + x1 - scroll_x = vp_x1 (region x1 touches viewport x1) + The max is >= the min due to the guard on the if. */ + else + f->scroll_x = clamp(f->scroll_x, x2 - vp_x2, x1 - vp_x1); + + if(y2 - y1 > vp_y2 - vp_y1) + f->scroll_y = (y1 + y2) / 2 - (vp_y1 + vp_y2) / 2; + else + f->scroll_y = clamp(f->scroll_y, y2 - vp_y2, y1 - vp_y1); + + /* Safety clamp */ + f->scroll_x = clamp(f->scroll_x, 0, f->max_scroll_x); + f->scroll_y = clamp(f->scroll_y, 0, f->max_scroll_y); + f->widget.update = 1; } //--- diff --git a/src/jlist.c b/src/jlist.c new file mode 100644 index 0000000..cf3cdac --- /dev/null +++ b/src/jlist.c @@ -0,0 +1,272 @@ +#include +#include +#include +#include "util.h" + +#include +#include + +/* Type identifier for jlist */ +static int jlist_type_id = -1; + +/* Events */ +uint16_t JLIST_ITEM_TRIGGERED; +uint16_t JLIST_SELECTION_MOVED; +uint16_t JLIST_MODEL_UPDATED; + +struct jlist_item_info { + /* Whether the item can be selected */ + bool selectable; +}; + +jlist *jlist_create(void *parent, jlist_item_info_function info_function, + jlist_item_paint_function paint_function) +{ + if(jlist_type_id < 0) + return NULL; + + jlist *l = malloc(sizeof *l); + if(!l) + return NULL; + + jwidget_init(&l->widget, jlist_type_id, parent); + + l->item_count = 0; + l->items = NULL; + l->info_function = info_function; + l->paint_function = paint_function; + l->cursor = -1; + return l; +} + +//--- +// Selection management +//--- + +static int prev_selectable(jlist *l, int cursor) +{ + for(int i = cursor - 1; i >= 0; i--) { + if(l->items[i].selectable) + return i; + } + return cursor; +} + +static int next_selectable(jlist *l, int cursor) +{ + for(int i = cursor + 1; i < l->item_count; i++) { + if(l->items[i].selectable) + return i; + } + return cursor; +} + +static int first_selectable(jlist *l) +{ + return next_selectable(l, -1); +} + +static int last_selectable(jlist *l) +{ + int p = prev_selectable(l, l->item_count); + return p == l->item_count ? -1 : p; +} + +static int nearest_selectable(jlist *l, int cursor) +{ + if(cursor < 0) + return first_selectable(l); + if(cursor >= l->item_count) + return last_selectable(l); + if(l->items[cursor].selectable) + return cursor; + + int i = prev_selectable(l, cursor); + if(i != cursor) + return i; + i = next_selectable(l, cursor); + if(i != cursor) + return i; + return -1; +} + +void jlist_select(jlist *l, int cursor) +{ + /* Normalize out-of-bounds to -1 */ + if(cursor < 0 || cursor >= l->item_count) + cursor = -1; + if(l->cursor == cursor || (cursor > 0 && !l->items[cursor].selectable)) + return; + + l->cursor = cursor; + jwidget_emit(l, (jevent){ .type = JLIST_SELECTION_MOVED }); + l->widget.update = 1; +} + +int jlist_selected_item(jlist *l) +{ + return l->cursor; +} + +//--- +// Item management +//--- + +void jlist_update_model(jlist *l, int item_count) +{ + if(l->item_count != item_count) { + l->items = realloc(l->items, item_count * sizeof *l->items); + if(!l->items) { + item_count = 0; + return; + } + } + + l->item_count = item_count; + for(int i = 0; i < item_count; i++) { + l->info_function(l, i, &l->items[i]); + } + + jlist_select(l, nearest_selectable(l, l->cursor)); + jwidget_emit(l, (jevent){ .type = JLIST_MODEL_UPDATED }); + l->widget.dirty = 1; +} + +void jlist_clear(jlist *l) +{ + jlist_update_model(l, 0); +} + +jrect jlist_selected_region(jlist *l) +{ + int y=0, h=0; + + for(int i = 0; i <= l->cursor; i++) { + jlist_item_info *info = &l->items[i]; + y += h; + if(info->delegate) + h = jwidget_full_height(info->delegate); + else + h = info->natural_height; + } + + return (jrect){ .x = 0, .y = y, .w = jwidget_content_width(l), .h = h }; +} + +//--- +// Polymorphic widget operations +//--- + +static void jlist_poly_csize(void *l0) +{ + jlist *l = l0; + jwidget *w = &l->widget; + + w->w = 0; + w->h = 0; + + for(int i = 0; i < l->item_count; i++) { + jlist_item_info *info = &l->items[i]; + int item_w, item_h; + + if(info->delegate) { + item_w = jwidget_full_width(info->delegate); + item_h = jwidget_full_height(info->delegate); + } + else { + item_w = info->natural_width; + item_h = info->natural_height; + } + + w->w = max(w->w, item_w); + w->h += item_h; + } +} + +static void jlist_poly_render(void *l0, int x, int y) +{ + jlist *l = l0; + int x1 = x; + int x2 = x + jwidget_content_width(l) - 1; + + for(int i = 0; i < l->item_count; i++) { + jlist_item_info *info = &l->items[i]; + + if(info->delegate) { + jwidget_render(info->delegate, x1, y); + y += jwidget_full_height(info->delegate); + } + else { + l->paint_function(x1, y, x2-x1+1, info->natural_height, l, i, + l->cursor == i); + y += info->natural_height; + } + } +} + +static bool jlist_poly_event(void *l0, jevent e) +{ + jlist *l = l0; + + if(e.type != JWIDGET_KEY || l->cursor < 0) + return false; + + key_event_t ev = e.key; + if(ev.type != KEYEV_DOWN && ev.type != KEYEV_HOLD) + return false; + + int key = ev.key; + + /* Cursor movement */ + + if(key == KEY_UP && ev.shift && !ev.alpha) { + jlist_select(l, first_selectable(l)); + return true; + } + if(key == KEY_DOWN && ev.shift && !ev.alpha) { + jlist_select(l, last_selectable(l)); + return true; + } + if(key == KEY_UP && !ev.alpha) { + jlist_select(l, prev_selectable(l, l->cursor)); + return true; + } + if(key == KEY_DOWN && !ev.alpha) { + jlist_select(l, next_selectable(l, l->cursor)); + return true; + } + + /* Triggering items */ + + if(key == KEY_EXE && l->items[l->cursor].triggerable) { + jevent e = { .type = JLIST_ITEM_TRIGGERED, .data = l->cursor }; + jwidget_emit(l, e); + return true; + } + + return false; +} + +static void jlist_poly_destroy(void *l0) +{ + jlist *l = l0; + free(l->items); +} + +/* jlist type definition */ +static jwidget_poly type_jlist = { + .name = "jlist", + .csize = jlist_poly_csize, + .render = jlist_poly_render, + .event = jlist_poly_event, + .destroy = jlist_poly_destroy, +}; + +__attribute__((constructor(1001))) +static void j_register_jlist(void) +{ + jlist_type_id = j_register_widget(&type_jlist, "jwidget"); + JLIST_ITEM_TRIGGERED = j_register_event(); + JLIST_SELECTION_MOVED = j_register_event(); + JLIST_MODEL_UPDATED = j_register_event(); +} diff --git a/src/jscrolledlist.c b/src/jscrolledlist.c new file mode 100644 index 0000000..e50031c --- /dev/null +++ b/src/jscrolledlist.c @@ -0,0 +1,68 @@ +#include +#include +#include +#include "util.h" + +#include + +/* Type identifier for jscrolledlist */ +static int jscrolledlist_type_id = -1; + +jscrolledlist *jscrolledlist_create(void *parent, + jlist_item_info_function info_function, + jlist_item_paint_function paint_function) +{ + if(jscrolledlist_type_id < 0) + return NULL; + + jscrolledlist *l = malloc(sizeof *l); + if(!l) + return NULL; + + jwidget_init(&l->widget, jscrolledlist_type_id, parent); + jwidget_set_stretch(l, 1, 1, false); + jlayout_set_vbox(l); + + l->frame = jframe_create(l); + jwidget_set_stretch(l->frame, 1, 1, false); + jframe_set_align(l->frame, J_ALIGN_LEFT, J_ALIGN_TOP); + + l->list = jlist_create(l->frame, info_function, paint_function); + jwidget_set_stretch(l->list, 1, 1, false); + + return l; +} + +//--- +// Polymorphic widget operations +//--- + +static bool jscrolledlist_poly_event(void *l0, jevent e) +{ + jscrolledlist *l = l0; + + if((e.type == JLIST_SELECTION_MOVED || e.type == JLIST_MODEL_UPDATED) + && e.source == l->list) { + int cursor = jlist_selected_item(l->list); + if(cursor >= 0) { + jrect r = jlist_selected_region(l->list); + jframe_scroll_to_region(l->frame, r); + /* Allow the event to propagate up */ + return false; + } + } + + return false; +} + +/* jscrolledlist type definition */ +static jwidget_poly type_jscrolledlist = { + .name = "jscrolledlist", + .event = jscrolledlist_poly_event, +}; + +__attribute__((constructor(1002))) +static void j_register_jscrolledlist(void) +{ + jscrolledlist_type_id = j_register_widget(&type_jscrolledlist, "jwidget"); +}