@gjsify/adwaita-core 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,8 @@
1
+ import type { AdwComboOption, AdwListItemsChanged } from './list.js';
2
+ export { ADW_COMBO_NO_SELECTION, normalizeComboOptions } from './list.js';
3
+ export type { AdwComboOption, AdwComboOptionInput } from './list.js';
4
+ export { SpinState } from './adjustment.js';
5
+ export type { SpinStateChange, SpinStateListener, SpinStateRangeListener } from './adjustment.js';
1
6
  /** Subscriber for {@link ExpanderState} changes — receives the new expanded flag. */
2
7
  export type ExpanderStateListener = (expanded: boolean) => void;
3
8
  /**
@@ -23,44 +28,6 @@ export declare class ExpanderState {
23
28
  /** Collapse the disclosure. Returns whether it changed. */
24
29
  collapse(): boolean;
25
30
  }
26
- /** One selectable option in a {@link ComboState}. */
27
- export interface AdwComboOption {
28
- /** Display label shown in the row + chooser. */
29
- label: string;
30
- /** Underlying value returned by {@link ComboState.selectedValue}. */
31
- value: string;
32
- }
33
- /**
34
- * What an author may write for one option before {@link normalizeComboOptions}
35
- * has seen it: a bare string (value === label, the `Gtk.StringList` case), or a
36
- * partial descriptor where either half may stand in for the other.
37
- */
38
- export type AdwComboOptionInput = string | {
39
- value?: unknown;
40
- label?: unknown;
41
- };
42
- /**
43
- * Raw authored options → the stable `{ value, label }` descriptors every combo surface
44
- * works with.
45
- *
46
- * A bare string is both value and label (`Gtk.StringList`'s model, which is what
47
- * `Adw.ComboRow` is fed in the common case). In a descriptor either half stands in for the
48
- * missing other, so `{ label: 'Apple' }` is addressable by the value `'Apple'` rather than
49
- * by `undefined`. ONE home for the rule, so `<adw-combo-row>` and `<gtk-drop-down>` cannot
50
- * accept two different option vocabularies.
51
- */
52
- export declare function normalizeComboOptions(raw: ReadonlyArray<AdwComboOptionInput> | null | undefined): AdwComboOption[];
53
- /**
54
- * The "no item is selected" index — the TS mirror of `GTK_INVALID_LIST_POSITION`
55
- * (`AdwComboRow:selected`'s default and empty value).
56
- *
57
- * libadwaita spells it `G_MAXUINT` because the property is a `guint`; `-1` is the idiomatic
58
- * TS sentinel and the spelling {@link ADW_SIDEBAR_NO_SELECTION} uses for the same GTK
59
- * constant. Without it, an EMPTY model and a model whose first item has an empty label are
60
- * indistinguishable — both report index 0 with `selectedValue === ''` — so a renderer that
61
- * wants to draw a placeholder has nothing to test.
62
- */
63
- export declare const ADW_COMBO_NO_SELECTION = -1;
64
31
  /** Payload of a {@link ComboState} change. */
