Project panel: the Name column fits long names and resizes (#3094)

* Project panel: the Name column fits long names and resizes

The Name column was 40 % of the panel and at most 260 points wide, with
no way to resize it, so long item names (compositions of an imported
After Effects project) ended in "..." even in a wide panel.

It now fits the longest listed name, never narrower than before and at
most the panel's width less the Label column (the optional columns keep
scrolling under it). Dragging the column's edge in the header resizes
it, a double-click on the edge fits the names again. The width is kept
in UiState::project_name_w (None = fit the names); the edge has the
automation id project.nameColumn.edge.

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

* Project panel: the Name column leaves room for the optional columns; no name fit for a manual width

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-11-06 17:36:59 +01:00
1 parent 43a47a5d6a
commit bb920d68ec
3 files changed
+125 -3

No files matched your search

+98 -3
View File
@@ -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::<bool>(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();
+5
View File
@@ -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<f32>,
/// 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(),
+22
View File
@@ -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);
}