system settings
git clone https://git.lucas.co/cce-system-interface.git
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,