diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index 13631e4537f..0b069d1c3b3 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -1153,6 +1153,42 @@ pub fn reorder_overlay_track( true } +/// Where `lane` ends up when lane `from` is dragged to `to`: the moved lane +/// takes `to`, and the lanes it passed shift by one toward `from`. +fn reordered_lane(lane: u32, from: u32, to: u32) -> u32 { + if lane == from { + to + } else if from < to && lane > from && lane <= to { + lane - 1 + } else if from > to && lane >= to && lane < from { + lane + 1 + } else { + lane + } +} + +/// Moves `kind`'s lane names along with a [`reorder_track_lane`], so a name +/// stays on the lane it was given to. +pub fn reorder_track_names( + names: &mut std::collections::BTreeMap, + kind: TrackKind, + from: u32, + to: u32, +) { + let prefix = format!("{}:", kind.name_key()); + let moved: Vec<(u32, String)> = names + .iter() + .filter_map(|(key, name)| { + let lane = key.strip_prefix(&prefix)?.parse::().ok()?; + Some((lane, name.clone())) + }) + .collect(); + names.retain(|key, _| !key.starts_with(&prefix)); + for (lane, name) in moved { + names.insert(format!("{prefix}{}", reordered_lane(lane, from, to)), name); + } +} + pub fn reorder_track_lane( timeline: &mut TimelineConfiguration, kind: TrackKind, @@ -1172,15 +1208,7 @@ pub fn reorder_track_lane( let mut changed = false; for segment in segments { let lane = segment.lane(); - let next = if lane == from { - to - } else if from < to && lane > from && lane <= to { - lane - 1 - } else if from > to && lane >= to && lane < from { - lane + 1 - } else { - lane - }; + let next = reordered_lane(lane, from, to); if next != lane { set(segment, next); changed = true; @@ -2895,6 +2923,29 @@ mod tests { // -- Selection ---------------------------------------------------------- + #[test] + fn lane_names_follow_their_lane_when_lanes_are_reordered() { + let mut names = std::collections::BTreeMap::from([ + ("audio:0".to_string(), "Voice".to_string()), + ("audio:1".to_string(), "Music".to_string()), + ("style:0".to_string(), "Intro".to_string()), + ]); + reorder_track_names(&mut names, TrackKind::Audio, 1, 0); + assert_eq!(names.get("audio:0").map(String::as_str), Some("Music")); + assert_eq!(names.get("audio:1").map(String::as_str), Some("Voice")); + assert_eq!(names.get("style:0").map(String::as_str), Some("Intro")); + + // Dragging lane 0 to the bottom of three shifts the others up. + let mut names = std::collections::BTreeMap::from([ + ("audio:0".to_string(), "A".to_string()), + ("audio:2".to_string(), "C".to_string()), + ]); + reorder_track_names(&mut names, TrackKind::Audio, 0, 2); + assert_eq!(names.get("audio:2").map(String::as_str), Some("A")); + assert_eq!(names.get("audio:1").map(String::as_str), Some("C")); + assert!(!names.contains_key("audio:0")); + } + #[test] fn a_plain_click_selects_one_segment() { let selection = click_selection(None, TrackKind::Zoom, 3, false, false); diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index b4a22b8b16c..c3e370ff83b 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -949,6 +949,23 @@ pub enum TrackKind { } impl TrackKind { + /// The track's part of a `ProjectConfiguration::track_names` key. + pub fn name_key(self) -> &'static str { + match self { + Self::Clip => "clip", + Self::Style => "style", + Self::Image => "image", + Self::Caption => "caption", + Self::Keyboard => "keyboard", + Self::Text => "text", + Self::Mask => "mask", + Self::Audio => "audio", + Self::Zoom => "zoom", + Self::ThreeD => "3d", + Self::Scene => "scene", + } + } + /// `trackDefinitions` (`TL/index.tsx:89-144`) and `trackIcons` (`:70-80`). pub fn label(self) -> &'static str { match self { @@ -1291,6 +1308,8 @@ impl TrackRow { /// Everything the timeline draws, derived once per project-config change. #[derive(Debug, Clone, Default)] pub struct TimelineModel { + /// Lane names the user gave, by `":"`. + pub track_names: std::collections::BTreeMap, pub style: Vec, pub image: Vec, pub rows: Vec, @@ -1530,6 +1549,7 @@ impl TimelineModel { }) .collect(); let mut model = Self { + track_names: Default::default(), style, image, rows: Vec::new(), @@ -1568,6 +1588,12 @@ impl TimelineModel { ) -> Self { let mut model = Self::build(config, has_camera, multiple_clips); model.rows = build_rows(config, &model, has_camera, lanes); + model.track_names = config + .track_names + .iter() + .filter(|(_, name)| !name.trim().is_empty()) + .map(|(key, name)| (key.clone(), SharedString::from(name.clone()))) + .collect(); model } } @@ -1887,10 +1913,14 @@ impl SegmentUi<'_> { // Rendering // --------------------------------------------------------------------------- +pub fn track_name_key(row: TrackRow) -> String { + format!("{}:{}", row.kind.name_key(), row.lane) +} + /// The gutter cell: a tinted 22px tile carrying the track's glyph, then the /// track's name. The saturated pill the source draws /// (`TL/TrackManager.tsx:264-281`) is gone. -pub fn track_chip(theme: &Theme, kind: TrackKind) -> impl IntoElement { +pub fn track_chip(theme: &Theme, kind: TrackKind, name: Option) -> impl IntoElement { let color = kind.color(); div() .w_full() @@ -1925,7 +1955,7 @@ pub fn track_chip(theme: &Theme, kind: TrackKind) -> impl IntoElement { .text_size(px(11.)) .line_height(px(14.)) .font_weight(FontWeight::MEDIUM) - .child(kind.label()), + .child(name.unwrap_or_else(|| SharedString::new_static(kind.label()))), ) } @@ -2222,7 +2252,11 @@ pub fn render_row( .w(px(TRACK_GUTTER)) .flex_none() .relative() - .child(track_chip(theme, row.kind)), + .child(track_chip( + theme, + row.kind, + model.track_names.get(&track_name_key(row)).cloned(), + )), ) .child( div() @@ -3865,6 +3899,38 @@ mod tests { const AUDIO_SAMPLE_RATE: usize = 48_000; + #[test] + fn lane_names_come_from_the_project_by_track_and_lane() { + let mut config = ProjectConfiguration::default(); + config.track_names.insert("audio:1".into(), "Music".into()); + config.track_names.insert("text:0".into(), " ".into()); + let lanes = TrackLanes { + style: 0, + image: 0, + caption: false, + keyboard: false, + scene: false, + three_d: false, + text: 1, + mask: 0, + audio: 2, + }; + let model = TimelineModel::build_with_lanes(&config, false, false, &lanes); + let audio = TrackRow { + kind: TrackKind::Audio, + lane: 1, + }; + assert_eq!(track_name_key(audio), "audio:1"); + assert_eq!( + model.track_names.get("audio:1").map(|n| n.as_ref()), + Some("Music") + ); + assert!( + !model.track_names.contains_key("text:0"), + "blank names are ignored" + ); + } + // -- The ruler ---------------------------------------------------------- #[test] diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 64edb658fe8..b9ec354ab6d 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -1592,6 +1592,9 @@ pub struct EditorWindow { /// exactly as the Solid dialogs replace the dropdown. presets_menu: Option, preset_dialog: Option, + /// The lane being renamed (double-click on its label), and the input. + track_rename: Option, + track_name_input: Entity, caption_sync_signature: Option, pub(crate) export: Option, pub(crate) sharing: Option, @@ -2023,6 +2026,23 @@ impl EditorWindow { _ => {} }, )); + let track_name_input = cx.new(|cx| { + let mut input = ui::TextInputState::single_line(window, cx); + input.set_placeholder("Track name"); + input + }); + text_events.push(cx.subscribe_in( + &track_name_input, + window, + |this: &mut Self, _input, event: &ui::TextInputEvent, window, cx| match event { + ui::TextInputEvent::Confirmed => this.commit_track_rename(window, cx), + ui::TextInputEvent::Cancelled => { + this.track_rename = None; + cx.notify(); + } + _ => {} + }, + )); let player_frame_rect = crate::editor_canvas::canvas_rect_cell(); let preview = cx.new(|_| PreviewFrameView::new(player_frame_rect.clone())); let editor = cx.entity(); @@ -2135,6 +2155,8 @@ impl EditorWindow { hex_inputs, picker_hex, preset_name_input, + track_rename: None, + track_name_input, fields: HashMap::new(), field_editing: None, _text_events: text_events, @@ -4748,14 +4770,23 @@ impl EditorWindow { edits::reorder_overlay_track(&mut self.project, &available, from, reorder.target_index) } else { rows.get(reorder.target_index).is_some_and(|target| { - self.project.timeline.as_mut().is_some_and(|timeline| { + let moved = self.project.timeline.as_mut().is_some_and(|timeline| { edits::reorder_track_lane( timeline, reorder.source.kind, reorder.source.lane, target.lane, ) - }) + }); + if moved { + edits::reorder_track_names( + &mut self.project.track_names, + reorder.source.kind, + reorder.source.lane, + target.lane, + ); + } + moved }) }; if changed { @@ -7379,6 +7410,146 @@ impl EditorWindow { cx.notify(); } + /// Double-click on a lane's label: rename it. An empty name restores the + /// track's own label. + fn open_track_rename(&mut self, row: TrackRow, window: &mut Window, cx: &mut Context) { + if !self.project_ready() { + return; + } + let current = self + .project + .track_names + .get(&timeline::track_name_key(row)) + .cloned() + .unwrap_or_else(|| row.kind.label().to_string()); + self.track_rename = Some(row); + self.track_name_input.update(cx, |input, cx| { + input.set_text(current, cx); + input.focus_and_select_all(window, cx); + }); + cx.notify(); + } + + fn commit_track_rename(&mut self, window: &mut Window, cx: &mut Context) { + let Some(row) = self.track_rename.take() else { + return; + }; + let name = self.track_name_input.read(cx).text().trim().to_string(); + let key = timeline::track_name_key(row); + let default = name.is_empty() || name == row.kind.label(); + // Written straight to the project: `edit_project` goes through an + // open style group's apply path, which keeps only style settings. + let track_names = &mut self.project.track_names; + let changed = if default { + track_names.remove(&key).is_some() + } else if track_names.get(&key) == Some(&name) { + false + } else { + track_names.insert(key, name); + true + }; + if changed && self.project_ready() { + self.project_changed(window, cx); + self.note_project_edit("track-rename"); + } + let focus = self.focus.clone(); + window.focus(&focus, cx); + cx.notify(); + } + + fn render_track_rename(&self, cx: &mut Context) -> Option { + self.track_rename?; + let theme = self.theme; + Some( + div() + .absolute() + .top_0() + .left_0() + .size_full() + .flex() + .items_center() + .justify_center() + .child( + div() + .id("track-rename-backdrop") + .absolute() + .top_0() + .left_0() + .size_full() + .bg(gpui::hsla(0., 0., 0., 0.5)) + .on_click(cx.listener(|this, _, _window, cx| { + this.track_rename = None; + cx.notify(); + })), + ) + .child( + div() + .w(px(360.)) + .flex() + .flex_col() + .gap(px(12.)) + .p(px(16.)) + .rounded(px(12.)) + .border_1() + .border_color(Hsla::from(theme.editor.line)) + .bg(Hsla::from(theme.editor.card)) + .shadow(theme.editor.pop_shadow()) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + div() + .text_size(px(14.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(Hsla::from(theme.editor.text_1)) + .child("Rename track"), + ) + .child( + ui::TextInput::plain(&theme, "track-name", &self.track_name_input) + .padding_x(px(12.)) + .height(px(36.)) + .radius(px(8.)) + .text_size(px(14.)), + ) + .child( + div() + .flex() + .flex_row() + .justify_end() + .gap(px(8.)) + .child( + ui::Button::plain( + &theme, + "track-rename-cancel", + ui::ButtonVariant::Gray, + ui::ButtonSize::Md, + ) + .label("Cancel") + .on_click(cx.listener( + |this, _, _window, cx| { + this.track_rename = None; + cx.notify(); + }, + )), + ) + .child( + ui::Button::plain( + &theme, + "track-rename-confirm", + ui::ButtonVariant::Blue, + ui::ButtonSize::Md, + ) + .label("Rename") + .on_click(cx.listener( + |this, _, window, cx| { + this.commit_track_rename(window, cx); + }, + )), + ), + ), + ) + .into_any_element(), + ) + } + fn scene_available(&self) -> bool { timeline::scene_available(&self.project, self.has_camera) } @@ -11282,7 +11453,9 @@ impl EditorWindow { MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, window, cx| { if f32::from(event.position.x) < timeline::content_left() { - if can_reorder { + if event.click_count == 2 { + this.open_track_rename(track_row, window, cx); + } else if can_reorder { this.begin_lane_reorder(track_row, event.position, window, cx); } else { this.select_lane_header(kind, lane, cx); @@ -12341,6 +12514,7 @@ impl Render for EditorWindow { .children(self.with_style_controls(|this| this.render_color_picker_popover(cx))) .children(self.render_presets_menu(cx)) .children(self.render_preset_dialog(cx)) + .children(self.render_track_rename(cx)) // The clips overlays: import menu, record modal, and the card // drag's window-wide layer with its floating ghost. .children(self.render_clips_overlays(cx)) diff --git a/crates/editor/src/audio.rs b/crates/editor/src/audio.rs index f793e41d67c..b5a07723ae6 100644 --- a/crates/editor/src/audio.rs +++ b/crates/editor/src/audio.rs @@ -1954,6 +1954,7 @@ fn audio_project_key(project: &ProjectConfiguration) -> Option> { color_correction: _, text_size_version: _, text_anim_version: _, + track_names: _, } = project; let timeline = timeline.as_ref().map(|timeline| { let TimelineConfiguration { diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index bb0a0491097..7c6aa37c5cc 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -2707,6 +2707,10 @@ pub struct ProjectConfiguration { /// durations from `fade_duration`. #[serde(default)] pub text_anim_version: u32, + /// Names the user gave timeline lanes, keyed `":"` (for + /// example `"audio:1"`). Lanes without one show the track's own label. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub track_names: std::collections::BTreeMap, } pub const TEXT_SIZE_VERSION: u32 = 1; @@ -2758,6 +2762,7 @@ impl Default for ProjectConfiguration { color_correction: Default::default(), text_size_version: TEXT_SIZE_VERSION, text_anim_version: TEXT_ANIM_VERSION, + track_names: Default::default(), } } }