mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
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:
4 files changed
+173
-2
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user