diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 4bdda89a6..038dcc9b9 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -517,6 +517,67 @@ fn column_at(cw: &Cols, x: f32) -> Option<&'static str> { spans.into_iter().find(|(_, on, a, b)| *on && x >= *a && x < *b).map(|s| s.0) } +/// The column headers that show an icon or a symbol instead of a name, and what hovering one +/// says: (column, key, short name, explanation), in the order the header draws them. Over a layer +/// switch the key is that switch's name, and the row's switch shows the same text +/// ([`switch_tip`]). +pub const HEADER_TIPS: [(&str, &str, &str, &str); 15] = [ + ("av", "video", "Video", "Video: shows or hides the layer's picture in the composition"), + ("av", "audio", "Audio", "Audio: turns the layer's sound on or off"), + ("av", "solo", "Solo", "Solo: while any layer is soloed, only the soloed layers render"), + ("av", "lock", "Lock", "Lock: keeps the layer from being selected or changed"), + ("label", "label", "Label", "Label: the layer's color label; click a layer's swatch to pick another"), + ("num", "num", "#", "Layer number: the layer's place in the stack, counted from the top"), + ("switches", "shy", "Shy", "Shy: the layer leaves the Timeline while Hide Shy Layers is on"), + ( + "switches", + "collapse", + "Collapse Transformations / Continuously Rasterize", + "Collapse Transformations / Continuously Rasterize: a precomposition's layers render straight into this composition; a vector layer is redrawn sharp at every scale", + ), + ("switches", "quality", "Quality and Sampling", "Quality and Sampling: Draft renders faster; Best renders with subpixel accuracy and smooth sampling"), + ("switches", "fx", "Effect", "Effect: turns the layer's effects on or off"), + ("switches", "frameBlend", "Frame Blending", "Frame Blending: smooths footage whose speed or frame rate differs from the composition's"), + ("switches", "motionBlur", "Motion Blur", "Motion Blur: blurs the layer along its movement while the composition's Motion Blur is on"), + ("switches", "adjustment", "Adjustment Layer", "Adjustment Layer: the layer's effects apply to every layer below it"), + ("switches", "threeD", "3D Layer", "3D Layer: the layer moves and turns in depth and reacts to cameras and lights"), + ( + "modes", + "preserveTransparency", + "Preserve Transparency", + "Preserve Transparency: the layer shows only over the layers below it, and only as opaque as they are", + ), +]; + +/// The hover text of the layer switch (or the header over it) named `key`. +fn switch_tip(key: &str) -> Option<&'static str> { + HEADER_TIPS.iter().find(|t| t.1 == key).map(|t| t.3) +} + +/// Where each shown entry of [`HEADER_TIPS`] sits in the column header row `ch`: (column, key, +/// rect), over the icon the header draws there. Hidden columns give none. +fn header_tip_rects(cw: &Cols, ch: Rect) -> Vec<(&'static str, &'static str, Rect)> { + let v = cw.vis; + let mut out = Vec::new(); + for (col, key, _, _) in HEADER_TIPS { + // The icon's place among its column's icons. + let i = HEADER_TIPS.iter().filter(|t| t.0 == col).position(|t| t.1 == key).unwrap_or(0) as f32; + let span = match col { + "av" => v.av.then_some((cw.av + 1.0 + 18.0 * i, 18.0)), + "label" => v.label.then_some((cw.label, LABEL_W)), + "num" => v.num.then_some((cw.num, NUM_W)), + "switches" => v.switches.then_some((cw.switches + SW * i, SW)), + // The `T` over the rows' Preserve Transparency boxes. + "modes" => v.modes.then_some((cw.mode + 72.0, 18.0)), + _ => None, + }; + if let Some((x, w)) = span { + out.push((col, key, Rect::from_min_max(pos2(x, ch.min.y), pos2(x + w, ch.max.y)))); + } + } + out +} + /// Snap a time to the nearest candidate within `tolerance` (all seconds); Shift-dragging the /// current-time indicator snaps to keyframes, layer in/out points, markers and the work area. pub fn snap_time(candidates: &[f64], t: f64, tolerance: f64) -> f64 { @@ -1514,6 +1575,11 @@ fn layer_switch( ) { let SwitchSpec { row, br, icon, on, id, layer, name, others } = *spec; let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag()); + // What the switch does, as its column header says. + let resp = match switch_tip(name) { + Some(tip) => resp.on_hover_text(tr(tip)), + None => resp, + }; // Alt-click on an A/V feature sets the layer apart: the other layers go off (or locked), // and come back on the next Alt-click. With several layers selected, the whole selection is // set apart. @@ -2653,6 +2719,23 @@ fn column_header_interact(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut T let hdr_rect = Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)); let hresp = ui.interact(hdr_rect, egui::Id::unique("tl-colhdr"), Sense::click()); app.auto.add("timeline.header", hdr_rect, "Columns (right-click)"); + // The icon-only headers say what they are on hover. Hover-only, on top of the header: its + // clicks and right-click menu still go to the header. + for (col, key, r) in header_tip_rects(cw, ch) { + let r = r.intersect(hdr_rect); + let Some((_, _, name, tip)) = HEADER_TIPS.iter().find(|t| t.1 == key) else { continue }; + if !r.is_positive() { + continue; + } + // The node first: the hover text becomes its accessible description only on an existing node. + let resp = ui.interact(r, egui::Id::unique(("tl-colhdr-tip", key)), Sense::hover()); + crate::a11y::role_name(&resp, egui::accesskit::Role::ColumnHeader, tr(name)); + let _ = resp.on_hover_text(tr(tip)); + // `label` and `#` are a column each: `timeline.header.label` / `.num` already name them. + if col != key { + app.auto.add(&format!("timeline.header.{col}.{key}"), r, name); + } + } for (id, label) in COLUMNS { if let Some(x0) = match id { "av" => vis.av.then_some((cw.av, AV_W)), @@ -3102,7 +3185,12 @@ fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: } let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency"); - if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).changed() { + let pt = widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))); + let pt = match switch_tip("preserveTransparency") { + Some(tip) => pt.on_hover_text(crate::i18n::tr(tip)), + None => pt, + }; + if pt.changed() { // The click gives the selection the state the clicked row toggles to, // whatever the first selected layer's value (as the switches do). actions.push(( diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 677673a0b..983d80ae6 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -906,6 +906,38 @@ fn mask_reveal_collapsing_a_mask_hides_its_children_under_mixed_filters() { assert_eq!(labels(&app).iter().filter(|r| r.trim() == "Mask Feather").count(), 1); } +/// Every icon-only column header says what it is on hover, over the icon it explains, and every +/// layer switch under one shows the same text. +#[test] +fn every_icon_only_header_has_a_hover_text() { + let all = crate::state::TimelineState { columns: COLUMNS.iter().map(|c| c.0.to_string()).collect(), show_modes: true, ..Default::default() }; + let cw = cols(0.0, 2000.0, Vis::of(&all)); + let ch = Rect::from_min_max(pos2(0.0, 0.0), pos2(2000.0, 20.0)); + let rects = header_tip_rects(&cw, ch); + assert_eq!(rects.len(), HEADER_TIPS.len(), "every header icon is placed with every column shown"); + for (col, key, r) in &rects { + assert_eq!(column_at(&cw, r.center().x), Some(*col), "{key} sits in its column"); + let (_, _, name, tip) = HEADER_TIPS.iter().find(|t| t.1 == *key).unwrap(); + assert!(tip.starts_with(&format!("{name}:")) || *key == "num", "{key}: {tip}"); + } + for (i, (_, a, ra)) in rects.iter().enumerate() { + for (_, b, rb) in &rects[i + 1..] { + assert!(!ra.intersects(*rb) || ra.intersect(*rb).area() < 1.0, "{a} and {b} overlap"); + } + } + // The icons the header draws over the A/V and switch columns, left to right. + let av: Vec<&str> = rects.iter().filter(|r| r.0 == "av").map(|r| r.1).collect(); + assert_eq!(av, ["video", "audio", "solo", "lock"]); + let sw: Vec<&str> = rects.iter().filter(|r| r.0 == "switches").map(|r| r.1).collect(); + assert_eq!(sw, ["shy", "collapse", "quality", "fx", "frameBlend", "motionBlur", "adjustment", "threeD"]); + for name in av.iter().chain(&sw).chain(&["preserveTransparency"]) { + assert!(switch_tip(name).is_some(), "the {name} switch has a hover text"); + } + // A hidden column has no hover targets. + let none = crate::state::TimelineState { columns: Default::default(), show_modes: false, ..Default::default() }; + assert!(header_tip_rects(&cols(0.0, 800.0, Vis::of(&none)), ch).is_empty()); +} + /// One box spans the outline and the time graph (#3056): rows it crosses are selected on both /// sides of the boundary, and only keys in the time graph count (the part over the outline is cut). #[test] diff --git a/crates/ui-egui/tests/it/ui_accessibility_audit.rs b/crates/ui-egui/tests/it/ui_accessibility_audit.rs index 8f2f9cc4b..a4c10340a 100644 --- a/crates/ui-egui/tests/it/ui_accessibility_audit.rs +++ b/crates/ui-egui/tests/it/ui_accessibility_audit.rs @@ -41,7 +41,7 @@ const BASELINE: &[(&str, usize, usize)] = &[ ("EffectControls:default", 28, 14), ("Composition:default", 4, 2), ("Layer:default", 2, 1), - ("Timeline:default", 89, 42), + ("Timeline:default", 72, 25), ("Audio:default", 4, 2), ("Properties:default", 31, 15), ("Character:default", 46, 22), diff --git a/crates/ui-egui/tests/it/ui_tail.rs b/crates/ui-egui/tests/it/ui_tail.rs index ba02072f8..293ba87ae 100644 --- a/crates/ui-egui/tests/it/ui_tail.rs +++ b/crates/ui-egui/tests/it/ui_tail.rs @@ -300,6 +300,57 @@ fn project_panel_columns_scroll_horizontally() { assert_eq!(rect(&h, "project.sort.name").min.x, name0, "Name stays frozen"); } +/// Hovering an icon-only Timeline column header explains it, and the header's own right-click +/// column menu still opens under the icon. +#[test] +fn timeline_header_icons_explain_themselves_on_hover() { + use egui_kittest::kittest::Queryable; + let mut h = harness(1400.0); + // English whatever the system language: the menu item checked below is translated. + h.state_mut().session.prefs.general.language = "en".into(); + h.run_steps(3); + for (id, tip) in [ + ("timeline.header.av.solo", "Solo: while any layer is soloed, only the soloed layers render"), + ("timeline.header.av.video", "Video: shows or hides the layer's picture in the composition"), + ("timeline.header.switches.shy", "Shy: the layer leaves the Timeline while Hide Shy Layers is on"), + ("timeline.header.switches.threeD", "3D Layer: the layer moves and turns in depth and reacts to cameras and lights"), + ] { + // The tooltip is a node of its own, next to any the widget's accessible text makes. + let before = h.query_all_by_label(tip).count(); + let at = rect(&h, id).center(); + h.event(Event::PointerMoved(at)); + h.run_steps(60); + assert!(h.query_all_by_label(tip).count() > before, "{id}: hovering shows {tip:?}"); + h.event(Event::PointerMoved(pos2(5.0, 5.0))); + h.run_steps(5); + } + // The header's accessible node carries the text as its description (screen readers). + { + use egui_kittest::kittest::NodeT; + let tip = "Solo: while any layer is soloed, only the soloed layers render"; + let described = h.query_all_by_label("Solo").any(|n| n.accesskit_node().description().as_deref() == Some(tip)); + assert!(described, "the Solo header's node describes it"); + } + // A layer's own switch shows the same text. + let lid = h.state().session.active_comp().unwrap().layers[0].id.0; + let lock = "Lock: keeps the layer from being selected or changed"; + let before = h.query_all_by_label(lock).count(); + let at = rect(&h, &format!("timeline.layer.{lid}.lock")).center(); + h.event(Event::PointerMoved(at)); + h.run_steps(60); + assert!(h.query_all_by_label(lock).count() > before, "the layer's Lock switch shows its text"); + // Right-click on an icon still opens the header's column menu. + let at = rect(&h, "timeline.header.av.solo").center(); + h.event(Event::PointerMoved(at)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(3); + assert!(h.query_by_label("Hide This").is_some(), "the column menu opens under a header icon"); +} + /// The Project panel's Name column edge drags to resize the column, and a double-click on the /// edge fits the names again. #[test]