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);