UI: an inactive comp tab shows its × on hover

The Timeline's comp tabs had a × on the shown comp only, so closing another
comp meant showing it first. A comp tab the Timeline does not show now shows
its × while the pointer is over it, and clicking it closes that comp without
showing it. Every comp tab keeps the ×'s room, so a tab keeps its width when
it is shown or hovered. Automation: `panel.tab.Timeline.<comp>.close` for
each comp's ×; `panel.tab.Timeline.close` stays for the shown comp's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pravdomil
2026-10-29 20:41:46 +01:00
co-authored by Claude Opus 5.5
parent a56a42c096
commit b70d69fcb1
3 changed files with 72 additions and 29 deletions
+42 -27
View File
@@ -531,7 +531,8 @@ pub enum DocAction {
const TAB_PADDING: f32 = 24.0;
/// Width of the » button that lists the comp tabs with no room in the strip.
const DOC_OVERFLOW: f32 = 22.0;
/// Room for the × after the shown comp's name.
/// Room for the × after a comp's name. Every comp tab keeps it, so a tab keeps its width when it
/// is shown or hovered.
const DOC_CLOSE: f32 = 16.0;
/// A comp tab's text: the label swatch, then the name, as the panel tabs with a swatch show it.
@@ -547,24 +548,46 @@ fn text_width(ctx: &egui::Context, job: egui::text::LayoutJob) -> f32 {
ctx.fonts_mut(|f| f.layout_job(job)).size().x
}
/// Whether comp tab `doc` carries the ×: the comp the Timeline shows, while it is the group's
/// shown panel.
fn has_close(doc: &DocTab, selected: bool) -> bool {
selected && doc.active
/// The width each comp tab asks for in the Timeline group's tab row.
pub fn doc_widths(ctx: &egui::Context, docs: &[DocTab], font: &egui::FontId) -> Vec<f32> {
docs.iter().map(|d| text_width(ctx, doc_job(d, font, egui::Color32::WHITE)) + TAB_PADDING + DOC_CLOSE).collect()
}
/// The width each comp tab asks for in the Timeline group's tab row.
pub fn doc_widths(ctx: &egui::Context, docs: &[DocTab], selected: bool, font: &egui::FontId) -> Vec<f32> {
docs.iter().map(|d| text_width(ctx, doc_job(d, font, egui::Color32::WHITE)) + TAB_PADDING + if has_close(d, selected) { DOC_CLOSE } else { 0.0 }).collect()
/// The × of comp tab `doc` at `rect`, clipped to `clip`; true when clicked. `shown` is the comp
/// the Timeline shows while it is the group's shown panel. Automation:
/// `panel.tab.Timeline.<comp>.close`, and `panel.tab.Timeline.close` for the shown comp's.
fn doc_close(
ui: &mut egui::Ui,
rect: Rect,
clip: Rect,
doc: &DocTab,
shown: bool,
style: &craft_ui::docking::DockStyle,
reg: &mut crate::automation::Registry,
) -> bool {
let timeline = PanelKind::Timeline.id();
let hit = ui.interact(rect.expand(2.0), egui::Id::unique(("comp-tab-close", doc.id)), Sense::click());
hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, hit.enabled(), "Close"));
let cc = if hit.hovered() { style.text } else { style.inactive_text };
let k = 3.5;
let painter = ui.painter().with_clip_rect(clip);
painter.line_segment([rect.center() + vec2(-k, -k), rect.center() + vec2(k, k)], Stroke::new(1.2, cc));
painter.line_segment([rect.center() + vec2(-k, k), rect.center() + vec2(k, -k)], Stroke::new(1.2, cc));
reg.add_response(&format!("panel.tab.{timeline}.{}.close", doc.id), &hit, "Close");
if shown {
reg.add_response(&format!("panel.tab.{timeline}.close"), &hit, "Close");
}
hit.clicked()
}
/// Draw the Timeline's comp tabs into `strip`, the part of the Timeline group's tab row after its
/// panel tabs. Each is a tab like the panel tabs: the comp's label swatch and name, and × on the
/// shown comp while the Timeline is the group's shown panel (`selected`). Tabs that do not fit go
/// to a » menu at the end; the shown comp's tab always stays in the row. Shift+, and Shift+. step
/// through every open comp. Automation: `panel.tab.Timeline.<comp>`, `panel.tab.Timeline.close`
/// for the shown comp's ×, `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.<comp>`
/// for its items.
/// panel tabs. Each is a tab like the panel tabs: the comp's label swatch and name, then a ×
/// that closes the comp's Timeline: always on the shown comp while the Timeline is the group's
/// shown panel (`selected`), on the other comp tabs while the pointer is over them. Tabs that do
/// not fit go to a » menu at the end; the shown comp's tab always stays in the row. Shift+, and
/// Shift+. step through every open comp. Automation: `panel.tab.Timeline.<comp>`, the ×s (see
/// [`doc_close`]), `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.<comp>` for its
/// items.
pub fn doc_tabs(
ui: &mut egui::Ui,
strip: Rect,
@@ -575,7 +598,7 @@ pub fn doc_tabs(
) -> Vec<DocAction> {
let mut out = Vec::new();
let timeline = PanelKind::Timeline.id();
let widths = doc_widths(ui.ctx(), docs, selected, &style.font);
let widths = doc_widths(ui.ctx(), docs, &style.font);
let active = docs.iter().position(|d| d.active).unwrap_or(0);
let fit = craft_ui::tabs::fit_whole(&widths, active, strip.width(), DOC_OVERFLOW);
let clip = strip.intersect(ui.clip_rect());
@@ -587,10 +610,9 @@ pub fn doc_tabs(
x += width;
let on = selected && doc.active;
let color = if on { style.text } else { style.inactive_text };
let close = has_close(doc, selected);
let mut job = doc_job(doc, &style.font, color);
job.wrap = egui::text::TextWrapping {
max_width: (tab.width() - TAB_PADDING - if close { DOC_CLOSE } else { 0.0 }).max(0.0),
max_width: (tab.width() - TAB_PADDING - DOC_CLOSE).max(0.0),
max_rows: 1,
break_anywhere: true,
overflow_character: Some('\u{2026}'),
@@ -608,17 +630,10 @@ pub fn doc_tabs(
);
let resp = if galley.elided { resp.on_hover_text(&doc.title) } else { resp };
reg.add_response(&format!("panel.tab.{timeline}.{}", doc.id), &resp, &doc.title);
if close {
// After the tab, so the × takes the clicks over it.
if on || ui.rect_contains_pointer(tab.intersect(clip)) {
let cr = Rect::from_center_size(pos2(label_end + DOC_CLOSE / 2.0, mid), vec2(10.0, 10.0));
let hit = ui.interact(cr.expand(2.0), egui::Id::unique(("comp-tab-close", doc.id)), Sense::click());
hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, hit.enabled(), "Close"));
let cc = if hit.hovered() { style.text } else { style.inactive_text };
let k = 3.5;
let painter = ui.painter().with_clip_rect(tab.intersect(clip));
painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc));
painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc));
reg.add_response(&format!("panel.tab.{timeline}.close"), &hit, "Close");
if hit.clicked() {
if doc_close(ui, cr, tab.intersect(clip), doc, on, style, reg) {
out.push(DocAction::Close(doc.id));
}
}
+2 -2
View File
@@ -843,11 +843,11 @@ impl DockContent<PanelKind> for EffectContent<'_> {
}
}
fn group_extra_tabs_width(&self, panels: &[PanelKind], active: &PanelKind, _floating: bool) -> f32 {
fn group_extra_tabs_width(&self, panels: &[PanelKind], _active: &PanelKind, _floating: bool) -> f32 {
if !self.has_doc_tabs(panels) {
return 0.0;
}
dock::doc_widths(&self.ctx, &self.docs, *active == PanelKind::Timeline, &self.style.font).iter().sum()
dock::doc_widths(&self.ctx, &self.docs, &self.style.font).iter().sum()
}
/// The Timeline's comp tabs, after its group's panel tabs whichever panel is shown.
+28
View File
@@ -466,3 +466,31 @@ fn open_comps_are_tabs_in_the_timeline_row_and_the_rest_are_in_its_overflow_menu
assert_eq!(open_comps(&h), comps);
assert!(has(&h, &format!("panel.tab.Timeline.{hidden}")));
}
/// Whether `id` was registered in the last frame (not the one before).
fn drawn(h: &Harness<'_, EffectcraftApp>, id: &str) -> bool {
h.state().auto.elements.iter().any(|e| e.id == id)
}
#[test]
fn an_inactive_comp_tab_shows_its_close_button_on_hover_and_closes_without_showing_its_comp() {
let (mut h, pre, main) = harness();
h.state_mut().session.state.open_comps = vec![ItemId(main), ItemId(pre)];
h.run_steps(3);
let (tm, tp) = (rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}")));
// The shown comp's × is always there; Pre's only while the pointer is over its tab.
h.event(Event::PointerMoved(pos2(800.0, 900.0)));
h.run_steps(2);
assert!(drawn(&h, "panel.tab.Timeline.close") && drawn(&h, &format!("panel.tab.Timeline.{main}.close")));
assert!(!drawn(&h, &format!("panel.tab.Timeline.{pre}.close")), "no × on an inactive tab the pointer is not over");
h.event(Event::PointerMoved(tp.center()));
h.run_steps(2);
let close = rect(&h, &format!("panel.tab.Timeline.{pre}.close"));
assert!(tp.contains_rect(close), "{close:?} inside {tp:?}");
// Every comp tab keeps the ×'s room: hovering changes no width.
assert_eq!((rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}"))), (tm, tp));
// Its × closes Pre's Timeline; Main stays shown.
click_n(&mut h, close.center(), 1);
assert_eq!(open_comps(&h), vec![main]);
assert_eq!(h.state().session.state.active_comp, Some(ItemId(main)));
}