65
32
  export interface ComboStateChange {
66
33
  /** The (new) selected index, or {@link ADW_COMBO_NO_SELECTION}. */
@@ -72,41 +39,94 @@ export interface ComboStateChange {
72
39
  /** True for a user pick ({@link ComboState.select}); false for a programmatic set. */
73
40
  interactive: boolean;
74
41
  }
75
- /** Subscriber for {@link ComboState} changes. */
42
+ /** Subscriber for {@link ComboState} selection changes. */
76
43
  export type ComboStateListener = (change: ComboStateChange) => void;
44
+ /** Subscriber for {@link ComboState} MODEL changes — one `items-changed` per replacement. */
45
+ export type ComboItemsListener = (change: AdwListItemsChanged) => void;
77
46
  /**
78
- * The selection state of a combo row: an options list plus the selected index,
79
- * with a two-way index↔value mapping and empty/out-of-range guards. Mirrors
80
- * `Adw.ComboRow`: a programmatic set ({@link setSelectedIndex} / {@link setSelectedValue}
81
- * / {@link setOptions}) updates silently (`interactive: false` — the renderer refreshes
82
- * the inline value but re-emits no `notify::selected`), while {@link select} is the
83
- * user pick that notifies (`interactive: true`).
47
+ * The selection state of a combo row: a portable list model (`AdwListModel`) plus
48
+ * the selected index, with a two-way index↔value mapping and empty/out-of-range guards.
49
+ * Mirrors `Adw.ComboRow`: a programmatic set ({@link setSelectedIndex} /
50
+ * {@link setSelectedValue} / {@link setModel}) updates silently (`interactive: false` —
51
+ * the renderer refreshes the inline value but re-emits no `notify::selected`), while
52
+ * {@link select} is the user pick that notifies (`interactive: true`).
53
+ *
54
+ * TWO SIGNALS, because they answer different questions and a renderer needs both
55
+ * (ADR 0046). {@link subscribe} reports the SELECTION; {@link subscribeItems} reports WHERE
56
+ * the model changed, so a renderer splices its item views instead of rebuilding them.
57
+ * `Adw.ComboRow` gets both from GTK for free — the first is `notify::selected`, the second
58
+ * is `Gio.ListModel::items-changed` on the model it holds — and this class had only the
59
+ * first, which is why both browser selectors dropped every option node on every
60
+ * assignment.
84
61
  */
85
62
  export declare class ComboState {
86
63
  private _options;
87
64
  private _selected;
88
65
  private readonly _listeners;
66
+ private readonly _itemListeners;
89
67
  /** Subscribe to selection changes. Returns an unsubscribe function. */
90
68
  subscribe(listener: ComboStateListener): () => void;
69
+ /**
70
+ * Subscribe to MODEL changes — `Gio.ListModel::items-changed`, one splice per
71
+ * {@link setModel}. Returns an unsubscribe function.
72
+ *
73
+ * Fires BEFORE the selection change {@link setModel} also emits, which is the order
74
+ * that makes it usable: the selection subscriber writes the new index into the item
75
+ * views, so those views have to exist by then. The rebuild path had the inverse
76
+ * ordering hazard written down in `<adw-combo-row>` — "AFTER the rebuild, never during
77
+ * it" — and this is what removes it rather than documenting it again.
78
+ */
79
+ subscribeItems(listener: ComboItemsListener): () => void;
91
80
  private _emit;
92
81
  private _setIndex;
93
- /** The selectable options. */
94
- get options(): AdwComboOption[];
95
82
  /**
96
- * Replace the options, then notify (`interactive: false`) so the renderer re-syncs the
97
- * inline value label — which may change even at an unchanged index, because the label is
98
- * read out of the MODEL.
83
+ * The list model — `Adw.ComboRow:model` / `Gtk.DropDown:model`, in portable form.
84
+ *
85
+ * A COPY, items included, and that is load-bearing rather than defensive. The splice
86
+ * (ADR 0046) decides what changed by COMPARING the assigned model against the stored
87
+ * one, so anything that leaks a reference into `_options` makes the comparison read the
88
+ * new value on both sides and answer "nothing changed". Handing back `_options` itself
89
+ * did exactly that, and it turned a pattern that WORKED under the old full rebuild into
90
+ * a silent no-op:
91
+ *
92
+ * ```ts
93
+ * const m = row.model; m.push(item); row.model = m; // → no splice, DOM unchanged
94
+ * ```
95
+ *
96
+ * Fresh items and not just a fresh array, because the same failure sits one level down:
97
+ * `m[0].label = 'x'` through a shared descriptor mutates the stored one too, and the
98
+ * comparison is by BOTH halves. A frozen item would have made the mutation loud; a copy
99
+ * makes the round trip CORRECT, which is the difference between documenting the hazard
100
+ * and removing it.
101
+ *
102
+ * Cost, MEASURED rather than assumed (Node 24.19, `{value,label}` records): linear, about
103
+ * four nanoseconds per item — so a read is well under a microsecond at every size a combo
104
+ * row actually holds, and the assignment beside it already costs more. What the shape
105
+ * rules out is a per-item read: the three call sites that had one — `_applyItemsChanged`
106
+ * in each browser selector and `_filter` in `<gtk-drop-down>`, which runs per row per
107
+ * keystroke — hoist it out of their loop, so a read is O(n) once per operation instead of
108
+ * O(n²). Nothing reads it per frame. A model large enough for the linear term to matter is
109
+ * a `Gtk.ListView` and belongs to `ListController`'s virtualisation, which ADR 0046 § 7
110
+ * keeps out of this class on purpose.
111
+ */
112
+ get model(): AdwComboOption[];
113
+ /**
114
+ * Replace the model: emit the splice that turns the old one into the new
115
+ * ({@link subscribeItems}), then notify the SELECTION (`interactive: false`) so the
116
+ * renderer re-syncs the inline value label — which may change even at an unchanged
117
+ * index, because the label is read out of the model.
99
118
  *
100
- * Replacing the model re-runs autoselect, so an index the new one does not have falls
101
- * back to 0 — including the sentinel, which is how a row recovers a selection when its
102
- * model grows. An EMPTY model has no 0 to fall back to and lands on
103
- * {@link ADW_COMBO_NO_SELECTION}.
119
+ * Replacing the model re-runs autoselect ({@link clampListSelection}), so an index the
120
+ * new one does not have falls back to 0 — including the sentinel, which is how a row
121
+ * recovers a selection when its model grows. An EMPTY model has no 0 to fall back to
122
+ * and lands on {@link ADW_COMBO_NO_SELECTION}.
104
123
  *
105
- * The check is `hasIndex`, not `selected >= length`: the sentinel is BELOW the range, so
106
- * a one-sided comparison leaves a `-1` in place and a model that was emptied and
107
- * refilled comes back with nothing selected.
124
+ * The selection notify is UNCONDITIONAL while the splice is not: an assignment of an
125
+ * equal model emits no `items-changed` (GTK emits none either) but still re-runs the
126
+ * label sync, because that is the behaviour every renderer has depended on since this
127
+ * class existed and nothing measured says it is wrong.
108
128
  */
109
- setOptions(options: AdwComboOption[]): void;
129
+ setModel(model: AdwComboOption[]): void;
110
130
  /**
111
131
  * The selected option index, or {@link ADW_COMBO_NO_SELECTION}. May also sit PAST the
112
132
  * options length: {@link setSelectedIndex} is deliberately permissive, mirroring a
@@ -157,57 +177,6 @@ export declare class ComboState {
157
177
  /** Number of options. */
158
178
  get count(): number;
159
179
  }
160
- /** Payload of a {@link SpinState} change. */
161
- export interface SpinStateChange {
162
- /** The new value (already clamped to `[min, max]`). */
163
- value: number;
164
- /** True for a stepper press ({@link SpinState.increment}/{@link SpinState.decrement}); false for a programmatic set/re-clamp. */
165
- interactive: boolean;
166
- }
167
- /** Subscriber for {@link SpinState} changes. */
168
- export type SpinStateListener = (change: SpinStateChange) => void;
169
- /**
170
- * The numeric adjustment state of a spin row: `value` bounded by `[min, max]` and
171
- * stepped by `step`. Every mutation clamps into range. Mirrors `Adw.SpinRow`: a
172
- * programmatic {@link setValue} (or a re-clamp from a moved {@link setMin}/{@link setMax})
173
- * notifies `interactive: false` (the renderer refreshes the display but re-emits no
174
- * `notify::value`), while {@link increment}/{@link decrement} are the stepper presses
175
- * that notify `interactive: true`.
176
- */
177
- export declare class SpinState {
178
- private _value;
179
- private _min;
180
- private _max;
181
- private _step;
182
- private readonly _listeners;
183
- /** Subscribe to value changes. Returns an unsubscribe function. */
184
- subscribe(listener: SpinStateListener): () => void;
185
- private _emit;
186
- private _clamp;
187
- private _bump;
188
- /** Re-clamp the current value after a bound moved; notifies `interactive: false` on change. */
189
- private _reclamp;
190
- /** The current numeric value (always within `[min, max]`). */
191
- get value(): number;
192
- /** Lower bound. */
193
- get min(): number;
194
- /** Upper bound. */
195
- get max(): number;
196
- /** Increment/decrement step applied per stepper press. */
197
- get step(): number;
198
- /** Programmatic value set — clamp, notify `interactive: false` on change. Returns whether it changed. */
199
- setValue(value: number): boolean;
200
- /** Set the lower bound and re-clamp the value into range. Returns whether the value changed. */
201
- setMin(value: number): boolean;
202
- /** Set the upper bound and re-clamp the value into range. Returns whether the value changed. */
203
- setMax(value: number): boolean;
204
- /** Set the step. A non-positive or non-finite step falls back to `1`. */
205
- setStep(value: number): void;
206
- /** Step up by `step`, clamped to `max`. Notifies `interactive: true` on change. Returns whether it changed. */
207
- increment(): boolean;
208
- /** Step down by `step`, clamped to `min`. Notifies `interactive: true` on change. Returns whether it changed. */
209
- decrement(): boolean;
210
- }
211
180
  /** Payload of a {@link ToggleGroupState} change. */
212
181
  export interface ToggleGroupStateChange {
213
182
  /** The newly-selected segment index. */
@@ -1,3 +1,4 @@
1
+ import type { AdwMenuInput, AdwMenuItem, AdwMenuModel, AdwMenuPath } from './menu.js';
1
2
  /**
2
3
  * The four mutually exclusive content slots of the action half. `'empty'` is the
3
4
  * initial state — `label`, `icon-name` and `child` are all `NULL`.
@@ -21,22 +22,6 @@ export type SplitButtonStyleClass = 'image-button' | 'text-button';
21
22
  * single freeze/thaw, and that sequence cannot be reconstructed from the final state alone.
22
23
  */
23
24
  export type SplitButtonProperty = 'label' | 'icon-name' | 'child' | 'use-underline' | 'menu-model' | 'popover' | 'direction' | 'dropdown-tooltip';
24
- /**
25
- * One `GMenuModel` item: a label plus the detailed action it invokes, i.e. what
26
- * `Gio.Menu.append (label, action)` produces. `id` and `icon` are the renderers'
27
- * menu-button extras, so every menu call site can converge on ONE type; the name is
28
- * family-neutral because `Adw.MenuButton` needs exactly this.
29
- */
30
- export interface AdwMenuEntry {
31
- /** Display label. */
32
- label: string;
33
- /** Detailed action name, e.g. `app.save-as`. */
34
- action?: string;
35
- /** Stable identifier, defaulting to {@link label} on activation. */
36
- id?: string;
37
- /** Symbolic icon name for the item. */
38
- icon?: string;
39
- }
40
25
  /** Payload of a {@link SplitButtonState} notification. */
41
26
  export interface SplitButtonChange {
42
27
  /** Which content slot is filled. */
@@ -55,7 +40,7 @@ export interface SplitButtonChange {
55
40
  open: boolean;
56
41
  /** The `notify::*` properties C emits for this one mutation, in emission order. */
57
42
  notified: readonly SplitButtonProperty[];
58
- /** True for a user gesture ({@link SplitButtonState.toggleMenu} / {@link SplitButtonState.activateMenuEntry}). */
43
+ /** True for a user gesture ({@link SplitButtonState.toggleMenu} / {@link SplitButtonState.activateMenuItem}). */
59
44
  interactive: boolean;
60
45
  }
61
46
  /** Subscriber for {@link SplitButtonState} changes. */
@@ -127,12 +112,6 @@ export declare function splitButtonRootState(action: SplitButtonHalfState, dropd
127
112
  active: boolean;
128
113
  checked: boolean;
129
114
  };
130
- /**
131
- * Parse a JSON menu attribute into entries. Total by construction — malformed JSON, a
132
- * non-array root and entries without a `label` key all yield `[]` / are dropped — because
133
- * the input is author-written markup and a typo must not take the widget down with it.
134
- */
135
- export declare function parseMenuEntries(json: string | null | undefined): AdwMenuEntry[];
136
115
  /**
137
116
  * The whole `Adw.SplitButton` state machine: content mode, use-underline,
138
117
  * menu-model/popover exclusivity, direction, dropdown tooltip and the popup flag. Every
@@ -194,19 +173,25 @@ export declare class SplitButtonState {
194
173
  * PROPERTY lives here; the `_S` → underlined-S derivation belongs to `GtkLabel`.
195
174
  */
196
175
  setUseUnderline(value: boolean): boolean;
197
- /** The menu entries, or `null` when no menu model is set. */
198
- get menuModel(): readonly AdwMenuEntry[] | null;
176
+ /** The menu model, or `null` when none is set. Always normalised (ADR 0042). */
177
+ get menuModel(): AdwMenuModel | null;
199
178
  /**
200
179
  * Set the menu model, dissociating any popover and replacing it with the model-derived
201
180
  * one.
202
181
  *
182
+ * NORMALISES AT THE DOOR, so every surface stores the same shape whatever it was
183
+ * handed — a JSON attribute, a bare `string[]`, a JSX array of descriptors. It is
184
+ * {@link normalizeMenuModel}'s idempotence that makes `setMenuModel(state.menuModel)`
185
+ * a no-op rather than a quiet flattening.
186
+ *
203
187
  * DELIBERATE NORMALISATION: an EMPTY list is stored as "no menu model". C can hold a
204
188
  * non-`NULL` but empty `GMenu`, which leaves the dropdown live and pops up an empty
205
- * popover — but no renderer can construct that state (one parses a JSON array, another
206
- * takes a label list), so collapsing the two is what makes "no menu ⇒ insensitive
207
- * dropdown" hold from a renderer's point of view.
189
+ * popover; collapsing the two is what makes "no menu ⇒ insensitive dropdown" hold
190
+ * from a renderer's point of view. A model that is non-empty but draws nothing — one
191
+ * empty section — is NOT collapsed: it has a node, so it is a menu, which is the
192
+ * answer GTK gives for the same `GMenu`.
208
193
  */
209
- setMenuModel(entries: readonly AdwMenuEntry[] | null | undefined): boolean;
194
+ setMenuModel(input: AdwMenuInput | null | undefined): boolean;
210
195
  /** The popover: the one the app set, or the model-derived stand-in, or `null`. */
211
196
  get popover(): object | null;
212
197
  /**
@@ -248,13 +233,17 @@ export declare class SplitButtonState {
248
233
  /** The dropdown half was activated by the user; flips the menu open/closed. */
249
234
  toggleMenu(): boolean;
250
235
  /**
251
- * Activate a menu entry BY POSITION and dismiss the menu, returning the entry
252
- * (or `null` for an out-of-range index).
236
+ * Activate a menu item BY POSITION and dismiss the menu, returning the item
237
+ * (or `null` for a path that names no item).
253
238
  *
254
239
  * Position is the addressing a `GMenuModel` actually uses — each item carries its own
255
240
  * detailed action. Resolving a choice by LABEL instead silently dispatches the first of
256
241
  * two identically named entries and cannot tell an entry called `Cancel` from a
257
242
  * dismissed sheet.
243
+ *
244
+ * A PATH rather than a single index (ADR 0042), because a link is a model of its own:
245
+ * `[1, 0]` is the first item of the submenu at position 1, and a flat index cannot
246
+ * name it. A top-level choice is `[2]`.
258
247
  */
259
- activateMenuEntry(index: number): AdwMenuEntry | null;
248
+ activateMenuItem(path: AdwMenuPath): AdwMenuItem | null;
260
249
  }
@@ -1,3 +1,4 @@
1
+ import type { AdwListItemsChanged } from './list.js';
1
2
  /**
2
3
  * Input shape for a tab page.
3
4
  *
@@ -120,6 +121,29 @@ export interface TabViewPagesChange {
120
121
  }
121
122
  /** Subscriber for {@link TabViewState} page-list changes. */
122
123
  export type TabViewPagesListener = (change: TabViewPagesChange) => void;
124
+ /**
125
+ * The same change as a portable `items-changed` (ADR 0046) — the mapping that makes "the
126
+ * tab view's page signal IS the positional list signal" a CHECKABLE claim rather than a
127
+ * sentence in an ADR.
128
+ *
129
+ * `TabViewPagesChange` was the only positional collection signal in this package, and it
130
+ * is the evidence the portable one is shaped right; the portable one is GLib's, because
131
+ * `AdwTabView:pages` really is a `GListModel` even though the PAGES are appended by a
132
+ * method. The five kinds fold onto the three numbers exactly:
133
+ *
134
+ * attached (p, 0, 1) an item arrived at p
135
+ * detached (p, 1, 0) an item left p
136
+ * updated (p, 1, 1) the item at p is a different thing to draw — which is how
137
+ * `GListModel` spells a per-item change; it has no other verb
138
+ * reordered \
139
+ * pinned / the span between the two positions, removed and re-added. A move has no
140
+ * verb either, and `g_list_store_move` reports it the same way.
141
+ *
142
+ * NOT a lossy conversion in the direction that matters: {@link AdwListItemsChanged} plus
143
+ * the change's `id` is enough to replay the whole page order, which is what the two
144
+ * renderer tab-view suites assert against the orders their real widgets end on.
145
+ */
146
+ export declare function tabViewItemsChanged(change: TabViewPagesChange): AdwListItemsChanged;
123
147
  /** What a tab draws in its icon and indicator slots — the result of {@link tabIconState}. */
124
148
  export interface TabIconState {
125
149
  /** The icon name to paint, or `null` when there is none to paint. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-core",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "Headless Adwaita widget behavior (breakpoint condition grammar + apply/unapply state machine, color-scheme observable, avatar derivation) shared by the web and NativeScript Adwaita renderers — pure TS, no platform imports",
5
5
  "type": "module",
6
6
  "module": "lib/esm/index.js",
@@ -50,8 +50,8 @@
50
50
  "headless"
51
51
  ],
52
52
  "devDependencies": {
53
- "@gjsify/cli": "^0.46.0",
54
- "@gjsify/unit": "^0.46.0",
53
+ "@gjsify/cli": "^0.48.0",
54
+ "@gjsify/unit": "^0.48.0",
55
55
  "@types/node": "^25.9.2",
56
56
  "typescript": "^6.0.3"
57
57
  },