git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit402c6ace5aa66e8ec6c237b84dd6773222a25e0d
parent6d729a233c
authorLucas Galante <[email protected]>
date2026-08-12 10:40
Add a Browser settings page

Homepage, search engine (DuckDuckGo/Google/Bing/Wikipedia), download
directory, and history-recording toggle, editing cce-browser's app
config (~/.config/cce/cce-browser/config.kdl, browser section — keys
written as dotted paths; write_config_value's section arg is
vestigial). Mirrors the Notifications page: widget section + Apply,
plus a 5s config poll while the page is open so external edits
refresh it.

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

 src/app.rs           |   6 ++
 src/main.rs          |   9 ++
 src/pages/browser.rs | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/pages/mod.rs     |   6 +-
 src/watchers.rs      |  32 ++++++-
 5 files changed, 298 insertions(+), 2 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 7f60dc9..ed6a453 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -13,12 +13,14 @@ use crate::pages::fonts;
 use crate::pages::accounts;
 use crate::pages::packages;
 use crate::pages::notifications;
+use crate::pages::browser;
 use crate::pages::Page;
 
 pub struct AppState {
     pub current_page: Page,
     pub audio: audio::AudioState,
     pub bluetooth: bluetooth::BluetoothState,
+    pub browser: browser::BrowserState,
     pub default_apps: default_apps::DefaultAppsState,
     pub network: network::NetworkState,
     pub processes: processes::ProcessesState,
@@ -38,6 +40,7 @@ impl Default for AppState {
             current_page: Page::ALL[0],
             audio: audio::AudioState::default(),
             bluetooth: bluetooth::BluetoothState::default(),
+            browser: browser::BrowserState::default(),
             default_apps: default_apps::DefaultAppsState::default(),
             network: network::NetworkState::default(),
             processes: processes::ProcessesState::default(),
@@ -59,6 +62,7 @@ impl AppState {
             Page::Accounts => &self.accounts,
             Page::Audio => &self.audio,
             Page::Bluetooth => &self.bluetooth,
+            Page::Browser => &self.browser,
             Page::DefaultApps => &self.default_apps,
             Page::Packages => &self.packages,
             Page::Processes => &self.processes,
@@ -77,6 +81,7 @@ impl AppState {
             Page::Accounts => &mut self.accounts,
             Page::Audio => &mut self.audio,
             Page::Bluetooth => &mut self.bluetooth,
+            Page::Browser => &mut self.browser,
             Page::DefaultApps => &mut self.default_apps,
             Page::Packages => &mut self.packages,
             Page::Processes => &mut self.processes,
@@ -103,6 +108,7 @@ impl AppState {
 pub enum AppAction {
     Exit,
     Audio(audio::AudioMessage),
+    Browser(browser::BrowserMessage),
     DefaultApps(default_apps::DefaultAppsMessage),
     Network(network::NetworkMessage),
     Bluetooth(bluetooth::BluetoothMessage),
diff --git a/src/main.rs b/src/main.rs
index 6ca06b0..68ca397 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -60,6 +60,7 @@ struct SystemInterface {
     rx_system: std::sync::mpsc::Receiver<pages::system_info::SystemInfo>,
     rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageState>,
     rx_notifications: std::sync::mpsc::Receiver<pages::notifications::NotificationsConfig>,
+    rx_browser: std::sync::mpsc::Receiver<pages::browser::BrowserConfig>,
     rx_services: std::sync::mpsc::Receiver<Vec<pages::services::ServiceInfo>>,
     rx_default_apps: std::sync::mpsc::Receiver<pages::default_apps::DefaultAppsInfo>,
     rx_timers: std::sync::mpsc::Receiver<Vec<pages::timers::TimerInfo>>,
@@ -163,6 +164,7 @@ impl cce_ui::engine::Application for SystemInterface {
             rx_system: watchers.rx_system,
             rx_storage: watchers.rx_storage,
             rx_notifications: watchers.rx_notifications,
+            rx_browser: watchers.rx_browser,
             rx_services: watchers.rx_services,
             rx_fonts: watchers.rx_fonts,
             rx_accounts: watchers.rx_accounts,
@@ -532,6 +534,12 @@ impl SystemInterface {
                 self.needs_rebuild = true;
             }
         }
+        while let Ok(s) = self.rx_browser.try_recv() {
+            pages::browser::update(&mut self.app.browser, pages::browser::BrowserMessage::Refreshed(s));
+            if self.app.current_page == Page::Browser {
+                self.needs_rebuild = true;
+            }
+        }
         while let Ok(s) = self.rx_fonts.try_recv() {
             self.sans_serif_family = s.sans_serif.clone();
             self.serif_family = s.serif.clone();
@@ -593,6 +601,7 @@ impl SystemInterface {
             AppAction::Services(m) => services::update(&mut self.app.services, m.clone()),
             AppAction::DefaultApps(m) => pages::default_apps::update(&mut self.app.default_apps, m.clone()),
             AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone()),
+            AppAction::Browser(m) => pages::browser::update(&mut self.app.browser, m.clone()),
             AppAction::Storage(m) => match m {
                 pages::storage::StorageMessage::StartBackup => {
                     pages::storage::update(&mut self.app.storage, pages::storage::StorageMessage::StartBackup);
diff --git a/src/pages/browser.rs b/src/pages/browser.rs
new file mode 100644
index 0000000..6a58861
--- /dev/null
+++ b/src/pages/browser.rs
@@ -0,0 +1,247 @@
+//! Browser (cce-browser) settings: homepage, search engine, download
+//! directory, history recording. Edits the browser's own app config
+//! (`~/.config/cce/cce-browser/config.kdl`, section "browser") — the
+//! browser reloads it when its window regains focus.
+
+use std::fs;
+
+use cce_ui::layout::{LayoutStrategy, PageLayoutBuilder};
+use cce_ui::widget::input::{Dropdown, Toggle};
+use cce_ui::widget::{TextBox, WidgetHost};
+
+use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::pages::AppPage;
+
+/// config key, menu label — the browser maps the key onto a query URL.
+pub const SEARCH_ENGINES: [(&str, &str); 4] = [
+    ("duckduckgo", "DuckDuckGo"),
+    ("google", "Google"),
+    ("bing", "Bing"),
+    ("wikipedia", "Wikipedia"),
+];
+
+const DEFAULT_HOMEPAGE: &str = "https://servo.org";
+
+#[derive(Debug, Clone)]
+pub struct BrowserConfig {
+    pub homepage: String,
+    pub search: String,
+    pub download_dir: String,
+    pub history: bool,
+}
+
+pub struct BrowserState {
+    pub loaded: bool,
+    pub homepage: String,
+    pub search: String,
+    pub download_dir: String,
+    pub history: bool,
+    pub homepage_box: cce_ui::widget::Adapted<TextBox>,
+    pub search_menu: cce_ui::widget::Adapted<Dropdown>,
+    pub download_dir_box: cce_ui::widget::Adapted<TextBox>,
+    pub history_toggle: cce_ui::widget::Adapted<Toggle>,
+}
+
+impl Default for BrowserState {
+    fn default() -> Self {
+        let config = read_browser_config();
+        let mut homepage_box = TextBox::new(config.homepage.clone())
+            .with_draw_bg_border(true)
+            .with_label("Homepage");
+        homepage_box.edit_buffer = config.homepage.clone();
+        let mut download_dir_box = TextBox::new(config.download_dir.clone())
+            .with_draw_bg_border(true)
+            .with_label("Download Directory");
+        download_dir_box.edit_buffer = config.download_dir.clone();
+        Self {
+            loaded: true,
+            homepage: config.homepage,
+            search: config.search.clone(),
+            download_dir: config.download_dir,
+            history: config.history,
+            homepage_box,
+            search_menu: Dropdown::new(
+                SEARCH_ENGINES.iter().map(|(_, label)| label.to_string()).collect(),
+                search_index(&config.search),
+            )
+            .with_label("Search Engine"),
+            download_dir_box,
+            history_toggle: Toggle::new().with_label("Record History"),
+        }
+    }
+}
+
+fn search_index(key: &str) -> usize {
+    SEARCH_ENGINES.iter().position(|(k, _)| *k == key).unwrap_or(0)
+}
+
+#[derive(Debug, Clone)]
+pub enum BrowserMessage {
+    SetSearch(String),
+    ToggleHistory,
+    /// Commit the homepage / download-dir text fields.
+    Apply,
+    Refreshed(BrowserConfig),
+}
+
+/// A TextBox's live contents: the in-progress edit buffer while focused,
+/// the committed text otherwise (the recurring TextBox landmine).
+fn live_text(tb: &cce_ui::widget::Adapted<TextBox>) -> String {
+    if tb.editing {
+        tb.edit_buffer.trim().to_string()
+    } else {
+        tb.text.trim().to_string()
+    }
+}
+
+pub fn update(state: &mut BrowserState, msg: BrowserMessage) {
+    match msg {
+        BrowserMessage::SetSearch(key) => {
+            state.search = key.clone();
+            write_config_value("search", &key);
+        }
+        BrowserMessage::ToggleHistory => {
+            state.history = !state.history;
+            write_config_value("history", &state.history.to_string());
+        }
+        BrowserMessage::Apply => {
+            state.homepage = live_text(&state.homepage_box);
+            if state.homepage.is_empty() {
+                state.homepage = DEFAULT_HOMEPAGE.to_string();
+                state.homepage_box.text = state.homepage.clone();
+                state.homepage_box.edit_buffer = state.homepage.clone();
+            }
+            state.download_dir = live_text(&state.download_dir_box);
+            write_config_value("homepage", &state.homepage);
+            write_config_value("download-dir", &state.download_dir);
+        }
+        BrowserMessage::Refreshed(new) => {
+            state.loaded = true;
+            state.search = new.search;
+            state.history = new.history;
+            // Don't clobber fields mid-edit with watcher refreshes.
+            if !state.homepage_box.editing && state.homepage != new.homepage {
+                state.homepage = new.homepage.clone();
+                state.homepage_box.text = new.homepage.clone();
+                state.homepage_box.edit_buffer = new.homepage;
+            }
+            if !state.download_dir_box.editing && state.download_dir != new.download_dir {
+                state.download_dir = new.download_dir.clone();
+                state.download_dir_box.text = new.download_dir.clone();
+                state.download_dir_box.edit_buffer = new.download_dir;
+            }
+        }
+    }
+}
+
+fn get_config_path() -> String {
+    cce_ui::config::get_app_config_path("cce-browser")
+        .to_string_lossy()
+        .into_owned()
+}
+
+pub fn read_browser_config() -> BrowserConfig {
+    let content = fs::read_to_string(get_config_path()).unwrap_or_default();
+    let val = cce_ui::config::parse_kdl_to_json(&content);
+    BrowserConfig {
+        homepage: val["browser"]["homepage"]
+            .as_str()
+            .unwrap_or(DEFAULT_HOMEPAGE)
+            .to_string(),
+        search: val["browser"]["search"].as_str().unwrap_or("duckduckgo").to_string(),
+        download_dir: val["browser"]["download-dir"].as_str().unwrap_or("").to_string(),
+        history: val["browser"]["history"].as_bool().unwrap_or(true),
+    }
+}
+
+fn write_config_value(key: &str, value: &str) {
+    let path = get_config_path();
+    // The per-app config dir may not exist yet.
+    if let Some(dir) = std::path::Path::new(&path).parent() {
+        let _ = fs::create_dir_all(dir);
+    }
+    // Section nesting comes from the dotted key path (the section arg of
+    // write_config_value is vestigial).
+    cce_ui::config::write_config_value(&path, &format!("browser.{key}"), value, "browser");
+}
+
+impl AppPage for BrowserState {
+    // Sections: [Browser Settings]
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
+        vec![vec![
+            self.homepage_box.id(),
+            self.search_menu.id(),
+            self.download_dir_box.id(),
+            self.history_toggle.id(),
+        ]]
+    }
+
+    fn view(
+        &mut self,
+        cx: f32,
+        cy: f32,
+        cw: f32,
+        ch: f32,
+        _root_focused: bool,
+        sec_focused: &[bool],
+        layout: &mut dyn LayoutStrategy,
+        ctx: &mut cce_ui::context::UiContext,
+    ) -> PageContent {
+        let mut final_pc = PageContent::new();
+        let sec_w = 320.0f32;
+        let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
+
+        builder.add_section(&mut final_pc, "Browser Settings", sec_focused.first().copied().unwrap_or(false), |sec| {
+            let mut stack = sec.vstack(8.0);
+            let sec_w = stack.context.cw;
+            let row_w = sec_w - 28.0;
+
+            self.homepage_box.set_row_rect(stack.context.left + 14.0, row_w);
+            stack.add_widget(&mut self.homepage_box, row_w, 44.0, ctx);
+
+            self.search_menu.selected = search_index(&self.search);
+            self.search_menu.set_row_rect(stack.context.left + 14.0, row_w);
+            stack.add_widget(&mut self.search_menu, row_w, 44.0, ctx);
+
+            self.download_dir_box.set_row_rect(stack.context.left + 14.0, row_w);
+            stack.add_widget(&mut self.download_dir_box, row_w, 44.0, ctx);
+
+            self.history_toggle.set_toggled(self.history);
+            stack.add_widget(&mut self.history_toggle, row_w, cce_ui::layout::toggle_height(), ctx);
+
+            let btn_h = 32.0;
+            stack.add_row(1, 0.0, btn_h, |ctx, _, x, w| {
+                ctx.button(
+                    "Apply",
+                    x,
+                    ctx.ay(),
+                    w,
+                    btn_h,
+                    [0.20, 0.40, 0.65, 1.0],
+                    [0.28, 0.50, 0.78, 1.0],
+                    [1.0, 1.0, 1.0, 1.0],
+                    AppAction::Browser(BrowserMessage::Apply),
+                );
+            });
+        });
+
+        final_pc
+    }
+
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
+        if self.search_menu.take_change() {
+            let key = SEARCH_ENGINES
+                .get(self.search_menu.selected)
+                .map(|(k, _)| k.to_string())
+                .unwrap_or_else(|| "duckduckgo".to_string());
+            actions.push(AppAction::Browser(BrowserMessage::SetSearch(key)));
+        }
+        if self.history_toggle.take_change() {
+            actions.push(AppAction::Browser(BrowserMessage::ToggleHistory));
+        }
+        // Enter in either text field commits both.
+        if self.homepage_box.take_change() || self.download_dir_box.take_change() {
+            actions.push(AppAction::Browser(BrowserMessage::Apply));
+        }
+    }
+}
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 373f85e..3698f86 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -1,5 +1,6 @@
 pub mod audio;
 pub mod bluetooth;
+pub mod browser;
 pub mod default_apps;
 pub mod network;
 pub mod storage;
@@ -17,6 +18,7 @@ pub enum Page {
     Accounts,
     Audio,
     Bluetooth,
+    Browser,
     DefaultApps,
     Network,
     Notifications,
@@ -30,10 +32,11 @@ pub enum Page {
 }
 
 impl Page {
-    pub const ALL: [Page; 13] = [
+    pub const ALL: [Page; 14] = [
         Page::Accounts,
         Page::Audio,
         Page::Bluetooth,
+        Page::Browser,
         Page::DefaultApps,
         Page::Fonts,
         Page::Network,
@@ -51,6 +54,7 @@ impl Page {
             Page::Accounts => "Accounts",
             Page::Audio => "Audio",
             Page::Bluetooth => "Bluetooth",
+            Page::Browser => "Browser",
             Page::DefaultApps => "Default Apps",
             Page::Network => "Network",
             Page::Notifications => "Notifications",
diff --git a/src/watchers.rs b/src/watchers.rs
index c29205e..6b5853f 100644
--- a/src/watchers.rs
+++ b/src/watchers.rs
@@ -1,7 +1,7 @@
 use std::sync::Arc;
 use std::sync::atomic::{AtomicU8, Ordering};
 use std::sync::mpsc::{channel, Receiver, Sender};
-use crate::pages::{Page, audio, bluetooth, default_apps, network, fonts, processes, services, system_info, storage, packages, accounts, notifications, timers};
+use crate::pages::{Page, audio, bluetooth, browser, default_apps, network, fonts, processes, services, system_info, storage, packages, accounts, notifications, timers};
 
 pub struct Watchers {
     pub rx_audio: Receiver<audio::AudioState>,
@@ -11,6 +11,7 @@ pub struct Watchers {
     pub rx_system: Receiver<system_info::SystemInfo>,
     pub rx_storage: Receiver<storage::StorageState>,
     pub rx_notifications: Receiver<notifications::NotificationsConfig>,
+    pub rx_browser: Receiver<browser::BrowserConfig>,
     pub rx_services: Receiver<Vec<services::ServiceInfo>>,
     pub rx_default_apps: Receiver<default_apps::DefaultAppsInfo>,
     pub rx_timers: Receiver<Vec<timers::TimerInfo>>,
@@ -94,6 +95,34 @@ pub fn spawn_all(
         rx
     };
 
+    // Config-file poll while the Browser page is open: catches edits made
+    // outside this app (the browser itself, cce-data-editor).
+    let rx_browser = {
+        let (tx, rx) = channel::<browser::BrowserConfig>();
+        let current_page_shared = current_page_shared.clone();
+        tokio::spawn(async move {
+            let mut last_fetch: Option<std::time::Instant> = None;
+            loop {
+                let current_page = current_page_shared.load(Ordering::SeqCst);
+                if current_page == Page::Browser.index() as u8 {
+                    let should_fetch = match last_fetch {
+                        None => true,
+                        Some(t) => t.elapsed() >= std::time::Duration::from_secs(5),
+                    };
+                    if should_fetch {
+                        let val = tokio::task::spawn_blocking(browser::read_browser_config).await;
+                        if let Ok(val) = val {
+                            if tx.send(val).is_err() { break; }
+                        }
+                        last_fetch = Some(std::time::Instant::now());
+                    }
+                }
+                tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+            }
+        });
+        rx
+    };
+
     let rx_fonts = spawn_bg_active(current_page_shared.clone(), Page::Fonts.index() as u8, 30, || fonts::fetch_typeface_state());
     let rx_services = spawn_bg_active(current_page_shared.clone(), Page::Services.index() as u8, 3, || services::fetch_services());
     let rx_default_apps = spawn_bg_active(current_page_shared.clone(), Page::DefaultApps.index() as u8, 10, || default_apps::fetch_default_apps());
@@ -113,6 +142,7 @@ pub fn spawn_all(
             rx_system,
             rx_storage,
             rx_notifications,
+            rx_browser,
             rx_services,
             rx_default_apps,
             rx_timers,