git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commitac5927391ea083bb4d74fcb06b240b09ef8d2238
parentb280e6bc75
authorLucas Galante <[email protected]>
date2026-08-22 16:13
feat: anchor the utility bar to the top or bottom window edge

The floating bar was hardcoded to the top: bar_rect pinned y to
BAR_MARGIN, and tabs_y/controls_y/btn_rect/url_rect each rebuilt their
own y from BAR_MARGIN rather than from the bar. A new browser.bar-position
key ("top" | "bottom", written by cce-system-interface's Browser page)
picks the edge, and every bar-relative rect now derives from the bar rect,
so the draw and the hit-tests move together — which is the whole risk
here, a bar drawn at one edge and clickable at the other.

Nothing else has to change: the page is already full-bleed under the
floating bar, so Servo neither knows nor cares which edge the chrome is
on. The one non-geometric fix is that a focus-regain reload now reports
whether it changed anything and flags a rebuild when it did; the settings
that existed before (homepage, search, download dir, history) needed no
repaint, so the hook never asked for one.

Live-verified in a headless session, bottom bar: URL field click-focus,
"+" opening a second tab, and an in-page link at the top of the window —
the strip the bar vacated — navigating. Config edits flip the bar live on
focus regain, both directions, on one process.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/main.rs     | 116 +++++++++++++++++++++++++++++++++-----------------------
 src/settings.rs |  23 +++++++++++
 2 files changed, 91 insertions(+), 48 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index b93287b..c06a591 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -94,43 +94,49 @@ fn hit(r: &Rect, x: f32, y: f32) -> bool {
     x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
 }
 
-/// The floating utility bar, overlaid on the page content.
-fn bar_rect(win_w: f32) -> Rect {
+/// The floating utility bar, overlaid on the page content. Anchored to the
+/// top or bottom window edge per the config; the page is full-bleed either
+/// way, so nothing but the chrome geometry depends on this. Every other
+/// bar-relative rect below is derived from this one — never from
+/// `BAR_MARGIN` directly, or it would stay pinned to the top.
+fn bar_rect(win: (f32, f32), position: settings::BarPosition) -> Rect {
+    let y = match position {
+        settings::BarPosition::Top => BAR_MARGIN,
+        settings::BarPosition::Bottom => (win.1 - BAR_MARGIN - BAR_H).max(BAR_MARGIN),
+    };
     Rect {
         x: BAR_MARGIN,
-        y: BAR_MARGIN,
-        width: (win_w - 2.0 * BAR_MARGIN).max(120.0),
+        y,
+        width: (win.0 - 2.0 * BAR_MARGIN).max(120.0),
         height: BAR_H,
     }
 }
 
 /// Y of the tab-strip row.
-fn tabs_y() -> f32 {
-    BAR_MARGIN + BAR_PAD
+fn tabs_y(bar: &Rect) -> f32 {
+    bar.y + BAR_PAD
 }
 
 /// Y of the nav-controls row.
-fn controls_y() -> f32 {
-    BAR_MARGIN + BAR_PAD + TAB_H + ROW_GAP
+fn controls_y(bar: &Rect) -> f32 {
+    bar.y + BAR_PAD + TAB_H + ROW_GAP
 }
 
-fn plus_rect(win_w: f32) -> Rect {
-    let bar = bar_rect(win_w);
+fn plus_rect(bar: &Rect) -> Rect {
     Rect {
         x: bar.x + bar.width - BAR_PAD - PLUS_W,
-        y: tabs_y(),
+        y: tabs_y(bar),
         width: PLUS_W,
         height: TAB_H,
     }
 }
 
-fn tab_rect(win_w: f32, count: usize, i: usize) -> Rect {
-    let bar = bar_rect(win_w);
+fn tab_rect(bar: &Rect, count: usize, i: usize) -> Rect {
     let avail = bar.width - 2.0 * BAR_PAD - PLUS_W - TAB_GAP - (count.max(1) - 1) as f32 * TAB_GAP;
     let w = (avail / count.max(1) as f32).clamp(TAB_MIN_W, TAB_MAX_W);
     Rect {
         x: bar.x + BAR_PAD + i as f32 * (w + TAB_GAP),
-        y: tabs_y(),
+        y: tabs_y(bar),
         width: w,
         height: TAB_H,
     }
@@ -146,32 +152,30 @@ fn tab_close_rect(pill: &Rect) -> Option<Rect> {
     })
 }
 
-fn btn_rect(i: usize) -> Rect {
+fn btn_rect(bar: &Rect, i: usize) -> Rect {
     Rect {
-        x: BAR_MARGIN + BAR_PAD + i as f32 * (BTN_W + BTN_GAP),
-        y: controls_y(),
+        x: bar.x + BAR_PAD + i as f32 * (BTN_W + BTN_GAP),
+        y: controls_y(bar),
         width: BTN_W,
         height: BTN_H,
     }
 }
 
 /// The bookmark star, at the right end of the controls row.
-fn star_rect(win_w: f32) -> Rect {
-    let bar = bar_rect(win_w);
+fn star_rect(bar: &Rect) -> Rect {
     Rect {
         x: bar.x + bar.width - BAR_PAD - BTN_W,
-        y: controls_y(),
+        y: controls_y(bar),
         width: BTN_W,
         height: BTN_H,
     }
 }
 
-fn url_rect(win_w: f32) -> Rect {
-    let bar = bar_rect(win_w);
-    let x = BAR_MARGIN + BAR_PAD + 3.0 * (BTN_W + BTN_GAP) + 4.0;
+fn url_rect(bar: &Rect) -> Rect {
+    let x = bar.x + BAR_PAD + 3.0 * (BTN_W + BTN_GAP) + 4.0;
     Rect {
         x,
-        y: controls_y(),
+        y: controls_y(bar),
         width: (bar.x + bar.width - BAR_PAD - BTN_W - BTN_GAP - x).max(60.0),
         height: BTN_H,
     }
@@ -289,6 +293,12 @@ fn next_boundary(s: &str, i: usize) -> usize {
 }
 
 impl BrowserApp {
+    /// The utility bar's rect for the current window size and configured
+    /// edge — the single source every chrome hit-test and draw reads.
+    fn bar(&self) -> Rect {
+        bar_rect(self.win, self.settings.bar_position)
+    }
+
     /// The page fills the whole window; the utility bar floats above it.
     fn content_px(&self) -> (u32, u32) {
         (
@@ -318,19 +328,20 @@ impl BrowserApp {
         }
     }
 
-    /// New blank tab with the URL bar focused for typing.
     /// Pick up settings edits (system-interface, cce-data-editor) when the
-    /// window regains focus.
-    fn reload_settings(&mut self) {
+    /// window regains focus. Returns whether anything changed.
+    fn reload_settings(&mut self) -> bool {
         let new = settings::load();
         if new == self.settings {
-            return;
+            return false;
         }
         downloads::set_download_dir(new.download_dir.clone());
         self.host.set_history_enabled(new.history);
         self.settings = new;
+        true
     }
 
+    /// New blank tab with the URL bar focused for typing.
     fn new_tab(&mut self) {
         let url = Url::parse("about:blank").expect("about:blank");
         self.host.open_tab(url);
@@ -527,9 +538,11 @@ impl Application for BrowserApp {
 
     fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
 
-    fn handle_focus_change(&mut self, focused: bool, _needs_rebuild: &mut bool) {
-        if focused {
-            self.reload_settings();
+    fn handle_focus_change(&mut self, focused: bool, needs_rebuild: &mut bool) {
+        // A settings change can move the bar to the other edge, so a reload
+        // that changed anything has to redraw the chrome.
+        if focused && self.reload_settings() {
+            *needs_rebuild = true;
         }
     }
 
@@ -542,7 +555,7 @@ impl Application for BrowserApp {
 
     fn handle_pointer_move(&mut self, pos: LogicalPosition, _needs_rebuild: &mut bool) {
         self.pointer = (pos.x, pos.y);
-        if !hit(&bar_rect(self.win.0), pos.x, pos.y) {
+        if !hit(&self.bar(), pos.x, pos.y) {
             let s = self.scale as f32;
             self.host.mouse_move(pos.x * s, pos.y * s);
         }
@@ -557,7 +570,8 @@ impl Application for BrowserApp {
     ) -> Option<Self::Message> {
         let pressed = state == ElementState::Pressed;
 
-        if hit(&bar_rect(self.win.0), pos.x, pos.y) {
+        let bar = self.bar();
+        if hit(&bar, pos.x, pos.y) {
             if !pressed || !matches!(button, MouseButton::Left | MouseButton::Middle) {
                 return None;
             }
@@ -565,7 +579,7 @@ impl Application for BrowserApp {
             // Tab strip: activate / close (x region or middle click) / new tab.
             let count = self.host.tab_count();
             for i in 0..count {
-                let pill = tab_rect(self.win.0, count, i);
+                let pill = tab_rect(&bar, count, i);
                 if !hit(&pill, pos.x, pos.y) {
                     continue;
                 }
@@ -580,18 +594,18 @@ impl Application for BrowserApp {
             if button != MouseButton::Left {
                 return None;
             }
-            if hit(&plus_rect(self.win.0), pos.x, pos.y) {
+            if hit(&plus_rect(&bar), pos.x, pos.y) {
                 self.new_tab();
-            } else if hit(&btn_rect(0), pos.x, pos.y) {
+            } else if hit(&btn_rect(&bar, 0), pos.x, pos.y) {
                 self.host.back();
-            } else if hit(&btn_rect(1), pos.x, pos.y) {
+            } else if hit(&btn_rect(&bar, 1), pos.x, pos.y) {
                 self.host.forward();
-            } else if hit(&btn_rect(2), pos.x, pos.y) {
+            } else if hit(&btn_rect(&bar, 2), pos.x, pos.y) {
                 self.host.reload();
-            } else if hit(&star_rect(self.win.0), pos.x, pos.y) {
+            } else if hit(&star_rect(&bar), pos.x, pos.y) {
                 self.host.toggle_bookmark();
             } else {
-                let field = url_rect(self.win.0);
+                let field = url_rect(&bar);
                 if hit(&field, pos.x, pos.y) {
                     self.cursor = self.cursor_from_click(pos.x, &field);
                     self.url_focused = true;
@@ -622,7 +636,7 @@ impl Application for BrowserApp {
     }
 
     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, _needs_rebuild: &mut bool) {
-        if hit(&bar_rect(self.win.0), pos.x, pos.y) {
+        if hit(&self.bar(), pos.x, pos.y) {
             return;
         }
         // WheelDelta keeps cce-ui's winit sign convention (positive = scroll
@@ -719,17 +733,23 @@ impl Application for BrowserApp {
         let mut pc = PaintCtx::new();
         let w = size.width;
 
+        let bar = self.bar();
+
         // Page: full-bleed under the floating bar.
         let content = Rect { x: 0.0, y: 0.0, width: w, height: size.height };
         pc.quad(content, PAGE_BG);
         if let Some((id, ..)) = self.host.image() {
             pc.image(id, content, 1.0);
         } else {
-            pc.text("Loading...", BAR_MARGIN + 6.0, BAR_MARGIN + BAR_H + 22.0, 13.0, TEXT_DIM);
+            // Just clear of the bar, whichever edge it is on.
+            let y = match self.settings.bar_position {
+                settings::BarPosition::Top => bar.y + bar.height + 22.0,
+                settings::BarPosition::Bottom => BAR_MARGIN + 22.0,
+            };
+            pc.text("Loading...", BAR_MARGIN + 6.0, y, 13.0, TEXT_DIM);
         }
 
         // Floating utility bar: a blur-behind plate frosting the page under it.
-        let bar = bar_rect(w);
         let radii = (BAR_RADIUS, BAR_RADIUS, BAR_RADIUS, BAR_RADIUS);
         pc.plate(bar, radii, BAR_FILL, cce_ui::layout::bevel_width().min(4.0));
         if self.loading {
@@ -746,7 +766,7 @@ impl Application for BrowserApp {
         let count = self.host.tab_count();
         let active = self.host.active_index();
         for i in 0..count {
-            let pill = tab_rect(w, count, i);
+            let pill = tab_rect(&bar, count, i);
             let is_active = i == active;
             pc.rounded_rect(
                 pill,
@@ -788,7 +808,7 @@ impl Application for BrowserApp {
                 );
             }
         }
-        let plus = plus_rect(w);
+        let plus = plus_rect(&bar);
         pc.rounded_rect(plus, 7.0, (true, true, true, true), BTN_BG);
         let pw = measure_text_width("+", &sans, 14.0);
         pc.text(
@@ -802,7 +822,7 @@ impl Application for BrowserApp {
         let labels = ["<", ">", "R"];
         let enabled = [self.host.can_go_back(), self.host.can_go_forward(), true];
         for (i, label) in labels.iter().enumerate() {
-            let r = btn_rect(i);
+            let r = btn_rect(&bar, i);
             pc.rounded_rect(r, 6.0, (true, true, true, true), BTN_BG);
             let color = if enabled[i] { TEXT } else { TEXT_DIM };
             let (sans, ..) = cce_ui::layout::read_preferred_fonts();
@@ -817,7 +837,7 @@ impl Application for BrowserApp {
         }
 
         // Bookmark star: accent-lit when the page is bookmarked.
-        let star = star_rect(w);
+        let star = star_rect(&bar);
         pc.rounded_rect(star, 6.0, (true, true, true, true), BTN_BG);
         let starred = self.host.active_bookmarked();
         let star_color: [u8; 3] = if starred { [150, 190, 240] } else { TEXT_DIM };
@@ -831,7 +851,7 @@ impl Application for BrowserApp {
         );
 
         // URL field: rim + recess, brighter rim when focused.
-        let f = url_rect(w);
+        let f = url_rect(&bar);
         let rim = if self.url_focused { RIM_FOCUS } else { RIM };
         pc.rounded_rect(
             Rect { x: f.x - 1.0, y: f.y - 1.0, width: f.width + 2.0, height: f.height + 2.0 },
diff --git a/src/settings.rs b/src/settings.rs
index b788c45..e214a19 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -8,6 +8,25 @@ use std::path::PathBuf;
 
 pub const DEFAULT_HOMEPAGE: &str = "https://servo.org";
 
+/// Which edge the floating utility bar is anchored to. The page is
+/// full-bleed under the bar either way, so this is chrome geometry only.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum BarPosition {
+    #[default]
+    Top,
+    Bottom,
+}
+
+impl BarPosition {
+    /// Config keys as written by the system-interface Browser page.
+    fn from_key(key: &str) -> Self {
+        match key {
+            "bottom" => Self::Bottom,
+            _ => Self::Top,
+        }
+    }
+}
+
 #[derive(Debug, Clone, PartialEq)]
 pub struct Settings {
     pub homepage: String,
@@ -17,6 +36,8 @@ pub struct Settings {
     pub download_dir: Option<PathBuf>,
     /// Record page visits to cce://history.
     pub history: bool,
+    /// Window edge the utility bar floats against.
+    pub bar_position: BarPosition,
 }
 
 impl Default for Settings {
@@ -26,6 +47,7 @@ impl Default for Settings {
             search_prefix: search_prefix("duckduckgo").to_string(),
             download_dir: None,
             history: true,
+            bar_position: BarPosition::Top,
         }
     }
 }
@@ -65,5 +87,6 @@ pub fn load() -> Settings {
         search_prefix: search_prefix(b["search"].as_str().unwrap_or("duckduckgo")).to_string(),
         download_dir,
         history: b["history"].as_bool().unwrap_or(true),
+        bar_position: BarPosition::from_key(b["bar-position"].as_str().unwrap_or("top")),
     }
 }