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 1/2] 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); From d86f7aed7548bd64cc2256c92ca78df67a842df0 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:52:55 +0200 Subject: [PATCH 2/2] Theme: the field test only counts a fill or outline that is actually painted (#1204) --- crates/ui-egui/src/field_frame_tests.rs | 41 ++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/field_frame_tests.rs b/crates/ui-egui/src/field_frame_tests.rs index 60ac4b74..14fdee3d 100644 --- a/crates/ui-egui/src/field_frame_tests.rs +++ b/crates/ui-egui/src/field_frame_tests.rs @@ -5,7 +5,7 @@ use deckcraft_engine::Session; use egui::accesskit::Role; use egui::epaint::Shape; -use egui::{Color32, Rect}; +use egui::{Color32, Rect, Stroke}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use serde_json::json; @@ -31,7 +31,7 @@ fn distance(a: Color32, b: Color32) -> u8 { } /// 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) { +fn frame_at(h: &Harness<'_, SlideApp>, field: Rect) -> (Color32, Stroke) { h.output() .shapes .iter() @@ -42,20 +42,49 @@ fn frame_at(h: &Harness<'_, SlideApp>, field: Rect) -> (Color32, egui::Stroke) { .unwrap_or_else(|| panic!("no frame painted for the field at {field:?}")) } +/// What `c` shows when painted over the opaque `surface`. `Color32` is premultiplied, so each +/// channel is `c + surface * (255 - alpha) / 255`. +fn over(c: Color32, surface: Color32) -> Color32 { + let behind = |s: u8| (u16::from(s) * u16::from(255 - c.a()) / 255) as u8; + Color32::from_rgb(c.r().saturating_add(behind(surface.r())), c.g().saturating_add(behind(surface.g())), c.b().saturating_add(behind(surface.b()))) +} + +/// A frame with this `fill` and `stroke` stands apart from the opaque `surface`: it paints a fill +/// or an outline (at least 1 px wide) that, as actually shown over the surface, differs from it. +fn stands_apart(fill: Color32, stroke: Stroke, surface: Color32) -> bool { + let outlined = stroke.width >= 1.0 && distance(over(stroke.color, surface), surface) >= 24; + outlined || distance(over(fill, surface), surface) >= 16 +} + /// 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, + stands_apart(fill, stroke, surface), "{what}: the field at {field:?} (fill {fill:?}, outline {stroke:?}) can't be told from the {surface:?} behind it" ); } } +#[test] +fn only_a_painted_fill_or_outline_stands_apart() { + let surface = Color32::WHITE; + // Stroke::NONE is transparent black: nothing is painted, whatever its RGB. + assert!(!stands_apart(surface, Stroke::NONE, surface)); + assert!(!stands_apart(Color32::WHITE, Stroke::NONE, Color32::from_rgb(250, 250, 250))); + // A fully transparent fill shows the surface. + assert!(!stands_apart(Color32::TRANSPARENT, Stroke::NONE, surface)); + assert!(!stands_apart(Color32::TRANSPARENT, Stroke::new(0.5, Color32::BLACK), surface)); + // A transparent outline paints nothing either. + assert!(!stands_apart(surface, Stroke::new(1.0, Color32::TRANSPARENT), surface)); + // A real 1 px dark outline, or a clearly different opaque fill, does. + assert!(stands_apart(surface, Stroke::new(1.0, Color32::from_gray(120)), surface)); + assert!(stands_apart(Color32::from_gray(200), Stroke::NONE, surface)); +} + #[test] fn light_text_fields_show_in_dialogs_panes_and_the_ribbon() { let light = theme::Tokens::light(); @@ -71,7 +100,9 @@ fn light_text_fields_show_in_dialogs_panes_and_the_ribbon() { 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:?}"); + // Same surface as the dialog check above: the field sits on the dialog's window fill. + let surface = h.ctx.global_style().visuals.window_fill; + assert!(stands_apart(fill, stroke, surface), "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()));