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

src/pages/browser.rs (15.5K)

  1 //! Browser (cce-browser) settings: homepage, search engine, download
  2 //! directory, history recording, Raindrop bookmark sync, vi mode, navigation-bar
  3 //! position, page color scheme. Edits the
  4 //! browser's own app config (`~/.config/cce/cce-browser/config.kdl`,
  5 //! section "browser") — the browser reloads it when its window regains
  6 //! focus.
  7 
  8 use std::fs;
  9 
 10 use cce_ui::context::UiContext;
 11 use cce_ui::widget::Handle;
 12 use cce_ui::layout::{PageFlow, PageLayoutBuilder};
 13 use cce_ui::widget::input::{Dropdown, Toggle};
 14 use cce_ui::widget::TextBox;
 15 
 16 use crate::app::{AppAction, PageContent};
 17 use crate::pages::AppPage;
 18 
 19 /// config key, menu label — the browser maps the key onto a query URL.
 20 pub const SEARCH_ENGINES: [(&str, &str); 4] = [
 21     ("duckduckgo", "DuckDuckGo"),
 22     ("google", "Google"),
 23     ("bing", "Bing"),
 24     ("wikipedia", "Wikipedia"),
 25 ];
 26 
 27 /// config key, menu label — the window edge the browser's floating
 28 /// navigation bar is anchored to.
 29 pub const BAR_POSITIONS: [(&str, &str); 2] = [("top", "Top"), ("bottom", "Bottom")];
 30 
 31 /// config key, menu label — the first two are reported to pages as
 32 /// `prefers-color-scheme`, so sites that ship a dark stylesheet use it.
 33 /// "Force Dark" additionally inverts the page, for sites that ship no dark
 34 /// theme; it fights their palette, so it is a deliberate last resort.
 35 pub const COLOR_SCHEMES: [(&str, &str); 3] =
 36     [("dark", "Dark"), ("light", "Light"), ("force-dark", "Force Dark")];
 37 
 38 const DEFAULT_HOMEPAGE: &str = "https://servo.org";
 39 
 40 #[derive(Debug, Clone)]
 41 pub struct BrowserConfig {
 42     pub homepage: String,
 43     pub search: String,
 44     pub download_dir: String,
 45     pub history: bool,
 46     /// `browser.raindrop`: sync bookmarks with Raindrop.io's Unsorted
 47     /// collection (cce-browser's RAINDROP-SYNC.md). Off unless set.
 48     pub raindrop: bool,
 49     /// `browser.vi-mode`: qutebrowser-style modal keys (cce-browser's
 50     /// `src/vi.rs`). Off unless set.
 51     pub vi_mode: bool,
 52     pub bar_position: String,
 53     pub color_scheme: String,
 54 }
 55 
 56 pub struct BrowserState {
 57     pub loaded: bool,
 58     pub homepage: String,
 59     pub search: String,
 60     pub download_dir: String,
 61     pub history: bool,
 62     pub raindrop: bool,
 63     pub vi_mode: bool,
 64     pub bar_position: String,
 65     pub color_scheme: String,
 66     pub homepage_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 67     pub search_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
 68     pub bar_position_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
 69     pub color_scheme_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
 70     pub download_dir_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 71     pub history_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
 72     pub raindrop_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
 73     pub vi_mode_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
 74 }
 75 
 76 impl Default for BrowserState {
 77     /// The data half, read from the config; the widgets are `new`'s.
 78     fn default() -> Self {
 79         let config = read_browser_config();
 80         Self {
 81             loaded: true,
 82             homepage: config.homepage,
 83             search: config.search.clone(),
 84             download_dir: config.download_dir,
 85             history: config.history,
 86             raindrop: config.raindrop,
 87             vi_mode: config.vi_mode,
 88             bar_position: config.bar_position.clone(),
 89             color_scheme: config.color_scheme.clone(),
 90             homepage_box: Handle::none(),
 91             search_menu: Handle::none(),
 92             bar_position_menu: Handle::none(),
 93             color_scheme_menu: Handle::none(),
 94             download_dir_box: Handle::none(),
 95             history_toggle: Handle::none(),
 96             raindrop_toggle: Handle::none(),
 97             vi_mode_toggle: Handle::none(),
 98         }
 99     }
100 }
101 
102 impl BrowserState {
103     /// The page's state, its widgets inserted into `ctx` and filled from the config.
104     pub fn new(ctx: &mut UiContext) -> Self {
105         let s = Self::default();
106         let mut homepage_box = TextBox::new(s.homepage.clone())
107             .with_draw_bg_border(true)
108             .with_label("Homepage");
109         homepage_box.edit_buffer = s.homepage.clone();
110         let mut download_dir_box = TextBox::new(s.download_dir.clone())
111             .with_draw_bg_border(true)
112             .with_label("Download Directory");
113         download_dir_box.edit_buffer = s.download_dir.clone();
114         Self {
115             homepage_box: ctx.insert(homepage_box),
116             search_menu: ctx.insert(Dropdown::new(
117                 SEARCH_ENGINES.iter().map(|(_, label)| label.to_string()).collect(),
118                 search_index(&s.search),
119             )
120             .with_label("Search Engine")),
121             bar_position_menu: ctx.insert(Dropdown::new(
122                 BAR_POSITIONS.iter().map(|(_, label)| label.to_string()).collect(),
123                 bar_position_index(&s.bar_position),
124             )
125             .with_label("Navigation Bar Position")),
126             color_scheme_menu: ctx.insert(Dropdown::new(
127                 COLOR_SCHEMES.iter().map(|(_, label)| label.to_string()).collect(),
128                 color_scheme_index(&s.color_scheme),
129             )
130             .with_label("Page Color Scheme")),
131             download_dir_box: ctx.insert(download_dir_box),
132             // Left-aligned, as the designer's parameter pane sets its toggles:
133             // a toggle's run is half its width, so the seam where run meets
134             // well sits at the midpoint, and a centred label on a row-wide
135             // toggle had it drawn straight through the text.
136             history_toggle: ctx.insert(Toggle::new().with_label("Record History").with_left_align(true)),
137             raindrop_toggle: ctx.insert(Toggle::new().with_label("Sync Bookmarks with Raindrop").with_left_align(true)),
138             vi_mode_toggle: ctx.insert(Toggle::new().with_label("Vi Keys (qutebrowser-style)").with_left_align(true)),
139             ..s
140         }
141     }
142 }
143 
144 fn search_index(key: &str) -> usize {
145     SEARCH_ENGINES.iter().position(|(k, _)| *k == key).unwrap_or(0)
146 }
147 
148 fn bar_position_index(key: &str) -> usize {
149     BAR_POSITIONS.iter().position(|(k, _)| *k == key).unwrap_or(0)
150 }
151 
152 fn color_scheme_index(key: &str) -> usize {
153     COLOR_SCHEMES.iter().position(|(k, _)| *k == key).unwrap_or(0)
154 }
155 
156 #[derive(Debug, Clone)]
157 pub enum BrowserMessage {
158     SetSearch(String),
159     SetBarPosition(String),
160     SetColorScheme(String),
161     ToggleHistory,
162     ToggleRaindrop,
163     ToggleViMode,
164     /// Commit the homepage / download-dir text fields.
165     Apply,
166     Refreshed(BrowserConfig),
167 }
168 
169 /// A TextBox's live contents: the in-progress edit buffer while focused,
170 /// the committed text otherwise (the recurring TextBox landmine).
171 fn live_text(tb: &cce_ui::widget::Adapted<TextBox>) -> String {
172     if tb.editing {
173         tb.edit_buffer.trim().to_string()
174     } else {
175         tb.text.trim().to_string()
176     }
177 }
178 
179 pub fn update(state: &mut BrowserState, msg: BrowserMessage, ctx: &mut UiContext) {
180     match msg {
181         BrowserMessage::SetSearch(key) => {
182             state.search = key.clone();
183             write_config_value("search", &key);
184         }
185         BrowserMessage::SetBarPosition(key) => {
186             state.bar_position = key.clone();
187             write_config_value("bar-position", &key);
188         }
189         BrowserMessage::SetColorScheme(key) => {
190             state.color_scheme = key.clone();
191             write_config_value("color-scheme", &key);
192         }
193         BrowserMessage::ToggleHistory => {
194             state.history = !state.history;
195             write_config_value("history", &state.history.to_string());
196         }
197         BrowserMessage::ToggleRaindrop => {
198             state.raindrop = !state.raindrop;
199             write_config_value("raindrop", &state.raindrop.to_string());
200         }
201         BrowserMessage::ToggleViMode => {
202             state.vi_mode = !state.vi_mode;
203             write_config_value("vi-mode", &state.vi_mode.to_string());
204         }
205         BrowserMessage::Apply => {
206             state.homepage = live_text(&ctx[state.homepage_box]);
207             if state.homepage.is_empty() {
208                 state.homepage = DEFAULT_HOMEPAGE.to_string();
209                 ctx[state.homepage_box].text = state.homepage.clone();
210                 ctx[state.homepage_box].edit_buffer = state.homepage.clone();
211             }
212             state.download_dir = live_text(&ctx[state.download_dir_box]);
213             write_config_value("homepage", &state.homepage);
214             write_config_value("download-dir", &state.download_dir);
215         }
216         BrowserMessage::Refreshed(new) => {
217             state.loaded = true;
218             state.search = new.search;
219             state.history = new.history;
220             state.raindrop = new.raindrop;
221             state.vi_mode = new.vi_mode;
222             state.bar_position = new.bar_position;
223             state.color_scheme = new.color_scheme;
224             // Don't clobber fields mid-edit with watcher refreshes.
225             if !ctx[state.homepage_box].editing && state.homepage != new.homepage {
226                 state.homepage = new.homepage.clone();
227                 ctx[state.homepage_box].text = new.homepage.clone();
228                 ctx[state.homepage_box].edit_buffer = new.homepage;
229             }
230             if !ctx[state.download_dir_box].editing && state.download_dir != new.download_dir {
231                 state.download_dir = new.download_dir.clone();
232                 ctx[state.download_dir_box].text = new.download_dir.clone();
233                 ctx[state.download_dir_box].edit_buffer = new.download_dir;
234             }
235         }
236     }
237 }
238 
239 fn get_config_path() -> String {
240     cce_ui::config::get_app_config_path("cce-browser")
241         .to_string_lossy()
242         .into_owned()
243 }
244 
245 pub fn read_browser_config() -> BrowserConfig {
246     let content = fs::read_to_string(get_config_path()).unwrap_or_default();
247     let val = cce_ui::config::parse_kdl_to_json(&content);
248     BrowserConfig {
249         homepage: val["browser"]["homepage"]
250             .as_str()
251             .unwrap_or(DEFAULT_HOMEPAGE)
252             .to_string(),
253         search: val["browser"]["search"].as_str().unwrap_or("duckduckgo").to_string(),
254         download_dir: val["browser"]["download-dir"].as_str().unwrap_or("").to_string(),
255         history: val["browser"]["history"].as_bool().unwrap_or(true),
256         raindrop: val["browser"]["raindrop"].as_bool().unwrap_or(false),
257         vi_mode: val["browser"]["vi-mode"].as_bool().unwrap_or(false),
258         bar_position: val["browser"]["bar-position"].as_str().unwrap_or("top").to_string(),
259         color_scheme: val["browser"]["color-scheme"].as_str().unwrap_or("dark").to_string(),
260     }
261 }
262 
263 fn write_config_value(key: &str, value: &str) {
264     let path = get_config_path();
265     // The per-app config dir may not exist yet.
266     if let Some(dir) = std::path::Path::new(&path).parent() {
267         let _ = fs::create_dir_all(dir);
268     }
269     // Section nesting comes from the dotted key path (the section arg of
270     // write_config_value is vestigial).
271     cce_ui::config::write_config_value(&path, &format!("browser.{key}"), value, "browser");
272 }
273 
274 impl AppPage for BrowserState {
275     // Sections: [Browser Settings]
276     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
277         vec![vec![
278             self.homepage_box.id(),
279             self.search_menu.id(),
280             self.bar_position_menu.id(),
281             self.color_scheme_menu.id(),
282             self.download_dir_box.id(),
283             self.history_toggle.id(),
284             self.raindrop_toggle.id(),
285             self.vi_mode_toggle.id(),
286         ]]
287     }
288 
289     fn view(
290         &mut self,
291         cx: f32,
292         cy: f32,
293         cw: f32,
294         ch: f32,
295         _root_focused: bool,
296         sec_focused: &[bool],
297         layout: &mut PageFlow,
298         ctx: &mut cce_ui::context::UiContext,
299     ) -> PageContent {
300         let mut final_pc = PageContent::new();
301         let sec_w = 320.0f32;
302         let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
303 
304         builder.add_section(&mut final_pc, "Browser Settings", sec_focused.first().copied().unwrap_or(false), |sec| {
305             ctx[self.search_menu].selected = search_index(&self.search);
306             ctx[self.bar_position_menu].selected = bar_position_index(&self.bar_position);
307             ctx[self.color_scheme_menu].selected = color_scheme_index(&self.color_scheme);
308             ctx[self.history_toggle].set_toggled(self.history);
309             // Needs a Raindrop token in the keyring (service=raindrop.io);
310             // the browser shows the sync's status on cce://bookmarks.
311             ctx[self.raindrop_toggle].set_toggled(self.raindrop);
312             ctx[self.vi_mode_toggle].set_toggled(self.vi_mode);
313 
314             let (field_h, menu_h, toggle_h) = (
315                 cce_ui::layout::textbox_height(),
316                 cce_ui::layout::dropdown_height(),
317                 cce_ui::layout::toggle_height(),
318             );
319             let mut form = sec.form();
320             form.column()
321                 .widget_h(ctx, self.homepage_box, field_h)
322                 .widget_h(ctx, self.search_menu, menu_h)
323                 .widget_h(ctx, self.bar_position_menu, menu_h)
324                 .widget_h(ctx, self.color_scheme_menu, menu_h)
325                 .widget_h(ctx, self.download_dir_box, field_h)
326                 .widget_h(ctx, self.history_toggle, toggle_h)
327                 .widget_h(ctx, self.raindrop_toggle, toggle_h)
328                 .widget_h(ctx, self.vi_mode_toggle, toggle_h)
329                 .draw(0.0, cce_ui::layout::button_height(), false, |pc, r, _| {
330                     pc.button(
331                         "Apply",
332                         r.x,
333                         r.y,
334                         r.width,
335                         r.height,
336                         [0.20, 0.40, 0.65, 1.0],
337                         [0.28, 0.50, 0.78, 1.0],
338                         [1.0, 1.0, 1.0, 1.0],
339                         AppAction::Browser(BrowserMessage::Apply),
340                     );
341                 });
342             sec.place(form, ctx);
343         });
344 
345         final_pc
346     }
347 
348     fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>, ctx: &mut UiContext) {
349         if ctx[self.search_menu].take_change() {
350             let key = SEARCH_ENGINES
351                 .get(ctx[self.search_menu].selected)
352                 .map(|(k, _)| k.to_string())
353                 .unwrap_or_else(|| "duckduckgo".to_string());
354             actions.push(AppAction::Browser(BrowserMessage::SetSearch(key)));
355         }
356         if ctx[self.bar_position_menu].take_change() {
357             let key = BAR_POSITIONS
358                 .get(ctx[self.bar_position_menu].selected)
359                 .map(|(k, _)| k.to_string())
360                 .unwrap_or_else(|| "top".to_string());
361             actions.push(AppAction::Browser(BrowserMessage::SetBarPosition(key)));
362         }
363         if ctx[self.color_scheme_menu].take_change() {
364             let key = COLOR_SCHEMES
365                 .get(ctx[self.color_scheme_menu].selected)
366                 .map(|(k, _)| k.to_string())
367                 .unwrap_or_else(|| "dark".to_string());
368             actions.push(AppAction::Browser(BrowserMessage::SetColorScheme(key)));
369         }
370         if ctx[self.history_toggle].take_change() {
371             actions.push(AppAction::Browser(BrowserMessage::ToggleHistory));
372         }
373         if ctx[self.raindrop_toggle].take_change() {
374             actions.push(AppAction::Browser(BrowserMessage::ToggleRaindrop));
375         }
376         if ctx[self.vi_mode_toggle].take_change() {
377             actions.push(AppAction::Browser(BrowserMessage::ToggleViMode));
378         }
379         // Enter in either text field commits both.
380         if ctx[self.homepage_box].take_change() || ctx[self.download_dir_box].take_change() {
381             actions.push(AppAction::Browser(BrowserMessage::Apply));
382         }
383     }
384 }