From f37da21462ef27218aed8ddf34525e8f2ec7b928 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 12 Jul 2026 09:02:15 +0200 Subject: [PATCH] feat(editor): add theme and auto_sync preset prefs with rotate-on-Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalise the palette so Enter advances any pref to its next value and wraps; a boolean toggle is the two-option case. Both string prefs share a next_option(current, &OPTIONS) helper (off-list values snap to the head). - theme (light/dark): rendered as a single whole-frame Frame::invert at the end of draw(), so text, selection, caret, panel and palette flip together. - auto_sync: cycles 2m/5m/10m/15m/30m. Set-ahead only — still read by nothing until the v0.7 periodic push. toggle_pref -> cycle_pref; palette hint "Enter toggle" -> "Enter change". --- display/src/lib.rs | 10 +++ editor/src/lib.rs | 165 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 155 insertions(+), 20 deletions(-) diff --git a/display/src/lib.rs b/display/src/lib.rs index b76a3a9..269a36b 100644 --- a/display/src/lib.rs +++ b/display/src/lib.rs @@ -76,6 +76,16 @@ impl Frame { pub fn bytes(&self) -> &[u8] { &self.buf } + + /// Flip every pixel black↔white across the whole framebuffer. The editor + /// draws its native black-ink-on-white-paper frame, then calls this once at + /// the end for the dark theme — so text, selection, caret, panel and palette + /// all invert together and each stays legible against the flipped ground. + pub fn invert(&mut self) { + for b in &mut self.buf { + *b = !*b; + } + } } impl OriginDimensions for Frame { diff --git a/editor/src/lib.rs b/editor/src/lib.rs index b521def..695171b 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -181,10 +181,17 @@ pub struct Prefs { /// Show the absolute line-number gutter (built always-on in v0.2). Off /// reclaims the gutter's columns for text — applied live by [`gutter_cols`]. pub line_numbers: bool, - /// Max-staleness cap for opportunistic auto-publish, as a duration string - /// (`"10m"`, `"0"`/empty disables). **Schema + default only in v0.5** — the - /// periodic push itself rides v0.7/v0.8, so nothing reads this yet; it is - /// parsed and preserved through a round-trip but has no behaviour here. + /// Panel colour polarity: `"light"` (native black-ink-on-white-paper) or + /// `"dark"` (white-on-black). On the 1-bit panel this is a whole-frame invert + /// applied at the end of [`draw`](Editor::draw) via [`Frame::invert`], so any + /// value other than `"dark"` reads as light. The palette rotates it through + /// [`THEME_OPTIONS`]; a hand-typed value still round-trips. + pub theme: String, + /// Max-staleness cap for opportunistic auto-publish, as a duration string. + /// The palette rotates it through [`AUTO_SYNC_OPTIONS`] (`"2m"`..`"30m"`); + /// hand-editing can still set any string. **Persisted-but-inert in v0.5** — + /// the periodic push that reads it rides v0.7/v0.8, so cycling it changes the + /// stored/displayed value but triggers nothing yet. pub auto_sync: String, } @@ -194,6 +201,7 @@ impl Default for Prefs { save_on_idle: true, format_on_save: true, line_numbers: true, + theme: "light".into(), auto_sync: "10m".into(), } } @@ -231,6 +239,7 @@ impl Prefs { p.line_numbers = b; } } + "theme" => p.theme = val.trim_matches('"').to_string(), "auto_sync" => p.auto_sync = val.trim_matches('"').to_string(), _ => {} } @@ -245,12 +254,17 @@ impl Prefs { pub fn to_toml(&self) -> String { format!( "# Typoena editor preferences — hand-editable, git-tracked.\n\ - # Edit here, or toggle live from the Cmd-P palette (type `>`).\n\ + # Edit here, or change live from the Cmd-P palette (type `>`).\n\ save_on_idle = {}\n\ format_on_save = {}\n\ line_numbers = {}\n\ + theme = \"{}\"\n\ auto_sync = \"{}\"\n", - self.save_on_idle, self.format_on_save, self.line_numbers, self.auto_sync, + self.save_on_idle, + self.format_on_save, + self.line_numbers, + self.theme, + self.auto_sync, ) } } @@ -265,6 +279,25 @@ fn parse_bool(v: &str) -> Option { } } +/// The panel-polarity presets the palette rotates [`Prefs::theme`] through. +const THEME_OPTIONS: [&str; 2] = ["light", "dark"]; + +/// The auto-publish intervals the palette rotates [`Prefs::auto_sync`] through. +/// Hand-editing the TOML can still set any duration string; these are just the +/// values the `>` palette cycles. +const AUTO_SYNC_OPTIONS: [&str; 5] = ["2m", "5m", "10m", "15m", "30m"]; + +/// The option after `current` in `options`, wrapping past the end — the +/// rotate-on-Enter for a preset string pref. A `current` that isn't in the list +/// (e.g. hand-typed into the TOML) snaps to the first option, so one Enter +/// always lands on a known value. +fn next_option<'a>(current: &str, options: &[&'a str]) -> &'a str { + match options.iter().position(|&o| o == current) { + Some(i) => options[(i + 1) % options.len()], + None => options[0], + } +} + /// Resolve a `:e`/`:enew` argument (or palette pick) to an absolute path + /// [`Scope`]. Everything the writer can reach lives on the card under `/sd`, so /// the `/sd` prefix is **optional**: `/sd/repo/x`, `/repo/x`, and `repo/x` all @@ -386,21 +419,27 @@ fn palette_label(path: &str) -> &str { /// A palette command (the `>` mode). v0.5 exposes the three boolean prefs as /// live toggles; each command's *label* carries the pref's current state /// ([`Editor::command_label`]), so the list doubles as a settings readout. This -/// registry is the discoverable surface later features (`:fmt`, theme, font) -/// grow into. `auto_sync` is deliberately absent until v0.7 gives it behaviour — -/// a value control that changes nothing would be a dead switch. +/// registry is the discoverable surface later features (`:fmt`, font) grow into. +/// A boolean flips on Enter; a preset like [`Theme`](PaletteCmd::Theme) or +/// [`AutoSync`](PaletteCmd::AutoSync) rotates to its next option and wraps. +/// `auto_sync` has no behaviour yet (v0.7), so cycling it only changes the +/// stored/displayed value — see [`Prefs::auto_sync`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PaletteCmd { SaveOnIdle, FormatOnSave, LineNumbers, + Theme, + AutoSync, } /// The palette command list, in display order (empty `>` query shows them all). -const PALETTE_CMDS: [PaletteCmd; 3] = [ +const PALETTE_CMDS: [PaletteCmd; 5] = [ PaletteCmd::SaveOnIdle, PaletteCmd::FormatOnSave, PaletteCmd::LineNumbers, + PaletteCmd::Theme, + PaletteCmd::AutoSync, ]; /// A pending operator awaiting a motion or text object (`d`elete / `c`hange / @@ -1638,6 +1677,8 @@ impl Editor { PaletteCmd::SaveOnIdle => format!("save on idle: {}", on(self.prefs.save_on_idle)), PaletteCmd::FormatOnSave => format!("format on save: {}", on(self.prefs.format_on_save)), PaletteCmd::LineNumbers => format!("line numbers: {}", on(self.prefs.line_numbers)), + PaletteCmd::Theme => format!("theme: {}", self.prefs.theme), + PaletteCmd::AutoSync => format!("auto sync: {}", self.prefs.auto_sync), } } @@ -1663,25 +1704,34 @@ impl Editor { /// which closes: opening a file switches away, toggling a pref does not. fn palette_run_command(&mut self) { if let Some(&ci) = self.palette_command_matches().get(self.palette_sel) { - self.toggle_pref(PALETTE_CMDS[ci]); + self.cycle_pref(PALETTE_CMDS[ci]); } } - /// Flip the boolean pref a command targets, apply it live (the next - /// [`draw`](Self::draw) reflects it — line numbers appear/vanish at once), - /// queue the prefs-file write ([`Effect::SavePrefs`]), and confirm the new - /// state on the snackbar. The queued `SavePrefs` is what makes the change - /// durable and lets it ride the next `:sync` to other devices. - fn toggle_pref(&mut self, cmd: PaletteCmd) { + /// Advance the pref a command targets to its next value, apply it live (the + /// next [`draw`](Self::draw) reflects it — line numbers appear/vanish, the + /// theme flips at once), queue the prefs-file write ([`Effect::SavePrefs`]), + /// and confirm the new state on the snackbar. A boolean flips; a preset + /// string ([`Theme`](PaletteCmd::Theme), [`AutoSync`](PaletteCmd::AutoSync)) + /// rotates to its next option and wraps — so from the palette every setting + /// is "press Enter to change". The queued `SavePrefs` is what makes the + /// change durable and lets it ride the next `:sync` to other devices. + fn cycle_pref(&mut self, cmd: PaletteCmd) { match cmd { PaletteCmd::SaveOnIdle => self.prefs.save_on_idle = !self.prefs.save_on_idle, PaletteCmd::FormatOnSave => self.prefs.format_on_save = !self.prefs.format_on_save, PaletteCmd::LineNumbers => self.prefs.line_numbers = !self.prefs.line_numbers, + PaletteCmd::Theme => { + self.prefs.theme = next_option(&self.prefs.theme, &THEME_OPTIONS).to_string() + } + PaletteCmd::AutoSync => { + self.prefs.auto_sync = next_option(&self.prefs.auto_sync, &AUTO_SYNC_OPTIONS).to_string() + } } self.requests.push(Effect::SavePrefs { contents: self.prefs.to_toml(), }); - // The label already reflects the just-flipped state (e.g. "line numbers: off"). + // The label already reflects the just-changed state (e.g. "theme: dark"). self.set_notice(format!("{} - saved", self.command_label(cmd))); } @@ -2703,6 +2753,12 @@ impl Editor { if self.mode == Mode::Palette { self.draw_palette(&mut f); } + // Dark theme: flip the whole frame in one pass, after everything else is + // painted, so text, selection, caret, panel and palette all invert + // together. Any value but "dark" stays native black-on-white. + if self.prefs.theme == "dark" { + f.invert(); + } f } @@ -2928,7 +2984,7 @@ impl Editor { } let hint = if command_mode { - "^N/^P move Enter toggle Esc close" + "^N/^P move Enter change Esc close" } else { "^N/^P move Enter open Esc close" }; @@ -4646,6 +4702,7 @@ mod tests { assert!(p.save_on_idle); assert!(p.format_on_save); assert!(p.line_numbers); + assert_eq!(p.theme, "light"); assert_eq!(p.auto_sync, "10m"); } @@ -4689,11 +4746,19 @@ mod tests { save_on_idle: false, format_on_save: true, line_numbers: false, + theme: "dark".into(), auto_sync: "5m".into(), }; assert_eq!(Prefs::parse(&p.to_toml()), p); } + #[test] + fn prefs_parse_reads_theme_and_auto_sync_strings() { + let p = Prefs::parse("theme = \"dark\"\nauto_sync = \"15m\"\n"); + assert_eq!(p.theme, "dark"); + assert_eq!(p.auto_sync, "15m"); + } + #[test] fn empty_prefs_file_yields_defaults() { assert_eq!(Prefs::parse(""), Prefs::default()); @@ -4734,7 +4799,7 @@ mod tests { fn leading_gt_switches_the_palette_to_command_mode() { let e = palette_type(&["/sd/repo/notes.md"], ">"); assert!(e.palette_command_mode()); - // All three prefs commands are offered on a bare `>`. + // Every prefs command is offered on a bare `>`. assert_eq!(e.palette_command_matches().len(), PALETTE_CMDS.len()); } @@ -4811,6 +4876,66 @@ mod tests { assert_eq!(e.notice.as_deref(), Some("save on idle: off - saved")); } + // ---- Preset (non-boolean) prefs: rotate through options on Enter ---- + + #[test] + fn next_option_rotates_and_wraps() { + assert_eq!(next_option("light", &THEME_OPTIONS), "dark"); + assert_eq!(next_option("dark", &THEME_OPTIONS), "light"); // wraps + assert_eq!(next_option("10m", &AUTO_SYNC_OPTIONS), "15m"); + assert_eq!(next_option("30m", &AUTO_SYNC_OPTIONS), "2m"); // wraps + } + + #[test] + fn next_option_snaps_an_unknown_value_to_the_head() { + // A hand-typed value outside the preset list lands on the first option. + assert_eq!(next_option("sepia", &THEME_OPTIONS), "light"); + assert_eq!(next_option("7m", &AUTO_SYNC_OPTIONS), "2m"); + } + + #[test] + fn theme_command_label_reflects_the_current_value() { + let e = palette_editor(&["/sd/repo/notes.md"]); + assert_eq!(e.command_label(PaletteCmd::Theme), "theme: light"); + assert_eq!(e.command_label(PaletteCmd::AutoSync), "auto sync: 10m"); + } + + #[test] + fn running_the_theme_command_rotates_the_preset_and_queues_a_save_prefs() { + // >theme flips light -> dark, in-core, and asks the host to persist. + let mut e = palette_type(&["/sd/repo/notes.md"], ">theme"); + assert_eq!(e.prefs().theme, "light"); + e.handle(Key::Enter); + assert_eq!(e.prefs().theme, "dark"); // applied live + assert_eq!(e.mode(), Mode::Palette); // stays open for more changes + assert_eq!(kinds(&e.take_effects()), vec![Kind::SavePrefs]); + e.handle(Key::Enter); + assert_eq!(e.prefs().theme, "light"); // rotates back, wrapping + } + + #[test] + fn running_the_auto_sync_command_walks_the_interval_presets() { + // Default 10m; Enter rotates 10m -> 15m -> 30m -> 2m (wrap). + let mut e = palette_type(&["/sd/repo/notes.md"], ">auto"); + assert_eq!(e.prefs().auto_sync, "10m"); + for expected in ["15m", "30m", "2m"] { + e.handle(Key::Enter); + assert_eq!(e.prefs().auto_sync, expected); + } + } + + #[test] + fn dark_theme_inverts_the_whole_frame() { + // The dark frame is the exact bitwise inverse of the light one. + let mut e = Editor::with_text("hello world".into()); + e.caret = 0; + let light = e.draw(true).bytes().to_vec(); + e.prefs.theme = "dark".into(); + let dark = e.draw(true).bytes().to_vec(); + assert_eq!(light.len(), dark.len()); + assert!(light.iter().zip(&dark).all(|(l, d)| *l == !*d)); + } + #[test] fn a_no_match_command_query_runs_nothing() { let mut e = palette_type(&["/sd/repo/notes.md"], ">zzzzz");