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 }