From a56a42c09622509ad9592445c2c77cf7d083a858 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:33:45 +0200 Subject: [PATCH 1/2] UI: show an open padlock on an unlocked Composition / Timeline viewer lock The viewer lock in a tab group's header drew the same closed padlock in both states, told apart only by brightness. Unlocked now draws an open padlock, locked the closed one. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 5 +++-- crates/ui-egui/src/icons.rs | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 054aa3598..ac6ed92f9 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -654,7 +654,8 @@ pub fn doc_tabs( out } -/// A viewer lock button (Composition viewers and the Timeline keep their comp while locked). +/// A viewer lock button (Composition viewers and the Timeline keep their comp while locked): a +/// closed padlock while locked, an open one while unlocked. /// Returns true when clicked. Automation: `panel.tab..lock`. pub fn lock_button(ui: &mut egui::Ui, rect: Rect, panel: PanelKind, locked: bool, t: &Tokens, reg: &mut crate::automation::Registry) -> bool { let resp = ui.interact(rect, egui::Id::unique(("tab-lock", panel.id())), Sense::click()); @@ -665,7 +666,7 @@ pub fn lock_button(ui: &mut egui::Ui, rect: Rect, panel: PanelKind, locked: bool } else { t.text_faint }; - icons::paint(ui.painter(), rect.shrink(2.0), Icon::Lock, color); + icons::paint(ui.painter(), rect.shrink(2.0), if locked { Icon::Lock } else { Icon::LockOpen }, color); reg.add_response(&format!("panel.tab.{}.lock", panel.id()), &resp, if locked { "Unlock" } else { "Lock" }); crate::widgets::focus_outline(ui, &resp, t); resp.clicked() diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 949ea3771..385a6b1ca 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -48,6 +48,8 @@ pub enum Icon { Speaker, Solo, Lock, + /// An open padlock: the shackle lifted clear of the body on the right. + LockOpen, Shy, Collapse, Quality, @@ -402,6 +404,12 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(8.0, 7.0, 3.0, 180.0, 360.0); pen.dot(8.0, 10.5, 1.0); } + LockOpen => { + pen.rect(3.5, 7.0, 12.5, 14.0); + pen.arc(8.0, 4.0, 3.0, 180.0, 360.0); + pen.line(&[(5.0, 4.0), (5.0, 7.0)]); + pen.dot(8.0, 10.5, 1.0); + } Shy => { pen.circle(8.0, 8.0, 5.8); pen.dot(6.0, 7.0, 0.8); From b70d69fcb1e8d71133ee01400ed5db976ae77347 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:41:46 +0100 Subject: [PATCH 2/2] =?UTF-8?q?UI:=20an=20inactive=20comp=20tab=20shows=20?= =?UTF-8?q?its=20=C3=97=20on=20hover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..close` for each comp's ×; `panel.tab.Timeline.close` stays for the shown comp's. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 69 ++++++++++++++++++------------ crates/ui-egui/src/dock_ui.rs | 4 +- crates/ui-egui/tests/it/ui_tabs.rs | 28 ++++++++++++ 3 files changed, 72 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index ac6ed92f9..8e8fd5eca 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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 { + 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 { - 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..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.`, `panel.tab.Timeline.close` -/// for the shown comp's ×, `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.` -/// 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.`, the ×s (see +/// [`doc_close`]), `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.` for its +/// items. pub fn doc_tabs( ui: &mut egui::Ui, strip: Rect, @@ -575,7 +598,7 @@ pub fn doc_tabs( ) -> Vec { 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)); } } diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index cf180eec0..d17af3b46 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -843,11 +843,11 @@ impl DockContent 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. diff --git a/crates/ui-egui/tests/it/ui_tabs.rs b/crates/ui-egui/tests/it/ui_tabs.rs index 65b429f44..5fc3d8598 100644 --- a/crates/ui-egui/tests/it/ui_tabs.rs +++ b/crates/ui-egui/tests/it/ui_tabs.rs @@ -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))); +}