diff --git a/ObjectivelyMVC.vs15/ObjectivelyMVC.vcxproj b/ObjectivelyMVC.vs15/ObjectivelyMVC.vcxproj
index d64b2d89..74ae0655 100644
--- a/ObjectivelyMVC.vs15/ObjectivelyMVC.vcxproj
+++ b/ObjectivelyMVC.vs15/ObjectivelyMVC.vcxproj
@@ -43,6 +43,8 @@
+
+
@@ -52,6 +54,8 @@
+
+
@@ -98,6 +102,8 @@
+
+
@@ -106,6 +112,8 @@
+
+
diff --git a/ObjectivelyMVC.vs15/ObjectivelyMVC.vcxproj.filters b/ObjectivelyMVC.vs15/ObjectivelyMVC.vcxproj.filters
index 4d7697a2..6b0bc5a3 100644
--- a/ObjectivelyMVC.vs15/ObjectivelyMVC.vcxproj.filters
+++ b/ObjectivelyMVC.vs15/ObjectivelyMVC.vcxproj.filters
@@ -57,6 +57,12 @@
Sources\ObjectivelyMVC
+
+ Sources\ObjectivelyMVC
+
+
+ Sources\ObjectivelyMVC
+
Sources\ObjectivelyMVC
@@ -84,6 +90,12 @@
Sources\ObjectivelyMVC
+
+ Sources\ObjectivelyMVC
+
+
+ Sources\ObjectivelyMVC
+
Sources\ObjectivelyMVC
@@ -245,6 +257,12 @@
Sources\ObjectivelyMVC
+
+ Sources\ObjectivelyMVC
+
+
+ Sources\ObjectivelyMVC
+
Sources\ObjectivelyMVC
@@ -269,6 +287,12 @@
Sources\ObjectivelyMVC
+
+ Sources\ObjectivelyMVC
+
+
+ Sources\ObjectivelyMVC
+
Sources\ObjectivelyMVC
diff --git a/Sources/ObjectivelyMVC/Button.c b/Sources/ObjectivelyMVC/Button.c
index 123515ce..63e33b2a 100644
--- a/Sources/ObjectivelyMVC/Button.c
+++ b/Sources/ObjectivelyMVC/Button.c
@@ -56,7 +56,8 @@ static void awakeWithDictionary(View *self, const Dictionary *dictionary) {
Button *this = (Button *) self;
const Inlet inlets[] = MakeInlets(
- MakeInlet("title", InletTypeView, &this->title, NULL)
+ MakeInlet("title", InletTypeView, &this->title, NULL),
+ MakeInlet("image", InletTypeImage, &this->image->image, NULL)
);
$(self, bind, inlets, dictionary);
diff --git a/Sources/ObjectivelyMVC/Control.c b/Sources/ObjectivelyMVC/Control.c
index 866cb7b3..576e1ca4 100644
--- a/Sources/ObjectivelyMVC/Control.c
+++ b/Sources/ObjectivelyMVC/Control.c
@@ -350,6 +350,11 @@ static void stateDidChange(Control *self) {
$(this, emitViewEvent, ViewEventBlur, NULL);
}
+ // A control's state pseudo-classes (:selected, :highlighted, :focused) can style
+ // DESCENDANTS, not just the control itself -- e.g. `Checkbox:selected ImageView`
+ // toggles the checkmark, `CollectionItemView:selected > .selectionOverlay`, etc.
+ // So a state change must re-theme the whole subtree. State changes are discrete
+ // user events (click/focus/select), so this is not a hot path.
$(this, invalidateStyle);
this->needsLayout = true;
diff --git a/Sources/ObjectivelyMVC/KeyValueTableView.c b/Sources/ObjectivelyMVC/KeyValueTableView.c
new file mode 100644
index 00000000..162b18e4
--- /dev/null
+++ b/Sources/ObjectivelyMVC/KeyValueTableView.c
@@ -0,0 +1,202 @@
+/*
+ * ObjectivelyMVC: Object oriented MVC framework for SDL3 and C.
+ * Copyright (C) 2014 Jay Dolan
+ *
+ * This software is provided 'as-is', without any express or implied
+ * warranty. In no event will the authors be held liable for any damages
+ * arising from the use of this software.
+ *
+ * Permission is granted to anyone to use this software for any purpose,
+ * including commercial applications, and to alter it and redistribute it
+ * freely, subject to the following restrictions:
+ *
+ * 1. The origin of this software must not be misrepresented; you must not
+ * claim that you wrote the original software. If you use this software
+ * in a product, an acknowledgment in the product documentation would be
+ * appreciated but is not required.
+ *
+ * 2. Altered source versions must be plainly marked as such, and must not be
+ * misrepresented as being the original software.
+ *
+ * 3. This notice may not be removed or altered from any source distribution.
+ */
+
+#include
+
+#include "KeyValueTableView.h"
+
+#define _Class _KeyValueTableView
+
+#pragma mark - View
+
+/**
+ * @see View::applyStyle(View *, const Style *)
+ */
+static void applyStyle(View *self, const Style *style) {
+
+ super(View, self, applyStyle, style);
+
+ KeyValueTableView *this = (KeyValueTableView *) self;
+
+ const Inlet inlets[] = MakeInlets(
+ MakeInlet("-key-width", InletTypeInteger, &this->keyWidth, NULL),
+ MakeInlet("-value-width", InletTypeInteger, &this->valueWidth, NULL)
+ );
+
+ $(self, bind, inlets, (Dictionary *) style->attributes);
+
+ self->needsLayout = true;
+}
+
+/**
+ * @see View::init(View *)
+ */
+static View *init(View *self) {
+ return (View *) $((KeyValueTableView *) self, initWithFrame, NULL);
+}
+
+/**
+ * @see View::layoutSubviews(View *)
+ */
+static void layoutSubviews(View *self) {
+
+ KeyValueTableView *this = (KeyValueTableView *) self;
+
+ // Push the table's fixed columns into every row so all rows share one set of
+ // column widths. setColumnWidths only writes when a clamp actually changes, so
+ // once the rows match this is a no-op -- it cannot churn the layout. Using the
+ // fixed -value-width (not a bounds-derived fill) avoids a re-layout feedback
+ // loop with the global `TextView { min-width }` floor.
+ const Array *subviews = (Array *) self->subviews;
+ for (size_t i = 0; i < subviews->count; i++) {
+ KeyValueView *row = $(subviews, objectAtIndex, i);
+
+ if (row->keyWidth != this->keyWidth || row->valueWidth != this->valueWidth) {
+ $(row, setColumnWidths, this->keyWidth, this->valueWidth);
+ }
+ }
+
+ super(View, self, layoutSubviews);
+}
+
+#pragma mark - KeyValueTableView
+
+/**
+ * @fn KeyValueTableView *KeyValueTableView::initWithFrame(KeyValueTableView *self, const SDL_Rect *frame)
+ * @memberof KeyValueTableView
+ */
+static KeyValueTableView *initWithFrame(KeyValueTableView *self, const SDL_Rect *frame) {
+
+ self = (KeyValueTableView *) super(StackView, self, initWithFrame, frame);
+ if (self) {
+
+ $((View *) self, addClassName, "keyValueTable");
+
+ self->stackView.axis = StackViewAxisVertical;
+
+ // A small gap between rows so the table does not feel cramped.
+ self->stackView.spacing = 6;
+
+ // Grow in height with the rows; fill the parent's width so the table claims
+ // the box. CSS may override either of these via applyStyle.
+ ((View *) self)->autoresizingMask = ViewAutoresizingContain | ViewAutoresizingWidth;
+
+ // Default columns so rows have widths before the stylesheet's -key-width /
+ // -value-width are applied; CSS overrides these in applyStyle.
+ self->keyWidth = 140;
+ self->valueWidth = 280;
+ }
+
+ return self;
+}
+
+/**
+ * @fn KeyValueView *KeyValueTableView::addRow(KeyValueTableView *self, View *key, View *value)
+ * @memberof KeyValueTableView
+ */
+static KeyValueView *addRow(KeyValueTableView *self, View *key, View *value) {
+
+ KeyValueView *row = $(alloc(KeyValueView), initWithFrame, NULL);
+ assert(row);
+
+ if (key) {
+ $(row, setKey, key);
+ }
+
+ if (value) {
+ $(row, setValue, value);
+ }
+
+ $(row, setColumnWidths, self->keyWidth, self->valueWidth);
+
+ $((View *) self, addSubview, (View *) row);
+ release(row);
+
+ ((View *) self)->needsLayout = true;
+
+ return row;
+}
+
+/**
+ * @fn void KeyValueTableView::addRowView(KeyValueTableView *self, KeyValueView *row)
+ * @memberof KeyValueTableView
+ */
+static void addRowView(KeyValueTableView *self, KeyValueView *row) {
+
+ $(row, setColumnWidths, self->keyWidth, self->valueWidth);
+
+ $((View *) self, addSubview, (View *) row);
+
+ ((View *) self)->needsLayout = true;
+}
+
+/**
+ * @fn void KeyValueTableView::removeAllRows(KeyValueTableView *self)
+ * @memberof KeyValueTableView
+ */
+static void removeAllRows(KeyValueTableView *self) {
+
+ $((View *) self, removeAllSubviews);
+ ((View *) self)->needsLayout = true;
+}
+
+#pragma mark - Class lifecycle
+
+/**
+ * @see Class::initialize(Class *)
+ */
+static void initialize(Class *clazz) {
+
+ ((ViewInterface *) clazz->interface)->applyStyle = applyStyle;
+ ((ViewInterface *) clazz->interface)->init = init;
+ ((ViewInterface *) clazz->interface)->layoutSubviews = layoutSubviews;
+
+ ((KeyValueTableViewInterface *) clazz->interface)->initWithFrame = initWithFrame;
+ ((KeyValueTableViewInterface *) clazz->interface)->addRow = addRow;
+ ((KeyValueTableViewInterface *) clazz->interface)->addRowView = addRowView;
+ ((KeyValueTableViewInterface *) clazz->interface)->removeAllRows = removeAllRows;
+}
+
+/**
+ * @fn Class *KeyValueTableView::_KeyValueTableView(void)
+ * @memberof KeyValueTableView
+ */
+Class *_KeyValueTableView(void) {
+ static Class *clazz;
+ static Once once;
+
+ do_once(&once, {
+ clazz = _initialize(&(const ClassDef) {
+ .name = "KeyValueTableView",
+ .superclass = _StackView(),
+ .instanceSize = sizeof(KeyValueTableView),
+ .interfaceOffset = offsetof(KeyValueTableView, interface),
+ .interfaceSize = sizeof(KeyValueTableViewInterface),
+ .initialize = initialize,
+ });
+ });
+
+ return clazz;
+}
+
+#undef _Class
diff --git a/Sources/ObjectivelyMVC/KeyValueTableView.h b/Sources/ObjectivelyMVC/KeyValueTableView.h
new file mode 100644
index 00000000..5f12a755
--- /dev/null
+++ b/Sources/ObjectivelyMVC/KeyValueTableView.h
@@ -0,0 +1,131 @@
+/*
+ * ObjectivelyMVC: Object oriented MVC framework for SDL3 and C.
+ * Copyright (C) 2014 Jay Dolan
+ *
+ * This software is provided 'as-is', without any express or implied
+ * warranty. In no event will the authors be held liable for any damages
+ * arising from the use of this software.
+ *
+ * Permission is granted to anyone to use this software for any purpose,
+ * including commercial applications, and to alter it and redistribute it
+ * freely, subject to the following restrictions:
+ *
+ * 1. The origin of this software must not be misrepresented; you must not
+ * claim that you wrote the original software. If you use this software
+ * in a product, an acknowledgment in the product documentation would be
+ * appreciated but is not required.
+ *
+ * 2. Altered source versions must be plainly marked as such, and must not be
+ * misrepresented as being the original software.
+ *
+ * 3. This notice may not be removed or altered from any source distribution.
+ */
+
+#pragma once
+
+#include
+
+#include
+
+/**
+ * @file
+ * @brief A vertical table of KeyValueView rows with inherited columns.
+ * @details A KeyValueTableView is a vertical StackView that owns the column
+ * widths for its rows. On every layout it pushes its `-key-width` /
+ * `-value-width` (CSS attributes on its own root) into every row via
+ * KeyValueView::setColumnWidths -- so all rows (including dynamically added
+ * ones) share one set of columns, configured in one place and enforced in C,
+ * immune to the per-widget min-width floors that CSS width rules fight.
+ *
+ * The table GROWS with its content; it does not scroll. Scrolling, when needed,
+ * is provided by an ancestor StackView with `scroll: true`.
+ */
+
+typedef struct KeyValueTableView KeyValueTableView;
+typedef struct KeyValueTableViewInterface KeyValueTableViewInterface;
+
+/**
+ * @brief A vertical table of KeyValueView rows.
+ * @extends StackView
+ */
+struct KeyValueTableView {
+
+ /**
+ * @brief The superclass.
+ * @private
+ */
+ StackView stackView;
+
+ /**
+ * @brief The interface.
+ * @private
+ */
+ KeyValueTableViewInterface *interface;
+
+ /**
+ * @brief Column-0 width pushed to every row. Style attribute `-key-width`.
+ */
+ int keyWidth;
+
+ /**
+ * @brief Column-1 width pushed to every row. Style attribute `-value-width`.
+ */
+ int valueWidth;
+};
+
+/**
+ * @brief The KeyValueTableView interface.
+ */
+struct KeyValueTableViewInterface {
+
+ /**
+ * @brief The superclass interface.
+ */
+ StackViewInterface stackViewInterface;
+
+ /**
+ * @fn KeyValueTableView *KeyValueTableView::initWithFrame(KeyValueTableView *self, const SDL_Rect *frame)
+ * @brief Initializes this KeyValueTableView with the given frame.
+ * @param self The KeyValueTableView.
+ * @param frame The frame, or `NULL`.
+ * @return The initialized KeyValueTableView, or `NULL` on error.
+ * @memberof KeyValueTableView
+ */
+ KeyValueTableView *(*initWithFrame)(KeyValueTableView *self, const SDL_Rect *frame);
+
+ /**
+ * @fn KeyValueView *KeyValueTableView::addRow(KeyValueTableView *self, View *key, View *value)
+ * @brief Appends a row holding `key` and `value`, inheriting the table columns.
+ * @param self The KeyValueTableView.
+ * @param key The View for column 0 (any View), or `NULL`.
+ * @param value The View for column 1 (any View), or `NULL`.
+ * @return The new KeyValueView row.
+ * @memberof KeyValueTableView
+ */
+ KeyValueView *(*addRow)(KeyValueTableView *self, View *key, View *value);
+
+ /**
+ * @fn void KeyValueTableView::addRowView(KeyValueTableView *self, KeyValueView *row)
+ * @brief Appends a pre-built KeyValueView (e.g. an editable subclass) as a row.
+ * @param self The KeyValueTableView.
+ * @param row The row to append; it inherits the table columns.
+ * @memberof KeyValueTableView
+ */
+ void (*addRowView)(KeyValueTableView *self, KeyValueView *row);
+
+ /**
+ * @fn void KeyValueTableView::removeAllRows(KeyValueTableView *self)
+ * @brief Removes every row from the table.
+ * @param self The KeyValueTableView.
+ * @memberof KeyValueTableView
+ */
+ void (*removeAllRows)(KeyValueTableView *self);
+};
+
+/**
+ * @fn Class *KeyValueTableView::_KeyValueTableView(void)
+ * @brief The KeyValueTableView archetype.
+ * @return The KeyValueTableView Class.
+ * @memberof KeyValueTableView
+ */
+OBJECTIVELYMVC_EXPORT Class *_KeyValueTableView(void);
diff --git a/Sources/ObjectivelyMVC/KeyValueView.c b/Sources/ObjectivelyMVC/KeyValueView.c
new file mode 100644
index 00000000..fac9fc48
--- /dev/null
+++ b/Sources/ObjectivelyMVC/KeyValueView.c
@@ -0,0 +1,216 @@
+/*
+ * ObjectivelyMVC: Object oriented MVC framework for SDL3 and C.
+ * Copyright (C) 2014 Jay Dolan
+ *
+ * This software is provided 'as-is', without any express or implied
+ * warranty. In no event will the authors be held liable for any damages
+ * arising from the use of this software.
+ *
+ * Permission is granted to anyone to use this software for any purpose,
+ * including commercial applications, and to alter it and redistribute it
+ * freely, subject to the following restrictions:
+ *
+ * 1. The origin of this software must not be misrepresented; you must not
+ * claim that you wrote the original software. If you use this software
+ * in a product, an acknowledgment in the product documentation would be
+ * appreciated but is not required.
+ *
+ * 2. Altered source versions must be plainly marked as such, and must not be
+ * misrepresented as being the original software.
+ *
+ * 3. This notice may not be removed or altered from any source distribution.
+ */
+
+#include "KeyValueView.h"
+
+#define _Class _KeyValueView
+
+#pragma mark - Helpers
+
+/**
+ * @brief Rigidly clamps `view`'s width, establishing its column. Only writes when
+ * the clamp actually changes, so re-asserting it every layout cannot churn.
+ */
+static void applyColumnWidth(View *view, const int width) {
+ if (view && width > 0 && (view->minSize.w != width || view->maxSize.w != width)) {
+ view->minSize.w = view->maxSize.w = width;
+ view->needsLayout = true;
+ }
+}
+
+#pragma mark - View
+
+/**
+ * @see View::awakeWithDictionary(View *, const Dictionary *)
+ */
+static void awakeWithDictionary(View *self, const Dictionary *dictionary) {
+
+ super(View, self, awakeWithDictionary, dictionary);
+
+ KeyValueView *this = (KeyValueView *) self;
+
+ View *key = NULL, *value = NULL;
+
+ const Inlet inlets[] = MakeInlets(
+ MakeInlet("key", InletTypeView, &key, NULL),
+ MakeInlet("value", InletTypeView, &value, NULL)
+ );
+
+ $(self, bind, inlets, dictionary);
+
+ // The inlet binding hands us a retained View; parenting it retains it again,
+ // so release our reference once it is in place.
+ if (key) {
+ $(this, setKey, key);
+ release(key);
+ }
+
+ if (value) {
+ $(this, setValue, value);
+ release(value);
+ }
+}
+
+/**
+ * @see View::init(View *)
+ */
+static View *init(View *self) {
+ return (View *) $((KeyValueView *) self, initWithFrame, NULL);
+}
+
+/**
+ * @see View::applyStyle(View *, const Style *)
+ * @brief Re-assert the column clamps right after the stylesheet is applied. This
+ * is the only place that reliably wins the column width: applyStyle runs AFTER
+ * super applies the global `TextView { min-width: 200 }` floor (common.css), so
+ * clamping here always overrides it -- regardless of selector cascade. And unlike
+ * a layoutSubviews clamp, this does NOT loop: applyStyle is not re-run when its
+ * applyColumnWidth marks the view needsLayout, so it settles in one pass. This is
+ * what keeps every row's key cell a rigid keyWidth without a per-frame fight.
+ */
+static void applyStyle(View *self, const Style *style) {
+
+ super(View, self, applyStyle, style);
+
+ KeyValueView *this = (KeyValueView *) self;
+
+ applyColumnWidth(this->key, this->keyWidth);
+ applyColumnWidth(this->value, this->valueWidth);
+}
+
+#pragma mark - KeyValueView
+
+/**
+ * @fn KeyValueView *KeyValueView::initWithFrame(KeyValueView *self, const SDL_Rect *frame)
+ * @memberof KeyValueView
+ */
+static KeyValueView *initWithFrame(KeyValueView *self, const SDL_Rect *frame) {
+
+ self = (KeyValueView *) super(StackView, self, initWithFrame, frame);
+ if (self) {
+ ((StackView *) self)->axis = StackViewAxisHorizontal;
+
+ // Fill the table's width so the value column can expand to the right edge.
+ ((View *) self)->autoresizingMask = ViewAutoresizingContain | ViewAutoresizingWidth;
+
+ $((View *) self, addClassName, "keyValue");
+ }
+
+ return self;
+}
+
+/**
+ * @fn void KeyValueView::setKey(KeyValueView *self, View *key)
+ * @memberof KeyValueView
+ */
+static void setKey(KeyValueView *self, View *key) {
+
+ if (self->key) {
+ $(self->key, removeFromSuperview);
+ self->key = NULL;
+ }
+
+ if (key) {
+ if (self->value) {
+ $((View *) self, addSubviewRelativeTo, key, self->value, ViewPositionBefore);
+ } else {
+ $((View *) self, addSubview, key);
+ }
+ self->key = key;
+ applyColumnWidth(key, self->keyWidth);
+ }
+}
+
+/**
+ * @fn void KeyValueView::setValue(KeyValueView *self, View *value)
+ * @memberof KeyValueView
+ */
+static void setValue(KeyValueView *self, View *value) {
+
+ if (self->value) {
+ $(self->value, removeFromSuperview);
+ self->value = NULL;
+ }
+
+ if (value) {
+ $((View *) self, addSubview, value);
+ self->value = value;
+ applyColumnWidth(value, self->valueWidth);
+ }
+}
+
+/**
+ * @fn void KeyValueView::setColumnWidths(KeyValueView *self, int keyWidth, int valueWidth)
+ * @memberof KeyValueView
+ */
+static void setColumnWidths(KeyValueView *self, int keyWidth, int valueWidth) {
+
+ self->keyWidth = keyWidth;
+ self->valueWidth = valueWidth;
+
+ applyColumnWidth(self->key, keyWidth);
+ applyColumnWidth(self->value, valueWidth);
+
+ ((View *) self)->needsLayout = true;
+}
+
+#pragma mark - Class lifecycle
+
+/**
+ * @see Class::initialize(Class *)
+ */
+static void initialize(Class *clazz) {
+
+ ((ViewInterface *) clazz->interface)->awakeWithDictionary = awakeWithDictionary;
+ ((ViewInterface *) clazz->interface)->applyStyle = applyStyle;
+ ((ViewInterface *) clazz->interface)->init = init;
+
+ ((KeyValueViewInterface *) clazz->interface)->initWithFrame = initWithFrame;
+ ((KeyValueViewInterface *) clazz->interface)->setKey = setKey;
+ ((KeyValueViewInterface *) clazz->interface)->setValue = setValue;
+ ((KeyValueViewInterface *) clazz->interface)->setColumnWidths = setColumnWidths;
+}
+
+/**
+ * @fn Class *KeyValueView::_KeyValueView(void)
+ * @memberof KeyValueView
+ */
+Class *_KeyValueView(void) {
+ static Class *clazz;
+ static Once once;
+
+ do_once(&once, {
+ clazz = _initialize(&(const ClassDef) {
+ .name = "KeyValueView",
+ .superclass = _StackView(),
+ .instanceSize = sizeof(KeyValueView),
+ .interfaceOffset = offsetof(KeyValueView, interface),
+ .interfaceSize = sizeof(KeyValueViewInterface),
+ .initialize = initialize,
+ });
+ });
+
+ return clazz;
+}
+
+#undef _Class
diff --git a/Sources/ObjectivelyMVC/KeyValueView.h b/Sources/ObjectivelyMVC/KeyValueView.h
new file mode 100644
index 00000000..fee0b768
--- /dev/null
+++ b/Sources/ObjectivelyMVC/KeyValueView.h
@@ -0,0 +1,135 @@
+/*
+ * ObjectivelyMVC: Object oriented MVC framework for SDL3 and C.
+ * Copyright (C) 2014 Jay Dolan
+ *
+ * This software is provided 'as-is', without any express or implied
+ * warranty. In no event will the authors be held liable for any damages
+ * arising from the use of this software.
+ *
+ * Permission is granted to anyone to use this software for any purpose,
+ * including commercial applications, and to alter it and redistribute it
+ * freely, subject to the following restrictions:
+ *
+ * 1. The origin of this software must not be misrepresented; you must not
+ * claim that you wrote the original software. If you use this software
+ * in a product, an acknowledgment in the product documentation would be
+ * appreciated but is not required.
+ *
+ * 2. Altered source versions must be plainly marked as such, and must not be
+ * misrepresented as being the original software.
+ *
+ * 3. This notice may not be removed or altered from any source distribution.
+ */
+
+#pragma once
+
+#include
+
+/**
+ * @file
+ * @brief A single key -> value row holding an arbitrary View in each column.
+ * @details A KeyValueView is a horizontal StackView holding two arbitrary
+ * Views: the key (column 0) and the value (column 1). Any View may be used for
+ * either (Label, TextView, Select, Slider, Checkbox, ...). Column widths are
+ * enforced by setColumnWidths, which rigidly clamps each widget's own
+ * minSize/maxSize width (the same mechanism View::resize honors for any widget,
+ * which is why it never loses to a stylesheet min-width floor). Alignment thus
+ * never depends on per-widget CSS — only the widget's own look (font, color) is
+ * styled, by type-level rules that bind reliably. Authored declaratively via
+ * the "key" and "value" JSON inlets, or in C via setKey/setValue.
+ */
+
+typedef struct KeyValueView KeyValueView;
+typedef struct KeyValueViewInterface KeyValueViewInterface;
+
+/**
+ * @brief A key -> value row of two arbitrary Views.
+ * @extends StackView
+ */
+struct KeyValueView {
+
+ /**
+ * @brief The superclass.
+ * @private
+ */
+ StackView stackView;
+
+ /**
+ * @brief The interface.
+ * @private
+ */
+ KeyValueViewInterface *interface;
+
+ /**
+ * @brief The current key widget (column 0). Weak; owned as a subview.
+ */
+ View *key;
+
+ /**
+ * @brief The current value widget (column 1). Weak; owned as a subview.
+ */
+ View *value;
+
+ /**
+ * @brief The column widths last set, re-applied when a widget is replaced.
+ * @private
+ */
+ int keyWidth, valueWidth;
+};
+
+/**
+ * @brief The KeyValueView interface.
+ */
+struct KeyValueViewInterface {
+
+ /**
+ * @brief The superclass interface.
+ */
+ StackViewInterface stackViewInterface;
+
+ /**
+ * @fn KeyValueView *KeyValueView::initWithFrame(KeyValueView *self, const SDL_Rect *frame)
+ * @brief Initializes this KeyValueView with the given frame.
+ * @param self The KeyValueView.
+ * @param frame The frame, or `NULL`.
+ * @return The initialized KeyValueView, or `NULL` on error.
+ * @memberof KeyValueView
+ */
+ KeyValueView *(*initWithFrame)(KeyValueView *self, const SDL_Rect *frame);
+
+ /**
+ * @fn void KeyValueView::setKey(KeyValueView *self, View *key)
+ * @brief Places `key` (any View) into the key cell, filling its width.
+ * @param self The KeyValueView.
+ * @param key The View to display in column 0, or `NULL` to clear it.
+ * @memberof KeyValueView
+ */
+ void (*setKey)(KeyValueView *self, View *key);
+
+ /**
+ * @fn void KeyValueView::setValue(KeyValueView *self, View *value)
+ * @brief Places `value` (any View) into the value cell, filling its width.
+ * @param self The KeyValueView.
+ * @param value The View to display in column 1, or `NULL` to clear it.
+ * @memberof KeyValueView
+ */
+ void (*setValue)(KeyValueView *self, View *value);
+
+ /**
+ * @fn void KeyValueView::setColumnWidths(KeyValueView *self, int keyWidth, int valueWidth)
+ * @brief Rigidly sizes the two cells, establishing the row's columns.
+ * @param self The KeyValueView.
+ * @param keyWidth The column-0 width, in points. Ignored if <= 0.
+ * @param valueWidth The column-1 width, in points. Ignored if <= 0.
+ * @memberof KeyValueView
+ */
+ void (*setColumnWidths)(KeyValueView *self, int keyWidth, int valueWidth);
+};
+
+/**
+ * @fn Class *KeyValueView::_KeyValueView(void)
+ * @brief The KeyValueView archetype.
+ * @return The KeyValueView Class.
+ * @memberof KeyValueView
+ */
+OBJECTIVELYMVC_EXPORT Class *_KeyValueView(void);
diff --git a/Sources/ObjectivelyMVC/Makefile.am b/Sources/ObjectivelyMVC/Makefile.am
index 6523f37d..da4e7e15 100644
--- a/Sources/ObjectivelyMVC/Makefile.am
+++ b/Sources/ObjectivelyMVC/Makefile.am
@@ -22,6 +22,8 @@ pkginclude_HEADERS = \
ImageView.h \
Input.h \
Text.h \
+ KeyValueTableView.h \
+ KeyValueView.h \
Label.h \
Log.h \
NavigationViewController.h \
@@ -31,6 +33,8 @@ pkginclude_HEADERS = \
ProgressBar.h \
Renderer.h \
RGBColorPicker.h \
+ Scrollbar.h \
+ ScrollThumb.h \
ScrollView.h \
Select.h \
Selector.h \
@@ -78,6 +82,8 @@ libObjectivelyMVC_la_SOURCES = \
Image.c \
ImageView.c \
Input.c \
+ KeyValueTableView.c \
+ KeyValueView.c \
Label.c \
NavigationViewController.c \
Option.c \
@@ -86,6 +92,8 @@ libObjectivelyMVC_la_SOURCES = \
ProgressBar.c \
Renderer.c \
RGBColorPicker.c \
+ Scrollbar.c \
+ ScrollThumb.c \
ScrollView.c \
Select.c \
Selector.c \
diff --git a/Sources/ObjectivelyMVC/PageView.c b/Sources/ObjectivelyMVC/PageView.c
index 14e1554d..6144d386 100644
--- a/Sources/ObjectivelyMVC/PageView.c
+++ b/Sources/ObjectivelyMVC/PageView.c
@@ -93,6 +93,101 @@ static Array *visibleSubviews(const View *self) {
return $$(Array, arrayWithArray, (Array *) self->subviews);
}
+/**
+ * @see View::layoutIfNeeded(View *)
+ * @brief Identical to View::layoutIfNeeded, but does not recurse into hidden
+ * (non-current) pages. Layout is driven TOP-DOWN through layoutIfNeeded (each view
+ * recurses into all of its subviews), so without this every hidden tab page and
+ * its entire content subtree is laid out on every pass -- the actual source of the
+ * tabbed-panel layout stall. A page is laid out lazily when it becomes current:
+ * setCurrentPage unhides it and flags this view for layout.
+ */
+static void layoutIfNeeded(View *self) {
+
+ const Array *subviews = (Array *) self->subviews;
+ for (size_t i = 0; i < subviews->count; i++) {
+
+ View *subview = subviews->elements[i];
+
+ if (!subview->hidden) {
+ $(subview, layoutIfNeeded);
+ }
+ }
+
+ if (self->needsLayout) {
+ $(self, clearWarnings, WarningTypeLayout);
+ $(self, layoutSubviews);
+ self->needsLayout = false;
+ }
+}
+
+/**
+ * @see View::layoutSubviews(View *)
+ * @brief Identical to View::layoutSubviews, but skips hidden (non-current) pages.
+ * The base implementation lays out every subview, so a tabbed panel re-lays-out
+ * each hidden page on every layout pass -- which becomes very expensive when a
+ * hidden page's content is dirtied (e.g. an unseen tab's controller refreshing on
+ * appear). A hidden page is laid out lazily instead: setCurrentPage unhides it and
+ * flags this view for layout, so the now-current page is laid out when shown.
+ */
+static void layoutSubviews(View *self) {
+
+ if (self->autoresizingMask & ViewAutoresizingContain) {
+ $(self, sizeToContain);
+ } else if (self->autoresizingMask & ViewAutoresizingFit) {
+ $(self, sizeToFit);
+ }
+
+ const SDL_Rect bounds = $(self, bounds);
+
+ const Array *subviews = (Array *) self->subviews;
+ for (size_t i = 0; i < subviews->count; i++) {
+
+ View *subview = subviews->elements[i];
+
+ if (subview->hidden) {
+ continue;
+ }
+
+ SDL_Size subviewSize = $(subview, size);
+
+ if (subview->autoresizingMask & ViewAutoresizingWidth) {
+ subviewSize.w = bounds.w;
+ }
+
+ if (subview->autoresizingMask & ViewAutoresizingHeight) {
+ subviewSize.h = bounds.h;
+ }
+
+ $(subview, resize, &subviewSize);
+ $(subview, layoutIfNeeded);
+
+ switch (subview->alignment & ViewAlignmentMaskHorizontal) {
+ case ViewAlignmentLeft:
+ subview->frame.x = 0;
+ break;
+ case ViewAlignmentCenter:
+ subview->frame.x = (bounds.w - subview->frame.w) * 0.5f;
+ break;
+ case ViewAlignmentRight:
+ subview->frame.x = bounds.w - subview->frame.w;
+ break;
+ }
+
+ switch (subview->alignment & ViewAlignmentMaskVertical) {
+ case ViewAlignmentMaskTop:
+ subview->frame.y = 0;
+ break;
+ case ViewAlignmentMaskMiddle:
+ subview->frame.y = (bounds.h - subview->frame.h) * 0.5f;
+ break;
+ case ViewAlignmentMaskBottom:
+ subview->frame.y = bounds.h - subview->frame.h;
+ break;
+ }
+ }
+}
+
#pragma mark - PageView
/**
@@ -163,6 +258,8 @@ static void initialize(Class *clazz) {
((ViewInterface *) clazz->interface)->addSubview = addSubview;
((ViewInterface *) clazz->interface)->init = init;
+ ((ViewInterface *) clazz->interface)->layoutIfNeeded = layoutIfNeeded;
+ ((ViewInterface *) clazz->interface)->layoutSubviews = layoutSubviews;
((ViewInterface *) clazz->interface)->removeSubview = removeSubview;
((ViewInterface *) clazz->interface)->visibleSubviews = visibleSubviews;
diff --git a/Sources/ObjectivelyMVC/ScrollThumb.c b/Sources/ObjectivelyMVC/ScrollThumb.c
new file mode 100644
index 00000000..e95ed3fa
--- /dev/null
+++ b/Sources/ObjectivelyMVC/ScrollThumb.c
@@ -0,0 +1,120 @@
+/*
+ * ObjectivelyMVC: Object oriented MVC framework for SDL3 and C.
+ * Copyright (C) 2014 Jay Dolan
+ *
+ * This software is provided 'as-is', without any express or implied
+ * warranty. In no event will the authors be held liable for any damages
+ * arising from the use of this software.
+ *
+ * Permission is granted to anyone to use this software for any purpose,
+ * including commercial applications, and to alter it and redistribute it
+ * freely, subject to the following restrictions:
+ *
+ * 1. The origin of this software must not be misrepresented; you must not
+ * claim that you wrote the original software. If you use this software
+ * in a product, an acknowledgment in the product documentation would be
+ * appreciated but is not required.
+ *
+ * 2. Altered source versions must be plainly marked as such, and must not be
+ * misrepresented as being the original software.
+ *
+ * 3. This notice may not be removed or altered from any source distribution.
+ */
+
+#include "ScrollThumb.h"
+
+#define _Class _ScrollThumb
+
+#pragma mark - View
+
+/**
+ * @see View::init(View *)
+ */
+static View *init(View *self) {
+ return (View *) $((ScrollThumb *) self, initWithFrame, NULL);
+}
+
+#pragma mark - Control
+
+/**
+ * @see Control::captureEvent(Control *, const SDL_Event *)
+ */
+static bool captureEvent(Control *self, const SDL_Event *event) {
+
+ ScrollThumb *this = (ScrollThumb *) self;
+
+ if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) {
+ if ($((View *) self, didReceiveEvent, event)) {
+ self->state |= ControlStateHighlighted;
+ return true;
+ }
+ } else if (event->type == SDL_EVENT_MOUSE_MOTION) {
+ if (self->state & ControlStateHighlighted) {
+ if (this->delegate.didDrag) {
+ this->delegate.didDrag(this, (int) event->motion.yrel);
+ }
+ return true;
+ }
+ } else if (event->type == SDL_EVENT_MOUSE_BUTTON_UP) {
+ if (self->state & ControlStateHighlighted) {
+ self->state &= ~ControlStateHighlighted;
+ return true;
+ }
+ }
+
+ return super(Control, self, captureEvent, event);
+}
+
+#pragma mark - ScrollThumb
+
+/**
+ * @fn ScrollThumb *ScrollThumb::initWithFrame(ScrollThumb *self, const SDL_Rect *frame)
+ * @memberof ScrollThumb
+ */
+static ScrollThumb *initWithFrame(ScrollThumb *self, const SDL_Rect *frame) {
+
+ self = (ScrollThumb *) super(Control, self, initWithFrame, frame);
+ if (self) {
+ $((View *) self, addClassName, "thumb");
+ }
+
+ return self;
+}
+
+#pragma mark - Class lifecycle
+
+/**
+ * @see Class::initialize(Class *)
+ */
+static void initialize(Class *clazz) {
+
+ ((ViewInterface *) clazz->interface)->init = init;
+
+ ((ControlInterface *) clazz->interface)->captureEvent = captureEvent;
+
+ ((ScrollThumbInterface *) clazz->interface)->initWithFrame = initWithFrame;
+}
+
+/**
+ * @fn Class *ScrollThumb::_ScrollThumb(void)
+ * @memberof ScrollThumb
+ */
+Class *_ScrollThumb(void) {
+ static Class *clazz;
+ static Once once;
+
+ do_once(&once, {
+ clazz = _initialize(&(const ClassDef) {
+ .name = "ScrollThumb",
+ .superclass = _Control(),
+ .instanceSize = sizeof(ScrollThumb),
+ .interfaceOffset = offsetof(ScrollThumb, interface),
+ .interfaceSize = sizeof(ScrollThumbInterface),
+ .initialize = initialize,
+ });
+ });
+
+ return clazz;
+}
+
+#undef _Class
diff --git a/Sources/ObjectivelyMVC/ScrollThumb.h b/Sources/ObjectivelyMVC/ScrollThumb.h
new file mode 100644
index 00000000..38557b76
--- /dev/null
+++ b/Sources/ObjectivelyMVC/ScrollThumb.h
@@ -0,0 +1,112 @@
+/*
+ * ObjectivelyMVC: Object oriented MVC framework for SDL3 and C.
+ * Copyright (C) 2014 Jay Dolan
+ *
+ * This software is provided 'as-is', without any express or implied
+ * warranty. In no event will the authors be held liable for any damages
+ * arising from the use of this software.
+ *
+ * Permission is granted to anyone to use this software for any purpose,
+ * including commercial applications, and to alter it and redistribute it
+ * freely, subject to the following restrictions:
+ *
+ * 1. The origin of this software must not be misrepresented; you must not
+ * claim that you wrote the original software. If you use this software
+ * in a product, an acknowledgment in the product documentation would be
+ * appreciated but is not required.
+ *
+ * 2. Altered source versions must be plainly marked as such, and must not be
+ * misrepresented as being the original software.
+ *
+ * 3. This notice may not be removed or altered from any source distribution.
+ */
+
+#pragma once
+
+#include
+
+/**
+ * @file
+ * @brief The draggable "grabby-widget" of a Scrollbar.
+ * @details ScrollThumb is the only interactive part of a Scrollbar: a Control
+ * that, while grabbed, reports each increment of vertical mouse motion to its
+ * delegate. Its owner (Scrollbar) maps that motion onto the scrolled view's
+ * content offset. Kept generic (no knowledge of what it scrolls) and
+ * deliberately not named like Slider's handle to avoid any control-name
+ * collision.
+ */
+
+typedef struct ScrollThumb ScrollThumb;
+typedef struct ScrollThumbInterface ScrollThumbInterface;
+
+/**
+ * @brief The ScrollThumb delegate protocol.
+ */
+typedef struct {
+
+ /**
+ * @brief The delegate self pointer (the Scrollbar).
+ */
+ ident self;
+
+ /**
+ * @brief Called for each mouse-motion increment while the thumb is grabbed.
+ * @param thumb The ScrollThumb.
+ * @param delta The vertical motion this event, in window pixels.
+ */
+ void (*didDrag)(ScrollThumb *thumb, int delta);
+
+} ScrollThumbDelegate;
+
+/**
+ * @brief A draggable scrollbar thumb.
+ * @extends Control
+ */
+struct ScrollThumb {
+
+ /**
+ * @brief The superclass.
+ * @private
+ */
+ Control control;
+
+ /**
+ * @brief The interface.
+ * @private
+ */
+ ScrollThumbInterface *interface;
+
+ /**
+ * @brief The delegate.
+ */
+ ScrollThumbDelegate delegate;
+};
+
+/**
+ * @brief The ScrollThumb interface.
+ */
+struct ScrollThumbInterface {
+
+ /**
+ * @brief The superclass interface.
+ */
+ ControlInterface controlInterface;
+
+ /**
+ * @fn ScrollThumb *ScrollThumb::initWithFrame(ScrollThumb *self, const SDL_Rect *frame)
+ * @brief Initializes this ScrollThumb with the given frame.
+ * @param self The ScrollThumb.
+ * @param frame The frame, or `NULL`.
+ * @return The initialized ScrollThumb, or `NULL` on error.
+ * @memberof ScrollThumb
+ */
+ ScrollThumb *(*initWithFrame)(ScrollThumb *self, const SDL_Rect *frame);
+};
+
+/**
+ * @fn Class *ScrollThumb::_ScrollThumb(void)
+ * @brief The ScrollThumb archetype.
+ * @return The ScrollThumb Class.
+ * @memberof ScrollThumb
+ */
+OBJECTIVELYMVC_EXPORT Class *_ScrollThumb(void);
diff --git a/Sources/ObjectivelyMVC/Scrollbar.c b/Sources/ObjectivelyMVC/Scrollbar.c
new file mode 100644
index 00000000..a7405cf1
--- /dev/null
+++ b/Sources/ObjectivelyMVC/Scrollbar.c
@@ -0,0 +1,358 @@
+/*
+ * ObjectivelyMVC: Object oriented MVC framework for SDL3 and C.
+ * Copyright (C) 2014 Jay Dolan
+ *
+ * This software is provided 'as-is', without any express or implied
+ * warranty. In no event will the authors be held liable for any damages
+ * arising from the use of this software.
+ *
+ * Permission is granted to anyone to use this software for any purpose,
+ * including commercial applications, and to alter it and redistribute it
+ * freely, subject to the following restrictions:
+ *
+ * 1. The origin of this software must not be misrepresented; you must not
+ * claim that you wrote the original software. If you use this software
+ * in a product, an acknowledgment in the product documentation would be
+ * appreciated but is not required.
+ *
+ * 2. Altered source versions must be plainly marked as such, and must not be
+ * misrepresented as being the original software.
+ *
+ * 3. This notice may not be removed or altered from any source distribution.
+ */
+
+#include
+
+#include "Scrollbar.h"
+
+#define _Class _Scrollbar
+
+// Fallback defaults, used only if the CSS `.scrollbar` rule is absent.
+// The live/tunable values are these fields bound from CSS in applyStyle.
+#define SCROLLBAR_ADORNER_SIZE 12
+#define SCROLLBAR_ADORNER_SHADE 28
+#define SCROLLBAR_STEP 32
+#define SCROLLBAR_MIN_THUMB 16
+
+const EnumName ScrollbarOrientationNames[] = MakeEnumNames(
+ MakeEnumAlias(ScrollbarOrientationRight, right),
+ MakeEnumAlias(ScrollbarOrientationLeft, left)
+);
+
+#pragma mark - Helpers
+
+/**
+ * @return `c` brightened by `delta` per channel (clamped), preserving alpha.
+ */
+static SDL_Color shade(const SDL_Color c, const int delta) {
+ return (SDL_Color) {
+ .r = (Uint8) clamp(c.r + delta, 0, 255),
+ .g = (Uint8) clamp(c.g + delta, 0, 255),
+ .b = (Uint8) clamp(c.b + delta, 0, 255),
+ .a = c.a
+ };
+}
+
+/**
+ * @brief Scrolls the bound StackView by `dy` pixels and re-syncs the thumb.
+ */
+static void scrollBy(Scrollbar *self, const int dy) {
+
+ if (self->stackView) {
+ SDL_Point offset = self->stackView->contentOffset;
+ offset.y += dy;
+ $(self->stackView, scrollToOffset, &offset);
+ $(self, update);
+
+ // Force a full bar re-layout so the adorner frames are re-asserted. A click
+ // restyles the adorner Button (invalidateStyle + needsLayout), and the base
+ // theme's `Button { autoresizing-mask: contain }` would otherwise shrink it to
+ // its empty content on that self-layout. layoutSubviews (below) neutralizes the
+ // mask and re-frames it.
+ ((View *) self)->needsLayout = true;
+ }
+}
+
+#pragma mark - Delegate callbacks
+
+/**
+ * @brief ScrollThumbDelegate; maps thumb travel onto the StackView's content offset.
+ */
+static void thumbDidDrag(ScrollThumb *thumb, int delta) {
+
+ Scrollbar *self = thumb->delegate.self;
+
+ if (self->stackView) {
+
+ const SDL_Rect panel = $(self->scrollPanel, bounds);
+ const SDL_Size content = $((View *) self->stackView, sizeThatFits);
+ const SDL_Rect visible = $((View *) self->stackView, bounds);
+
+ const int scrollRange = content.h - visible.h;
+ const int travel = panel.h - ((View *) self->thumb)->frame.h;
+
+ if (scrollRange > 0 && travel > 0) {
+ scrollBy(self, -(int) (delta * ((float) scrollRange / travel)));
+ }
+ }
+}
+
+/**
+ * @brief ButtonDelegate; the top cap steps the content toward its start.
+ */
+static void didClickTopAdorner(Button *button) {
+ Scrollbar *self = button->delegate.self;
+ scrollBy(self, self->step);
+}
+
+/**
+ * @brief ButtonDelegate; the bottom cap steps the content toward its end.
+ */
+static void didClickBottomAdorner(Button *button) {
+ Scrollbar *self = button->delegate.self;
+ scrollBy(self, -self->step);
+}
+
+#pragma mark - Object
+
+/**
+ * @see Object::dealloc(Object *)
+ */
+static void dealloc(Object *self) {
+
+ Scrollbar *this = (Scrollbar *) self;
+
+ release(this->topAdorner);
+ release(this->scrollPanel);
+ release(this->bottomAdorner);
+ release(this->thumb);
+
+ super(Object, self, dealloc);
+}
+
+#pragma mark - View
+
+/**
+ * @see View::applyStyle(View *, const Style *)
+ */
+static void applyStyle(View *self, const Style *style) {
+
+ super(View, self, applyStyle, style);
+
+ Scrollbar *this = (Scrollbar *) self;
+
+ const Inlet inlets[] = MakeInlets(
+ MakeInlet("-adorner-size", InletTypeInteger, &this->adornerSize, NULL),
+ MakeInlet("-adorner-shade", InletTypeInteger, &this->adornerShade, NULL),
+ MakeInlet("-orientation", InletTypeEnum, &this->orientation, (ident) ScrollbarOrientationNames),
+ MakeInlet("-bgcolor", InletTypeColor, &this->bgColor, NULL),
+ MakeInlet("-fgcolor", InletTypeColor, &this->fgColor, NULL),
+ MakeInlet("-thumb-min", InletTypeInteger, &this->thumbMin, NULL),
+ MakeInlet("-step", InletTypeInteger, &this->step, NULL)
+ );
+
+ $(self, bind, inlets, (Dictionary *) style->attributes);
+
+ self->backgroundColor = this->bgColor;
+
+ const SDL_Color adorn = shade(this->bgColor, this->adornerShade);
+ ((View *) this->topAdorner)->backgroundColor = adorn;
+ ((View *) this->bottomAdorner)->backgroundColor = adorn;
+ ((View *) this->thumb)->backgroundColor = this->fgColor;
+
+ self->needsLayout = true;
+}
+
+/**
+ * @see View::init(View *)
+ */
+static View *init(View *self) {
+ return (View *) $((Scrollbar *) self, initWithStackView, NULL);
+}
+
+/**
+ * @see View::layoutSubviews(View *)
+ */
+static void layoutSubviews(View *self) {
+
+ Scrollbar *this = (Scrollbar *) self;
+
+ // The base theme styles our adorner Buttons with `autoresizing-mask: contain`,
+ // which makes them self-shrink to their (empty) content -- clamped to the base
+ // `min-width: 100` -- on any self-layout. We frame the adorners by hand, so
+ // neutralize that mask before the base layout pass runs it.
+ ((View *) this->topAdorner)->autoresizingMask = ViewAutoresizingNone;
+ ((View *) this->bottomAdorner)->autoresizingMask = ViewAutoresizingNone;
+
+ super(View, self, layoutSubviews);
+
+ const SDL_Rect bounds = $(self, bounds);
+ const int a = this->adornerSize;
+
+ ((View *) this->topAdorner)->frame = (SDL_Rect) { 0, 0, bounds.w, a };
+ this->scrollPanel->frame = (SDL_Rect) { 0, a, bounds.w, max(0, bounds.h - 2 * a) };
+ ((View *) this->bottomAdorner)->frame = (SDL_Rect) { 0, bounds.h - a, bounds.w, a };
+
+ $(this, update);
+}
+
+/**
+ * @see View::render(View *, Renderer *)
+ */
+static void render(View *self, Renderer *renderer) {
+
+ Scrollbar *this = (Scrollbar *) self;
+
+ // Re-sync the thumb from the StackView's geometry every frame. StackView fires
+ // no didScroll callback (so wheel/drag offset changes are only reflected here),
+ // and the content's natural size is often not final on the Scrollbar's first
+ // layout -- so computing the thumb only in layoutSubviews left it stuck at full
+ // height until something (e.g. an adorner click) forced a re-layout. update()
+ // is cheap and idempotent.
+ if (this->stackView) {
+ $(this, update);
+ }
+
+ super(View, self, render, renderer);
+}
+
+#pragma mark - Scrollbar
+
+/**
+ * @fn Scrollbar *Scrollbar::initWithStackView(Scrollbar *self, StackView *stackView)
+ * @memberof Scrollbar
+ */
+static Scrollbar *initWithStackView(Scrollbar *self, StackView *stackView) {
+
+ self = (Scrollbar *) super(View, self, initWithFrame, NULL);
+ if (self) {
+
+ $((View *) self, addClassName, "scrollbar");
+
+ self->adornerSize = SCROLLBAR_ADORNER_SIZE;
+ self->adornerShade = SCROLLBAR_ADORNER_SHADE;
+ self->orientation = ScrollbarOrientationRight;
+ self->bgColor = (SDL_Color) { 0x22, 0x22, 0x22, 0xc0 };
+ self->fgColor = (SDL_Color) { 0x1e, 0x4e, 0x62, 0xdd };
+ self->thumbMin = SCROLLBAR_MIN_THUMB;
+ self->step = SCROLLBAR_STEP;
+
+ self->topAdorner = $(alloc(Button), initWithFrame, NULL);
+ assert(self->topAdorner);
+ $((View *) self->topAdorner, addClassName, "adorner");
+ self->topAdorner->delegate.self = self;
+ self->topAdorner->delegate.didClick = didClickTopAdorner;
+ $((View *) self, addSubview, (View *) self->topAdorner);
+
+ self->scrollPanel = $(alloc(View), initWithFrame, NULL);
+ assert(self->scrollPanel);
+ $(self->scrollPanel, addClassName, "scrollPanel");
+ self->scrollPanel->clipsSubviews = true;
+ $((View *) self, addSubview, self->scrollPanel);
+
+ self->bottomAdorner = $(alloc(Button), initWithFrame, NULL);
+ assert(self->bottomAdorner);
+ $((View *) self->bottomAdorner, addClassName, "adorner");
+ self->bottomAdorner->delegate.self = self;
+ self->bottomAdorner->delegate.didClick = didClickBottomAdorner;
+ $((View *) self, addSubview, (View *) self->bottomAdorner);
+
+ self->thumb = $(alloc(ScrollThumb), initWithFrame, NULL);
+ assert(self->thumb);
+ self->thumb->delegate.self = self;
+ self->thumb->delegate.didDrag = thumbDidDrag;
+ $(self->scrollPanel, addSubview, (View *) self->thumb);
+
+ $(self, setStackView, stackView);
+ }
+
+ return self;
+}
+
+/**
+ * @fn void Scrollbar::setStackView(Scrollbar *self, StackView *stackView)
+ * @memberof Scrollbar
+ */
+static void setStackView(Scrollbar *self, StackView *stackView) {
+
+ self->stackView = stackView;
+
+ ((View *) self)->needsLayout = true;
+}
+
+/**
+ * @fn void Scrollbar::update(Scrollbar *self)
+ * @memberof Scrollbar
+ */
+static void update(Scrollbar *self) {
+
+ if (self->stackView == NULL) {
+ return;
+ }
+
+ const SDL_Rect panel = $(self->scrollPanel, bounds);
+ const SDL_Size content = $((View *) self->stackView, sizeThatFits);
+ const SDL_Rect visible = $((View *) self->stackView, bounds);
+
+ View *thumb = (View *) self->thumb;
+
+ if (content.h <= visible.h || panel.h <= 0) {
+ thumb->frame = (SDL_Rect) { 0, 0, panel.w, panel.h };
+ } else {
+
+ const int scrollRange = content.h - visible.h;
+
+ int thumbH = (int) ((float) panel.h * visible.h / content.h);
+ thumbH = clamp(thumbH, self->thumbMin, panel.h);
+
+ const int travel = panel.h - thumbH;
+ const float fraction = clamp((float) (-self->stackView->contentOffset.y) / scrollRange, 0.f, 1.f);
+
+ thumb->frame = (SDL_Rect) { 0, (int) (fraction * travel), panel.w, thumbH };
+ }
+
+ self->syncedOffset = self->stackView->contentOffset;
+}
+
+#pragma mark - Class lifecycle
+
+/**
+ * @see Class::initialize(Class *)
+ */
+static void initialize(Class *clazz) {
+
+ ((ObjectInterface *) clazz->interface)->dealloc = dealloc;
+
+ ((ViewInterface *) clazz->interface)->applyStyle = applyStyle;
+ ((ViewInterface *) clazz->interface)->init = init;
+ ((ViewInterface *) clazz->interface)->layoutSubviews = layoutSubviews;
+ ((ViewInterface *) clazz->interface)->render = render;
+
+ ((ScrollbarInterface *) clazz->interface)->initWithStackView = initWithStackView;
+ ((ScrollbarInterface *) clazz->interface)->setStackView = setStackView;
+ ((ScrollbarInterface *) clazz->interface)->update = update;
+}
+
+/**
+ * @fn Class *Scrollbar::_Scrollbar(void)
+ * @memberof Scrollbar
+ */
+Class *_Scrollbar(void) {
+ static Class *clazz;
+ static Once once;
+
+ do_once(&once, {
+ clazz = _initialize(&(const ClassDef) {
+ .name = "Scrollbar",
+ .superclass = _View(),
+ .instanceSize = sizeof(Scrollbar),
+ .interfaceOffset = offsetof(Scrollbar, interface),
+ .interfaceSize = sizeof(ScrollbarInterface),
+ .initialize = initialize,
+ });
+ });
+
+ return clazz;
+}
+
+#undef _Class
diff --git a/Sources/ObjectivelyMVC/Scrollbar.h b/Sources/ObjectivelyMVC/Scrollbar.h
new file mode 100644
index 00000000..031c850f
--- /dev/null
+++ b/Sources/ObjectivelyMVC/Scrollbar.h
@@ -0,0 +1,223 @@
+/*
+ * ObjectivelyMVC: Object oriented MVC framework for SDL3 and C.
+ * Copyright (C) 2014 Jay Dolan
+ *
+ * This software is provided 'as-is', without any express or implied
+ * warranty. In no event will the authors be held liable for any damages
+ * arising from the use of this software.
+ *
+ * Permission is granted to anyone to use this software for any purpose,
+ * including commercial applications, and to alter it and redistribute it
+ * freely, subject to the following restrictions:
+ *
+ * 1. The origin of this software must not be misrepresented; you must not
+ * claim that you wrote the original software. If you use this software
+ * in a product, an acknowledgment in the product documentation would be
+ * appreciated but is not required.
+ *
+ * 2. Altered source versions must be plainly marked as such, and must not be
+ * misrepresented as being the original software.
+ *
+ * 3. This notice may not be removed or altered from any source distribution.
+ */
+
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include
+
+/**
+ * @file
+ * @brief A visible, draggable vertical scrollbar for any scrolling StackView.
+ * @details StackView can opt in to panning its own subviews (see the `scroll`
+ * style attribute) but draws no indicator. Scrollbar adds one, bound directly
+ * to the StackView it drives -- no wrapping ScrollView required. It is a
+ * container View (the "bar base") that lays its three regions out vertically
+ * by hand -- fixed caps top and bottom, the panel filling between them. All
+ * interactivity lives in its children: the ScrollThumb drags, the adorners
+ * step-scroll on click.
+ *
+ * Anatomy:
+ *
+ * Scrollbar (bar base, vertical View)
+ * |-- topAdorner step-up cap at the top, height = -adorner-size
+ * |-- scrollPanel the middle track the thumb travels within
+ * | `-- thumb the ScrollThumb grabby-widget
+ * `-- bottomAdorner step-down cap at the bottom, height = -adorner-size
+ *
+ * The thumb's size tracks the visible/content ratio and its position tracks
+ * the StackView's contentOffset; dragging the thumb scrolls the content,
+ * clicking an adorner steps it, and the bar re-syncs the thumb whenever the
+ * content's offset changes (StackView fires no didScroll callback). The whole
+ * bar may sit on the LEFT or RIGHT of the content (see -orientation).
+ *
+ * The bound StackView must opt in to scrolling itself -- set its `scroll`
+ * style attribute (or `scrolls` field) to `true` -- Scrollbar only drives the
+ * offset, it does not force scrolling on.
+ *
+ * Styling (all set in CSS, read in applyStyle):
+ * width the bar thickness (standard View attribute)
+ * -adorner-size the height of the top and bottom caps
+ * -adorner-shade per-channel brighten of -bgcolor for the caps (derive amount)
+ * -orientation left | right -- which side of the content the bar occupies
+ * -bgcolor the bar-base color; the adorners derive a calculated shade
+ * of this so the caps read as part of the bar
+ * -fgcolor the thumb (grabby control) color
+ * -thumb-min the minimum thumb height in pixels
+ * -step the pixels scrolled per adorner-cap click
+ */
+
+/**
+ * @brief Which side of the content a Scrollbar occupies.
+ */
+typedef enum {
+ ScrollbarOrientationRight,
+ ScrollbarOrientationLeft
+} ScrollbarOrientation;
+
+/**
+ * @brief String <-> ScrollbarOrientation mapping for the `-orientation` attribute.
+ */
+OBJECTIVELYMVC_EXPORT const EnumName ScrollbarOrientationNames[];
+
+typedef struct Scrollbar Scrollbar;
+typedef struct ScrollbarInterface ScrollbarInterface;
+
+/**
+ * @brief A draggable scrollbar bound to a scrolling StackView.
+ * @extends View
+ */
+struct Scrollbar {
+
+ /**
+ * @brief The superclass.
+ * @private
+ */
+ View view;
+
+ /**
+ * @brief The interface.
+ * @private
+ */
+ ScrollbarInterface *interface;
+
+ /**
+ * @brief The StackView this bar drives. Weak reference (not retained).
+ */
+ StackView *stackView;
+
+ /**
+ * @brief The step-up cap at the top of the bar (.adorner). A click steps up.
+ */
+ Button *topAdorner;
+
+ /**
+ * @brief The middle track within which the thumb travels (.scrollPanel).
+ */
+ View *scrollPanel;
+
+ /**
+ * @brief The step-down cap at the bottom of the bar (.adorner). Click steps down.
+ */
+ Button *bottomAdorner;
+
+ /**
+ * @brief The grabby-widget: the draggable thumb (.thumb).
+ */
+ ScrollThumb *thumb;
+
+ /**
+ * @brief The contentOffset the thumb was last synced to (for change detection).
+ * @private
+ */
+ SDL_Point syncedOffset;
+
+ /**
+ * @brief Which side of the content the bar sits on. Attribute `-orientation`.
+ */
+ ScrollbarOrientation orientation;
+
+ /**
+ * @brief The height of the top and bottom caps. Attribute `-adorner-size`.
+ */
+ int adornerSize;
+
+ /**
+ * @brief The bar-base color. Attribute `-bgcolor`. Adorners derive a shade.
+ */
+ SDL_Color bgColor;
+
+ /**
+ * @brief The thumb color. Attribute `-fgcolor`.
+ */
+ SDL_Color fgColor;
+
+ /**
+ * @brief Per-channel brighten applied to `bgColor` for the adorner caps, so
+ * they read as part of the bar. Attribute `-adorner-shade`.
+ */
+ int adornerShade;
+
+ /**
+ * @brief Minimum thumb height in pixels. Attribute `-thumb-min`.
+ */
+ int thumbMin;
+
+ /**
+ * @brief Pixels scrolled per adorner-cap click. Attribute `-step`.
+ */
+ int step;
+};
+
+/**
+ * @brief The Scrollbar interface.
+ */
+struct ScrollbarInterface {
+
+ /**
+ * @brief The superclass interface.
+ */
+ ViewInterface viewInterface;
+
+ /**
+ * @fn Scrollbar *Scrollbar::initWithStackView(Scrollbar *self, StackView *stackView)
+ * @brief Initializes this Scrollbar bound to the given StackView.
+ * @param self The Scrollbar.
+ * @param stackView The StackView to drive, or `NULL` (set later).
+ * @return The initialized Scrollbar, or `NULL` on error.
+ * @memberof Scrollbar
+ */
+ Scrollbar *(*initWithStackView)(Scrollbar *self, StackView *stackView);
+
+ /**
+ * @fn void Scrollbar::setStackView(Scrollbar *self, StackView *stackView)
+ * @brief Binds this Scrollbar to the given StackView and syncs the thumb.
+ * @details Does NOT itself enable scrolling -- the StackView must opt in on
+ * its own (`scroll: true` in CSS, or `scrolls = true` in code). This keeps
+ * opt-in single-sourced in the StackView's own configuration.
+ * @param self The Scrollbar.
+ * @param stackView The StackView to drive.
+ * @memberof Scrollbar
+ */
+ void (*setStackView)(Scrollbar *self, StackView *stackView);
+
+ /**
+ * @fn void Scrollbar::update(Scrollbar *self)
+ * @brief Resizes and repositions the thumb from the StackView's geometry.
+ * @param self The Scrollbar.
+ * @memberof Scrollbar
+ */
+ void (*update)(Scrollbar *self);
+};
+
+/**
+ * @fn Class *Scrollbar::_Scrollbar(void)
+ * @brief The Scrollbar archetype.
+ * @return The Scrollbar Class.
+ * @memberof Scrollbar
+ */
+OBJECTIVELYMVC_EXPORT Class *_Scrollbar(void);
diff --git a/Sources/ObjectivelyMVC/Select.c b/Sources/ObjectivelyMVC/Select.c
index 48d763cb..099e5b31 100644
--- a/Sources/ObjectivelyMVC/Select.c
+++ b/Sources/ObjectivelyMVC/Select.c
@@ -81,6 +81,27 @@ static void layoutSubviews(View *self) {
$((View *) this->stackView, sizeToFit);
$((View *) this->stackView, layoutIfNeeded);
+
+ // Keep the expanded menu on-screen. The options are only un-hidden (and the menu
+ // sized to its full height) just above, so this is the earliest point the true
+ // height is known -- stateDidChange sees a collapsed, all-hidden menu. The menu
+ // opens downward from the control and is parented to the window root (its frame
+ // is window space); if it runs past the bottom edge, shift it up, clamped to the
+ // top. Idempotent: once its bottom sits at the window edge, re-running is a no-op.
+ View *menu = (View *) this->stackView;
+ if ($(control, isHighlighted) && menu->window) {
+
+ int windowHeight;
+ SDL_GetWindowSize(menu->window, NULL, &windowHeight);
+
+ const int bottom = menu->frame.y + menu->frame.h;
+ if (bottom > windowHeight) {
+ menu->frame.y -= (bottom - windowHeight);
+ if (menu->frame.y < 0) {
+ menu->frame.y = 0;
+ }
+ }
+ }
}
super(View, self, layoutSubviews);
diff --git a/Sources/ObjectivelyMVC/SelectorSequence.c b/Sources/ObjectivelyMVC/SelectorSequence.c
index a35570ba..1d361395 100644
--- a/Sources/ObjectivelyMVC/SelectorSequence.c
+++ b/Sources/ObjectivelyMVC/SelectorSequence.c
@@ -103,7 +103,10 @@ static SequenceCombinator sequenceCombinator(const char *c) {
SequenceCombinator combinator = SequenceCombinatorNone;
- while (isspace(*c)) {
+ // Cast to unsigned char: isspace() is UB for negative values (non-ASCII bytes
+ // sign-extend to negative char), which the MSVC debug CRT asserts on. This
+ // also lets the parser tolerate arbitrary input bytes without crashing.
+ while (isspace((unsigned char) *c)) {
combinator = SequenceCombinatorDescendent;
c++;
}
diff --git a/Sources/ObjectivelyMVC/StackView.c b/Sources/ObjectivelyMVC/StackView.c
index 6f2d1a70..3bb16396 100644
--- a/Sources/ObjectivelyMVC/StackView.c
+++ b/Sources/ObjectivelyMVC/StackView.c
@@ -38,6 +38,8 @@ const EnumName StackViewDistributionNames[] = MakeEnumNames(
#define _Class _StackView
+#define DEFAULT_STACK_VIEW_SCROLL_STEP 12
+
#pragma mark - View
/**
@@ -52,7 +54,8 @@ static void applyStyle(View *self, const Style *style) {
const Inlet inlets[] = MakeInlets(
MakeInlet("axis", InletTypeEnum, &this->axis, (ident) StackViewAxisNames),
MakeInlet("distribution", InletTypeEnum, &this->distribution, (ident) StackViewDistributionNames),
- MakeInlet("spacing", InletTypeInteger, &this->spacing, NULL)
+ MakeInlet("spacing", InletTypeInteger, &this->spacing, NULL),
+ MakeInlet("scroll", InletTypeBool, &this->scrolls, NULL)
);
$(self, bind, inlets, (Dictionary *) style->attributes);
@@ -109,6 +112,13 @@ static void layoutSubviews(View *self) {
int pos = 0;
+ // Opt-in scrolling: when enabled, shift the stack by the (negative) scroll
+ // offset and clip the overflow. Disabled StackViews lay out exactly as before.
+ if (this->scrolls) {
+ pos = (this->axis == StackViewAxisVertical) ? this->contentOffset.y : this->contentOffset.x;
+ self->clipsSubviews = true;
+ }
+
const float scale = requestedSize ? availableSize / (float) requestedSize : 1.f;
for (size_t i = 0; i < subviews->count; i++) {
@@ -248,6 +258,34 @@ static SDL_Size sizeThatFits(const View *self) {
return size;
}
+/**
+ * @see View::respondToEvent(View *, const SDL_Event *)
+ */
+static void respondToEvent(View *self, const SDL_Event *event) {
+
+ StackView *this = (StackView *) self;
+
+ if (this->scrolls && event->type == SDL_EVENT_MOUSE_WHEEL) {
+ if ($(self, didReceiveEvent, event)) {
+
+ SDL_Point offset = this->contentOffset;
+ switch (this->axis) {
+ case StackViewAxisVertical:
+ offset.y += (int) (event->wheel.y * this->step);
+ break;
+ case StackViewAxisHorizontal:
+ offset.x -= (int) (event->wheel.x * this->step);
+ break;
+ }
+
+ $(this, scrollToOffset, &offset);
+ return;
+ }
+ }
+
+ super(View, self, respondToEvent, event);
+}
+
#pragma mark - StackView
/**
@@ -259,11 +297,45 @@ static StackView *initWithFrame(StackView *self, const SDL_Rect *frame) {
self = (StackView *) super(View, self, initWithFrame, frame);
if (self) {
self->view.autoresizingMask = ViewAutoresizingContain;
+
+ // Scrolling is opt-in; legacy StackViews stay inert.
+ self->scrolls = false;
+ self->step = DEFAULT_STACK_VIEW_SCROLL_STEP;
}
return self;
}
+/**
+ * @fn void StackView::scrollToOffset(StackView *self, const SDL_Point *offset)
+ * @memberof StackView
+ */
+static void scrollToOffset(StackView *self, const SDL_Point *offset) {
+
+ if (self->scrolls) {
+
+ const SDL_Size content = $((View *) self, sizeThatFits);
+ const SDL_Rect bounds = $((View *) self, bounds);
+
+ if (content.w > bounds.w) {
+ self->contentOffset.x = clamp(offset->x, -(content.w - bounds.w), 0);
+ } else {
+ self->contentOffset.x = 0;
+ }
+
+ if (content.h > bounds.h) {
+ self->contentOffset.y = clamp(offset->y, -(content.h - bounds.h), 0);
+ } else {
+ self->contentOffset.y = 0;
+ }
+
+ } else {
+ self->contentOffset.x = self->contentOffset.y = 0;
+ }
+
+ self->view.needsLayout = true;
+}
+
#pragma mark - Class lifecycle
/**
@@ -275,9 +347,11 @@ static void initialize(Class *clazz) {
((ViewInterface *) clazz->interface)->init = init;
((ViewInterface *) clazz->interface)->layoutSubviews = layoutSubviews;
+ ((ViewInterface *) clazz->interface)->respondToEvent = respondToEvent;
((ViewInterface *) clazz->interface)->sizeThatFits = sizeThatFits;
((StackViewInterface *) clazz->interface)->initWithFrame = initWithFrame;
+ ((StackViewInterface *) clazz->interface)->scrollToOffset = scrollToOffset;
}
/**
diff --git a/Sources/ObjectivelyMVC/StackView.h b/Sources/ObjectivelyMVC/StackView.h
index 2c016414..fe805c64 100644
--- a/Sources/ObjectivelyMVC/StackView.h
+++ b/Sources/ObjectivelyMVC/StackView.h
@@ -64,6 +64,12 @@ typedef struct StackViewInterface StackViewInterface;
* @brief StackViews are containers that manage the arrangement of their subviews.
* @extends View
* @ingroup Containers
+ * @remarks A StackView can optionally pan its own stacked subviews when its
+ * content overflows its frame. This is strictly opt-in: the `scroll` flag
+ * defaults to `false`, so legacy layouts behave exactly as before unless the
+ * `scroll` style attribute (or code) enables it. Scrolling lives directly on
+ * StackView (it remains a View, not a Control/ScrollView) to avoid inheriting
+ * framework defaults that would blanket every StackView in the UI.
*/
struct StackView {
@@ -92,6 +98,24 @@ struct StackView {
* @brief The subview spacing.
*/
int spacing;
+
+ /**
+ * @brief If `true`, this StackView pans its subviews along its axis when its
+ * content overflows its frame. Opt-in via the `scroll` style attribute.
+ */
+ bool scrolls;
+
+ /**
+ * @brief The scroll offset, in pixels. Negative along the primary axis as the
+ * content is panned (the first subview moves off the leading edge). Only used
+ * when `scrolls` is `true`.
+ */
+ SDL_Point contentOffset;
+
+ /**
+ * @brief The scroll step, in pixels, applied per mouse-wheel notch.
+ */
+ int step;
};
/**
@@ -113,6 +137,16 @@ struct StackViewInterface {
* @memberof StackView
*/
StackView *(*initWithFrame)(StackView *self, const SDL_Rect *frame);
+
+ /**
+ * @fn void StackView::scrollToOffset(StackView *self, const SDL_Point *offset)
+ * @brief Pans the stacked content to the given offset, clamped to the
+ * scrollable range. No-op unless `scrolls` is `true`.
+ * @param self The StackView.
+ * @param offset The desired content offset (negative along the axis to advance).
+ * @memberof StackView
+ */
+ void (*scrollToOffset)(StackView *self, const SDL_Point *offset);
};
/**
diff --git a/Sources/ObjectivelyMVC/Style.c b/Sources/ObjectivelyMVC/Style.c
index ff56f54c..2c17d679 100644
--- a/Sources/ObjectivelyMVC/Style.c
+++ b/Sources/ObjectivelyMVC/Style.c
@@ -415,6 +415,38 @@ static ident parseValue(String *string) {
return value;
}
+/**
+ * @brief Returns a newly allocated copy of `css` with all `/ * ... * /` comments
+ * removed. The tokenizer below splits on `{ : ; }` and has no notion of comments,
+ * so a comment absorbed into a selector or declaration silently corrupts the rule.
+ * Each comment is replaced by a single space so it still separates adjacent tokens.
+ * The caller must free the returned buffer.
+ */
+static char *stripComments(const char *css) {
+
+ const size_t len = strlen(css);
+
+ char *out = calloc(1, len + 1);
+ assert(out);
+
+ size_t w = 0;
+ for (size_t r = 0; r < len; ) {
+ if (css[r] == '/' && css[r + 1] == '*') {
+ r += 2;
+ while (r < len && !(css[r] == '*' && css[r + 1] == '/')) {
+ r++;
+ }
+ r = (r < len) ? r + 2 : len;
+ out[w++] = ' ';
+ } else {
+ out[w++] = css[r++];
+ }
+ }
+
+ out[w] = '\0';
+ return out;
+}
+
/**
* @fn Array *Style::parse(const char *css)
* @memberof Style
@@ -426,7 +458,9 @@ static Array *parse(const char *css) {
if (css) {
- StringReader *reader = $(alloc(StringReader), initWithCharacters, css);
+ char *stripped = stripComments(css);
+
+ StringReader *reader = $(alloc(StringReader), initWithCharacters, stripped);
assert(reader);
Style *style = NULL;
@@ -477,6 +511,7 @@ static Array *parse(const char *css) {
}
release(reader);
+ free(stripped);
}
return (Array *) styles;
diff --git a/Sources/ObjectivelyMVC/TabView.c b/Sources/ObjectivelyMVC/TabView.c
index 157d716c..61418ff5 100644
--- a/Sources/ObjectivelyMVC/TabView.c
+++ b/Sources/ObjectivelyMVC/TabView.c
@@ -115,6 +115,10 @@ static void respondToEvent(View *self, const SDL_Event *event) {
TabViewItem *tab = $(tabs, objectAtIndex, i);
+ if (tab->state & TabStateDisabled) {
+ continue; // a disabled tab does not respond to clicks
+ }
+
if ($((View *) tab->label, didReceiveEvent, event)) {
$(this, selectTab, tab);
}
diff --git a/Sources/ObjectivelyMVC/TabViewItem.c b/Sources/ObjectivelyMVC/TabViewItem.c
index 08298473..abefe94b 100644
--- a/Sources/ObjectivelyMVC/TabViewItem.c
+++ b/Sources/ObjectivelyMVC/TabViewItem.c
@@ -99,6 +99,12 @@ static void setState(TabViewItem *self, int state) {
} else {
$((View *) self->label, removeClassName, "selected");
}
+
+ if (self->state & TabStateDisabled) {
+ $((View *) self->label, addClassName, "disabled");
+ } else {
+ $((View *) self->label, removeClassName, "disabled");
+ }
}
#pragma mark - Class lifecycle
diff --git a/Sources/ObjectivelyMVC/TabViewItem.h b/Sources/ObjectivelyMVC/TabViewItem.h
index 9390172a..d7594e7f 100644
--- a/Sources/ObjectivelyMVC/TabViewItem.h
+++ b/Sources/ObjectivelyMVC/TabViewItem.h
@@ -38,7 +38,8 @@
*/
typedef enum {
TabStateDefault = 0x0,
- TabStateSelected = 0x1
+ TabStateSelected = 0x1,
+ TabStateDisabled = 0x2
} TabState;
typedef struct TabViewItem TabViewItem;
diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c
index f1428810..88a432af 100644
--- a/Sources/ObjectivelyMVC/View.c
+++ b/Sources/ObjectivelyMVC/View.c
@@ -543,40 +543,78 @@ static void clearWarnings(const View *self, WarningType type) {
* @fn SDL_Rect View::clippingFrame(const View *self)
* @memberof View
*/
+/**
+ * @brief Border-expands `frame` by `view`'s border, in place (matches the
+ * per-pixel expansion the renderer applies).
+ */
+static SDL_Rect expandForBorder(const View *view, SDL_Rect frame) {
+
+ if (view->borderWidth && view->borderColor.a) {
+ frame.x -= view->borderWidth;
+ frame.y -= view->borderWidth;
+ frame.w += view->borderWidth * 2;
+ frame.h += view->borderWidth * 2;
+ }
+
+ return frame;
+}
+
+#define VIEW_MAX_CLIP_DEPTH 128
+
static SDL_Rect clippingFrame(const View *self) {
- SDL_Rect frame = $(self, renderFrame);
+ // A view's clipping frame is its (border-expanded) window-space frame,
+ // intersected with the (border-expanded) window-space frame of every ancestor
+ // that clips its subviews. The previous implementation recursed into
+ // `superview->clippingFrame` for each clipping ancestor, which made this
+ // O(depth^2). Since the renderer calls this for every view every frame (and it
+ // also backs hit-testing), that was the dominant cost in a deep, wide UI. This
+ // computes the same result in a single O(depth) pass: gather the ancestor
+ // chain, derive each view's absolute frame top-down exactly as renderFrame
+ // does, then intersect self with the clipping ancestors.
- if (self->borderWidth && self->borderColor.a) {
- for (int i = 0; i < self->borderWidth; i++) {
- frame.x -= 1;
- frame.y -= 1;
- frame.w += 2;
- frame.h += 2;
- }
+ const View *chain[VIEW_MAX_CLIP_DEPTH];
+ SDL_Rect abs[VIEW_MAX_CLIP_DEPTH];
+
+ size_t depth = 0;
+ for (const View *v = self; v && depth < VIEW_MAX_CLIP_DEPTH; v = v->superview) {
+ chain[depth++] = v;
}
- const View *superview = self->superview;
- while (superview) {
- if (superview->clipsSubviews) {
- const SDL_Rect clippingFrame = $(superview, clippingFrame);
- if (SDL_GetRectIntersection(&clippingFrame, &frame, &frame) == false) {
+ // Pathologically deep tree (should never happen): fall back to renderFrame so
+ // we never read past the fixed chain and silently mis-clip.
+ if (depth == VIEW_MAX_CLIP_DEPTH && chain[depth - 1]->superview) {
+ return $(self, renderFrame);
+ }
- if (MVC_LogEnabled(SDL_LOG_PRIORITY_VERBOSE)) {
- String *desc = $((Object *) self, description);
- String *superdesc = $((Object *) superview, description);
+ // Top-down: each view's absolute origin = its frame plus its parent's absolute
+ // origin, plus the parent's padding when the child is not internally aligned
+ // (identical accumulation to renderFrame).
+ int accX = 0, accY = 0;
+ for (size_t i = depth; i > 0; i--) {
- MVC_LogVerbose("%s is clipped by %s\n", desc->chars, superdesc->chars);
+ const View *v = chain[i - 1];
+ abs[i - 1] = (SDL_Rect) { v->frame.x + accX, v->frame.y + accY, v->frame.w, v->frame.h };
- release(desc);
- release(superdesc);
- }
+ if (i - 1 > 0) {
+ const View *child = chain[i - 2];
+ const bool pad = child->alignment != ViewAlignmentInternal;
+ accX = abs[i - 1].x + (pad ? v->padding.left : 0);
+ accY = abs[i - 1].y + (pad ? v->padding.top : 0);
+ }
+ }
+
+ SDL_Rect frame = expandForBorder(self, abs[0]);
+ for (size_t i = 1; i < depth; i++) {
+ const View *ancestor = chain[i];
+ if (ancestor->clipsSubviews) {
+ const SDL_Rect clip = expandForBorder(ancestor, abs[i]);
+ if (SDL_GetRectIntersection(&clip, &frame, &frame) == false) {
frame.w = frame.h = 0;
break;
}
}
- superview = superview->superview;
}
return frame;
diff --git a/Sources/ObjectivelyMVC/WindowController.c b/Sources/ObjectivelyMVC/WindowController.c
index 736a5d59..ee72d82d 100644
--- a/Sources/ObjectivelyMVC/WindowController.c
+++ b/Sources/ObjectivelyMVC/WindowController.c
@@ -33,23 +33,73 @@
#pragma mark - Delegates
/**
- * @brief ViewEnumerator for `SDL_EVENT_MOUSE_MOTION`.
+ * @return True if `ancestor` is `view` or one of its superviews.
*/
-static void mouseMotion_enumerate(View *view, ident data) {
+static bool isInChain(const View *ancestor, const View *view) {
+
+ for (const View *v = view; v; v = v->superview) {
+ if (v == ancestor) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * @brief Dispatches mouse-enter / mouse-leave for an `SDL_EVENT_MOUSE_MOTION`.
+ * @details Only the views along the hit-test paths of the old and new cursor
+ * positions can change hover state, so we walk those two paths (via `superview`)
+ * and fire enter/leave on their difference -- instead of scanning the entire
+ * visible tree and hit-testing every view twice. The old approach was
+ * O(views * depth^2) per motion event (containsPoint recomputes an uncached
+ * clippingFrame up the ancestor chain), which becomes pathological in a deep,
+ * wide UI (e.g. a large scrolling list) and floods during any drag or hover.
+ * This is O(depth).
+ */
+static void mouseMotion(WindowController *self, const SDL_MouseMotionEvent *event) {
+
+ View *root = self->viewController->view;
- const SDL_MouseMotionEvent *event = data;
const SDL_Point a = MakePoint(event->x - event->xrel, event->y - event->yrel);
const SDL_Point b = MakePoint(event->x, event->y);
- if ($(view, containsPoint, &a) && !$(view, containsPoint, &b)) {
- $(view, emitViewEvent, ViewEventMouseLeave, NULL);
- $(view, invalidateStyle);
- } else if ($(view, containsPoint, &b) && !$(view, containsPoint, &a)) {
- $(view, emitViewEvent, ViewEventMouseEnter, NULL);
- $(view, invalidateStyle);
+ View *from = $(root, hitTest, &a);
+ View *to = $(root, hitTest, &b);
+
+ // Same topmost view under both positions: nothing entered or left. This is the
+ // overwhelmingly common case (the cursor moving within a single control).
+ if (from == to) {
+ return;
+ }
+
+ // Leaving: views under the old position that are no longer under the new one.
+ for (View *v = from; v; v = v->superview) {
+ if (!isInChain(v, to)) {
+ $(v, emitViewEvent, ViewEventMouseLeave, NULL);
+ v->needsApplyTheme = true;
+ }
+ }
+
+ // Entering: views under the new position that were not under the old one.
+ for (View *v = to; v; v = v->superview) {
+ if (!isInChain(v, from)) {
+ $(v, emitViewEvent, ViewEventMouseEnter, NULL);
+ v->needsApplyTheme = true;
+ }
}
}
+// The `:hover` pseudo (see View::matchesSelector) matches a view purely by
+// whether the cursor is over its OWN frame -- there is no `ancestor:hover
+// descendant` cascade -- so a hover change on a view can only alter that view's
+// own computed style. We therefore mark just the entered/left view for
+// re-theming (needsApplyTheme), NOT its whole subtree via invalidateStyle. That
+// distinction is critical: invalidateStyle re-styles every descendant, so
+// entering a large container (e.g. the editor's scrolling stage list) would
+// re-compute the style of thousands of views in a single frame -- a massive
+// spike the instant the cursor crosses into it.
+
#pragma mark - Object
/**
@@ -262,7 +312,7 @@ static void respondToEvent(WindowController *self, const SDL_Event *event) {
}
if (event->type == SDL_EVENT_MOUSE_MOTION) {
- $(self->viewController->view, enumerateVisible, mouseMotion_enumerate, (ident) event);
+ mouseMotion(self, &event->motion);
}
View *keyResponder = $(self, keyResponder), *touchResponder = $(self, touchResponder);