jlist, jscrolledlist: add versatile paintable list widgets

This commit is contained in:
Lephenixnoir 2022-12-01 10:37:34 +01:00
parent 51fbf2f582
commit beeb0c0724
Signed by: Lephenixnoir
GPG Key ID: 1BBA026E13FC0495
8 changed files with 597 additions and 23 deletions

View File

@ -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}}

View File

@ -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 */

View File

@ -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 */

102
include/justui/jlist.h Normal file
View File

@ -0,0 +1,102 @@
//---
// JustUI.jlist: List widget with arbitrary, selectable children
//---
#ifndef _J_JLIST
#define _J_JLIST
#include <justui/defs.h>
#include <justui/jwidget.h>
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 */

View File

@ -0,0 +1,33 @@
//---
// JustUI.jscrolledlist: A jlist inside a jframe
//---
#ifndef _J_JSCROLLEDLIST
#define _J_JSCROLLEDLIST
#include <justui/defs.h>
#include <justui/jlist.h>
#include <justui/jframe.h>
/* 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 */

View File

@ -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;
}
//---

272
src/jlist.c Normal file
View File

@ -0,0 +1,272 @@
#include <justui/jwidget.h>
#include <justui/jwidget-api.h>
#include <justui/jlist.h>
#include "util.h"
#include <gint/display.h>
#include <stdlib.h>
/* 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();
}

68
src/jscrolledlist.c Normal file
View File

@ -0,0 +1,68 @@
#include <justui/jwidget.h>
#include <justui/jwidget-api.h>
#include <justui/jscrolledlist.h>
#include "util.h"
#include <stdlib.h>
/* 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");
}