Timeline: hover texts for the icon-only column headers and layer switches (#3096)

* Timeline: hover texts for the icon-only column headers and layer switches

The Timeline's column header is painted as one click target, so its
icon-only headers (A/V Features, Label, #, the eight switches and the
Modes column's T) had nothing to hover and nothing explained them.

HEADER_TIPS lists each one with a short name and an explanation in our
own words. Hover-only widgets on top of the header show the text, carry a
named ColumnHeader AccessKit node and register automation ids
(timeline.header.av.*, timeline.header.switches.*,
timeline.header.modes.preserveTransparency). The header's clicks and
right-click column menu still work underneath. The layer rows' A/V and
switch toggles and the Preserve Transparency box show the same texts.

The accessibility audit's Timeline baseline drops from 89/42 to 72/25
because the switches now have accessible names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timeline headers: accessible description, Preserve Transparency text, English menu test

The header's node is named before its hover text is added, so screen readers get the
explanation; Preserve Transparency says the layer follows the alpha below it, partial alpha
included; the hover test sets English before checking the translated Hide This item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge main: keep both sets of timeline tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timeline tests: close the test before the merged one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-06 18:55:35 +01:00
1 parent fb62b9d7e3
commit 329ba63f8d
4 files changed
+173 -2

No files matched your search

+89 -1
View File
@@ -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((
@@ -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]
@@ -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),
+51
View File
@@ -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]