From b61898bba5f1b3ebc37b44f0d5c0cb01314c6082 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:00:59 +0200 Subject: [PATCH] Theme: text fields show in the light theme (#1204) In the light theme a text field had the same white fill as the dialog, pane or ribbon it sits on and no outline (nor a visible focus outline), so only its label or hint showed. Fill text fields with a light grey, as the dark theme already sets them apart with a darker well. The File tab keeps its outlined white fields. --- crates/ui-egui/src/backstage.rs | 1 + crates/ui-egui/src/field_frame_tests.rs | 87 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/theme.rs | 5 ++ 4 files changed, 95 insertions(+) create mode 100644 crates/ui-egui/src/field_frame_tests.rs diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 999900d3..e569d062 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -388,6 +388,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { }); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(page_bg)).show(ui, |ui| { // Fields, boxes and buttons sit on the page's own colour: outline them, as PowerPoint does. + ui.visuals_mut().text_edit_bg_color = Some(t.field_bg); let w = &mut ui.visuals_mut().widgets; w.inactive.bg_fill = t.field_bg; w.inactive.weak_bg_fill = t.field_bg; diff --git a/crates/ui-egui/src/field_frame_tests.rs b/crates/ui-egui/src/field_frame_tests.rs new file mode 100644 index 00000000..60ac4b74 --- /dev/null +++ b/crates/ui-egui/src/field_frame_tests.rs @@ -0,0 +1,87 @@ +//! Text fields must show where they are: in the light theme the field was as white as the dialog, +//! pane or ribbon it sits on and had no outline, so only its label (or hint) was visible (#1204). +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use egui::accesskit::Role; +use egui::epaint::Shape; +use egui::{Color32, Rect}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp, theme}; + +fn harness(app: SlideApp) -> Harness<'static, SlideApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + // The app keeps asking for frames, so step instead of run(). + h.run_steps(3); + h +} + +/// The largest per-channel difference between two colours. +fn distance(a: Color32, b: Color32) -> u8 { + a.r().abs_diff(b.r()).max(a.g().abs_diff(b.g())).max(a.b().abs_diff(b.b())) +} + +/// The frame egui painted for the text field at `field` (its fill and outline). +fn frame_at(h: &Harness<'_, SlideApp>, field: Rect) -> (Color32, egui::Stroke) { + h.output() + .shapes + .iter() + .find_map(|c| match &c.shape { + Shape::Rect(r) if r.rect.min.distance(field.min) < 2.0 && r.rect.max.distance(field.max) < 2.0 => Some((r.fill, r.stroke)), + _ => None, + }) + .unwrap_or_else(|| panic!("no frame painted for the field at {field:?}")) +} + +/// The field stands apart from `surface` by its fill or by its outline. +fn assert_visible(h: &Harness<'_, SlideApp>, what: &str, surface: Color32) { + let fields: Vec = h.query_all_by_role(Role::TextInput).map(|n| n.rect()).collect(); + assert!(!fields.is_empty(), "{what}: no text field"); + for field in fields { + let (fill, stroke) = frame_at(h, field); + let outlined = stroke.width >= 1.0 && distance(stroke.color, surface) >= 24; + assert!( + outlined || distance(fill, surface) >= 16, + "{what}: the field at {field:?} (fill {fill:?}, outline {stroke:?}) can't be told from the {surface:?} behind it" + ); + } +} + +#[test] +fn light_text_fields_show_in_dialogs_panes_and_the_ribbon() { + let light = theme::Tokens::light(); + + // Home › Find: "Find what:" in the dialog card. + let mut h = harness(SlideApp::new(Session::with_new(), Services::default())); + h.state_mut().run("app.find", json!({})).unwrap(); + h.run_steps(3); + assert_visible(&h, "Find and Replace", h.ctx.global_style().visuals.window_fill); + // Still visible with the keyboard in it. + let field = h.get_by_role(Role::TextInput).rect(); + h.get_by_role(Role::TextInput).click(); + h.run_steps(2); + assert!(h.ctx.memory(|m| m.focused().is_some()), "the click puts the keyboard in the field"); + let (fill, stroke) = frame_at(&h, field); + assert!(distance(fill, light.card) >= 16 || distance(stroke.color, light.card) >= 24, "focused field: fill {fill:?}, outline {stroke:?}"); + + // Review › Thesaurus: the search box on the task pane. + let mut h = harness(SlideApp::new(Session::with_new(), Services::default())); + h.state_mut().ui.pane = Some("thesaurus".into()); + h.run_steps(3); + assert_visible(&h, "Thesaurus pane", light.card); + + // Transitions › Duration: the spinner on the ribbon. + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.ui.tab = "Transitions".into(); + let h = harness(app); + assert_visible(&h, "Transitions ribbon", light.card); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eef9b1b3..9a16cdb2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2052,6 +2052,8 @@ mod compare_tests; #[cfg(test)] mod eyedropper_tests; #[cfg(test)] +mod field_frame_tests; +#[cfg(test)] mod huge_deck_tests; #[cfg(test)] mod ime_tests; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 1847d47f..09b4671a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -20,6 +20,8 @@ pub struct Tokens { /// The ribbon card and popovers. pub card: Color32, pub card_border: Color32, + /// Text fields. They have no outline, so the fill alone must set them apart from the card. + pub text_field: Color32, /// Canvas around the slide. pub canvas: Color32, pub text: Color32, @@ -51,6 +53,7 @@ impl Tokens { chrome: Color32::from_rgb(0xF5, 0xF5, 0xF5), card: Color32::WHITE, card_border: Color32::from_rgb(0xE3, 0xE3, 0xE3), + text_field: Color32::from_rgb(0xEB, 0xEB, 0xEB), canvas: Color32::from_rgb(0xF0, 0xF0, 0xF0), text: Color32::from_rgb(0x24, 0x24, 0x24), text_dim: Color32::from_rgb(0x5C, 0x5C, 0x5C), @@ -79,6 +82,7 @@ impl Tokens { chrome: Color32::from_rgb(0x26, 0x26, 0x26), card: Color32::from_rgb(0x33, 0x33, 0x33), card_border: Color32::from_rgb(0x44, 0x44, 0x44), + text_field: Color32::from_rgb(0x2A, 0x2A, 0x2A), canvas: Color32::from_rgb(0x1E, 0x1E, 0x1E), text: Color32::from_rgb(0xEE, 0xEE, 0xEE), text_dim: Color32::from_rgb(0xBB, 0xBB, 0xBB), @@ -118,6 +122,7 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) { v.panel_fill = t.chrome; v.window_fill = t.card; v.extreme_bg_color = t.field_bg; + v.text_edit_bg_color = Some(t.text_field); v.faint_bg_color = t.hover; v.window_stroke = Stroke::new(1.0, t.card_border); v.window_corner_radius = CornerRadius::same(10);