From 001ade737da80d908bdf10db0b35c3bb38821ac5 Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sun, 31 May 2026 15:09:30 +0200 Subject: [PATCH] feat: collapsible, reorderable categories with draggable actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Categories view gains drag-and-drop and persistent collapse: - model: add `collapsed` to Slider/ButtonCategory (serde default), saved in the profile. Card order is the Vec order, so reordering persists. - glue: handlers for toggle-collapse, reorder-category (move card to an insertion index), and move-line (relocate an action/stream within or between categories of the same kind), each with the index-shift math. - UI: a collapse chevron per card; ⠿ grip handles on category headers and action rows drive a hand-rolled drag (Slint has no native DnD). Drop targets are hit-tested from the global pointer vs each element's absolute-position, with an accent drop-line indicator and a dimmed source. Geometry reads are ternary-guarded so absolute-position is only touched during an active drag — reading it at init recurses through text layout. Co-Authored-By: Claude Opus 4.8 --- src/glue.rs | 101 +++++++++++++++++- src/main.rs | 8 +- src/model.rs | 10 +- ui/app.slint | 292 ++++++++++++++++++++++++++++++++++++++++----------- 4 files changed, 340 insertions(+), 71 deletions(-) diff --git a/src/glue.rs b/src/glue.rs index ad72370..7b22995 100644 --- a/src/glue.rs +++ b/src/glue.rs @@ -116,10 +116,10 @@ pub fn wire( let mut s = shared.lock(); if kind == 0 { let id = next_id(&s.preset.slider_categories, |c| c.id); - s.preset.slider_categories.push(SliderCategory { id, name: name.to_string(), streams: vec![] }); + s.preset.slider_categories.push(SliderCategory { id, name: name.to_string(), streams: vec![], collapsed: false }); } else { let id = next_id(&s.preset.button_categories, |c| c.id); - s.preset.button_categories.push(ButtonCategory { id, name: name.to_string(), actions: vec![] }); + s.preset.button_categories.push(ButtonCategory { id, name: name.to_string(), actions: vec![], collapsed: false }); } let _ = crate::storage::save_preset(&s.preset); let Some(ui) = weak.upgrade() else { return }; @@ -212,6 +212,64 @@ pub fn wire( }); } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_toggle_category_collapse(move |kind, id| { + let mut s = shared.lock(); + let id = id as u32; + if kind == 0 { + if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == id) { + c.collapsed = !c.collapsed; + } + } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == id) { + c.collapsed = !c.collapsed; + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + // Reorder category cards (cosmetic, but persisted via Vec order). + ui.on_reorder_category(move |kind, from_id, to_index| { + let mut s = shared.lock(); + let from_id = from_id as u32; + let to = to_index.max(0) as usize; + if kind == 0 { + reorder_by_id(&mut s.preset.slider_categories, from_id, to, |c| c.id); + } else { + reorder_by_id(&mut s.preset.button_categories, from_id, to, |c| c.id); + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + refresh_home(&ui, &s); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + // Move an action/stream within or between categories (same target-kind). + ui.on_move_line(move |kind, from_cat, from_idx, to_cat, to_idx| { + let mut s = shared.lock(); + let (from_cat, to_cat) = (from_cat as u32, to_cat as u32); + let (from_idx, to_idx) = (from_idx.max(0) as usize, to_idx.max(0) as usize); + if kind == 0 { + move_line(&mut s.preset.slider_categories, from_cat, from_idx, to_cat, to_idx, + |c| c.id, |c| &mut c.streams); + } else { + move_line(&mut s.preset.button_categories, from_cat, from_idx, to_cat, to_idx, + |c| c.id, |c| &mut c.actions); + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + }); + } + // ─── wizard ──────────────────────────────────────────────────────────── { let weak = ui.as_weak(); @@ -702,6 +760,43 @@ fn preset_curve(_preset: &Preset) -> (crate::curve::CurvePreset, crate::curve::B (crate::curve::CurvePreset::Linear, crate::curve::BezierPoints::LINEAR) } +/// Move the category with `from_id` to insertion index `to_index` (0..=len). +fn reorder_by_id(cats: &mut Vec, from_id: u32, to_index: usize, id: impl Fn(&C) -> u32) { + let Some(from) = cats.iter().position(|c| id(c) == from_id) else { return }; + let item = cats.remove(from); + // `to_index` was computed against the original list; removing the item + // shifts everything after it down by one. + let to = if from < to_index { to_index - 1 } else { to_index }; + cats.insert(to.min(cats.len()), item); +} + +/// Move element `from_idx` of category `from_cat` to insertion index `to_idx` of +/// category `to_cat` (may be the same category). `to_idx` is in the destination's +/// original ordering. +fn move_line( + cats: &mut [C], + from_cat: u32, + from_idx: usize, + to_cat: u32, + to_idx: usize, + id: impl Fn(&C) -> u32, + lines: impl Fn(&mut C) -> &mut Vec, +) { + let Some(from_pos) = cats.iter().position(|c| id(c) == from_cat) else { return }; + let Some(to_pos) = cats.iter().position(|c| id(c) == to_cat) else { return }; + let item = { + let v = lines(&mut cats[from_pos]); + if from_idx >= v.len() { + return; + } + v.remove(from_idx) + }; + // Within the same category, a removal before the target shifts it down. + let to = if from_cat == to_cat && from_idx < to_idx { to_idx - 1 } else { to_idx }; + let v = lines(&mut cats[to_pos]); + v.insert(to.min(v.len()), item); +} + fn push_preset_to_ui(ui: &AppWindow, preset: &Preset) { let sliders: Vec = preset .slider_categories @@ -710,6 +805,7 @@ fn push_preset_to_ui(ui: &AppWindow, preset: &Preset) { id: c.id as i32, name: c.name.clone().into(), count: c.streams.len() as i32, + collapsed: c.collapsed, lines: ModelRc::new(VecModel::from( c.streams.iter().enumerate().map(|(i, s)| LineItem { id: i as i32, @@ -727,6 +823,7 @@ fn push_preset_to_ui(ui: &AppWindow, preset: &Preset) { id: c.id as i32, name: c.name.clone().into(), count: c.actions.len() as i32, + collapsed: c.collapsed, lines: ModelRc::new(VecModel::from( c.actions.iter().enumerate().map(|(i, a)| LineItem { id: i as i32, diff --git a/src/main.rs b/src/main.rs index c8baa9b..70ff379 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,11 +145,11 @@ fn main() -> anyhow::Result<()> { // seed two slider categories let scats = vec![ - CategorySummary { id: 1, name: "Music".into(), count: 1, + CategorySummary { id: 1, name: "Music".into(), count: 1, collapsed: false, lines: ModelRc::new(VecModel::from(vec![ LineItem { id: 0, primary: "spotify".into(), secondary: "process".into(), icon_kind: 0 }, ])) }, - CategorySummary { id: 2, name: "Chat".into(), count: 2, + CategorySummary { id: 2, name: "Chat".into(), count: 2, collapsed: false, lines: ModelRc::new(VecModel::from(vec![ LineItem { id: 0, primary: "discord".into(), secondary: "process".into(), icon_kind: 0 }, LineItem { id: 1, primary: "default mic".into(), secondary: "microphone".into(), icon_kind: 1 }, @@ -157,11 +157,11 @@ fn main() -> anyhow::Result<()> { ]; ui.set_slider_categories(ModelRc::new(VecModel::from(scats))); let bcats = vec![ - CategorySummary { id: 1, name: "Toggle mute".into(), count: 1, + CategorySummary { id: 1, name: "Toggle mute".into(), count: 1, collapsed: false, lines: ModelRc::new(VecModel::from(vec![ LineItem { id: 0, primary: "Mute microphone".into(), secondary: "default mic".into(), icon_kind: 1 }, ])) }, - CategorySummary { id: 2, name: "Media keys".into(), count: 2, + CategorySummary { id: 2, name: "Media keys".into(), count: 2, collapsed: false, lines: ModelRc::new(VecModel::from(vec![ LineItem { id: 0, primary: "Simulate key".into(), secondary: "Play/Pause (179)".into(), icon_kind: 4 }, LineItem { id: 1, primary: "Open website".into(), secondary: "https://example.com".into(), icon_kind: 3 }, diff --git a/src/model.rs b/src/model.rs index 1d50c8f..4dc6604 100644 --- a/src/model.rs +++ b/src/model.rs @@ -81,6 +81,9 @@ pub struct ButtonCategory { pub name: String, #[serde(default)] pub actions: Vec, + /// UI-only: whether the card is collapsed in the categories view. + #[serde(default)] + pub collapsed: bool, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -89,6 +92,9 @@ pub struct SliderCategory { pub name: String, #[serde(default)] pub streams: Vec, + /// UI-only: whether the card is collapsed in the categories view. + #[serde(default)] + pub collapsed: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -229,8 +235,8 @@ mod tests { #[test] fn next_id_grows() { let v: Vec = vec![ - SliderCategory { id: 1, name: "a".into(), streams: vec![] }, - SliderCategory { id: 5, name: "b".into(), streams: vec![] }, + SliderCategory { id: 1, name: "a".into(), streams: vec![], collapsed: false }, + SliderCategory { id: 5, name: "b".into(), streams: vec![], collapsed: false }, ]; assert_eq!(next_id(&v, |c| c.id), 6); } diff --git a/ui/app.slint b/ui/app.slint index 51a1176..22549ca 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -24,7 +24,7 @@ export struct SliderCell { value: int, percent: float, category: string, assigne export struct ButtonCell { pressed: bool, category: string, assigned: bool } // icon-kind: 0 process · 1 mic · 2 output · 3 website · 4 key · 5 volume/mute export struct LineItem { id: int, primary: string, secondary: string, icon-kind: int } -export struct CategorySummary { id: int, name: string, count: int, lines: [LineItem] } +export struct CategorySummary { id: int, name: string, count: int, collapsed: bool, lines: [LineItem] } export struct AssignPick { id: int, name: string } export struct PickerEntry { name: string, icon-kind: int, selected: bool } export struct WizardResult { @@ -736,6 +736,13 @@ component HomePage inherits VerticalBox { } } +// Thin accent line shown as a drop position indicator between rows / cards. +component DropMark inherits Rectangle { + in property active; + height: 3px; border-radius: 1.5px; + background: root.active ? Theme.accent-bg : transparent; +} + component CategoriesPage inherits VerticalBox { in property target-kind; in property <[CategorySummary]> categories; @@ -746,11 +753,30 @@ component CategoriesPage inherits VerticalBox { callback delete-line(int, int); callback add-line(int); callback edit-line(int, int); + callback toggle-collapse(int); // cat id + callback reorder-category(int, int); // from-id, to-insertion-index + callback move-line(int, int, int, int); // from-cat, from-idx, to-cat, to-idx in-out property draft-name: ""; in-out property renaming-id: -1; in-out property rename-draft: ""; + // ── drag state ────────────────────────────────────────────────────────── + // Global pointer position (window coords) while a drag is active. + property ptr-x; + property ptr-y; + property row-h: 46px; + // Line (action/stream) drag. + property line-drag: false; + property drag-from-cat: -1; + property drag-from-idx: -1; + property drop-cat: -1; + property drop-idx: -1; + // Category card drag. + property cat-drag: false; + property drag-cat-id: -1; + property drop-cat-pos: -1; + spacing: 10px; HorizontalLayout { @@ -789,77 +815,208 @@ component CategoriesPage inherits VerticalBox { horizontal-alignment: center; } } } - for cat in root.categories: Card { - VerticalLayout { - padding: 12px; spacing: 6px; - // Header row - HorizontalLayout { spacing: 8px; - if root.renaming-id != cat.id: Text { text: cat.name; - font-weight: 700; font-size: 14px; - font-family: "Inter"; vertical-alignment: center; - horizontal-stretch: 1; } - if root.renaming-id == cat.id: LineEdit { - text <=> root.rename-draft; height: 32px; - horizontal-stretch: 1; - accepted => { - if (root.rename-draft != "") { root.rename(cat.id, root.rename-draft); } - root.renaming-id = -1; - } + for cat[ci] in root.categories: VerticalLayout { + spacing: 8px; + // Drop indicator above this card (category reorder). + DropMark { active: root.cat-drag && root.drop-cat-pos == ci; } + card := Card { + // Coarse hit-test: is the drag pointer over this card? The ternary + // guard is load-bearing — `absolute-position` must only be read + // during an active drag. Reading it at init (Slint's `&&` does NOT + // short-circuit the operand away) recurses through text layout. + property ptr-over: + (root.line-drag || root.cat-drag) + ? (root.ptr-x >= card.absolute-position.x + && root.ptr-x < card.absolute-position.x + card.width + && root.ptr-y >= card.absolute-position.y + && root.ptr-y < card.absolute-position.y + card.height) + : false; + // Category-reorder target: which insertion slot this card maps to. + property cat-slot: + (root.cat-drag && card.ptr-over) + ? (root.ptr-y < card.absolute-position.y + card.height / 2 ? ci : ci + 1) + : -999; + changed cat-slot => { + if (card.cat-slot != -999) { root.drop-cat-pos = card.cat-slot; } + } + // Line-drop fallback for collapsed / empty cards (no rows to claim). + property line-over: root.line-drag && card.ptr-over; + changed line-over => { + if (card.line-over && (cat.collapsed || cat.lines.length == 0)) { + root.drop-cat = cat.id; root.drop-idx = cat.lines.length; } - if root.renaming-id != cat.id: Text { - text: cat.count + (cat.count == 1 ? " item" : " items"); - font-family: "Inter"; font-size: 11px; - color: Palette.foreground.with-alpha(0.55); - vertical-alignment: center; } - SquareIconButton { - icon: root.renaming-id == cat.id - ? @image-url("../assets/icons/check.svg") - : @image-url("../assets/icons/edit.svg"); - clicked => { - if (root.renaming-id == cat.id) { + } + opacity: (root.cat-drag && root.drag-cat-id == cat.id) ? 0.4 : 1.0; + + VerticalLayout { + padding: 12px; spacing: 6px; + // Header row + HorizontalLayout { spacing: 8px; + // Drag handle — reorder category cards. + catgrip := TouchArea { + width: 22px; mouse-cursor: grab; + pointer-event(ev) => { + if (ev.button == PointerEventButton.left && ev.kind == PointerEventKind.down) { + root.cat-drag = true; root.drag-cat-id = cat.id; root.drop-cat-pos = ci; + root.ptr-x = catgrip.absolute-position.x + catgrip.mouse-x; + root.ptr-y = catgrip.absolute-position.y + catgrip.mouse-y; + } else if (ev.button == PointerEventButton.left && ev.kind == PointerEventKind.up) { + if (root.cat-drag && root.drop-cat-pos != -1) { + root.reorder-category(root.drag-cat-id, root.drop-cat-pos); + } + root.cat-drag = false; root.drop-cat-pos = -1; + } + } + moved => { + root.ptr-x = catgrip.absolute-position.x + catgrip.mouse-x; + root.ptr-y = catgrip.absolute-position.y + catgrip.mouse-y; + } + Image { source: @image-url("../assets/icons/grid-dots.svg"); + width: 16px; height: 16px; + colorize: Palette.foreground.with-alpha(0.45); + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + } + SquareIconButton { + icon: cat.collapsed + ? @image-url("../assets/icons/chevron-right.svg") + : @image-url("../assets/icons/chevron-down.svg"); + clicked => { root.toggle-collapse(cat.id); } + } + if root.renaming-id != cat.id: Text { text: cat.name; + font-weight: 700; font-size: 14px; + font-family: "Inter"; vertical-alignment: center; + horizontal-stretch: 1; } + if root.renaming-id == cat.id: LineEdit { + text <=> root.rename-draft; height: 32px; + horizontal-stretch: 1; + accepted => { if (root.rename-draft != "") { root.rename(cat.id, root.rename-draft); } root.renaming-id = -1; - } else { - root.renaming-id = cat.id; root.rename-draft = cat.name; } } + if root.renaming-id != cat.id: Text { + text: cat.count + (cat.count == 1 ? " item" : " items"); + font-family: "Inter"; font-size: 11px; + color: Palette.foreground.with-alpha(0.55); + vertical-alignment: center; } + SquareIconButton { + icon: root.renaming-id == cat.id + ? @image-url("../assets/icons/check.svg") + : @image-url("../assets/icons/edit.svg"); + clicked => { + if (root.renaming-id == cat.id) { + if (root.rename-draft != "") { root.rename(cat.id, root.rename-draft); } + root.renaming-id = -1; + } else { + root.renaming-id = cat.id; root.rename-draft = cat.name; + } + } + } + SquareIconButton { icon: @image-url("../assets/icons/trash.svg"); + tint: #d32f2f.with-alpha(0.8); + clicked => { root.delete(cat.id); } } } - SquareIconButton { icon: @image-url("../assets/icons/trash.svg"); - tint: #d32f2f.with-alpha(0.8); - clicked => { root.delete(cat.id); } } - } - if cat.lines.length > 0: Rectangle { height: 1px; - background: Palette.border.with-alpha(0.5); } - for line[idx] in cat.lines: HorizontalLayout { - spacing: 8px; padding-top: 2px; padding-bottom: 2px; - Rectangle { width: 3px; background: Theme.accent-bg; - border-radius: 1.5px; } - KindIcon { kind: line.icon-kind; size: 20px; - y: parent.height / 2 - 10px; } - VerticalLayout { - horizontal-stretch: 1; spacing: 0px; - alignment: center; - Text { text: line.primary; font-weight: 500; - font-size: 12px; font-family: "Inter"; } - Text { text: line.secondary; font-size: 10px; - font-family: "Inter"; - color: Palette.foreground.with-alpha(0.55); } - } - SquareIconButton { icon: @image-url("../assets/icons/edit.svg"); - clicked => { root.edit-line(cat.id, idx); } } - SquareIconButton { icon: @image-url("../assets/icons/x.svg"); - tint: #d32f2f.with-alpha(0.8); - clicked => { root.delete-line(cat.id, idx); } } - } - HorizontalLayout { padding-top: 2px; - Rectangle { horizontal-stretch: 1; } - IconButton { - icon: @image-url("../assets/icons/plus.svg"); - label: root.target-kind == 0 ? "Add stream" : "Add action"; - clicked => { root.add-line(cat.id); } + + if !cat.collapsed: VerticalLayout { + spacing: 0px; + if cat.lines.length > 0: Rectangle { height: 1px; + background: Palette.border.with-alpha(0.5); } + for line[idx] in cat.lines: VerticalLayout { + spacing: 0px; + // Drop indicator above this row. + DropMark { active: root.line-drag && root.drop-cat == cat.id && root.drop-idx == idx; } + therow := Rectangle { + height: root.row-h; + // Insertion slot this row maps to while a line drag is + // active. Ternary-guarded so `absolute-position` is only + // read mid-drag (see the card hit-test note above). + property ptr-slot: + root.line-drag + ? ((root.ptr-x >= therow.absolute-position.x + && root.ptr-x < therow.absolute-position.x + therow.width + && root.ptr-y >= therow.absolute-position.y + && root.ptr-y < therow.absolute-position.y + therow.height) + ? (root.ptr-y < therow.absolute-position.y + therow.height / 2 ? idx : idx + 1) + : -999) + : -999; + changed ptr-slot => { + if (therow.ptr-slot != -999) { + root.drop-cat = cat.id; root.drop-idx = therow.ptr-slot; + } + } + opacity: (root.line-drag && root.drag-from-cat == cat.id + && root.drag-from-idx == idx) ? 0.4 : 1.0; + HorizontalLayout { + spacing: 8px; + // Drag handle — reorder / move this line. + linegrip := TouchArea { + width: 22px; mouse-cursor: grab; + pointer-event(ev) => { + if (ev.button == PointerEventButton.left && ev.kind == PointerEventKind.down) { + root.line-drag = true; root.drag-from-cat = cat.id; + root.drag-from-idx = idx; + root.drop-cat = cat.id; root.drop-idx = idx; + root.ptr-x = linegrip.absolute-position.x + linegrip.mouse-x; + root.ptr-y = linegrip.absolute-position.y + linegrip.mouse-y; + } else if (ev.button == PointerEventButton.left && ev.kind == PointerEventKind.up) { + if (root.line-drag && root.drop-cat != -1) { + root.move-line(root.drag-from-cat, root.drag-from-idx, + root.drop-cat, root.drop-idx); + } + root.line-drag = false; root.drop-cat = -1; root.drop-idx = -1; + } + } + moved => { + root.ptr-x = linegrip.absolute-position.x + linegrip.mouse-x; + root.ptr-y = linegrip.absolute-position.y + linegrip.mouse-y; + } + Image { source: @image-url("../assets/icons/grid-dots.svg"); + width: 15px; height: 15px; + colorize: Palette.foreground.with-alpha(0.4); + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + } + Rectangle { width: 3px; background: Theme.accent-bg; + border-radius: 1.5px; } + VerticalLayout { alignment: center; + KindIcon { kind: line.icon-kind; size: 20px; } } + VerticalLayout { + horizontal-stretch: 1; spacing: 0px; + alignment: center; + Text { text: line.primary; font-weight: 500; + font-size: 12px; font-family: "Inter"; } + Text { text: line.secondary; font-size: 10px; + font-family: "Inter"; + color: Palette.foreground.with-alpha(0.55); } + } + VerticalLayout { alignment: center; + SquareIconButton { icon: @image-url("../assets/icons/edit.svg"); + clicked => { root.edit-line(cat.id, idx); } } } + VerticalLayout { alignment: center; + SquareIconButton { icon: @image-url("../assets/icons/x.svg"); + tint: #d32f2f.with-alpha(0.8); + clicked => { root.delete-line(cat.id, idx); } } } + } + } + } + // Trailing drop indicator (append position). + DropMark { active: root.line-drag && root.drop-cat == cat.id + && root.drop-idx == cat.lines.length; } + + HorizontalLayout { padding-top: 2px; + Rectangle { horizontal-stretch: 1; } + IconButton { + icon: @image-url("../assets/icons/plus.svg"); + label: root.target-kind == 0 ? "Add stream" : "Add action"; + clicked => { root.add-line(cat.id); } + } + } } } } + // Trailing drop indicator after the last card (reorder to end). + if ci == root.categories.length - 1: DropMark { + active: root.cat-drag && root.drop-cat-pos == root.categories.length; + } } } } @@ -1568,6 +1725,9 @@ export component AppWindow inherits Window { callback refresh-live-lists(); callback wizard-state-changed(); callback wizard-toggle-pick(string); + callback toggle-category-collapse(int, int); // kind, cat-id + callback reorder-category(int, int, int); // kind, from-id, to-index + callback move-line(int, int, int, int, int); // kind, from-cat, from-idx, to-cat, to-idx callback exit-app(); callback rename-preset(string, string); callback export-preset(string); @@ -1753,6 +1913,9 @@ export component AppWindow inherits Window { delete-line(id, idx) => { root.delete-line(0, id, idx); } add-line(id) => { root.open-wizard(0, id); } edit-line(id, idx) => { root.edit-line(0, id, idx); } + toggle-collapse(id) => { root.toggle-category-collapse(0, id); } + reorder-category(fid, to) => { root.reorder-category(0, fid, to); } + move-line(fc, fi, tc, ti) => { root.move-line(0, fc, fi, tc, ti); } } if root.current-page == 2: CategoriesPage { target-kind: 1; categories: root.button-categories; @@ -1762,6 +1925,9 @@ export component AppWindow inherits Window { delete-line(id, idx) => { root.delete-line(1, id, idx); } add-line(id) => { root.open-wizard(1, id); } edit-line(id, idx) => { root.edit-line(1, id, idx); } + toggle-collapse(id) => { root.toggle-category-collapse(1, id); } + reorder-category(fid, to) => { root.reorder-category(1, fid, to); } + move-line(fc, fi, tc, ti) => { root.move-line(1, fc, fi, tc, ti); } } if root.current-page == 3: SettingsPage { theme-index <=> root.theme-index;