Improve readability: brighter left-pane text and an editor contrast slider

The left file pane now boosts its widget text colours (toward white in dark
mode, black in light mode) so file names stay legible against the panel
background; the change is scoped to that panel only.

Add a "Contrast" slider beside Zoom that interpolates the editor text colour
from the theme default toward maximum contrast, persisted as
editor_text_contrast in the config. build_editor_job takes the computed colour
so underlined grammar/spelling runs match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
landon
2026-07-27 11:55:25 -05:00
parent 5ecbf3ffbb
commit 496bbfd298
3 changed files with 106 additions and 4 deletions
+4 -1
View File
@@ -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
+96 -3
View File
@@ -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");
+6
View File
@@ -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(),