Skip to main content

fresh/view/
scene.rs

1//! Shared semantic UI projections — the single source of truth for *what* the
2//! chrome is, computed once in the core and consumed by every frontend.
3//!
4//! The guiding principle (see docs/internal/UNIFIED_SCENE_DESIGN.md): the TUI and
5//! the web/GUI must not re-implement the same logic. Everything semantic — which
6//! menus exist, which items are enabled/checked, their accelerators, which menu
7//! is open — is derived here, once. A frontend then only does the *rendering*
8//! (this model → cells for the TUI; this model → HTML for the web) and the input
9//! bridge (crossterm vs. DOM → the shared `handle_key`/`handle_mouse`).
10//!
11//! These projections derive `serde::Serialize` so the web bridge can ship them
12//! as-is; the field names match the JSON the browser frontend already consumes.
13
14use crate::app::Editor;
15use fresh_core::LeafId;
16use ratatui::layout::Rect;
17use serde::Serialize;
18use std::collections::HashMap;
19
20/// A cell rectangle, serialized as `{x, y, w, h}` (matching the bridge's
21/// historical `rect_json`).
22#[derive(Debug, Clone, Copy, Serialize)]
23pub struct RectView {
24    pub x: u16,
25    pub y: u16,
26    pub w: u16,
27    pub h: u16,
28}
29
30impl From<Rect> for RectView {
31    fn from(r: Rect) -> Self {
32        RectView {
33            x: r.x,
34            y: r.y,
35            w: r.width,
36            h: r.height,
37        }
38    }
39}
40
41/// One item in a menu, projected semantically (no cells). `kind` tags the
42/// variant so the frontend can render actions, separators, submenus and labels
43/// differently.
44#[derive(Debug, Clone, Serialize)]
45#[serde(tag = "kind", rename_all = "lowercase")]
46pub enum MenuItemView {
47    Action {
48        label: String,
49        action: String,
50        #[serde(skip_serializing_if = "HashMap::is_empty")]
51        args: HashMap<String, serde_json::Value>,
52        accel: Option<String>,
53        enabled: bool,
54        checked: Option<bool>,
55    },
56    Sep,
57    Submenu {
58        label: String,
59        items: Vec<MenuItemView>,
60    },
61    Label {
62        label: String,
63    },
64}
65
66/// A top-level menu: its label, its menu-bar cell position (when laid out), and
67/// its item tree.
68#[derive(Debug, Clone, Serialize)]
69pub struct MenuEntry {
70    pub label: String,
71    /// Whether this menu's `when` condition is satisfied. Derived once here via
72    /// the shared `is_menu_visible` (the same the TUI uses), so the frontend
73    /// doesn't re-decide visibility on its own.
74    pub visible: bool,
75    pub x: Option<u16>,
76    pub w: Option<u16>,
77    pub items: Vec<MenuItemView>,
78}
79
80/// The currently open dropdown's cell geometry (from the pipeline's MenuLayout),
81/// so a frontend can position native rows at the exact cells the editor
82/// hit-tests against.
83#[derive(Debug, Clone, Serialize)]
84pub struct DropdownView {
85    pub rect: Option<RectView>,
86    pub items: Vec<ItemArea>,
87    pub submenus: Vec<SubmenuArea>,
88}
89
90#[derive(Debug, Clone, Serialize)]
91pub struct ItemArea {
92    pub index: usize,
93    pub rect: RectView,
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct SubmenuArea {
98    pub depth: usize,
99    pub index: usize,
100    pub rect: RectView,
101}
102
103/// The full semantic menu model: the menu tree plus which menu/item is open and
104/// highlighted. The editor is the single source of truth for open/highlight;
105/// frontends render this and forward interactions back through `handle_mouse`.
106#[derive(Debug, Clone, Serialize)]
107#[serde(rename_all = "camelCase")]
108pub struct MenuView {
109    pub menus: Vec<MenuEntry>,
110    pub menu_open: Option<usize>,
111    pub menu_highlight: Option<usize>,
112    pub submenu_path: Vec<usize>,
113    pub dropdown: Option<DropdownView>,
114}
115
116fn item_view(editor: &Editor, item: &fresh_core::menu::MenuItem) -> MenuItemView {
117    use fresh_core::menu::MenuItem::*;
118    match item {
119        Separator { .. } => MenuItemView::Sep,
120        Action {
121            label,
122            action,
123            args,
124            when: _,
125            checkbox,
126        } => MenuItemView::Action {
127            label: label.clone(),
128            action: action.clone(),
129            args: args.clone(),
130            accel: editor.accelerator_for(action),
131            // Same enabled/checked logic the TUI MenuRenderer uses — one source.
132            enabled: crate::view::ui::menu::is_menu_item_enabled(
133                item,
134                &editor.menu_state().context,
135            ),
136            checked: checkbox.as_ref().map(|_| {
137                crate::view::ui::menu::is_checkbox_checked(checkbox, &editor.menu_state().context)
138            }),
139        },
140        Submenu { label, items } => MenuItemView::Submenu {
141            label: label.clone(),
142            items: items.iter().map(|i| item_view(editor, i)).collect(),
143        },
144        DynamicSubmenu { label, .. } => MenuItemView::Submenu {
145            label: label.clone(),
146            items: Vec::new(),
147        },
148        Label { info } => MenuItemView::Label {
149            label: info.clone(),
150        },
151    }
152}
153
154fn union_rect(rects: &[Rect]) -> Option<Rect> {
155    let mut acc: Option<Rect> = None;
156    for r in rects {
157        acc = Some(match acc {
158            None => *r,
159            Some(a) => {
160                let x0 = a.x.min(r.x);
161                let y0 = a.y.min(r.y);
162                let x1 = (a.x + a.width).max(r.x + r.width);
163                let y1 = (a.y + a.height).max(r.y + r.height);
164                Rect::new(x0, y0, x1 - x0, y1 - y0)
165            }
166        });
167    }
168    acc
169}
170
171impl Editor {
172    /// Build the semantic menu model. This is the *single* place the menu's
173    /// structure, enabled/checked state and accelerators are derived; the TUI
174    /// renderer and the web bridge both consume this rather than recomputing it.
175    ///
176    /// Geometry (`x`/`w`, dropdown rects) comes from the pipeline's `MenuLayout`,
177    /// which is populated during render — so this reflects the most recent frame.
178    pub fn menu_view(&self) -> MenuView {
179        let chrome = self.active_chrome();
180        let menu_areas: HashMap<usize, Rect> = chrome
181            .menu_layout
182            .as_ref()
183            .map(|m| m.menu_areas.iter().cloned().collect())
184            .unwrap_or_default();
185
186        // Same expanded menu list the TUI renderer uses (config + plugin menus),
187        // so the two frontends never diverge on which menus/items exist.
188        let menus: Vec<MenuEntry> = self
189            .all_menus_expanded()
190            .iter()
191            .enumerate()
192            .map(|(i, m)| MenuEntry {
193                label: m.label.clone(),
194                visible: crate::view::ui::menu::is_menu_visible(m, &self.menu_state().context),
195                x: menu_areas.get(&i).map(|r| r.x),
196                w: menu_areas.get(&i).map(|r| r.width),
197                items: m.items.iter().map(|it| item_view(self, it)).collect(),
198            })
199            .collect();
200
201        let dropdown = chrome.menu_layout.as_ref().and_then(|ml| {
202            if ml.item_areas.is_empty() {
203                return None;
204            }
205            let rects: Vec<Rect> = ml.item_areas.iter().map(|(_, r)| *r).collect();
206            Some(DropdownView {
207                rect: union_rect(&rects).map(RectView::from),
208                items: ml
209                    .item_areas
210                    .iter()
211                    .map(|(index, r)| ItemArea {
212                        index: *index,
213                        rect: RectView::from(*r),
214                    })
215                    .collect(),
216                submenus: ml
217                    .submenu_areas
218                    .iter()
219                    .map(|(depth, index, r)| SubmenuArea {
220                        depth: *depth,
221                        index: *index,
222                        rect: RectView::from(*r),
223                    })
224                    .collect(),
225            })
226        });
227
228        let ms = self.menu_state();
229        MenuView {
230            menus,
231            menu_open: ms.active_menu,
232            menu_highlight: ms.highlighted_item,
233            submenu_path: ms.submenu_path.clone(),
234            dropdown,
235        }
236    }
237}
238
239// ─────────────────────────── tabs ───────────────────────────
240
241/// One tab in a pane's tab bar (semantic; geometry from the pipeline's
242/// TabLayout for click/close hit-testing).
243#[derive(Debug, Clone, Serialize)]
244#[serde(rename_all = "camelCase")]
245pub struct TabView {
246    pub buffer_id: Option<usize>,
247    pub label: String,
248    pub active: bool,
249    pub modified: bool,
250    pub rect: RectView,
251    pub close_rect: RectView,
252}
253
254/// A pane's tab bar: the bar rect (when laid out) and its tabs.
255#[derive(Debug, Clone, Default, Serialize)]
256pub struct TabBarView {
257    pub bar: Option<RectView>,
258    pub tabs: Vec<TabView>,
259}
260
261// ─────────────────────────── status bar ───────────────────────────
262
263#[derive(Debug, Clone, Serialize)]
264pub struct StatusSegment {
265    pub name: &'static str,
266    pub key: Option<String>,
267    pub text: String,
268    pub x: u16,
269    pub w: u16,
270    pub side: &'static str,
271}
272
273#[derive(Debug, Clone, Serialize)]
274pub struct StatusView {
275    pub rect: RectView,
276    pub segments: Vec<StatusSegment>,
277}
278
279// ─────────────────────────── command palette / picker ───────────────────────────
280
281#[derive(Debug, Clone, Serialize)]
282pub struct SuggestionView {
283    pub text: String,
284    pub description: Option<String>,
285    pub keybinding: Option<String>,
286    pub disabled: bool,
287}
288
289#[derive(Debug, Clone, Serialize)]
290#[serde(rename_all = "camelCase")]
291pub struct PaletteView {
292    pub query: String,
293    pub message: String,
294    pub prompt_type: &'static str,
295    pub overlay: bool,
296    pub title: String,
297    pub status: String,
298    pub selected: Option<usize>,
299    pub scroll_start: usize,
300    pub visible_count: usize,
301    pub total: usize,
302    pub outer_rect: Option<RectView>,
303    pub list_rect: Option<RectView>,
304    /// Content rect of the live-grep / quick-open preview pane (the buffer
305    /// interior, inside the left border). The preview is real rendered cells,
306    /// so the bridge slices them from this rect and the frontend draws them
307    /// like a pane interior. `None` when no preview is showing.
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub preview_rect: Option<RectView>,
310    pub suggestions: Vec<SuggestionView>,
311    /// Optional plugin-built toolbar for the overlay header (real `WidgetSpec`
312    /// widgets — e.g. live-grep's scope toggles). Rendered natively; toggle/
313    /// button clicks route back through `toggle_overlay_toolbar_widget`.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub toolbar: Option<fresh_core::api::WidgetSpec>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub toolbar_focus: Option<String>,
318}
319
320/// Stable tag for a prompt type so the frontend can label the palette/picker.
321fn prompt_type_tag(t: &crate::view::prompt::PromptType) -> &'static str {
322    use crate::view::prompt::PromptType::*;
323    match t {
324        QuickOpen => "quickopen",
325        LiveGrep => "livegrep",
326        Search | ReplaceSearch | QueryReplaceSearch => "search",
327        OpenFile | OpenFileWithEncoding { .. } => "openfile",
328        SaveFileAs => "saveas",
329        GotoLine | GotoByteOffset => "goto",
330        _ => "input",
331    }
332}
333
334impl Editor {
335    /// Semantic tab bar for a pane (leaf). Single derivation of tab labels /
336    /// active / modified shared by the TUI tab renderer and the web bridge.
337    pub fn tab_bar_view(&self, leaf: LeafId) -> TabBarView {
338        let active = self.active_buffer();
339        let layout = self.active_layout();
340        match layout.tab_layouts.get(&leaf) {
341            None => TabBarView::default(),
342            Some(tl) => TabBarView {
343                bar: Some(RectView::from(tl.bar_area)),
344                tabs: tl
345                    .tabs
346                    .iter()
347                    .map(|tab| {
348                        let bid = tab.target.as_buffer();
349                        TabView {
350                            buffer_id: bid.map(|b| b.0),
351                            label: bid
352                                .and_then(|b| self.buffer_display_name(b))
353                                .unwrap_or_else(|| "untitled".into()),
354                            active: bid == Some(active),
355                            modified: bid.map(|b| self.buffer_is_modified(b)).unwrap_or(false),
356                            rect: RectView::from(tab.tab_area),
357                            close_rect: RectView::from(tab.close_area),
358                        }
359                    })
360                    .collect(),
361            },
362        }
363    }
364
365    /// Semantic status bar: the whole bar tiled into labeled indicator segments
366    /// plus the untracked text runs between them (file name / Ln,Col). The
367    /// segment *text* is lifted from the rendered `buf` for now. Single
368    /// derivation shared by both frontends.
369    pub fn status_view(&self) -> Option<StatusView> {
370        let chrome = self.active_chrome();
371        let (sy, sx, sw) = chrome.status_bar.area?;
372
373        // Read the status bar's semantic model captured by the renderer — no
374        // cell scraping. Each rendered element (indicators + text) is a segment,
375        // and `side` is the renderer's actual left/right tiling (carried on the
376        // segment), not a midpoint guess from `x`.
377        let segments: Vec<StatusSegment> = chrome
378            .status_bar
379            .segments
380            .iter()
381            .filter(|s| !s.text.trim().is_empty())
382            .map(|s| StatusSegment {
383                name: s.name,
384                key: s.key.clone(),
385                text: s.text.trim().to_string(),
386                x: s.x,
387                w: s.w,
388                side: s.side,
389            })
390            .collect();
391
392        Some(StatusView {
393            rect: RectView {
394                x: sx,
395                y: sy,
396                w: sw,
397                h: 1,
398            },
399            segments,
400        })
401    }
402
403    /// Semantic command palette / picker, derived from the active prompt and the
404    /// pipeline's suggestion-popup geometry. `None` unless a picker list (or a
405    /// floating overlay) is showing. Single derivation shared by both frontends.
406    pub fn palette_view(&self) -> Option<PaletteView> {
407        let chrome = self.active_chrome();
408        let sugg_outer = chrome.suggestions_outer_area;
409        let sugg_area = chrome.suggestions_area;
410        let prompt_results = chrome.prompt_results_area;
411        let p = self.active_window().prompt.as_ref()?;
412        if p.suggestions.is_empty() && !p.overlay {
413            return None;
414        }
415        let (scroll_start, visible, total) = sugg_area.map(|(_, s, v, t)| (s, v, t)).unwrap_or((
416            p.scroll_offset,
417            p.suggestions.len(),
418            p.suggestions.len(),
419        ));
420        Some(PaletteView {
421            query: p.input.clone(),
422            message: p.message.clone(),
423            prompt_type: prompt_type_tag(&p.prompt_type),
424            overlay: p.overlay,
425            title: p.title.iter().map(|t| t.text.as_str()).collect(),
426            status: p.status.clone(),
427            selected: p.selected_suggestion,
428            scroll_start,
429            visible_count: visible,
430            total,
431            outer_rect: sugg_outer.map(RectView::from),
432            list_rect: sugg_area
433                .map(|(r, _, _, _)| r)
434                .or(prompt_results)
435                .map(RectView::from),
436            // Inner content of the preview pane: the stored area minus its
437            // single left border column (matches `Block::borders(LEFT)` in
438            // render_overlay_prompt). Only meaningful for overlay prompts.
439            preview_rect: chrome.prompt_preview_area.and_then(|r| {
440                (r.width > 1 && r.height > 0).then(|| {
441                    RectView::from(Rect::new(
442                        r.x.saturating_add(1),
443                        r.y,
444                        r.width.saturating_sub(1),
445                        r.height,
446                    ))
447                })
448            }),
449            suggestions: p
450                .suggestions
451                .iter()
452                .map(|s| SuggestionView {
453                    text: s.text.clone(),
454                    description: s.description.clone(),
455                    keybinding: s.keybinding.clone(),
456                    disabled: s.disabled,
457                })
458                .collect(),
459            toolbar: p.toolbar_widget.clone(),
460            toolbar_focus: p.toolbar_focus.clone(),
461        })
462    }
463}
464
465// ─────────────────────────── popups (completion / hover / action / list / text) ───────────────────────────
466
467#[derive(Debug, Clone, Serialize)]
468pub struct PopupItemView {
469    pub text: String,
470    pub detail: Option<String>,
471    pub icon: Option<String>,
472    pub disabled: bool,
473}
474
475#[derive(Debug, Clone, Serialize)]
476#[serde(tag = "type", rename_all = "lowercase")]
477pub enum PopupContentView {
478    List {
479        items: Vec<PopupItemView>,
480        selected: usize,
481    },
482    Lines {
483        lines: Vec<String>,
484    },
485}
486
487/// A floating popup (completion menu, hover doc, action chooser, …) projected
488/// semantically. Geometry (`rect`/`content_rect`) is the pipeline's popup layout
489/// so the frontend can position the native box and forward clicks/scroll back
490/// through `handle_mouse` — the existing popup hit-tester resolves them.
491#[derive(Debug, Clone, Serialize)]
492#[serde(rename_all = "camelCase")]
493pub struct ScenePopup {
494    pub kind: &'static str,
495    pub title: Option<String>,
496    pub description: Option<String>,
497    pub rect: RectView,
498    pub content_rect: RectView,
499    pub scroll_offset: usize,
500    pub content: PopupContentView,
501}
502
503fn project_popup(
504    p: &crate::view::popup::Popup,
505    outer: Rect,
506    inner: Rect,
507    scroll: usize,
508) -> ScenePopup {
509    use crate::view::popup::{PopupContent, PopupKind};
510    let kind = match p.kind {
511        PopupKind::Completion => "completion",
512        PopupKind::Hover => "hover",
513        PopupKind::Action => "action",
514        PopupKind::List => "list",
515        PopupKind::Text => "text",
516    };
517    let content = match &p.content {
518        PopupContent::List { items, selected } => PopupContentView::List {
519            items: items
520                .iter()
521                .map(|i| PopupItemView {
522                    text: i.text.clone(),
523                    detail: i.detail.clone(),
524                    icon: i.icon.clone(),
525                    disabled: i.disabled,
526                })
527                .collect(),
528            selected: *selected,
529        },
530        PopupContent::Text(lines) | PopupContent::Custom(lines) => PopupContentView::Lines {
531            lines: lines.clone(),
532        },
533        PopupContent::Markdown(styled) => PopupContentView::Lines {
534            lines: styled
535                .iter()
536                .map(|l| l.spans.iter().map(|s| s.text.as_str()).collect::<String>())
537                .collect(),
538        },
539    };
540    ScenePopup {
541        kind,
542        title: p.title.clone(),
543        description: p.description.clone(),
544        rect: RectView::from(outer),
545        content_rect: RectView::from(inner),
546        scroll_offset: scroll,
547        content,
548    }
549}
550
551impl Editor {
552    /// All visible popups across the per-buffer and global stacks, projected
553    /// semantically. Single derivation shared by the web frontend (native HTML)
554    /// and available to the TUI compositor; geometry comes from the pipeline's
555    /// popup-area caches so clicks/scroll route through the existing hit-tester.
556    pub fn popups_view(&self) -> Vec<ScenePopup> {
557        let chrome = self.active_chrome();
558        let mut out = Vec::new();
559        let locals = self.active_state().popups.all();
560        for (idx, outer, inner, scroll, _n, _sb, _t) in &chrome.popup_areas {
561            if let Some(p) = locals.get(*idx) {
562                out.push(project_popup(p, *outer, *inner, *scroll));
563            }
564        }
565        let globals = self.global_popups.all();
566        for (idx, outer, inner, scroll, _n) in &chrome.global_popup_areas {
567            if let Some(p) = globals.get(*idx) {
568                out.push(project_popup(p, *outer, *inner, *scroll));
569            }
570        }
571        out
572    }
573}
574
575// ─────────────────────────── file explorer (sidebar tree) ───────────────────────────
576
577#[derive(Debug, Clone, Serialize)]
578#[serde(rename_all = "camelCase")]
579pub struct FileRow {
580    pub name: String,
581    pub depth: usize,
582    pub is_dir: bool,
583    pub expanded: bool,
584}
585
586#[derive(Debug, Clone, Serialize)]
587#[serde(rename_all = "camelCase")]
588pub struct FileExplorerView {
589    pub rect: RectView,
590    pub title: String,
591    pub scroll_offset: usize,
592    pub viewport_height: usize,
593    pub selected: Option<usize>,
594    pub rows: Vec<FileRow>,
595}
596
597impl Editor {
598    /// Semantic file-explorer sidebar: the flattened visible tree rows (the same
599    /// `get_display_nodes()` the TUI renderer uses) plus selection/scroll and the
600    /// sidebar rect. Rendered natively by the web frontend; row clicks route back
601    /// through `handle_mouse` at the sidebar's content cells, which the existing
602    /// file-explorer hit-test resolves to the same display index.
603    pub fn file_explorer_view(&self) -> Option<FileExplorerView> {
604        let rect = self.active_layout().file_explorer_area?;
605        let view = self.file_explorer()?;
606        let tree = view.tree();
607        let rows = view
608            .get_display_nodes()
609            .into_iter()
610            .filter_map(|(id, indent)| {
611                tree.get_node(id).map(|n| FileRow {
612                    name: n.entry.name.clone(),
613                    depth: indent,
614                    is_dir: n.is_dir(),
615                    expanded: n.is_expanded(),
616                })
617            })
618            .collect();
619        let title = tree
620            .get_node(tree.root_id())
621            .map(|n| n.entry.name.clone())
622            .unwrap_or_default();
623        Some(FileExplorerView {
624            rect: RectView::from(rect),
625            title,
626            scroll_offset: view.get_scroll_offset(),
627            viewport_height: view.viewport_height,
628            selected: view.get_selected_index(),
629            rows,
630        })
631    }
632}
633
634// ─────────────────────────── workspace-trust dialog ───────────────────────────
635
636#[derive(Debug, Clone, Serialize)]
637pub struct TrustOptionView {
638    pub label: String,
639    pub description: String,
640    pub selected: bool,
641    pub data: &'static str,
642    pub rect: RectView,
643}
644
645#[derive(Debug, Clone, Serialize)]
646#[serde(rename_all = "camelCase")]
647pub struct TrustDialogView {
648    pub dialog: RectView,
649    pub title: String,
650    pub path: String,
651    pub triggers: String,
652    pub cancellable: bool,
653    pub options: Vec<TrustOptionView>,
654    pub ok: RectView,
655    pub ok_label: String,
656    pub quit: RectView,
657    pub quit_label: String,
658}
659
660impl Editor {
661    /// Semantic workspace-trust dialog (the blocking "trust this folder?" modal).
662    /// `None` unless it's showing. Geometry comes from the pipeline's
663    /// `TrustDialogLayout`; clicks on the options / OK / Quit route back through
664    /// `handle_mouse` at those rects (the existing `handle_workspace_trust_mouse`).
665    pub fn trust_dialog_view(&self) -> Option<TrustDialogView> {
666        let layout = self.active_chrome().workspace_trust_dialog.clone()?;
667        let selected = self.current_workspace_trust_selection();
668        let data = ["trusted", "restricted", "blocked"];
669        let options = crate::view::workspace_trust_dialog::options()
670            .into_iter()
671            .enumerate()
672            .map(|(i, o)| TrustOptionView {
673                label: o.label,
674                description: o.description,
675                selected: i == selected,
676                data: data.get(i).copied().unwrap_or("restricted"),
677                rect: RectView::from(layout.radios[i]),
678            })
679            .collect();
680        let quit_label = if self.workspace_trust_cancellable() {
681            rust_i18n::t!("trust.dialog.btn_cancel").into_owned()
682        } else {
683            rust_i18n::t!("trust.dialog.btn_quit").into_owned()
684        };
685        Some(TrustDialogView {
686            dialog: RectView::from(layout.dialog),
687            title: rust_i18n::t!("trust.dialog.security_warning").into_owned(),
688            path: self.working_dir().display().to_string(),
689            triggers: self.workspace_trust_markers().join(", "),
690            cancellable: self.workspace_trust_cancellable(),
691            options,
692            ok: RectView::from(layout.ok),
693            ok_label: rust_i18n::t!("trust.dialog.btn_ok").into_owned(),
694            quit: RectView::from(layout.quit),
695            quit_label,
696        })
697    }
698}
699
700// ─────────────────────────── plugin widget surfaces (floating / dock) ───────────────────────────
701
702#[derive(Debug, Clone, Serialize)]
703#[serde(rename_all = "camelCase")]
704pub struct WidgetHitView {
705    /// Index into this surface's `hits` — sent back on click so the editor runs
706    /// the exact same hit it would for a TUI cell click.
707    pub index: usize,
708    pub widget_key: String,
709    pub widget_kind: String,
710    pub event_type: String,
711    pub payload: serde_json::Value,
712}
713
714/// Host-owned instance state a frontend needs to render a widget correctly
715/// (List/Tree selection + scroll). Keyed by widget `key`.
716#[derive(Debug, Clone, Serialize)]
717#[serde(rename_all = "camelCase")]
718pub struct WidgetInstanceView {
719    pub selected_index: Option<i32>,
720    pub scroll_offset: Option<u32>,
721    #[serde(skip_serializing_if = "Vec::is_empty")]
722    pub expanded_keys: Vec<String>,
723}
724
725#[derive(Debug, Clone, Serialize)]
726#[serde(rename_all = "camelCase")]
727pub struct WidgetSurfaceView {
728    /// "dock" (left dock) or "floatingModal" (centered).
729    pub kind: &'static str,
730    pub plugin: String,
731    pub panel_id: u64,
732    pub rect: RectView,
733    pub focus_key: String,
734    /// The raw, already-serializable `WidgetSpec` tree — rendered natively.
735    pub spec: fresh_core::api::WidgetSpec,
736    pub instances: HashMap<String, WidgetInstanceView>,
737    pub hits: Vec<WidgetHitView>,
738}
739
740impl Editor {
741    /// Semantic model for plugin-mounted floating / dock widget panels (e.g. the
742    /// orchestrator session dock). Each surface ships its `WidgetSpec` tree +
743    /// instance state + on-screen rect + hit list; the frontend renders the spec
744    /// natively and forwards a clicked hit's index back through `/widget`, which
745    /// runs the same `deliver_widget_hit` path as a TUI cell click. `None`
746    /// surfaces (unmounted panels) are simply omitted.
747    pub fn widgets_view(&self) -> Vec<WidgetSurfaceView> {
748        let mut out = Vec::new();
749        for (kind, slot) in [
750            ("dock", self.dock.as_ref()),
751            ("floatingModal", self.floating_widget_panel.as_ref()),
752        ] {
753            let Some(fwp) = slot else { continue };
754            let Some(rect) = fwp.last_inner_rect else {
755                continue;
756            };
757            let Some(panel) = self.widget_registry.get(&fwp.panel_key) else {
758                continue;
759            };
760            let mut instances = HashMap::new();
761            for (key, st) in &panel.instance_states {
762                use crate::widgets::WidgetInstanceState as W;
763                let view = match st {
764                    W::List {
765                        scroll_offset,
766                        selected_index,
767                        ..
768                    } => WidgetInstanceView {
769                        selected_index: Some(*selected_index),
770                        scroll_offset: Some(*scroll_offset),
771                        expanded_keys: Vec::new(),
772                    },
773                    W::Tree {
774                        scroll_offset,
775                        selected_index,
776                        expanded_keys,
777                    } => WidgetInstanceView {
778                        selected_index: Some(*selected_index),
779                        scroll_offset: Some(*scroll_offset),
780                        expanded_keys: expanded_keys.iter().cloned().collect(),
781                    },
782                    _ => continue,
783                };
784                instances.insert(key.clone(), view);
785            }
786            let hits = panel
787                .hits
788                .iter()
789                .enumerate()
790                .map(|(index, h)| WidgetHitView {
791                    index,
792                    widget_key: h.widget_key.clone(),
793                    widget_kind: h.widget_kind.to_string(),
794                    event_type: h.event_type.to_string(),
795                    payload: h.payload.clone(),
796                })
797                .collect();
798            out.push(WidgetSurfaceView {
799                kind,
800                plugin: fwp.panel_key.plugin.clone(),
801                panel_id: fwp.panel_key.id,
802                rect: RectView::from(rect),
803                focus_key: panel.focus_key.clone(),
804                spec: panel.spec.clone(),
805                instances,
806                hits,
807            });
808        }
809        out
810    }
811}
812
813// ─────────────────────────── context menus (right-click / new-tab) ───────────────────────────
814
815#[derive(Debug, Clone, Serialize)]
816#[serde(rename_all = "camelCase")]
817pub struct ContextMenuView {
818    /// "tab" | "newTab" | "fileExplorer" — for styling / debugging.
819    pub kind: &'static str,
820    pub x: u16,
821    pub y: u16,
822    pub highlighted: usize,
823    pub items: Vec<String>,
824}
825
826impl Editor {
827    /// The active right-click / new-tab context menu (only one shows at a time),
828    /// projected for native rendering. Items render at `y + 1 + i` (the bordered
829    /// box); a click forwarded to `handle_mouse` at `(x + 1, y + 1 + i)` resolves
830    /// to item `i` via the existing hover/hit-test (`item_idx = row - y - 1`).
831    pub fn context_menu_view(&self) -> Option<ContextMenuView> {
832        let w = self.active_window();
833        let chrome = self.active_chrome();
834        if let Some(m) = &w.file_explorer_context_menu {
835            let (x, y) = m.clamped_position(chrome.last_frame.width, chrome.last_frame.height);
836            return Some(ContextMenuView {
837                kind: "fileExplorer",
838                x,
839                y,
840                highlighted: m.highlighted,
841                items: m.items().iter().map(|i| i.label()).collect(),
842            });
843        }
844        if let Some(m) = &w.new_tab_menu {
845            return Some(ContextMenuView {
846                kind: "newTab",
847                x: m.position.0,
848                y: m.position.1,
849                highlighted: m.highlighted,
850                items: crate::app::types::NewTabMenuItem::all()
851                    .iter()
852                    .map(|i| i.label())
853                    .collect(),
854            });
855        }
856        if let Some(m) = &w.tab_context_menu {
857            return Some(ContextMenuView {
858                kind: "tab",
859                x: m.position.0,
860                y: m.position.1,
861                highlighted: m.highlighted,
862                items: crate::app::types::TabContextMenuItem::all()
863                    .iter()
864                    .map(|i| i.label())
865                    .collect(),
866            });
867        }
868        None
869    }
870}
871
872// ─────────────────────────── auxiliary modals (keybindings / event-debug / theme-info) ───────────────────────────
873
874#[derive(Debug, Clone, Serialize)]
875#[serde(rename_all = "camelCase")]
876pub struct AuxLine {
877    pub text: String,
878    pub selected: bool,
879}
880
881/// A small/secondary modal projected as a titled list of text lines. Covers the
882/// keybinding editor (binding list), the event-debug log, and the theme-info
883/// popup — read-mostly surfaces whose interaction (nav / Esc / rebind) already
884/// flows through `handle_key`. `rect` anchors the theme popup; `None` ⇒ the
885/// frontend centers it.
886#[derive(Debug, Clone, Serialize)]
887#[serde(rename_all = "camelCase")]
888pub struct AuxModalView {
889    pub kind: &'static str,
890    pub title: String,
891    pub rect: Option<RectView>,
892    pub lines: Vec<AuxLine>,
893    pub footer: Option<String>,
894}
895
896impl Editor {
897    /// The active auxiliary modal (keybinding editor / event-debug / theme-info),
898    /// projected as a titled line list for native rendering. Only one shows at a
899    /// time. Cells for these are suppressed on the web; keyboard drives them.
900    pub fn aux_modals_view(&self) -> Option<AuxModalView> {
901        // NOTE: the keybinding editor is intentionally NOT projected here — it's a
902        // full interactive modal (search, context/source filters, an add/edit
903        // sub-dialog, help overlay), Settings-grade rather than a line list. It
904        // renders as cells (functional) until it gets a proper native projection,
905        // grouped with the Settings UI.
906        let w = self.active_window();
907        // Event-debug log.
908        if let Some(ed) = &w.event_debug {
909            let mut lines: Vec<AuxLine> = ed
910                .history
911                .iter()
912                .map(|r| AuxLine {
913                    text: r.description.clone(),
914                    selected: false,
915                })
916                .collect();
917            if lines.is_empty() {
918                lines.push(AuxLine {
919                    text: rust_i18n::t!("event_debug.no_events").into_owned(),
920                    selected: false,
921                });
922            }
923            return Some(AuxModalView {
924                kind: "eventDebug",
925                title: rust_i18n::t!("event_debug.title").into_owned(),
926                rect: None,
927                lines,
928                footer: Some(rust_i18n::t!("event_debug.help_text").into_owned()),
929            });
930        }
931        // Theme-info popup (anchored at its click position).
932        if let Some(ti) = &w.theme_info_popup {
933            fn color_str(c: ratatui::style::Color) -> String {
934                match c {
935                    ratatui::style::Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
936                    other => format!("{other:?}"),
937                }
938            }
939            let info = &ti.info;
940            let mut lines = vec![AuxLine {
941                text: format!("Region: {}", info.region),
942                selected: false,
943            }];
944            if let Some(k) = &info.fg_key {
945                let c = info
946                    .fg_color
947                    .map(|c| format!("  {}", color_str(c)))
948                    .unwrap_or_default();
949                lines.push(AuxLine {
950                    text: format!("Foreground: {k}{c}"),
951                    selected: false,
952                });
953            }
954            if let Some(k) = &info.bg_key {
955                let c = info
956                    .bg_color
957                    .map(|c| format!("  {}", color_str(c)))
958                    .unwrap_or_default();
959                lines.push(AuxLine {
960                    text: format!("Background: {k}{c}"),
961                    selected: false,
962                });
963            }
964            if let Some(cat) = &info.syntax_category {
965                lines.push(AuxLine {
966                    text: format!("Category: {cat}"),
967                    selected: false,
968                });
969            }
970            return Some(AuxModalView {
971                kind: "themeInfo",
972                title: "Theme".to_string(),
973                rect: Some(RectView {
974                    x: ti.position.0,
975                    y: ti.position.1,
976                    w: 0,
977                    h: 0,
978                }),
979                lines,
980                footer: None,
981            });
982        }
983        None
984    }
985}
986
987// ─────────────────────────── keybinding editor (full native modal) ───────────────────────────
988
989#[derive(Debug, Clone, Serialize)]
990#[serde(rename_all = "camelCase")]
991pub struct KbSearchView {
992    pub active: bool,
993    pub focused: bool,
994    pub mode: &'static str, // "text" | "recordKey"
995    pub query: String,
996    pub key_display: String,
997}
998
999#[derive(Debug, Clone, Serialize)]
1000#[serde(tag = "type", rename_all = "camelCase")]
1001pub enum KbRow {
1002    Section {
1003        name: String,
1004        collapsed: bool,
1005        count: usize,
1006        selected: bool,
1007    },
1008    Binding {
1009        key: String,
1010        action: String,
1011        description: String,
1012        context: String,
1013        source: &'static str, // "keymap" | "custom" | "plugin" | ""
1014        selected: bool,
1015    },
1016}
1017
1018#[derive(Debug, Clone, Serialize)]
1019#[serde(rename_all = "camelCase")]
1020pub struct KbEditDialog {
1021    pub title: String,
1022    pub focus_area: usize, // 0=key 1=action 2=context 3=buttons
1023    pub key_display: String,
1024    pub key_capturing: bool,
1025    pub action_text: String,
1026    pub action_error: Option<String>,
1027    pub autocomplete: Vec<String>,
1028    pub autocomplete_selected: Option<usize>,
1029    pub context: String,
1030    pub context_options: Vec<String>,
1031    pub conflicts: Vec<String>,
1032    pub save_focused: bool,
1033    pub cancel_focused: bool,
1034}
1035
1036#[derive(Debug, Clone, Serialize)]
1037#[serde(rename_all = "camelCase")]
1038pub struct KbConfirm {
1039    pub buttons: Vec<String>,
1040    pub selected: usize,
1041}
1042
1043#[derive(Debug, Clone, Serialize)]
1044#[serde(rename_all = "camelCase")]
1045pub struct KeybindingEditorView {
1046    pub title: String,
1047    pub config_path: String,
1048    pub keymaps: Vec<String>,
1049    pub search: KbSearchView,
1050    pub context_filter: String,
1051    pub context_filtered: bool,
1052    pub source_filter: String,
1053    pub source_filtered: bool,
1054    pub count: String,
1055    pub has_changes: bool,
1056    pub rows: Vec<KbRow>,
1057    pub selected: usize,
1058    pub scroll_offset: u16,
1059    pub viewport: u16,
1060    pub showing_help: bool,
1061    pub edit_dialog: Option<KbEditDialog>,
1062    pub confirm: Option<KbConfirm>,
1063}
1064
1065impl Editor {
1066    /// Full semantic model of the keybinding editor modal (header + search +
1067    /// filters, the binding/section table, the add/edit sub-dialog, the confirm
1068    /// dialog and the help flag). Rendered natively; all interaction already
1069    /// flows through `handle_key` (the editor is keyboard-driven).
1070    pub fn keybinding_editor_view(&self) -> Option<KeybindingEditorView> {
1071        use crate::app::keybinding_editor::{
1072            BindingSource, ContextFilter, DisplayRow, SearchMode, SourceFilter,
1073        };
1074        let kb = self.keybinding_editor.as_ref()?;
1075
1076        let rows = kb
1077            .display_rows
1078            .iter()
1079            .enumerate()
1080            .map(|(i, dr)| {
1081                let selected = i == kb.selected;
1082                match dr {
1083                    DisplayRow::SectionHeader {
1084                        plugin_name,
1085                        collapsed,
1086                        binding_count,
1087                    } => KbRow::Section {
1088                        name: plugin_name.clone().unwrap_or_else(|| "Builtin".to_string()),
1089                        collapsed: *collapsed,
1090                        count: *binding_count,
1091                        selected,
1092                    },
1093                    DisplayRow::Binding(bi) => {
1094                        let b = &kb.bindings[*bi];
1095                        KbRow::Binding {
1096                            key: b.key_display.clone(),
1097                            action: b.action.clone(),
1098                            description: b.action_display.clone(),
1099                            context: b.context.clone(),
1100                            source: match b.source {
1101                                BindingSource::Keymap => "keymap",
1102                                BindingSource::Custom => "custom",
1103                                BindingSource::Plugin => "plugin",
1104                                BindingSource::Unbound => "",
1105                            },
1106                            selected,
1107                        }
1108                    }
1109                }
1110            })
1111            .collect();
1112
1113        let (context_filter, context_filtered) = match &kb.context_filter {
1114            ContextFilter::All => ("All".to_string(), false),
1115            ContextFilter::Specific(s) => (s.clone(), true),
1116        };
1117        let (source_filter, source_filtered) = match kb.source_filter {
1118            SourceFilter::All => ("All", false),
1119            SourceFilter::KeymapOnly => ("Keymap", true),
1120            SourceFilter::CustomOnly => ("Custom", true),
1121            SourceFilter::PluginOnly => ("Plugin", true),
1122        };
1123
1124        let edit_dialog = kb.edit_dialog.as_ref().map(|d| KbEditDialog {
1125            title: if d.editing_index.is_some() {
1126                "Edit Binding".to_string()
1127            } else {
1128                "Add Binding".to_string()
1129            },
1130            focus_area: d.focus_area,
1131            key_display: d.key_display.clone(),
1132            key_capturing: d.capturing_special,
1133            action_text: d.action_text.clone(),
1134            action_error: d.action_error.clone(),
1135            autocomplete: if d.autocomplete_visible {
1136                d.autocomplete_suggestions.clone()
1137            } else {
1138                Vec::new()
1139            },
1140            autocomplete_selected: d.autocomplete_selected,
1141            context: d.context.clone(),
1142            context_options: d.context_options.clone(),
1143            conflicts: d.conflicts.clone(),
1144            save_focused: d.focus_area == 3 && d.selected_button == 0,
1145            cancel_focused: d.focus_area == 3 && d.selected_button == 1,
1146        });
1147
1148        let confirm = kb.showing_confirm_dialog.then(|| KbConfirm {
1149            buttons: vec!["Save".into(), "Discard".into(), "Cancel".into()],
1150            selected: kb.confirm_selection,
1151        });
1152
1153        Some(KeybindingEditorView {
1154            title: format!("Keybindings — {}", kb.active_keymap),
1155            config_path: kb.config_file_path.clone(),
1156            keymaps: kb.keymap_names.clone(),
1157            search: KbSearchView {
1158                active: kb.search_active,
1159                focused: kb.search_focused,
1160                mode: match kb.search_mode {
1161                    SearchMode::Text => "text",
1162                    SearchMode::RecordKey => "recordKey",
1163                },
1164                query: kb.search_query.clone(),
1165                key_display: kb.search_key_display.clone(),
1166            },
1167            context_filter,
1168            context_filtered,
1169            source_filter: source_filter.to_string(),
1170            source_filtered,
1171            count: format!("{} / {}", kb.filtered_indices.len(), kb.bindings.len()),
1172            has_changes: kb.has_changes,
1173            rows,
1174            selected: kb.selected,
1175            scroll_offset: kb.scroll.offset,
1176            viewport: kb.scroll.viewport,
1177            showing_help: kb.showing_help,
1178            edit_dialog,
1179            confirm,
1180        })
1181    }
1182}
1183
1184// ─────────────────────────── settings UI (full native modal) ───────────────────────────
1185
1186#[derive(Debug, Clone, Serialize)]
1187#[serde(tag = "kind", rename_all = "camelCase")]
1188pub enum SettingControlView {
1189    Toggle {
1190        checked: bool,
1191    },
1192    Number {
1193        value: i64,
1194        min: Option<i64>,
1195        max: Option<i64>,
1196    },
1197    Dropdown {
1198        selected: usize,
1199        options: Vec<String>,
1200        open: bool,
1201    },
1202    Text {
1203        value: String,
1204        editing: bool,
1205        placeholder: String,
1206    },
1207    TextList {
1208        items: Vec<String>,
1209        focused: Option<usize>,
1210    },
1211    // Variant-level camelCase: the enum's `rename_all` renames variants but
1212    // not struct-variant *fields*, so the multi-word fields below need this to
1213    // match the camelCase JSON contract the frontend consumes.
1214    #[serde(rename_all = "camelCase")]
1215    DualList {
1216        included: Vec<String>,
1217        available: Vec<String>,
1218        /// Cursor row in each column and which column is active, so the web
1219        /// can mirror the TUI's selection highlight. Row indices line up with
1220        /// `included` / `available` (same order the dispatch hits use).
1221        included_cursor: usize,
1222        available_cursor: usize,
1223        active_column: &'static str, // "included" | "available"
1224    },
1225    Map {
1226        entries: Vec<MapEntryView>,
1227    },
1228    ObjectArray {
1229        entries: Vec<String>,
1230    },
1231    Json {
1232        value: String,
1233    },
1234    Complex {
1235        type_name: String,
1236    },
1237}
1238
1239#[derive(Debug, Clone, Serialize)]
1240#[serde(rename_all = "camelCase")]
1241pub struct MapEntryView {
1242    pub key: String,
1243    pub display: String,
1244}
1245
1246#[derive(Debug, Clone, Serialize)]
1247#[serde(rename_all = "camelCase")]
1248pub struct SettingItemView {
1249    pub index: usize,
1250    pub path: String,
1251    pub name: String,
1252    pub description: Option<String>,
1253    pub section: Option<String>,
1254    pub section_start: bool,
1255    pub modified: bool,
1256    pub read_only: bool,
1257    pub nullable: bool,
1258    pub is_null: bool,
1259    pub selected: bool,
1260    pub control: SettingControlView,
1261}
1262
1263#[derive(Debug, Clone, Serialize)]
1264#[serde(rename_all = "camelCase")]
1265pub struct SettingsCategoryView {
1266    pub index: usize,
1267    pub name: String,
1268    pub selected: bool,
1269    pub expandable: bool,
1270    pub expanded: bool,
1271    pub sections: Vec<String>,
1272}
1273
1274#[derive(Debug, Clone, Serialize)]
1275#[serde(rename_all = "camelCase")]
1276pub struct SettingsSearchResultView {
1277    pub name: String,
1278    pub category: String,
1279}
1280
1281#[derive(Debug, Clone, Serialize)]
1282#[serde(rename_all = "camelCase")]
1283pub struct EntryDialogView {
1284    pub title: String,
1285    pub is_new: bool,
1286    pub items: Vec<SettingItemView>,
1287    pub selected_item: usize,
1288    pub focus_on_buttons: bool,
1289    pub focused_button: usize,
1290    pub no_delete: bool,
1291}
1292
1293#[derive(Debug, Clone, Serialize)]
1294#[serde(rename_all = "camelCase")]
1295pub struct SettingsView {
1296    pub title: String,
1297    pub focus: &'static str, // "categories" | "settings" | "footer"
1298    pub target_layer: String,
1299    pub categories: Vec<SettingsCategoryView>,
1300    pub items: Vec<SettingItemView>,
1301    pub footer_buttons: Vec<String>,
1302    pub footer_selected: usize,
1303    pub search_active: bool,
1304    pub search_query: String,
1305    pub search_results: Vec<SettingsSearchResultView>,
1306    pub search_selected: usize,
1307    pub entry_dialog: Option<EntryDialogView>,
1308    pub showing_help: bool,
1309    pub showing_confirm: bool,
1310    pub showing_reset: bool,
1311}
1312
1313fn setting_control_view(c: &crate::view::settings::items::SettingControl) -> SettingControlView {
1314    use crate::view::settings::items::SettingControl as C;
1315    match c {
1316        C::Toggle(s) => SettingControlView::Toggle { checked: s.checked },
1317        C::Number(s) => SettingControlView::Number {
1318            value: s.value,
1319            min: s.min,
1320            max: s.max,
1321        },
1322        C::Dropdown(s) => SettingControlView::Dropdown {
1323            selected: s.selected,
1324            options: s.options.clone(),
1325            open: s.open,
1326        },
1327        C::Text(s) => SettingControlView::Text {
1328            value: s.value.clone(),
1329            editing: s.editing,
1330            placeholder: s.placeholder.clone(),
1331        },
1332        C::TextList(s) => SettingControlView::TextList {
1333            items: s.items.clone(),
1334            focused: s.focused_item,
1335        },
1336        C::DualList(s) => SettingControlView::DualList {
1337            // Use the control's own item enumerations so the row indices the
1338            // web sends back (ControlDualListIncluded/Available(idx,row)) match
1339            // exactly what `add_selected`/`remove_selected` index into.
1340            included: s
1341                .included_items()
1342                .iter()
1343                .map(|(_, n)| n.to_string())
1344                .collect(),
1345            available: s.available_items().iter().map(|(_, n)| n.clone()).collect(),
1346            included_cursor: s.included_cursor,
1347            available_cursor: s.available_cursor,
1348            active_column: match s.active_column {
1349                crate::view::controls::DualListColumn::Included => "included",
1350                crate::view::controls::DualListColumn::Available => "available",
1351            },
1352        },
1353        C::Map(s) => SettingControlView::Map {
1354            entries: s
1355                .entries
1356                .iter()
1357                .map(|(k, v)| MapEntryView {
1358                    key: k.clone(),
1359                    display: v
1360                        .as_str()
1361                        .map(|x| x.to_string())
1362                        .unwrap_or_else(|| v.to_string()),
1363                })
1364                .collect(),
1365        },
1366        C::ObjectArray(s) => SettingControlView::ObjectArray {
1367            entries: s
1368                .bindings
1369                .iter()
1370                .map(|v| {
1371                    s.display_field
1372                        .as_ref()
1373                        .and_then(|f| v.pointer(f))
1374                        .and_then(|x| x.as_str())
1375                        .map(|x| x.to_string())
1376                        .unwrap_or_else(|| v.to_string())
1377                })
1378                .collect(),
1379        },
1380        C::Json(s) => SettingControlView::Json { value: s.value() },
1381        C::Complex { type_name } => SettingControlView::Complex {
1382            type_name: type_name.clone(),
1383        },
1384    }
1385}
1386
1387fn setting_item_view(
1388    item: &crate::view::settings::items::SettingItem,
1389    i: usize,
1390    selected: bool,
1391) -> SettingItemView {
1392    SettingItemView {
1393        index: i,
1394        path: item.path.clone(),
1395        name: item.name.clone(),
1396        description: item.description.clone(),
1397        section: item.section.clone(),
1398        section_start: item.is_section_start,
1399        modified: item.modified,
1400        read_only: item.read_only,
1401        nullable: item.nullable,
1402        is_null: item.is_null,
1403        selected,
1404        control: setting_control_view(&item.control),
1405    }
1406}
1407
1408impl Editor {
1409    /// Full semantic model of the Settings modal: the category tree, the item
1410    /// list for the selected category (every control kind), search, the footer,
1411    /// and the add/edit entry sub-dialog (Map/ObjectArray). Keyboard-driven via
1412    /// `handle_key`; rendered natively. `None` unless settings is showing.
1413    pub fn settings_view(&self) -> Option<SettingsView> {
1414        use crate::view::settings::state::FocusPanel;
1415        let st = self.settings_state.as_ref()?;
1416        if !st.visible {
1417            return None;
1418        }
1419
1420        let categories = st
1421            .pages
1422            .iter()
1423            .enumerate()
1424            .map(|(i, p)| SettingsCategoryView {
1425                index: i,
1426                name: p.name.clone(),
1427                selected: i == st.selected_category,
1428                expandable: !p.subpages.is_empty() || p.sections.len() > 1,
1429                expanded: st.expanded_categories.contains(&i),
1430                sections: p.sections.iter().map(|s| s.name.clone()).collect(),
1431            })
1432            .collect();
1433
1434        let items = st
1435            .pages
1436            .get(st.selected_category)
1437            .map(|p| {
1438                p.items
1439                    .iter()
1440                    .enumerate()
1441                    .map(|(i, it)| setting_item_view(it, i, i == st.selected_item))
1442                    .collect()
1443            })
1444            .unwrap_or_default();
1445
1446        let entry_dialog = st.entry_dialog_stack.last().map(|d| EntryDialogView {
1447            title: d.title.clone(),
1448            is_new: d.is_new,
1449            items: d
1450                .items
1451                .iter()
1452                .enumerate()
1453                .map(|(i, it)| setting_item_view(it, i, i == d.selected_item))
1454                .collect(),
1455            selected_item: d.selected_item,
1456            focus_on_buttons: d.focus_on_buttons,
1457            focused_button: d.focused_button,
1458            no_delete: d.no_delete,
1459        });
1460
1461        Some(SettingsView {
1462            title: "Settings".to_string(),
1463            focus: match st.focus.current() {
1464                Some(FocusPanel::Settings) => "settings",
1465                Some(FocusPanel::Footer) => "footer",
1466                _ => "categories",
1467            },
1468            target_layer: format!("{:?}", st.target_layer),
1469            categories,
1470            items,
1471            footer_buttons: vec![
1472                format!("{:?}", st.target_layer),
1473                "Reset".into(),
1474                "Save".into(),
1475                "Cancel".into(),
1476            ],
1477            footer_selected: st.footer_button_index,
1478            search_active: st.search_active,
1479            search_query: st.search_query.clone(),
1480            search_results: st
1481                .search_results
1482                .iter()
1483                .map(|r| SettingsSearchResultView {
1484                    name: r.item.name.clone(),
1485                    category: r.breadcrumb.clone(),
1486                })
1487                .collect(),
1488            search_selected: st.selected_search_result,
1489            entry_dialog,
1490            showing_help: st.showing_help,
1491            showing_confirm: st.showing_confirm_dialog,
1492            showing_reset: st.showing_reset_dialog,
1493        })
1494    }
1495}