diff --git a/contributors/people.toml b/contributors/people.toml index db1f5ce59..c846d8725 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -98,3 +98,6 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.aleksei-suholita] +real_name = "Oleksii Sukholita" diff --git a/crates/ui-egui/src/canvas/board_bar.rs b/crates/ui-egui/src/canvas/board_bar.rs index 3d5baf43e..f9a43935c 100644 --- a/crates/ui-egui/src/canvas/board_bar.rs +++ b/crates/ui-egui/src/canvas/board_bar.rs @@ -299,10 +299,13 @@ fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &To let (r, cell) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click()); let cell = cell.on_hover_text(tr(b.label())); a11y::menu_item(&cell, tr(b.label()), "", Some(b == shape)); - if b == shape || cell.hovered() { + let lit = b == shape || cell.hovered(); + if lit { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } - paint_choice(ui.painter(), r.shrink(6.0), &Mark::Shape(b), t.menu_text); + // The menu is the theme's popup (white in the light theme): the theme's icon + // colour, white only on the green highlight. + paint_choice(ui.painter(), r.shrink(6.0), &Mark::Shape(b), if lit { t.menu_text } else { t.icon }); if cell.clicked() { app.run("board.setStyle", json!({"shape": b.id()})); ui.close(); diff --git a/crates/ui-egui/src/menu_contrast_tests.rs b/crates/ui-egui/src/menu_contrast_tests.rs index f3a9b1bda..dcd3548f6 100644 --- a/crates/ui-egui/src/menu_contrast_tests.rs +++ b/crates/ui-egui/src/menu_contrast_tests.rs @@ -1,8 +1,15 @@ -//! Panel dropdowns (the prototype device picker, Preferences, blend modes…) draw on the theme's -//! popup fill, so their rows' text must read on it in both themes (#972): drawn headless. +//! Panel dropdowns (the prototype device picker, Preferences, blend modes…), a file tab's menu and +//! the board bar's shape menu draw on the theme's popup fill, so what is in them must read on it +//! in both themes (#972): drawn headless. +use egui::accesskit::Node; +use egui::epaint::ColorMode; use egui::{Color32, Event, Order, PointerButton, Pos2, RawInput, Rect, Shape, pos2, vec2}; +use figcraft_doc::BoardShape; +use figcraft_engine::Session; +use serde_json::json; +use crate::present_tests::Ui; use crate::right::{menu_check, menu_item, menu_popup}; use crate::theme::{self, Tokens}; @@ -82,3 +89,100 @@ fn dropdown_rows_read_on_the_popup_in_both_themes() { } } } + +/// The whole app headless on `s` in the `dark` or light theme, with windows of their own allowed +/// (a tab offers Open in new window only then); the theme's tokens. +fn app(mut s: Session, dark: bool) -> (Ui, Tokens) { + s.execute(if dark { "view.themeDark" } else { "view.themeLight" }, &json!({})).unwrap(); + let mut u = Ui::new(s); + u.ctx.set_embed_viewports(false); + u.frame(vec![]); + let t = Tokens::get(&u.ctx); + assert_eq!(t.dark, dark, "the app took the theme"); + (u, t) +} + +fn click(u: &mut Ui, at: Pos2, button: PointerButton) { + for pressed in [true, false] { + u.frame(vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }]); + } +} + +/// The pointer away from the open menu until its fade-in is over: that frame's output. +fn settle(u: &mut Ui) -> egui::FullOutput { + for _ in 0..30 { + u.frame(vec![Event::PointerMoved(pos2(1430.0, 600.0))]); + } + u.frame_output(vec![]) +} + +/// The AccessKit nodes on screen. +fn nodes(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect() +} + +fn bounds(n: &Node) -> Rect { + let b = n.bounds().unwrap(); + Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)) +} + +/// The colours of the lines painted inside `r`. +fn lines_in(out: &egui::FullOutput, r: Rect) -> Vec { + fn walk(s: &Shape, r: Rect, out: &mut Vec) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, r, out)), + Shape::Path(p) if r.contains_rect(s.visual_bounding_rect()) => { + if let ColorMode::Solid(c) = p.stroke.color { + out.push(c); + } + } + _ => {} + } + } + let mut lines = Vec::new(); + for c in &out.shapes { + walk(&c.shape, r, &mut lines); + } + lines +} + +#[test] +fn a_file_tabs_menu_reads_on_its_popup_in_both_themes() { + for dark in [false, true] { + let (mut u, t) = app(Session::new(), dark); + click(&mut u, pos2(100.0, 20.0), PointerButton::Secondary); + let texts = menu_texts(&settle(&mut u)); + let Some((_, col)) = texts.iter().find(|(s, _)| s == "Open in new window") else { panic!("the tab's menu opened (dark {dark}): {texts:?}") }; + let ratio = color(*col).contrast(color(t.panel)); + assert!(ratio >= 4.5, "Open in new window at {ratio:.2}:1 on the popup (dark {dark})"); + } +} + +#[test] +fn the_board_bars_shape_menu_shows_every_shape_in_both_themes() { + for dark in [false, true] { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({})).unwrap(); + s.execute("board.shape", &json!({"shape": "rectangle", "x": 0, "y": 0})).unwrap(); + let (mut u, t) = app(s, dark); + let all = nodes(&mut u); + let button = all.iter().find(|n| n.label() == Some("Shape")).expect("the board bar's Shape button"); + click(&mut u, bounds(button).center(), PointerButton::Primary); + let all = nodes(&mut u); + let out = settle(&mut u); + // Every shape but the picked one (white on the green highlight) on the popup itself. + for b in BoardShape::ALL.into_iter().filter(|b| *b != BoardShape::Rectangle) { + let label = crate::i18n::tr(b.label()); + let cell = all.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("{label} in the menu (dark {dark})")); + let lines = lines_in(&out, bounds(cell)); + assert!(!lines.is_empty(), "{label}'s outline painted (dark {dark})"); + for c in lines { + // Non-text contrast (WCAG 1.4.11): at least 3:1. + let ratio = color(c).contrast(color(t.panel)); + assert!(ratio >= 3.0, "{label}'s outline at {ratio:.2}:1 on the popup (dark {dark})"); + } + } + } +} diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 78193b011..e59552d12 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -162,9 +162,10 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { None => {} } if several { - resp.context_menu(|ui| { - ui.set_width(200.0); - if crate::menus::item(ui, tr("Open in new window"), "", true, false, &t) { + // The tab strip's own menu takes the theme like its overflow menus (the in-app + // menus stay dark, #843): its rows read on the white popup of the light theme. + crate::right::context_popup(&resp).show(|ui| { + if crate::right::menu_item(ui, tr("Open in new window"), "") { detach = Some((i, None)); ui.close(); }