git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commitac54035376c865954eedc7cd119bacc8d61c2895
parentd4c1684083
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 10:17
feat(view): the view switch moves into the breadcrumb's context menu

The List/Graph/Space dropdown beside the breadcrumb is gone; the
breadcrumb runs the pane's full width. Right-clicking it opens the
toolkit's shared menu as before (segment header + Copy Path), which the
app re-shows in place with a separator and a row per other view. The
rows are app-run (BreadcrumbRow), routed ahead of the toolkit's
dispatch on press or release, and only while the menu's show
generation still matches. The current view is left out rather than
marked: the menu face has no radio or check glyph.

Page::view_label() names the rows, so nothing depends on a label list
tracking Page::ALL's order. pages::dropdown_relief had no other caller
and goes with the dropdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md            |   4 +-
 src/main.rs          | 191 +++++++++++++++++++++++++++------------------------
 src/pages/browse.rs  |  10 ++-
 src/pages/mod.rs     |  75 ++++++--------------
 src/pages/network.rs |  11 ++-
 src/pages/space.rs   |  12 ++--
 6 files changed, 138 insertions(+), 165 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 095d15c..2f1bfbf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -39,7 +39,7 @@ Shared, page-independent formatting helpers (`format_size`, `format_permissions`
 - **Overlay occlusion**: text/buttons under a popover, context menu, or dialog are either discarded or bound-clipped so they don't bleed through overlays. This is the logic behind commits like "Fix text rendering through popovers/overlays."
 
 ### Widgets register parentless, once per rebuild
-There are no composite container widgets. Every widget is owned outright by the app or by a page's state struct — `self.paginator`, `self.view_dropdown`, `self.browse.breadcrumb`, `self.browse.save_name_box`, `self.network.graph`, `self.space.breadcrumb`, … — and each `rebuild_layout` re-establishes the whole hierarchy from scratch: `ui_context.clear_hierarchy()`, then a teardown block calling `clear_children` + `set_parent(None)` on every widget, then registration via `ctx.register_widget(w.base().id(), w.as_ptr_mut())` with `set_parent(None)` again. Raw pointers are still involved (`as_ptr_mut`, plus a `self_ptr` alias so the registration loop can hold the app twice), but they belong to the cce-ui widget model rather than to any container of this crate's.
+There are no composite container widgets. Every widget is owned outright by the app or by a page's state struct — `self.paginator`, `self.browse.breadcrumb`, `self.browse.save_name_box`, `self.network.graph`, `self.space.breadcrumb`, … — and each `rebuild_layout` re-establishes the whole hierarchy from scratch: `ui_context.clear_hierarchy()`, then a teardown block calling `clear_children` + `set_parent(None)` on every widget, then registration via `ctx.register_widget(w.base().id(), w.as_ptr_mut())` with `set_parent(None)` again. Raw pointers are still involved (`as_ptr_mut`, plus a `self_ptr` alias so the registration loop can hold the app twice), but they belong to the cce-ui widget model rather than to any container of this crate's.
 
 **When adding a widget, add it to both halves of that pass** — the teardown block and the registration block. Skipping the teardown leaves hierarchy links alive across frames.
 
@@ -49,7 +49,7 @@ There are no composite container widgets. Every widget is owned outright by the
 - **Browse** — the `List` widget (columnar, integrated search box) plus a `Breadcrumb`. The right pane is a `Preview` widget, split from the list by `SplitPane` — app-owned (`main.rs`), carrying the `SplitBox` two-child horizontal math verbatim plus the divider quad, its hover tint, and the proportion drag.
 - **Network** — a `Graph` view of the same directory (nodes = entries), also split against the preview. It uses the configured lattice and node body as the designer's network does (a node is CENTRED on a crossing, its name hangs off its right side), laid out to fit its pane by `NetworkState::lay_out`: the pitch across holds a body, the name's gap and the widest name (cut with an ellipsis past `NAME_MAX`), as many columns as fit, column 0 a margin inside the pane, re-laid out when the pane changes size. The graph is cut to its pane (`PageContent::clipped_to`) for when it is panned. Until 2026-10-06 it set the retired cell model (140 x 70 cells, origin 60, 60, seven columns), which the lattice turned into nodes left of the pane, past its right edge, and names lying across their neighbours. `the_graph_fits_its_pane_and_its_names_fit_their_columns` is the test. Its wires and ports draw since 2026-10-06: `render_widget` hands a widget's strokes, arcs and discs to `RenderTarget::line` / `arc` / `circle` (cce-ui c9b073a), which `PageContent` keeps as `strokes` and `rebuild_layout` paints as `WidgetFx::Stroke`, cut by a clip as a glyph is. The nodes wear no geometry toggle (`Graph::set_show_toggles(false)`). Nodes stand from column and row 1, the lattice's (0, 0) a pitch outside the pane, so its heavy axes do not lie over column 0's wires. A stroke's page-view clip limits it top and bottom only: clipped to its own one-pixel box across, a vertical wire on a half pixel rounded away and only the horizontal runs drew.
 - **Space** — a GrandPerspective-style treemap of the whole subtree, also split against the preview.
-The active page is picked by `view_dropdown` next to the breadcrumb (there is no sidebar — it was removed; `has_sidebar` is hardcoded `false`). The dropdown's labels name the *visualization* ("List"/"Graph"/"Space") and are a separate list from `Page::label()` ("Browse"/"Network"/"Space") — but **its order must track `Page::ALL`**, because the selected index is indexed straight into it.
+The active page is picked from the breadcrumb's context menu (there is no sidebar — it was removed; `has_sidebar` is hardcoded `false` — and since 2026-10-06 no view dropdown beside the breadcrumb either, which now runs the pane's full width). Right-clicking the breadcrumb opens the toolkit's shared menu (segment header + Copy Path); the app then re-shows it in place with a separator and a row per *other* view, named by `Page::view_label()` ("List"/"Graph"/"Space" — the visualization, not `Page::label()`). The rows are app-run (`BreadcrumbRow`, `breadcrumb_menu_rows`), routed in `handle_mouse_input` ahead of the toolkit's dispatch like the plate-dock rows, on press or release, and only while `context_menu::generation()` still matches the show that made them. The current view is left out rather than marked: the menu face has no radio or check glyph.
 
 ### The list's scrollbar rides its centre line, behind the well
 Browse's row list (`row_list.rs`, `RowList`) follows the DE's one scrollbar rule (cce-ui's CLAUDE.md, "Every scrollbar rides a centre line, behind the plate"; since 2026-10-06 — it was a flat bar at the right edge, always on top). The bar is a pair of pills down the middle of the list's width, `centred_scrollbar_width()` thick, over the rows: no column reserves a lane for it. A `ScrollbarActivity` decides its depth. Sunk, it is drawn before the well's translucent `list_bg_color` fill, which dims it, and it takes no press: a press on its lane goes to the row under it. A wheel, a glide or coast in motion, `scroll_into_view` (how keyboard navigation scrolls the list) and a thumb drag's release raise it, and a pointer over a raised bar holds it up. `RowList::tick`, which the app's `tick` already runs for the glide, ticks the activity too and returns true while the hold or the fade is running, so frames keep coming until the bar has sunk. `push_prims` draws the idle copy at full alpha every frame and the fore copy after the row overlays at `fade()`. That order holds because the list's prims go into the PAGE's `PageContent`, whose rects are replayed in call order (only `plain_pc` is partitioned by radius — see the note at the top of `row_list.rs`). A part's icons and reliefs are replayed after all its rects, so the row glyphs and the well's wall still draw over the fore copy. `the_scrollbar_rides_the_centre_and_sinks_until_scrolled` is the test.
diff --git a/src/main.rs b/src/main.rs
index 79c360b..f1aa8ae 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -299,6 +299,36 @@ struct ContextMenu {
     hovered: Option<usize>,
 }
 
+/// A row of the breadcrumb's context menu that the app runs itself.
+#[derive(Clone, Debug, PartialEq)]
+enum BreadcrumbRow {
+    CopyPath(String),
+    View(Page),
+}
+
+/// The breadcrumb's context menu while it is open: which show of the shared
+/// menu it is (`context_menu::generation`), so a stale one never runs, and
+/// what each row does (`None` for the header and the separator).
+struct BreadcrumbMenu {
+    generation: u64,
+    rows: Vec<Option<BreadcrumbRow>>,
+}
+
+/// The breadcrumb menu below its header: Copy Path, then the view switch —
+/// a dropdown beside the breadcrumb until 2026-10-06 — offering the views
+/// other than the one showing. Not all three with the current one marked:
+/// the menu face has no radio or check glyph (a "●" drew as a blank), and
+/// the page itself says which view it is.
+fn breadcrumb_menu_rows(path: String, current: Page) -> (Vec<String>, Vec<Option<BreadcrumbRow>>) {
+    let mut labels = vec!["Copy Path".to_string(), "-".to_string()];
+    let mut rows = vec![Some(BreadcrumbRow::CopyPath(path)), None];
+    for page in Page::ALL.into_iter().filter(|&p| p != current) {
+        labels.push(page.view_label().to_string());
+        rows.push(Some(BreadcrumbRow::View(page)));
+    }
+    (labels, rows)
+}
+
 /// App-owned two-pane horizontal split, replacing the dissolved `SplitBox` +
 /// `BrowseContainer`/`NetworkContainer` shims (Phase 6y). Those existed to (a) position
 /// pane content — but the pages already lay out and render everything from the pane rect,
@@ -498,7 +528,6 @@ struct FilesystemApp {
     cursor_x: f32,
     cursor_y: f32,
     paginator: cce_ui::widget::Adapted<Paginator>,
-    view_dropdown: cce_ui::widget::Adapted<cce_ui::widget::Dropdown>,
     just_initialized: bool,
     ui_context: cce_ui::context::UiContext,
     watcher: Option<notify::RecommendedWatcher>,
@@ -521,6 +550,9 @@ struct FilesystemApp {
     /// Rows of the OPEN plate-dock corner menu (empty = not ours); routed
     /// before the generic context-menu dispatch.
     plate_menu_actions: Vec<cce_ui::widget::plate_dock::PlateDockAction>,
+    /// The breadcrumb's context menu when it is the one open; routed, like
+    /// the plate-dock rows, before the generic context-menu dispatch.
+    breadcrumb_menu: Option<BreadcrumbMenu>,
     // Space's double-click is tracked by path, not row index: its tiles are
     // renumbered by every relayout, so an index would not survive a resize.
     last_space_click_time: std::time::Instant,
@@ -674,7 +706,6 @@ impl FilesystemApp {
 
         // Clear all widgets' hierarchy links
         self.paginator.clear_children(&mut self.ui_context); self.paginator.set_parent(None, &mut self.ui_context);
-        self.view_dropdown.clear_children(&mut self.ui_context); self.view_dropdown.set_parent(None, &mut self.ui_context);
 
 
         self.browse.save_name_box.clear_children(&mut self.ui_context); self.browse.save_name_box.set_parent(None, &mut self.ui_context);
@@ -711,8 +742,6 @@ impl FilesystemApp {
                     self.ui_context.register_widget((*self_ptr).paginator.base().id(), (*self_ptr).paginator.as_ptr_mut());
                     (*self_ptr).paginator.set_parent(None, &mut self.ui_context);
                 }
-                self.ui_context.register_widget((*self_ptr).view_dropdown.base().id(), (*self_ptr).view_dropdown.as_ptr_mut());
-                (*self_ptr).view_dropdown.set_parent(None, &mut self.ui_context);
             }
         }
 
@@ -757,10 +786,6 @@ impl FilesystemApp {
             let self_ptr = self as *mut Self;
             unsafe {
                 let mut plain_pc = pages::PageContent::new();
-                // NOT the view dropdown: every page's `view()` already renders
-                // it, so a copy here was a second draw of the same widget — at
-                // the previous frame's rect, and compositing its label's
-                // antialiased edges twice into a faux-bold.
                 // The dissolved splitter's paint: its divider quad, then the preview
                 // pane (the only pane content the pages don't render themselves). The
                 // left pane's container copy is gone — the legacy aggregate painted it
@@ -800,10 +825,6 @@ impl FilesystemApp {
                 window_pc.rects.extend(plain);
                 window_pc.rects.extend(rounded);
                 window_pc.absorb(plain_pc);
-
-                // The view dropdown's flush inset plate is carved below, once
-                // the pages have laid the dropdown out — carving it here would
-                // read the previous frame's rect (`pages::dropdown_relief`).
             }
         }
 
@@ -812,36 +833,24 @@ impl FilesystemApp {
         match self.current_page {
             Page::Browse => {
                 let (bx, by, bw, bh) = self.browse_split.left_rect();
-                let browse_pc = pages::browse::view(&mut self.browse, &mut self.view_dropdown, bx, by, bw, bh, self.select_mode, &mut self.ui_context);
+                let browse_pc = pages::browse::view(&mut self.browse, bx, by, bw, bh, self.select_mode, &mut self.ui_context);
 
                 pc.absorb(browse_pc);
             }
             Page::Network => {
                 let (nx, ny, nw, nh) = self.network_split.left_rect();
-                let network_pc = pages::network::view(&mut self.network, &self.browse, &mut self.view_dropdown, nx, ny, nw, nh, &mut self.ui_context);
+                let network_pc = pages::network::view(&mut self.network, &self.browse, nx, ny, nw, nh, &mut self.ui_context);
 
                 pc.absorb(network_pc);
             }
             Page::Space => {
                 let (sx, sy, sw, sh) = self.space_split.left_rect();
-                let space_pc = pages::space::view(&mut self.space, &self.browse, &mut self.view_dropdown, sx, sy, sw, sh, &mut self.ui_context);
+                let space_pc = pages::space::view(&mut self.space, &self.browse, sx, sy, sw, sh, &mut self.ui_context);
 
                 pc.absorb(space_pc);
             }
         }
 
-        // The view dropdown's flush inset plate (control_relief) lives in its
-        // modern paint(); the flat view loses it, so carve it here — from the
-        // rect the page above just laid the dropdown out at, NOT the one it
-        // held when this method started.
-        {
-            let (dx, dy, dw, dh) = self.view_dropdown.rect();
-            pages::dropdown_relief(
-                &mut window_pc,
-                cce_ui::scene::layout::Rect { x: dx, y: dy, width: dw, height: dh },
-            );
-        }
-
         // Draw bottom selection bar if select_mode is enabled. It lives below the
         // content region, so it goes into window_pc: page content (pc) is clipped
         // to the viewport and would swallow the bar entirely.
@@ -1330,7 +1339,7 @@ impl Application for FilesystemApp {
     }
 
     // The engine ticks the exposed context each loop — this is what drives the
-    // dropdown expand/contract animation frames.
+    // registered widgets' animation frames.
     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
         Some(&mut self.ui_context)
     }
@@ -1418,14 +1427,6 @@ impl Application for FilesystemApp {
 
         let pages_names = Page::ALL.iter().map(|p| p.label().to_string()).collect::<Vec<_>>();
         let paginator = cce_ui::widget::Paginator::new(pages_names);
-        // These name the visualization rather than the page, so they are not
-        // Page::label(). Order MUST track Page::ALL — the selected index is
-        // indexed straight into it when the dropdown changes.
-        let view_dropdown = cce_ui::widget::Dropdown::new(
-            vec!["List".to_string(), "Graph".to_string(), "Space".to_string()],
-            0,
-        ).with_font_family(&cce_ui::layout::list_font_parsed().0);
-
         let fs_service = services::fs::FsService::new(sender.clone());
         let initial_w = if select_mode { 900 } else { 1200 };
         let initial_h = if select_mode { 500 } else { 720 };
@@ -1439,6 +1440,7 @@ impl Application for FilesystemApp {
             preview_dock: Default::default(),
             preview_prior_fracs: None,
             plate_menu_actions: Vec::new(),
+            breadcrumb_menu: None,
             select_mode,
             select_directory,
             save_mode,
@@ -1455,7 +1457,6 @@ impl Application for FilesystemApp {
             cursor_x: 0.0,
             cursor_y: 0.0,
             paginator,
-            view_dropdown,
             just_initialized: true,
             ui_context: cce_ui::context::UiContext::new(),
             watcher: None,
@@ -1535,7 +1536,6 @@ impl Application for FilesystemApp {
                 self.current_page = page;
                 let page_idx = Page::ALL.iter().position(|&p| p == page).unwrap_or(0);
                 self.paginator.set_selected_page(page_idx);
-                self.view_dropdown.selected = page_idx;
                 // Switching to Space is what triggers the first scan — it is
                 // far too expensive to run for a page nobody is looking at.
                 self.ensure_space_scan();
@@ -1737,9 +1737,8 @@ impl Application for FilesystemApp {
 
     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
         // Pump the widget tick walk (the cce-data-editor pattern): animating
-        // widgets — the view dropdown's expand/contract menu — register as
-        // tick receivers and report changed until their transition lands;
-        // without this the close animation freezes at fully open.
+        // widgets register as tick receivers and report changed until their
+        // transition lands; without this an animation freezes mid-way.
         if self.ui_context.tick(dt) {
             *needs_rebuild = true;
             self.needs_rebuild = true;
@@ -2047,13 +2046,6 @@ impl Application for FilesystemApp {
             }
         }
 
-        {
-            let root = self.view_dropdown.id();
-            if self.ui_context.propagate_event(&mv, root) {
-                changed = true;
-            }
-        }
-
         if self.current_page == Page::Browse {
             if self.select_mode {
                 let root = self.browse.save_name_box.id();
@@ -2159,6 +2151,37 @@ impl Application for FilesystemApp {
             return None;
         }
 
+        // The breadcrumb's menu rows run here too, ahead of the toolkit's
+        // dispatch — it would run Copy Path but has no notion of the view
+        // rows. On the press as well as the release: the toolkit runs a row
+        // on the PRESS when one reaches it, so whichever arrives first wins,
+        // and the hide it does leaves nothing for the other.
+        if button == MouseButton::Left {
+            use cce_ui::widget::context_menu as cm;
+            let live = self.breadcrumb_menu.as_ref().is_some_and(|m| cm::is_visible() && m.generation == cm::generation());
+            if !live {
+                self.breadcrumb_menu = None;
+            } else if cm::hit_test(pos.x as f32, pos.y as f32) {
+                let picked = cm::row_at(pos.x as f32, pos.y as f32)
+                    .and_then(|row| self.breadcrumb_menu.as_ref()?.rows.get(row).cloned().flatten());
+                // A press on the header or the separator leaves the menu up.
+                if let Some(row) = picked {
+                    cm::hide();
+                    self.breadcrumb_menu = None;
+                    *needs_rebuild = true;
+                    self.needs_rebuild = true;
+                    return match row {
+                        BreadcrumbRow::CopyPath(path) => {
+                            cce_ui::widget::clipboard::copy_to_clipboard(&path);
+                            None
+                        }
+                        BreadcrumbRow::View(page) => Some(Message::SwitchPage(page)),
+                    };
+                }
+                return None;
+            }
+        }
+
         // A left RELEASE on the preview pane's corner control opens its menu
         // (the designer also opens on release; presses do not reliably reach
         // this hook — see above). No press arming: the arming protocol
@@ -2291,9 +2314,20 @@ impl Application for FilesystemApp {
                 let ev = cce_ui::widget::Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
                 let root = breadcrumb.id();
                 // The breadcrumb opens the toolkit's shared context menu itself
-                // (open_context_menu → segment header + Copy Path); the app just
-                // routes the event and redraws — no app-side menu duplicate.
+                // (open_context_menu → segment header + Copy Path), recording
+                // the segment pressed. The app then shows that menu again,
+                // where it stands and under the same header, with the view
+                // switch added: the toolkit builds a breadcrumb's rows by type
+                // name and takes none from the app.
                 if self.ui_context.propagate_event(&ev, root) {
+                    use cce_ui::widget::context_menu as cm;
+                    let seg = breadcrumb.right_clicked_seg.unwrap_or(breadcrumb.path.len());
+                    let (labels, actions) = breadcrumb_menu_rows(breadcrumb.path_to_seg(seg), self.current_page);
+                    let header = cm::options().into_iter().next().unwrap_or_default();
+                    let options: Vec<String> = std::iter::once(header).chain(labels).collect();
+                    let rows = std::iter::once(None).chain(actions).collect();
+                    cm::show(cm::x(), cm::y(), options, 1, root);
+                    self.breadcrumb_menu = Some(BreadcrumbMenu { generation: cm::generation(), rows });
                     *needs_rebuild = true;
                     self.needs_rebuild = true;
                     return None;
@@ -2400,22 +2434,6 @@ impl Application for FilesystemApp {
             return None;
         }
 
-        if { let root = self.view_dropdown.id(); self.ui_context.propagate_event(&ev, root) } {
-            *needs_rebuild = true;
-            self.needs_rebuild = true;
-            if self.view_dropdown.take_change() {
-                // Indexed off Page::ALL rather than hand-mapped: the old
-                // `== 0 { Browse } else { Network }` silently sent every
-                // entry past the first to Network.
-                let new_page = Page::ALL
-                    .get(self.view_dropdown.selected)
-                    .copied()
-                    .unwrap_or(Page::Browse);
-                return Some(Message::SwitchPage(new_page));
-            }
-            return None;
-        }
-
         // The dissolved splitter's divider: a left press grabs it (stealing keyboard
         // focus like the legacy ctx.set_focused_ptr / release's clear_focus pair did),
         // a release ends the drag. A collapsed preview owns its column width;
@@ -2444,7 +2462,7 @@ impl Application for FilesystemApp {
         // plate-dock control above: the routed-widget path consumes left
         // PRESSES before this hook, so only releases reliably arrive here.
         // Every overlay above the page (plate-dock menu, context menu, dialog,
-        // dropdown, divider grab) has already returned, so a release reaching
+        // divider grab) has already returned, so a release reaching
         // here is on page content: move the accent ring to the well it landed
         // in, then let the release do its normal work. Releases outside any
         // well (toolbar, plate) change nothing.
@@ -2785,28 +2803,6 @@ impl Application for FilesystemApp {
             }
         }
 
-        // An open view dropdown takes the keyboard — Escape closes it, arrows
-        // move the hover, Enter selects — routed to the widget exactly like
-        // its mouse events are (see handle_mouse_input). Gated on `open`:
-        // this app routes keys per feature rather than to a whole tree, and
-        // the dropdown was simply never on the list, which left its own
-        // Escape handling unreachable.
-        if self.view_dropdown.open {
-            let kev = cce_ui::widget::Event::KeyInput(event.clone());
-            if { let root = self.view_dropdown.id(); self.ui_context.propagate_event(&kev, root) } {
-                *needs_rebuild = true;
-                self.needs_rebuild = true;
-                if self.view_dropdown.take_change() {
-                    let new_page = Page::ALL
-                        .get(self.view_dropdown.selected)
-                        .copied()
-                        .unwrap_or(Page::Browse);
-                    return Some(Message::SwitchPage(new_page));
-                }
-                return None;
-            }
-        }
-
         // The dissolved List's search keys, app-side: the open shortcut shows the strip
         // and focuses the box; the close shortcut hides it and clears the filter (the
         // legacy List set just_changed after clearing, which surfaced as an empty
@@ -3019,4 +3015,21 @@ mod tests {
         assert_eq!(b[0], 80.0);
         assert_eq!(b[2], WIN[2]);
     }
+
+    #[test]
+    fn the_breadcrumb_menu_offers_the_other_views() {
+        let (labels, rows) = breadcrumb_menu_rows("/home/me".to_string(), Page::Network);
+        assert_eq!(labels, ["Copy Path", "-", "List", "Space"]);
+        assert_eq!(rows, [
+            Some(BreadcrumbRow::CopyPath("/home/me".to_string())),
+            None,
+            Some(BreadcrumbRow::View(Page::Browse)),
+            Some(BreadcrumbRow::View(Page::Space)),
+        ]);
+        for page in Page::ALL {
+            let (labels, rows) = breadcrumb_menu_rows(String::new(), page);
+            assert_eq!(labels.len(), rows.len(), "a row for every label");
+            assert!(!rows.contains(&Some(BreadcrumbRow::View(page))), "{page:?} offers itself");
+        }
+    }
 }
diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index 5be0815..6b8fa8e 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -212,7 +212,7 @@ pub fn next_selection_index(state: &BrowseState, direction: BrowseNavigation) ->
 
 // ── View ────────────────────────────────────────────────────────────
 
-pub fn view(state: &mut BrowseState, view_dropdown: &mut cce_ui::widget::Adapted<cce_ui::widget::Dropdown>, cx: f32, cy: f32, cw: f32, ch: f32, select_mode: bool, ctx: &mut cce_ui::context::UiContext) -> PageContent {
+pub fn view(state: &mut BrowseState, cx: f32, cy: f32, cw: f32, ch: f32, select_mode: bool, ctx: &mut cce_ui::context::UiContext) -> PageContent {
     let mut pc = PageContent::new();
     let text_dim = cce_ui::color::TEXT_DIM;
 
@@ -232,10 +232,9 @@ pub fn view(state: &mut BrowseState, view_dropdown: &mut cce_ui::widget::Adapted
     let breadcrumb_h = 24.0;
     let textbox_h = cce_ui::layout::textbox_height();
 
-    // 1. Allocate and render Breadcrumb and Dropdown next to it
-    let dropdown_w = 120.0;
-    let breadcrumb_w = client_w - dropdown_w - gap;
-    let (bx, by, bw, bh) = layout.allocate(breadcrumb_w, breadcrumb_h);
+    // 1. Allocate and render the Breadcrumb, full width: the view switch that
+    // used to sit beside it is in its context menu now.
+    let (bx, by, bw, bh) = layout.allocate(client_w, breadcrumb_h);
     cce_ui::layout::render_widget(&mut pc, &mut state.breadcrumb, bx, by, bw, bh, ctx);
     // The breadcrumb's well + segment plate live in its modern paint(); the flat
     // view this host renders through loses them, so carve here: the full-width
@@ -245,7 +244,6 @@ pub fn view(state: &mut BrowseState, view_dropdown: &mut cce_ui::widget::Adapted
         let rect = cce_ui::scene::layout::Rect { x: bx, y: by, width: bw, height: bh };
         crate::pages::breadcrumb_relief(&mut pc, &state.breadcrumb, rect);
     }
-    cce_ui::layout::render_widget(&mut pc, view_dropdown, bx + bw + gap, by, dropdown_w, breadcrumb_h, ctx);
 
     // 2. Allocate and render List (ScrollBox)
     // The scrolling list height occupies the remaining vertical space:
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 6e1c842..4500b33 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -26,6 +26,16 @@ impl Page {
             Page::Space => "Space",
         }
     }
+
+    /// The page's row in the breadcrumb's view menu. These name the
+    /// visualization rather than the page, so they are not [`Self::label`].
+    pub fn view_label(self) -> &'static str {
+        match self {
+            Page::Browse => "List",
+            Page::Network => "Graph",
+            Page::Space => "Space",
+        }
+    }
 }
 
 /// Mirror the breadcrumb's relief into a flat-path [`PageContent`]: the
@@ -37,8 +47,10 @@ impl Page {
 /// in one place, since all three pages want it identically.
 ///
 /// **Audited against `Breadcrumb::paint` and deliberately left page-side** —
-/// it does NOT need [`dropdown_relief`]'s treatment, for two reasons that are
-/// easy to assume away:
+/// it does NOT need the treatment the view dropdown's ring needed (carved into
+/// `window_pc` after the pages had laid it out; the dropdown is gone since
+/// 2026-10-06, its List/Graph/Space rows moved into this breadcrumb's context
+/// menu), for two reasons that are easy to assume away:
 ///
 /// - *The depths already agree.* Each `relief_*`/`groove` helper derives depth
 ///   from the height it is handed, and all three here are handed what the
@@ -50,8 +62,8 @@ impl Page {
 ///   laying the breadcrumb out, rather than reading it back off the widget, so
 ///   there is no previous-frame rect to pick up.
 ///
-/// Nor does the `window_pc`-vs-`pc` split matter here, though `dropdown_relief`
-/// warns loudly about it. `display_list` emits ALL of a `PageContent`'s rects
+/// Nor does the `window_pc`-vs-`pc` split matter here, though it did for the
+/// dropdown's ring. `display_list` emits ALL of a `PageContent`'s rects
 /// before ALL of its reliefs, so the call order within `pc` is irrelevant; only
 /// a different PageContent could reorder these. The only quad the breadcrumb
 /// puts under the carve is the hover tint, and that is inset to the seam's
@@ -73,9 +85,9 @@ pub fn breadcrumb_relief(
     // the dropdown's configured fill: `render_widget` offers that fill for a
     // Dropdown through a per-type hook (layout.rs) but has no Breadcrumb arm,
     // so a run carved here with no face would keep showing the window plate
-    // while the dropdown beside it went opaque. Both controls read
-    // `dropdown_background_color` now, so they match under any config —
-    // transparent leaves the plate as the face for both.
+    // while the DE's dropdowns went opaque. It reads
+    // `dropdown_background_color` too, so they match under any config —
+    // transparent leaves the plate as the face.
     let radius = r.min(rh * 0.5);
     let depth = cce_ui::layout::bevel_width().min(rh * 0.2);
     let raw = cce_ui::color::dropdown_background_color();
@@ -92,48 +104,6 @@ pub fn breadcrumb_relief(
     }
 }
 
-/// Mirror the view dropdown's flush inset plate into a flat-path
-/// [`PageContent`]: the groove ring sunk around the control, and the control's
-/// own edge rolling back up out of it — face level with the window plate, so
-/// the seam is the only thing saying it is a separate part.
-///
-/// `Dropdown::paint` emits this as one `ctx.inset_plate`; `render_widget` keeps
-/// only quads and text, so the carve is app-side — the same story as
-/// [`breadcrumb_relief`], which is the well-and-plate this pairs with.
-///
-/// It reaches the SAME `inset_plate` call the widget makes, via
-/// [`PageContent::relief_inset`] → `WidgetFx::Inset`. It used to hand-roll the
-/// pair `inset_plate` expands to (`relief_recessed` over an expanded rect, then
-/// `relief_raised`) — which got the ring's depth wrong, because
-/// `relief_recessed` derives depth from the height it is HANDED, and that was
-/// the already-expanded one: a 5.76px wall against a 4.8px lip, so the
-/// descending wall over-ran the lip instead of meeting it in the tight V-groove
-/// with no flat floor that `inset_plate` documents.
-///
-/// **Carve it into `window_pc`, AFTER the page's `view()` has run.** Two
-/// constraints pin it there, and they pull in opposite directions:
-///
-/// - *After the pages* — because the pages are what lay the dropdown out. Read
-///   `view_dropdown.rect()` before they run and you get the rect they assigned
-///   on the PREVIOUS frame, so the ring trails the control by a frame through a
-///   resize (and on the very first frame it carves a 0×0 rect).
-/// - *Into `window_pc`, not the page's own `pc`* — because these are overlay
-///   carves, shaded against whatever is already beneath them. Emitting them
-///   page-side puts them after the dropdown's own background quad instead of
-///   before it, which visibly thins the lit top rim. Same rect, different
-///   material. (Verified by pixel-diffing the two orders; `CCE_PLATE_DEBUG=1`
-///   shows both as overlay fallback, so this is compositing order, not
-///   plate grouping.)
-pub fn dropdown_relief(pc: &mut PageContent, rect: cce_ui::scene::layout::Rect) {
-    pc.relief_inset(
-        rect.x,
-        rect.y,
-        rect.width,
-        rect.height,
-        cce_ui::layout::dropdown_corner_radius(),
-    );
-}
-
 pub const RELIEF_RECESSED: u8 = 0;
 pub const RELIEF_RAISED: u8 = 1;
 pub const RELIEF_INSET: u8 = 2;
@@ -511,8 +481,8 @@ impl RenderTarget for PageContent {
     /// The flat-path bridge for a widget's flush inset plate — the Dropdown's
     /// OPEN popover, which is its trigger surface grown over the unified box.
     /// Without this override the default degrades it to a plain rounded fill,
-    /// so the menu lost the groove ring and lip the closed trigger has (the
-    /// `dropdown_relief` carve) the moment it expanded.
+    /// so the menu lost the groove ring and lip the closed trigger has the
+    /// moment it expanded.
     ///
     /// The face and the walls go to different vecs on purpose — `reliefs` are
     /// edges-only, drawn over the faces `rects` own — and `display_list` emits
@@ -523,8 +493,7 @@ impl RenderTarget for PageContent {
     /// widget computes it from the TRIGGER's height; the box handed here is the
     /// trigger plus the revealed menu, several times taller. `relief_inset`
     /// would recompute `bevel_width().min(h * 0.2)` off that expanded height
-    /// and thicken the ring as the menu grows — the same mistake
-    /// [`dropdown_relief`] documents. A ring that swells during the open
+    /// and thicken the ring as the menu grows. A ring that swells during the open
     /// animation is exactly the artifact this override exists to avoid.
     fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32) {
         self.rects.push((color, x, y, w, h, radius, (true, true, true, true)));
diff --git a/src/pages/network.rs b/src/pages/network.rs
index c0a62df..ffc0033 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -201,21 +201,18 @@ impl NetworkState {
     }
 }
 
-pub fn view(state: &mut NetworkState, browse: &BrowseState, view_dropdown: &mut cce_ui::widget::Adapted<cce_ui::widget::Dropdown>, cx: f32, cy: f32, cw: f32, ch: f32, ctx: &mut cce_ui::context::UiContext) -> PageContent {
+pub fn view(state: &mut NetworkState, browse: &BrowseState, cx: f32, cy: f32, cw: f32, ch: f32, ctx: &mut cce_ui::context::UiContext) -> PageContent {
     let mut pc = PageContent::new();
 
-    // Render the Breadcrumb and Dropdown next to it
-    let dropdown_w = 120.0;
+    // Render the Breadcrumb, full width (the view switch is in its context menu).
     // TODO(style): the 4/6/16 offsets are a leftover inset from the pane rect
-    // that browse.rs has already dropped; the gap to the dropdown is the rung.
-    let gap = cce_ui::layout::root_plate_gap();
-    let breadcrumb_w = cw - 16.0 - dropdown_w - gap;
+    // that browse.rs has already dropped.
+    let breadcrumb_w = cw - 16.0;
     cce_ui::layout::render_widget(&mut pc, &mut state.breadcrumb, cx + 4.0, cy + 6.0, breadcrumb_w, 24.0, ctx);
     {
         let rect = cce_ui::scene::layout::Rect { x: cx + 4.0, y: cy + 6.0, width: breadcrumb_w, height: 24.0 };
         crate::pages::breadcrumb_relief(&mut pc, &state.breadcrumb, rect);
     }
-    cce_ui::layout::render_widget(&mut pc, view_dropdown, cx + 4.0 + breadcrumb_w + gap, cy + 6.0, dropdown_w, 24.0, ctx);
 
     // The pane the graph is laid out in, under the breadcrumb.
     let pane = (cx, cy + 28.0, cw, (ch - 28.0).max(0.0));
diff --git a/src/pages/space.rs b/src/pages/space.rs
index 8978237..1cc8d16 100644
--- a/src/pages/space.rs
+++ b/src/pages/space.rs
@@ -459,7 +459,6 @@ fn place(node: &TreeNode, path: &Path, rect: (f32, f32, f32, f32), depth: u32, o
 pub fn view(
     state: &mut SpaceState,
     browse: &BrowseState,
-    view_dropdown: &mut Adapted<cce_ui::widget::Dropdown>,
     cx: f32,
     cy: f32,
     cw: f32,
@@ -468,19 +467,16 @@ pub fn view(
 ) -> PageContent {
     let mut pc = PageContent::new();
 
-    // Breadcrumb + view dropdown, mirroring the Network page's header so the
-    // two views line up when you switch between them.
-    let dropdown_w = 120.0;
+    // The breadcrumb, mirroring the Network page's header so the two views
+    // line up when you switch between them.
     // TODO(style): the 4/6/16 offsets are a leftover inset from the pane rect
-    // that browse.rs has already dropped; the gap to the dropdown is the rung.
-    let gap = cce_ui::layout::root_plate_gap();
-    let breadcrumb_w = cw - 16.0 - dropdown_w - gap;
+    // that browse.rs has already dropped.
+    let breadcrumb_w = cw - 16.0;
     cce_ui::layout::render_widget(&mut pc, &mut state.breadcrumb, cx + 4.0, cy + 6.0, breadcrumb_w, 24.0, ctx);
     {
         let rect = cce_ui::scene::layout::Rect { x: cx + 4.0, y: cy + 6.0, width: breadcrumb_w, height: 24.0 };
         crate::pages::breadcrumb_relief(&mut pc, &state.breadcrumb, rect);
     }
-    cce_ui::layout::render_widget(&mut pc, view_dropdown, cx + 4.0 + breadcrumb_w + gap, cy + 6.0, dropdown_w, 24.0, ctx);
 
     let mut segments = Vec::new();
     for component in browse.current_dir.components() {