diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 720aad956..e58e56139 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -515,8 +515,39 @@ impl Columns { } } -fn layout_columns(app: &mut EffectcraftApp, rect: Rect) -> Columns { - let name_w = (rect.width() * 0.40).clamp(120.0, 260.0); +/// Narrowest Name column (points). +const MIN_NAME_W: f32 = 120.0; + +/// The Name column width that shows every listed name in full: indent, twirl and icon, the +/// name, and room for a problem marker. +fn name_fit(app: &EffectcraftApp, p: &egui::Painter, rows: &[(ItemId, usize)]) -> f32 { + rows.iter() + .filter_map(|(id, depth)| { + let it = app.session.project.item(*id)?; + let text = p.layout_no_wrap(it.name.clone(), Tokens::ui(12.0), Color32::WHITE).size().x; + Some(8.0 + 14.0 * *depth as f32 + 30.0 + text + 24.0) + }) + .fold(0.0, f32::max) +} + +/// The part of the panel the optional columns keep next to a widest Name column (points): room +/// for some of their content and a horizontal scroll bar track that can be dragged. +const MIN_OPTIONAL_VIEW: f32 = 40.0; + +/// The widest the Name column gets in a panel `width` points wide: the width less the Label +/// column (40 points), and less [`MIN_OPTIONAL_VIEW`] while optional columns are shown. +fn name_max_w(app: &EffectcraftApp, width: f32) -> f32 { + let optional = if app.ui.project_columns.iter().any(|k| COLUMNS.iter().any(|c| c.0 == k)) { MIN_OPTIONAL_VIEW } else { 0.0 }; + (width - 40.0 - optional).max(MIN_NAME_W) +} + +/// Column positions. The Name column is as wide as the user dragged it, else wide enough for +/// the names listed (`fit`), as After Effects shows full names; at most [`name_max_w`] (the +/// optional columns scroll under it). +fn layout_columns(app: &mut EffectcraftApp, rect: Rect, fit: f32) -> Columns { + let max_w = name_max_w(app, rect.width()); + let auto = (rect.width() * 0.40).clamp(MIN_NAME_W, 260.0).max(fit); + let name_w = app.ui.project_name_w.filter(|w| w.is_finite()).unwrap_or(auto).clamp(MIN_NAME_W, max_w); let label_x = rect.min.x + name_w + 14.0; let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)]; // Optional columns wider than the panel scroll horizontally under the frozen Name and Label @@ -590,6 +621,21 @@ fn draw_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, h } let hresp = ui.interact(Rect::from_min_max(pos2(end_x, hdr.min.y), hdr.max), egui::Id::unique("proj-hdr-rest"), Sense::CLICK); column_menu(app, &hresp); + // The Name column's right edge: drag to resize it, double-click to fit the names again. + let edge = Rect::from_center_size(pos2(hdr.min.x + cl.name_w + 5.0, hdr.center().y), vec2(8.0, hdr.height())); + let eresp = ui.interact(edge, egui::Id::unique("proj-name-edge"), Sense::click_and_drag()); + app.auto.add("project.nameColumn.edge", edge, "Drag to resize the Name column; double-click to fit the names"); + if eresp.hovered() || eresp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeColumn); + hp.line_segment([pos2(edge.center().x, hdr.min.y + 3.0), pos2(edge.center().x, hdr.max.y - 3.0)], Stroke::new(1.0, t.text_dim)); + } + if eresp.double_clicked() { + app.ui.project_name_w = None; + } else if eresp.dragged() { + let max_w = name_max_w(app, hdr.width()); + app.ui.project_name_w = Some((app.ui.project_name_w.unwrap_or(cl.name_w).min(max_w) + eresp.drag_delta().x).clamp(MIN_NAME_W, max_w)); + } + eresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr("Resize the Name column"))); app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)"); } @@ -1206,7 +1252,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let gutter_id = egui::Id::unique("project-gutter"); let gutter = ctx.data(|d| d.get_temp::(gutter_id)).unwrap_or(false); let content = if gutter { Rect::from_min_max(rect.min, pos2((rect.max.x - widgets::SCROLL_GUTTER).max(rect.min.x), rect.max.y)) } else { rect }; - let cl = layout_columns(app, content); + // Only an automatic width (or an invalid saved one) measures the names. + let fit = if app.ui.project_name_w.is_some_and(f32::is_finite) { 0.0 } else { name_fit(app, &p, &visible_rows(app)) }; + let cl = layout_columns(app, content, fit); let (name_w, label_x, opt_x0, overflow, hscroll) = (cl.name_w, cl.label_x, cl.opt_x0, cl.overflow, cl.hscroll); draw_header(app, ui, &p, hdr, &cl); // Rows. @@ -1451,6 +1499,53 @@ mod tests { } } + /// Long names show in full: the Name column fits them (it stopped at 260 points with "…") + /// up to the panel's width less the Label column and, while optional columns show, room for + /// them; a width the user dragged wins. + #[test] + fn the_name_column_fits_the_names_up_to_the_panel_width() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(700.0, 400.0)); + assert_eq!(layout_columns(&mut app, rect, 0.0).name_w, 260.0); + assert_eq!(layout_columns(&mut app, rect, 400.0).name_w, 400.0); + // The optional columns keep 40 points more: some content and a usable scroll bar track. + assert!(!app.ui.project_columns.is_empty()); + let cl = layout_columns(&mut app, rect, 2000.0); + assert_eq!(cl.name_w, 620.0); + assert!(rect.max.x - cl.opt_x0 >= 50.0, "the optional columns keep {} points", rect.max.x - cl.opt_x0); + let narrow = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 400.0)); + assert_eq!(layout_columns(&mut app, narrow, 400.0).name_w, MIN_NAME_W); + app.ui.project_name_w = Some(150.0); + assert_eq!(layout_columns(&mut app, rect, 400.0).name_w, 150.0); + app.ui.project_name_w = Some(f32::NAN); + assert_eq!(layout_columns(&mut app, rect, 400.0).name_w, 400.0); + app.ui.project_name_w = Some(5000.0); + assert_eq!(layout_columns(&mut app, rect, 0.0).name_w, 620.0); + // Without optional columns the Name column may take all but the Label column. + app.ui.project_columns.clear(); + assert_eq!(layout_columns(&mut app, rect, 0.0).name_w, 660.0); + } + + #[test] + fn name_fit_measures_the_longest_listed_name_with_its_indent() { + let mut s = effectcraft_engine::Session::default(); + let p = std::sync::Arc::make_mut(&mut s.project); + let folder = p.add_item("F", Label::Aqua, None, ItemKind::Folder); + let long = p.add_item("A rather long composition name for the list", Label::Aqua, Some(folder), ItemKind::Folder); + let app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + let mut fit = (0.0, 0.0); + ctx.run_ui(Default::default(), |ui| { + let painter = ui.painter(); + let text = painter.layout_no_wrap("A rather long composition name for the list".into(), Tokens::ui(12.0), Color32::WHITE).size().x; + fit = (name_fit(&app, painter, &[(folder, 0), (long, 1)]), 8.0 + 14.0 + 30.0 + text + 24.0); + }) + .textures_delta + .clear(); + assert_eq!(fit.0, fit.1); + assert!(fit.0 > 260.0); + } + #[test] fn size_keys_are_sampled_once_with_stable_ties() { let mut project = Project::default(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cab59fd5f..60a30e97e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -684,6 +684,10 @@ pub struct UiState { /// Horizontal scroll of the Project panel's optional columns (Name and Label stay put). #[serde(default)] pub project_hscroll: f32, + /// Width of the Project panel's Name column set by dragging its edge in the header (points); + /// `None` fits the longest name shown (double-clicking the edge goes back to that). + #[serde(default)] + pub project_name_w: Option, /// Vertical scroll of the Project panel's item list (points); only visible rows are drawn. #[serde(default)] pub project_scroll: f32, @@ -785,6 +789,7 @@ impl Default for UiState { project_sort: default_project_sort(), project_sort_desc: false, project_hscroll: 0.0, + project_name_w: None, project_scroll: 0.0, project_columns: default_project_columns(), fx_closed: BTreeSet::new(), diff --git a/crates/ui-egui/tests/it/ui_tail.rs b/crates/ui-egui/tests/it/ui_tail.rs index a824e065c..ba02072f8 100644 --- a/crates/ui-egui/tests/it/ui_tail.rs +++ b/crates/ui-egui/tests/it/ui_tail.rs @@ -299,3 +299,25 @@ fn project_panel_columns_scroll_horizontally() { assert!(rect(&h, "project.sort.comment").min.x < x0 - 20.0); assert_eq!(rect(&h, "project.sort.name").min.x, name0, "Name stays frozen"); } + +/// The Project panel's Name column edge drags to resize the column, and a double-click on the +/// edge fits the names again. +#[test] +fn project_name_column_edge_resizes_and_fits_again() { + let mut h = harness(1400.0); + h.state_mut().ui.start_screen = false; + h.run_steps(3); + let before = rect(&h, "project.nameColumn.edge").center(); + drag(&mut h, before, before + vec2(80.0, 0.0)); + assert!(h.state().ui.project_name_w.is_some()); + let edge = rect(&h, "project.nameColumn.edge").center(); + assert!((edge.x - (before.x + 80.0)).abs() < 2.0, "{edge:?} after dragging from {before:?}"); + h.input_mut().events.push(Event::PointerMoved(edge)); + for _ in 0..2 { + h.input_mut().events.push(Event::PointerButton { pos: edge, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.input_mut().events.push(Event::PointerButton { pos: edge, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + h.run_steps(3); + assert_eq!(h.state().ui.project_name_w, None); + assert!((rect(&h, "project.nameColumn.edge").center().x - before.x).abs() < 1.0); +}