diff --git a/README.md b/README.md index 1924283..73bf63d 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,10 @@ Terminal=false 3. Create files with **+ New**, edit on the right, `Ctrl+S` (or the Save button) to write to disk. Drag the `⠿` handles to reorder. Use the **Zoom** slider above the editor to scale the editor text (0% = default; **Reset** returns to - it). Text always wraps to the pane width, so there is no horizontal scrolling. + it), and the **Contrast** slider to brighten dim editor text (0% = theme + default; higher pushes the text toward white in dark mode / black in light + mode). Text always wraps to the pane width, so there is no horizontal + scrolling. 4. Set the **Chapter title** for the open file if you want to override the auto-derived one (the field shows the automatic title as a hint). Leaving it blank falls back to a `# Title:` line in the header, and if there is none, to diff --git a/src/app.rs b/src/app.rs index 7fd301f..1e5cd21 100644 --- a/src/app.rs +++ b/src/app.rs @@ -737,6 +737,10 @@ impl App { .resizable(true) .default_width(260.0) .show(ctx, |ui| { + // The default theme renders unselected list rows fairly dim; bump + // the widget text colours so file names stay legible (especially in + // dark mode) without affecting the rest of the app. + boost_list_contrast(ui.visuals_mut()); ui.add_space(4.0); ui.heading("Files"); ui.label( @@ -1124,6 +1128,25 @@ impl App { self.config.editor_zoom = 0.0; self.config.save(); } + ui.separator(); + ui.label("Contrast:"); + if ui + .add( + egui::Slider::new( + &mut self.config.editor_text_contrast, + 0.0..=100.0, + ) + .suffix("%") + .step_by(5.0), + ) + .on_hover_text( + "Editor text contrast (0% = theme default; higher \ + brightens the text in dark mode)", + ) + .changed() + { + self.config.save(); + } }); ui.horizontal(|ui| { ui.label("Chapter title:"); @@ -1187,6 +1210,9 @@ impl App { .unwrap_or(12.0); let size = (base * (1.0 + self.config.editor_zoom / 100.0)).max(4.0); + // Editor text colour, brightened toward max contrast by the contrast slider. + let text_color = editor_text_color(ui.visuals(), self.config.editor_text_contrast); + // Underline grammar/spelling matches, but only while the buffer still // equals the text they were computed against (edits invalidate offsets). let ranges: Vec<(usize, usize, bool)> = if self.buffer == self.lt_checked_text { @@ -1203,7 +1229,7 @@ impl App { .auto_shrink([false, false]) .show(ui, |ui| { let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| { - let job = build_editor_job(ui, text, size, &ranges, wrap_width); + let job = build_editor_job(text, size, text_color, &ranges, wrap_width); ui.fonts(|f| f.layout_job(job)) }; let resp = ui.add( @@ -1282,15 +1308,14 @@ impl eframe::App for App { /// `ranges` are `(start_byte, end_byte, is_spelling)` triples into `text`. /// Spelling issues get a red underline, grammar/style issues a blue one. fn build_editor_job( - ui: &egui::Ui, text: &str, size: f32, + color: egui::Color32, ranges: &[(usize, usize, bool)], wrap_width: f32, ) -> egui::text::LayoutJob { use egui::text::{LayoutJob, TextFormat}; let font_id = egui::FontId::monospace(size); - let color = ui.visuals().text_color(); let mut job = LayoutJob::default(); job.wrap.max_width = wrap_width; @@ -1330,6 +1355,51 @@ fn build_editor_job( job } +/// Raise the text contrast of list-style widgets in the current `ui`, so file +/// names in the left pane stay legible against the panel background. Only the +/// widget foreground colours are touched; layout and the rest of the app are +/// unaffected (the change is scoped to the panel that calls this). +fn boost_list_contrast(visuals: &mut egui::Visuals) { + if visuals.dark_mode { + // Unselected rows, hover, and the pressed state → toward white. + visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_gray(235); + visuals.widgets.hovered.fg_stroke.color = egui::Color32::WHITE; + visuals.widgets.active.fg_stroke.color = egui::Color32::WHITE; + // Selected-row text (SelectableLabel uses selection.stroke when selected). + visuals.selection.stroke.color = egui::Color32::WHITE; + } else { + visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_gray(20); + visuals.widgets.hovered.fg_stroke.color = egui::Color32::BLACK; + visuals.widgets.active.fg_stroke.color = egui::Color32::BLACK; + visuals.selection.stroke.color = egui::Color32::WHITE; + } +} + +/// The editor's text colour, interpolated from the theme's default toward the +/// maximum-contrast colour (white in dark mode, black in light mode) by +/// `contrast` percent (0 = theme default, 100 = full contrast). +fn editor_text_color(visuals: &egui::Visuals, contrast: f32) -> egui::Color32 { + let base = visuals.text_color(); + let target = if visuals.dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + lerp_color(base, target, contrast / 100.0) +} + +/// Linearly interpolate between two colours in gamma (sRGB byte) space. +/// `t` is clamped to `[0, 1]`; `t = 0` yields `a`, `t = 1` yields `b`. +fn lerp_color(a: egui::Color32, b: egui::Color32, t: f32) -> egui::Color32 { + let t = t.clamp(0.0, 1.0); + let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8; + egui::Color32::from_rgb( + mix(a.r(), b.r()), + mix(a.g(), b.g()), + mix(a.b(), b.b()), + ) +} + /// Very small markdown-ish preview (headings emphasised, everything else plain). fn render_preview(ui: &mut egui::Ui, markdown: &str) { use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; @@ -1534,6 +1604,29 @@ mod tests { assert!(matches.is_empty()); } + #[test] + fn lerp_color_hits_endpoints_and_midpoint() { + let a = egui::Color32::from_rgb(0, 0, 0); + let b = egui::Color32::from_rgb(200, 100, 50); + assert_eq!(lerp_color(a, b, 0.0), a); + assert_eq!(lerp_color(a, b, 1.0), b); + assert_eq!(lerp_color(a, b, 0.5), egui::Color32::from_rgb(100, 50, 25)); + // t is clamped to [0, 1]. + assert_eq!(lerp_color(a, b, -1.0), a); + assert_eq!(lerp_color(a, b, 2.0), b); + } + + #[test] + fn editor_contrast_moves_toward_white_in_dark_mode() { + let dark = egui::Visuals::dark(); + // 0% keeps the theme's text colour; 100% is fully white. + assert_eq!(editor_text_color(&dark, 0.0), dark.text_color()); + assert_eq!(editor_text_color(&dark, 100.0), egui::Color32::WHITE); + // In light mode, full contrast is black instead. + let light = egui::Visuals::light(); + assert_eq!(editor_text_color(&light, 100.0), egui::Color32::BLACK); + } + #[test] fn formats_thousands_separators() { assert_eq!(thousands(0), "0"); diff --git a/src/config.rs b/src/config.rs index 28a072a..503b2ac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,11 @@ pub struct Config { /// +100 = double, -50 = half). #[serde(default)] pub editor_zoom: f32, + /// Editor text contrast as a percentage: 0 = the theme's default text color, + /// 100 = maximum contrast against the background (white in dark mode, black + /// in light mode). Higher values make dim editor text easier to read. + #[serde(default)] + pub editor_text_contrast: f32, /// URL scheme for the LanguageTool server: `http` (local) or `https`. #[serde(default = "default_languagetool_scheme")] pub languagetool_scheme: String, @@ -78,6 +83,7 @@ impl Default for Config { draft_marker: default_marker(), zero_pad_index: false, editor_zoom: 0.0, + editor_text_contrast: 0.0, languagetool_scheme: default_languagetool_scheme(), languagetool_host: default_languagetool_host(), languagetool_port: default_languagetool_port(),