From 16ae7c42dfebee0901aa27620bfc2e04dfbc1eb0 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 9 Aug 2026 21:11:57 +0800 Subject: [PATCH] gpui_widgets defect fixes: menu checkmarks, dock ratios, meter orientation, CPU frames, drop-frame timecode - menu: add runtime set-checked path (MenuItem::set_checked/clear_checked, Menu::set_item_checked recursive, MenuBar::set_item_checked) - dock: Split now stores per-child ratios (sum 1.0) so 3+ panels on one axis keep distinct sizes; resize_split_child adjusts a single boundary, resize_split keeps its two-arg whole-share semantics; one handle per boundary; DockLayoutState VERSION bumped to 2 for the new ratios field - audio_meter: MeterOrientation::Vertical for the 26px transport strip, segments lit bottom to top - viewer: ViewerFrameSource::CpuFrame + set_cpu_frame (BGRA8 RenderImage via the sprite atlas) for platforms without CVPixelBuffer - timeline: TimeDisplay::TimecodeDropFrame (SMPTE drop-frame for NTSC rates, non-drop fallback otherwise) - gpui_widgets: regression test pinning the fractional spacing helpers (py_0p5/py_1p5/py_2p5/py_3p5 already exist; no _0_5 aliases added) --- Cargo.lock | 2 + crates/gpui/src/dock/dock_area.rs | 104 ++++--- crates/gpui/src/dock/layout.rs | 405 +++++++++++++++++++++---- crates/gpui/src/dock/mod.rs | 9 +- crates/gpui/src/dock/split_handle.rs | 60 ++-- crates/gpui/src/timeline/time.rs | 184 ++++++++++- crates/gpui_widgets/Cargo.toml | 2 + crates/gpui_widgets/src/audio_meter.rs | 143 +++++++-- crates/gpui_widgets/src/lib.rs | 45 +++ crates/gpui_widgets/src/menu/mod.rs | 41 +++ crates/gpui_widgets/src/menu/model.rs | 69 +++++ crates/gpui_widgets/src/viewer/mod.rs | 101 +++++- 12 files changed, 1004 insertions(+), 161 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6cc20aba28..728207044a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,6 +2766,8 @@ dependencies = [ "gpui", "gpui_elements", "gpui_platform", + "image", + "smallvec", "thiserror 2.0.19", ] diff --git a/crates/gpui/src/dock/dock_area.rs b/crates/gpui/src/dock/dock_area.rs index ac7362b015..88b1477c53 100644 --- a/crates/gpui/src/dock/dock_area.rs +++ b/crates/gpui/src/dock/dock_area.rs @@ -110,10 +110,12 @@ pub struct DockArea { /// path. Re-created when the tree changes shape and pruned each render. /// The subscription keeps the strip's events routed back to this view. tab_bars: HashMap, Subscription)>, - /// One split-handle entity per `Split` node, keyed by the node's current - /// path. Holds transient drag state (`SplitHandle::drag_origin`) across - /// frames; pruned with the tab bars each render. - split_handles: HashMap, Subscription)>, + /// One split-handle entity per boundary of each `Split` node, keyed by + /// the node's current path and the boundary index. Holds transient drag + /// state (`SplitHandle::drag_origin`) across frames; pruned with the tab + /// bars each render. A split with N children keeps N-1 handles so every + /// pair of panels can be resized independently. + split_handles: HashMap<(NodePath, usize), (Entity, Subscription)>, } impl DockArea { @@ -655,60 +657,84 @@ impl DockArea { } /// Gets (creating and subscribing on first use) the split-handle entity - /// for the `Split` node at `path`. + /// for the boundary at `index` of the `Split` node at `path`. fn split_handle_for( &mut self, path: &NodePath, + index: usize, direction: Axis, cx: &mut Context, ) -> Entity { - if let Some((handle, _)) = self.split_handles.get(path) { + if let Some((handle, _)) = self.split_handles.get(&(path.clone(), index)) { return handle.clone(); } - let handle = cx.new(|_cx| SplitHandle::new(direction, path.clone())); + let handle = cx.new(|_cx| SplitHandle::new(direction, path.clone(), index)); let subscription = cx.subscribe(&handle, |this, _handle, event: &SplitHandleEvent, cx| { match event { - SplitHandleEvent::ResizeRequested { path, ratio } => { - this.layout.resize_split(path, *ratio); + SplitHandleEvent::ResizeRequested { path, index, ratio } => { + this.layout.resize_split_child(path, *index, *ratio); this.emit_layout_changed(cx); } - SplitHandleEvent::ResetRequested { path } => { - this.layout.resize_split(path, SplitHandle::RESET_RATIO); + SplitHandleEvent::ResetRequested { path, index } => { + this.layout.resize_split_child(path, *index, SplitHandle::RESET_RATIO); this.emit_layout_changed(cx); } } }); - self.split_handles.insert(path.clone(), (handle.clone(), subscription)); + self.split_handles + .insert((path.clone(), index), (handle.clone(), subscription)); handle } - /// Routes a split-handle drag to the handle entity for `path`. + /// Routes a split-handle drag to the handle entity for the boundary at + /// `index` of the split at `path`. fn route_split_drag( &mut self, path: &NodePath, + index: usize, direction: Axis, event: &DragMoveEvent, cx: &mut Context, ) { - let Some((handle, _)) = self.split_handles.get(path) else { + let Some((handle, _)) = self.split_handles.get(&(path.clone(), index)) else { return; }; - let start_ratio = self.layout.split_ratio(path).unwrap_or(SplitHandle::RESET_RATIO); - let extent = match direction { + // The handle drag works on the pair's own extent: start_ratio is the + // index child's share of the pair, and the drag delta is a fraction + // of the pair's combined on-screen extent. + let ratios = self.layout.split_ratios(path).unwrap_or_default(); + let total: f32 = ratios.iter().sum(); + let pair_total = ratios.get(index).copied().unwrap_or(0.0) + + ratios.get(index + 1).copied().unwrap_or(0.0); + let start_ratio = if pair_total > 0.0 { + ratios[index] / pair_total + } else { + SplitHandle::RESET_RATIO + }; + let full_extent = match direction { Axis::Horizontal => event.bounds.size.width, Axis::Vertical => event.bounds.size.height, }; + let pair_extent = full_extent * pair_total / total.max(1.0); let position = match direction { Axis::Horizontal => event.event.position.x, Axis::Vertical => event.event.position.y, }; let handle = handle.clone(); - handle.update(cx, |handle, cx| handle.drag_to(position, extent, start_ratio, cx)); + handle.update(cx, |handle, cx| { + handle.drag_to(position, pair_extent, start_ratio, cx) + }); } - /// Ends a split-handle drag on the handle entity for `path`. - fn end_split_drag(&mut self, path: &NodePath, _drag: &SplitHandleDrag, cx: &mut Context) { - if let Some((handle, _)) = self.split_handles.get(path) { + /// Ends a split-handle drag on the handle entity for the boundary at + /// `index` of the split at `path`. + fn end_split_drag( + &mut self, + path: &NodePath, + drag: &SplitHandleDrag, + cx: &mut Context, + ) { + if let Some((handle, _)) = self.split_handles.get(&(path.clone(), drag.index)) { let handle = handle.clone(); handle.update(cx, |handle, _cx| handle.end_drag()); } @@ -759,7 +785,7 @@ impl DockArea { match node { DockNode::Split { direction, - ratio, + ratios, children, } => { let direction = *direction; @@ -770,8 +796,10 @@ impl DockArea { .id(ElementId::named_usize("dock-split", path_key(path))) .on_drag_move::( cx.listener(move |this, event: &DragMoveEvent, _window, cx| { - let path = event.drag(cx).path.clone(); - this.route_split_drag(&path, direction, event, cx); + let drag = event.drag(cx); + let path = drag.path.clone(); + let index = drag.index; + this.route_split_drag(&path, index, direction, event, cx); }), ) .on_drop::( @@ -791,19 +819,22 @@ impl DockArea { let mut child_path = path.clone(); child_path.0.push(index); let child = self.render_node(child, &child_path, cx); - // The first child is sized by the split's ratio; the rest - // share the remainder equally. - let child = if index == 0 { - child - .flex_basis(relative(*ratio)) - .flex_grow_0() - .flex_shrink_0() - } else { - child.flex_1() - }; + // Each child is sized by its own ratio (the entries sum to + // 1.0), so a split with three or more panels keeps + // distinct sizes instead of flattening to one ratio. + let share = ratios + .get(index) + .copied() + .unwrap_or(1.0 / children.len() as f32); + let child = child + .flex_basis(relative(share)) + .flex_grow_0() + .flex_shrink_0(); container = container.child(child); - if index == 0 && children.len() > 1 { - let handle = self.split_handle_for(path, direction, cx); + // One handle per boundary, so every pair of panels can be + // resized independently. + if index + 1 < children.len() { + let handle = self.split_handle_for(path, index, direction, cx); container = container.child(handle); } } @@ -910,7 +941,8 @@ impl Render for DockArea { let mut live: HashSet = HashSet::new(); self.collect_paths(&self.layout, &NodePath::default(), &mut live); self.tab_bars.retain(|path, _| live.contains(path)); - self.split_handles.retain(|path, _| live.contains(path)); + self.split_handles + .retain(|(path, _), _| live.contains(path)); if let Some(indicator) = self.render_drop_indicator(window, cx) { root = root.child(indicator); diff --git a/crates/gpui/src/dock/layout.rs b/crates/gpui/src/dock/layout.rs index 0e268a94d5..5edbbc8338 100644 --- a/crates/gpui/src/dock/layout.rs +++ b/crates/gpui/src/dock/layout.rs @@ -29,8 +29,9 @@ use std::hash::{DefaultHasher, Hash, Hasher}; /// /// - `Split.children` has at least two entries and contains no direct /// `Split` child with the same `direction` (such nests are flattened). -/// - `Split.ratio` is finite and clamped to `(0.0, 1.0)` exclusive; see -/// [`DockLayout::resize_split`] for clamping against minimum panel sizes. +/// - `Split.ratios` has exactly one entry per child, each strictly positive, +/// and the entries sum to `1.0` (they are the fraction of the parent extent +/// given to each child; see [`DockLayout::resize_split_child`]). /// - `Tabs.panels` is non-empty and `Tabs.active < panels.len()`. /// - Every [`PanelId`] occurs at most once in the whole tree. /// @@ -43,12 +44,13 @@ pub enum DockNode { /// The axis along which children are laid out: [`Axis::Horizontal`] /// places children side by side, [`Axis::Vertical`] stacks them. direction: Axis, - /// Fraction of the available extent (along `direction`) assigned to - /// the first child, relative to the remaining children. For two - /// children this is simply the first child's share. Adjusted by - /// dragging a split handle; see - /// [`DockLayout::resize_split`]. - ratio: f32, + /// The fraction of the available extent (along `direction`) assigned + /// to each child, in child order. Entries sum to `1.0`, so a split + /// with any number of panels can express distinct sizes (e.g. + /// `[0.5, 0.3, 0.2]` for three panels) instead of flattening to a + /// single shared ratio. Adjusted by dragging a split handle; see + /// [`DockLayout::resize_split_child`]. + ratios: Vec, /// The children, in layout order. Never empty, never a single child, /// and never contains a nested `Split` with the same `direction`. children: Vec, @@ -318,14 +320,16 @@ impl DockLayout { if let DockNode::Split { direction, + ratios, children, - .. } = &mut root { if *direction == axis { if before { + split_ratio_at(ratios, 0); children.insert(0, DockNode::Panel(panel)); } else { + split_ratio_at(ratios, ratios.len() - 1); children.push(DockNode::Panel(panel)); } self.root = Some(root); @@ -335,7 +339,7 @@ impl DockLayout { self.root = Some(DockNode::Split { direction: axis, - ratio: 0.5, + ratios: vec![0.5, 0.5], children: if before { vec![DockNode::Panel(panel), root] } else { @@ -356,18 +360,22 @@ impl DockLayout { let before = matches!(zone, DropZone::Left | DropZone::Top); // If the target sits inside a split along the same axis, insert the - // panel as a sibling rather than nesting a split inside a split. + // panel as a sibling rather than nesting a split inside a split. The + // new panel splits the target child's share in half, so every child + // keeps a distinct, independently resizable ratio. let mut parent = path.0.clone(); if parent.pop().is_some() { let parent_path = NodePath(parent); if let Some(DockNode::Split { direction, + ratios, children, .. }) = self.node_at_mut(&parent_path) { if *direction == axis { let index = *path.0.last().expect("non-root path has a last index"); + split_ratio_at(ratios, index); children.insert( if before { index } else { index + 1 }, DockNode::Panel(panel), @@ -385,7 +393,7 @@ impl DockLayout { let new_node = DockNode::Panel(panel); let replacement = DockNode::Split { direction: axis, - ratio: 0.5, + ratios: vec![0.5, 0.5], children: if before { vec![new_node, old_node] } else { @@ -449,9 +457,32 @@ impl DockLayout { } true } - DockNode::Split { children, .. } => children - .iter_mut() - .any(|child| Self::remove_from_node(child, panel)), + DockNode::Split { + ratios, children, .. + } => { + for (index, child) in children.iter_mut().enumerate() { + // Only a direct `Panel` leaf (or an emptied node below) + // is dropped from this split; the collapsed child's ratio + // is dropped with it and the rest renormalized so the + // freed space is redistributed proportionally. + let is_direct_leaf = matches!(child, DockNode::Panel(_)); + let removed = match child { + DockNode::Panel(id) => *id == panel, + other => Self::remove_from_node(other, panel), + }; + if removed { + if is_direct_leaf { + children.remove(index); + if index < ratios.len() { + ratios.remove(index); + } + renormalize_ratios(ratios); + } + return true; + } + } + false + } } } @@ -479,27 +510,72 @@ impl DockLayout { inserted } - /// Sets the `ratio` of the `Split` node at `path`. + /// Sets the share of the first child of the `Split` node at `path` and + /// redistributes the remaining extent proportionally among the other + /// children, keeping the `Split.ratios` sum at `1.0`. /// - /// `ratio` is clamped so that no child shrinks below its minimum extent - /// (see the min-size constants in - /// the `split_handle` module); out-of-range values are - /// clamped rather than rejected. + /// This two-argument form is kept for source compatibility with callers + /// that only resize a two-panel split (where "first child's share" and + /// "share of the first pair" coincide). For splits with three or more + /// children — or when only one boundary should move — use + /// [`resize_split_child`](Self::resize_split_child), which adjusts a + /// single pair without touching the others. /// /// # Panics /// /// Panics if `path` does not address a [`DockNode::Split`]. pub fn resize_split(&mut self, path: &NodePath, ratio: f32) { + let ratio = ratio.clamp(0.05, 0.95); let Some(node) = self.node_at_mut(path) else { panic!("resize_split: path {path:?} does not address a node"); }; - let DockNode::Split { ratio: current, .. } = node else { + let DockNode::Split { ratios, .. } = node else { panic!("resize_split: path {path:?} does not address a Split node"); }; - // Pixel-level minimum extents (see split_handle::MIN_CHILD_EXTENT) - // depend on the rendered size and cannot be enforced on a ratio; - // clamp to a conservative fraction so both children keep a share. - *current = ratio.clamp(0.05, 0.95); + let rest: f32 = ratios.iter().skip(1).sum(); + let Some(first) = ratios.first_mut() else { + panic!("resize_split: split at {path:?} has no children"); + }; + *first = ratio; + if rest > 0.0 { + let scale = (1.0 - ratio) / rest; + for share in ratios.iter_mut().skip(1) { + *share *= scale; + } + } + } + + /// Sets the share of child `index` within its pair (children `index` and + /// `index + 1`) of the `Split` node at `path`. + /// + /// `ratio` is the fraction of the pair's combined extent given to child + /// `index` (so `0.5` makes the pair even); the two children's entries are + /// rewritten proportionally and the rest of the split is untouched, so + /// each panel keeps its own distinct ratio even when the split has three + /// or more children. `ratio` is clamped to `[0.05, 0.95]` so neither + /// child can be squeezed out entirely; pixel-level minimum extents (see + /// the min-size constants in `split_handle`) are enforced by the caller. + /// + /// # Panics + /// + /// Panics if `path` does not address a [`DockNode::Split`], or if + /// `index` is not a boundary between two children. + pub fn resize_split_child(&mut self, path: &NodePath, index: usize, ratio: f32) { + let Some(node) = self.node_at_mut(path) else { + panic!("resize_split: path {path:?} does not address a node"); + }; + let DockNode::Split { ratios, .. } = node else { + panic!("resize_split: path {path:?} does not address a Split node"); + }; + assert!( + index + 1 < ratios.len(), + "resize_split_child: boundary {index} out of range for a split with {} children", + ratios.len(), + ); + let ratio = ratio.clamp(0.05, 0.95); + let pair = ratios[index] + ratios[index + 1]; + ratios[index] = ratio * pair; + ratios[index + 1] = (1.0 - ratio) * pair; } /// Re-establishes the [`DockNode`] invariants after structural edits. @@ -529,37 +605,50 @@ impl DockLayout { } DockNode::Split { direction, - ratio, + mut ratios, children, } => { - let children: Vec = children - .into_iter() - .filter_map(Self::cleanup_node) - .collect(); - // Flatten direct same-direction split nests. - let mut flat = Vec::with_capacity(children.len()); - for child in children { + let mut clean_children = Vec::new(); + let mut clean_ratios = Vec::new(); + for (index, child) in children.into_iter().enumerate() { + // A child that collapsed away (empty tabs, removed leaf) + // frees its share; the rest are renormalized below. + let Some(child) = Self::cleanup_node(child) else { + continue; + }; + let child_share = ratios.get(index).copied().unwrap_or(0.5); match child { DockNode::Split { direction: nested_direction, + ratios: nested_ratios, children: nested_children, - .. - } if nested_direction == direction => flat.extend(nested_children), - other => flat.push(other), + } if nested_direction == direction => { + // Flatten direct same-direction split nests, + // scaling the nested ratios by this child's share + // so the relative proportions are preserved. + for (nested_child, nested_ratio) in + nested_children.into_iter().zip(nested_ratios) + { + clean_children.push(nested_child); + clean_ratios.push(child_share * nested_ratio); + } + } + other => { + clean_children.push(other); + clean_ratios.push(child_share); + } } } - let ratio = if ratio.is_finite() { - ratio.clamp(0.05, 0.95) - } else { - 0.5 - }; - match flat.len() { + // Re-establish the "sum to 1" invariant: drops collapsed + // children's freed space proportionally and absorbs drift. + renormalize_ratios(&mut clean_ratios); + match clean_children.len() { 0 => None, - 1 => Some(flat.pop().expect("len == 1")), + 1 => Some(clean_children.pop().expect("len == 1")), _ => Some(DockNode::Split { direction, - ratio, - children: flat, + ratios: clean_ratios, + children: clean_children, }), } } @@ -587,11 +676,11 @@ impl DockLayout { } } - /// Returns the `ratio` of the `Split` node at `path`, or `None` if `path` - /// does not address a split. - pub fn split_ratio(&self, path: &NodePath) -> Option { + /// Returns the per-child ratios of the `Split` node at `path`, or `None` + /// if `path` does not address a split. The entries sum to `1.0`. + pub fn split_ratios(&self, path: &NodePath) -> Option> { match self.node_at(path) { - Some(DockNode::Split { ratio, .. }) => Some(*ratio), + Some(DockNode::Split { ratios, .. }) => Some(ratios.clone()), _ => None, } } @@ -745,8 +834,8 @@ enum SerializedNode { Split { /// See [`DockNode::Split::direction`]. direction: Axis, - /// See [`DockNode::Split::ratio`]. - ratio: f32, + /// See [`DockNode::Split::ratios`]. + ratios: Vec, /// See [`DockNode::Split::children`]. children: Vec, }, @@ -764,7 +853,11 @@ enum SerializedNode { impl DockLayoutState { /// The snapshot format version written by /// [`capture`](DockLayoutState::capture). - pub const VERSION: u32 = 1; + /// + /// v2: `Split` nodes store a per-child `ratios` vector instead of the + /// single `ratio` of v1. Snapshots written with v1 cannot be read by a + /// v2 reader; reject stale versions before restoring. + pub const VERSION: u32 = 2; /// Snapshots `layout`, translating panel ids to string keys via /// `registry`. @@ -811,7 +904,7 @@ impl DockLayoutState { } DockNode::Split { direction, - ratio, + ratios, children, } => { let children: Vec = children @@ -823,7 +916,7 @@ impl DockLayoutState { 1 => children.into_iter().next(), _ => Some(SerializedNode::Split { direction: *direction, - ratio: *ratio, + ratios: ratios.clone(), children, }), } @@ -860,7 +953,7 @@ impl DockLayoutState { } SerializedNode::Split { direction, - ratio, + ratios, children, } => { let children: Vec = children @@ -870,11 +963,19 @@ impl DockLayoutState { match children.len() { 0 => None, 1 => children.into_iter().next(), - _ => Some(DockNode::Split { - direction: *direction, - ratio: *ratio, - children, - }), + _ => { + // Evenly sized by default; `cleanup` (run by the + // caller) renormalizes and reconciles lengths. + let mut ratios = ratios.clone(); + if ratios.len() != children.len() { + ratios = vec![1.0 / children.len() as f32; children.len()]; + } + Some(DockNode::Split { + direction: *direction, + ratios, + children, + }) + } } } } @@ -916,3 +1017,187 @@ pub(crate) fn interim_id(key: &str) -> PanelId { key.hash(&mut hasher); PanelId::new(hasher.finish()) } + +/// Splits the share of child `index` in half and inserts the new child's +/// share right next to it (at position `index`), keeping the sum at `1.0`. +/// +/// Used when a panel becomes a sibling of an existing child in a same-axis +/// split: the newcomer takes half of the target child's extent, and the +/// target keeps the other half. +fn split_ratio_at(ratios: &mut Vec, index: usize) { + let half = ratios[index] / 2.0; + ratios[index] = half; + ratios.insert(index, half); +} + +/// Normalizes `ratios` to sum to `1.0`, so freed shares (from removed or +/// collapsed children) are redistributed proportionally and float drift is +/// absorbed. No-op for an empty or all-zero vector. +fn renormalize_ratios(ratios: &mut Vec) { + let sum: f32 = ratios.iter().sum(); + if sum > 0.0 { + for ratio in ratios.iter_mut() { + *ratio /= sum; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Axis; + + fn panel(id: u64) -> DockNode { + DockNode::Panel(PanelId::new(id)) + } + + /// Builds `[a] → [a | b] → [a | b | c]` all on one axis, exercising the + /// sibling-insert path that used to flatten to a single shared ratio. + fn three_panel_horizontal_layout() -> DockLayout { + let mut layout = DockLayout::new(); + assert!(layout.insert_panel(PanelId::new(1), None)); + assert!(layout.insert_panel( + PanelId::new(2), + Some(DropTarget { panel: Some(PanelId::new(1)), zone: DropZone::Right }) + )); + assert!(layout.insert_panel( + PanelId::new(3), + Some(DropTarget { panel: Some(PanelId::new(2)), zone: DropZone::Right }) + )); + layout + } + + #[test] + fn three_panels_on_one_axis_keep_distinct_ratios() { + let layout = three_panel_horizontal_layout(); + let root = layout.root().unwrap(); + let DockNode::Split { direction, ratios, children } = root else { + panic!("expected a single split at the root"); + }; + assert_eq!(*direction, Axis::Horizontal); + assert_eq!(children.len(), 3); + // Inserting c to the right of b halves b's share: [1/2, 1/4, 1/4]. + assert_eq!(ratios, &vec![0.5, 0.25, 0.25]); + let sum: f32 = ratios.iter().sum(); + assert!((sum - 1.0).abs() < 1e-6, "ratios must sum to 1, got {sum}"); + } + + #[test] + fn resize_split_child_adjusts_only_the_target_pair() { + let mut layout = three_panel_horizontal_layout(); + let path = NodePath::default(); + // Give the second panel 2/3 of its pair with the third: + // pair = [0.25, 0.25] → scaled so the second panel holds 2/3. + layout.resize_split_child(&path, 1, 2.0 / 3.0); + let ratios = layout.split_ratios(&path).unwrap(); + // The first panel's share is untouched. + assert!((ratios[0] - 0.5).abs() < 1e-6); + assert!((ratios[1] - 1.0 / 3.0).abs() < 1e-6); + assert!((ratios[2] - 1.0 / 6.0).abs() < 1e-6); + let sum: f32 = ratios.iter().sum(); + assert!((sum - 1.0).abs() < 1e-6); + } + + #[test] + fn resize_split_is_source_compatible_and_affects_first_child() { + let mut layout = three_panel_horizontal_layout(); + // The old two-argument form gives the first child its share of the + // whole and redistributes the rest proportionally: [1/2, 1/4, 1/4] + // with ratio 0.7 → [0.7, 0.15, 0.15]. + layout.resize_split(&NodePath::default(), 0.7); + let ratios = layout.split_ratios(&NodePath::default()).unwrap(); + assert!((ratios[0] - 0.7).abs() < 1e-6); + assert!((ratios[1] - 0.15).abs() < 1e-6); + assert!((ratios[2] - 0.15).abs() < 1e-6); + } + + #[test] + fn remove_panel_renormalizes_remaining_ratios() { + let mut layout = three_panel_horizontal_layout(); + // [1/2, 1/4, 1/4] → remove panel 2 → [1/2, 1/4] renormalized to [2/3, 1/3]. + assert!(layout.remove_panel(PanelId::new(2))); + let root = layout.root().unwrap(); + let DockNode::Split { ratios, children, .. } = root else { + panic!("expected a split after removal"); + }; + assert_eq!(children.len(), 2); + assert!((ratios[0] - 2.0 / 3.0).abs() < 1e-6); + assert!((ratios[1] - 1.0 / 3.0).abs() < 1e-6); + } + + #[test] + fn cleanup_flattens_same_axis_nest_and_scales_ratios() { + // Hand-construct a same-axis nest: root [a | inner], where inner is + // itself a horizontal split [b | c] with ratios [3/4, 1/4]. + let mut layout = DockLayout::new(); + layout.root = Some(DockNode::Split { + direction: Axis::Horizontal, + ratios: vec![0.5, 0.5], + children: vec![ + panel(1), + DockNode::Split { + direction: Axis::Horizontal, + ratios: vec![0.75, 0.25], + children: vec![panel(2), panel(3)], + }, + ], + }); + layout.cleanup(); + let root = layout.root().unwrap(); + let DockNode::Split { ratios, children, .. } = root else { + panic!("expected a flattened split at the root"); + }; + assert_eq!(children.len(), 3); + // Inner ratios scaled by the inner node's share: [0.5, 0.375, 0.125]. + assert!((ratios[0] - 0.5).abs() < 1e-6); + assert!((ratios[1] - 0.375).abs() < 1e-6); + assert!((ratios[2] - 0.125).abs() < 1e-6); + } + + #[test] + fn layout_state_round_trips_distinct_ratios() { + let mut layout = three_panel_horizontal_layout(); + layout.resize_split_child(&NodePath::default(), 1, 0.8); + let registry = TestRegistry; + let state = DockLayoutState::capture(&layout, ®istry); + assert_eq!(state.version, DockLayoutState::VERSION); + let restored = state.to_layout(); + // Panel ids are re-derived from registry keys on restore, so compare + // the tree *shape* (direction, ratios, structure) rather than ids. + let DockNode::Split { + direction, + ratios, + children, + } = restored.root().unwrap() + else { + panic!("expected a split at the restored root"); + }; + assert_eq!(*direction, Axis::Horizontal); + let original_ratios = layout.split_ratios(&NodePath::default()).unwrap(); + assert_eq!(ratios, &original_ratios); + assert_eq!(children.len(), 3); + } + + #[test] + fn insert_before_splits_the_target_child() { + let mut layout = three_panel_horizontal_layout(); + // [1/2, 1/4, 1/4]; inserting d to the LEFT of panel 2 halves panel 2's + // share and puts d in front of it. + assert!(layout.insert_panel( + PanelId::new(4), + Some(DropTarget { panel: Some(PanelId::new(2)), zone: DropZone::Left }) + )); + let ratios = layout.split_ratios(&NodePath::default()).unwrap(); + assert_eq!(ratios, vec![0.5, 0.125, 0.125, 0.25]); + } + + struct TestRegistry; + impl PanelRegistry for TestRegistry { + fn panel_key(&self, id: PanelId) -> Option { + Some(format!("panel-{}", id.raw())) + } + fn build_panel(&self, _key: &str, _window: &mut Window, _cx: &mut App) -> Option { + None + } + } +} diff --git a/crates/gpui/src/dock/mod.rs b/crates/gpui/src/dock/mod.rs index 53b73f23c2..98a1b9995a 100644 --- a/crates/gpui/src/dock/mod.rs +++ b/crates/gpui/src/dock/mod.rs @@ -10,10 +10,11 @@ //! The layout of a [`DockArea`](crate::dock::DockArea) is an immutable-by-convention tree of //! [`DockNode`](crate::dock::DockNode)s: //! -//! - `Split { direction, ratio, children }` — a row or column of child nodes, -//! sized proportionally. `ratio` is the fraction of the cross axis given to -//! the first child; with more than two children it is the fraction given to -//! the first child relative to the rest. See [`DockLayout::resize_split`](crate::dock::DockLayout::resize_split). +//! - `Split { direction, ratios, children }` — a row or column of child nodes, +//! sized proportionally. `ratios` gives each child its own share of the +//! extent (the entries sum to 1), so a split with any number of panels can +//! keep distinct sizes; each boundary is resized independently by dragging +//! its handle. See [`DockLayout::resize_split_child`](crate::dock::DockLayout::resize_split_child). //! - `Tabs { panels, active }` — a tab group showing one panel at a time, //! with a tab strip (the internal `tab_bar` component) for switching, //! closing, and reordering. diff --git a/crates/gpui/src/dock/split_handle.rs b/crates/gpui/src/dock/split_handle.rs index 7d101cfd00..6dd8468cc1 100644 --- a/crates/gpui/src/dock/split_handle.rs +++ b/crates/gpui/src/dock/split_handle.rs @@ -18,19 +18,24 @@ use super::{NodePath, path_key}; /// Events emitted by a [`SplitHandle`] toward its owning [`DockArea`]. #[derive(Clone, Debug)] pub(crate) enum SplitHandleEvent { - /// The user dragged the handle; the split at `path` should be resized to - /// `ratio`. + /// The user dragged the handle; the boundary at `index` of the split at + /// `path` should be resized to `ratio` (child `index`'s share of the + /// pair, already clamped to the allowed range). ResizeRequested { /// Path of the split node to resize. path: NodePath, + /// The boundary being moved: between children `index` and `index + 1`. + index: usize, /// Desired new ratio, already clamped to the allowed range. ratio: f32, }, - /// The user double-clicked the handle; the split at `path` should be - /// reset to [`SplitHandle::RESET_RATIO`]. + /// The user double-clicked the handle; the boundary at `index` of the + /// split at `path` should be reset to [`SplitHandle::RESET_RATIO`]. ResetRequested { - /// Path of the split node to reset. + /// Path of the split node to resize. path: NodePath, + /// The boundary being moved: between children `index` and `index + 1`. + index: usize, }, } @@ -40,6 +45,8 @@ pub(crate) enum SplitHandleEvent { pub(crate) struct SplitHandleDrag { /// Path of the split being resized. pub(crate) path: NodePath, + /// The boundary being dragged: between children `index` and `index + 1`. + pub(crate) index: usize, } /// A resize handle between two children of a split node. @@ -53,14 +60,18 @@ pub(crate) struct SplitHandleDrag { /// parent extent → fraction) and emits /// [`SplitHandleEvent::ResizeRequested`]; the owning /// [`DockArea`](crate::dock::DockArea) applies it via -/// [`DockLayout::resize_split`], clamping so neither side shrinks below +/// [`DockLayout::resize_split_child`](crate::dock::DockLayout::resize_split_child), +/// clamping so neither side shrinks below /// [`SplitHandle::MIN_CHILD_EXTENT`]. /// - Double-clicking emits [`SplitHandleEvent::ResetRequested`] to reset the -/// split to an even 50/50. +/// boundary to an even 50/50. /// -/// The handle carries the [`NodePath`] of its split so it can address the -/// correct node after unrelated edits elsewhere in the tree; paths are -/// re-derived on every render, never stored across frames. +/// A split with N children renders N-1 handles (one per boundary), so each +/// pair of panels can be resized independently while the others keep their +/// ratios. The handle carries the [`NodePath`] of its split and the boundary +/// `index` so it can address the correct node after unrelated edits elsewhere +/// in the tree; paths are re-derived on every render, never stored across +/// frames. pub(crate) struct SplitHandle { /// Axis along which the parent split lays out its children; the handle /// itself extends along the perpendicular axis. @@ -68,6 +79,8 @@ pub(crate) struct SplitHandle { /// Path of the split node this handle resizes, valid for the current /// frame only. path: NodePath, + /// Boundary this handle moves: between children `index` and `index + 1`. + index: usize, /// Pointer position where the current drag started, if dragging. drag_origin: Option, } @@ -86,11 +99,12 @@ impl SplitHandle { /// The ratio a double-click resets to (even split). pub(crate) const RESET_RATIO: f32 = 0.5; - /// Creates a handle for the split at `path`. - pub(crate) fn new(direction: Axis, path: NodePath) -> Self { + /// Creates a handle for the boundary at `index` of the split at `path`. + pub(crate) fn new(direction: Axis, path: NodePath, index: usize) -> Self { Self { direction, path, + index, drag_origin: None, } } @@ -101,33 +115,35 @@ impl SplitHandle { } /// Applies an in-progress drag: converts the pointer delta to a ratio - /// delta relative to the parent extent and emits a + /// delta relative to the pair extent and emits a /// [`SplitHandleEvent::ResizeRequested`]. /// - /// `start_ratio` is the split's ratio at drag start, re-read from the - /// layout by the owning dock area on every move so external edits during - /// the drag are respected. + /// `start_ratio` is the share of the `index` child within its pair at + /// drag start, re-read from the layout by the owning dock area on every + /// move so external edits during the drag are respected; `pair_extent` is + /// the combined on-screen extent of the two children, in pixels. pub(crate) fn drag_to( &mut self, position: Pixels, - parent_extent: Pixels, + pair_extent: Pixels, start_ratio: f32, cx: &mut Context, ) { let Some(origin) = self.drag_origin else { return; }; - if parent_extent.0 <= 0.0 { + if pair_extent.0 <= 0.0 { return; } // Keep both children above MIN_CHILD_EXTENT, but never clamp harder - // than a quarter of the parent so tiny parents stay resizable. - let min_ratio = (Self::MIN_CHILD_EXTENT.0 / parent_extent.0).min(0.25); + // than a quarter of the pair so tiny parents stay resizable. + let min_ratio = (Self::MIN_CHILD_EXTENT.0 / pair_extent.0).min(0.25); let max_ratio = 1.0 - min_ratio; - let ratio = (start_ratio + (position.0 - origin.0) / parent_extent.0) + let ratio = (start_ratio + (position.0 - origin.0) / pair_extent.0) .clamp(min_ratio, max_ratio); cx.emit(SplitHandleEvent::ResizeRequested { path: self.path.clone(), + index: self.index, ratio, }); cx.notify(); @@ -142,6 +158,7 @@ impl SplitHandle { pub(crate) fn reset(&mut self, cx: &mut Context) { cx.emit(SplitHandleEvent::ResetRequested { path: self.path.clone(), + index: self.index, }); cx.notify(); } @@ -201,6 +218,7 @@ impl Render for SplitHandle { root.on_drag( SplitHandleDrag { path: self.path.clone(), + index: self.index, }, ghost_ctor, ) diff --git a/crates/gpui/src/timeline/time.rs b/crates/gpui/src/timeline/time.rs index 7fdc8d0fa3..ad2f6ebc3a 100644 --- a/crates/gpui/src/timeline/time.rs +++ b/crates/gpui/src/timeline/time.rs @@ -192,14 +192,21 @@ impl FrameRange { /// How time should be presented to the user in rulers and inspectors. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] pub enum TimeDisplay { - /// `HH:MM:SS:FF` timecode. This is the default and the standard in - /// professional video editing. + /// `HH:MM:SS:FF` non-drop-frame timecode, the standard for integer frame + /// rates (24, 25, 30, 50, 60) and the fallback for NTSC rates when + /// wall-clock alignment is not required. /// - /// Drop-frame timecode (`;` separator, frame-number skipping for NTSC - /// rates) is **not implemented yet**; [`format_timecode`] currently - /// always produces non-drop-frame timecode. See its documentation. + /// This is the default and the professional-video standard for integer + /// rates. #[default] Timecode, + /// `HH:MM:SS;FF` SMPTE drop-frame timecode for NTSC-derived rates + /// (29.97, 23.976, 59.94, 119.88): frame numbers `00`/`01` (or `00`..`03` + /// at 59.94/119.88) are skipped at the start of every minute except the + /// tenth, keeping the timecode in lockstep with wall-clock time. For + /// non-NTSC rates [`format_timecode`] falls back to non-drop-frame + /// output. See [`format_timecode`] for the exact algorithm. + TimecodeDropFrame, /// A plain frame counter, e.g. `1048576`. Frames, /// Seconds with millisecond precision, e.g. `83.708`. @@ -257,18 +264,35 @@ pub fn seconds_to_frame(rate: FrameRate, seconds: f64) -> Frame { /// * [`TimeDisplay::Timecode`] — non-drop-frame `HH:MM:SS:FF`. The frame /// component width derives from the frame rate (two digits for rates below /// 100 fps). +/// * [`TimeDisplay::TimecodeDropFrame`] — SMPTE drop-frame `HH:MM:SS;FF` +/// (semicolon separator). See below. /// /// # Drop-frame timecode /// -/// Drop-frame timecode (the `HH:MM:SS;FF` convention used with NTSC rates so -/// that timecode stays in lockstep with wall-clock time) is **future work**. -/// Calling this with [`TimeDisplay::Timecode`] and an NTSC rate such as -/// [`FrameRate::NTSC_2997`] currently yields non-drop-frame timecode, which -/// drifts from wall-clock time by about 3.6 seconds per hour. Callers that -/// need broadcast-correct labels must not rely on this function yet. +/// NTSC-derived rates (29.97, 23.976, 59.94, 119.88 — any rate whose +/// [`FrameRate`] denominator is `1001`) run slightly slower than their +/// nominal integer rate, so non-drop-frame timecode drifts from wall-clock +/// time (≈3.6 s/hour at 29.97). Drop-frame timecode compensates by skipping +/// frame numbers at the start of every minute except the tenth — two frames +/// (`00`, `01`) per skipped minute at 29.97/23.976, four at 59.94, eight at +/// 119.88 — so the label stays within a frame of wall-clock time. +/// +/// The conversion is the classic SMPTE algorithm: +/// +/// 1. Split the real frame count into whole 10-minute blocks (`d`) and a +/// remainder (`m`). Each full block contributes `drop * 9` skipped frames +/// (every minute of the block except the tenth). +/// 2. Within the remainder, each complete minute contributes `drop` skipped +/// frames. +/// 3. Add the total skipped count to the real frame count, then format the +/// result at the nominal rate with `;` before the frame component. +/// +/// Rates with a denominator other than `1001` have no drop-frame convention; +/// [`TimeDisplay::TimecodeDropFrame`] then falls back to non-drop-frame +/// output (the two variants produce identical strings). /// /// Negative frames are formatted with a leading `-` applied to the whole -/// timecode (e.g. `-00:00:01:12`). +/// timecode (e.g. `-00:00:01;12`). /// /// # Examples /// @@ -281,6 +305,11 @@ pub fn seconds_to_frame(rate: FrameRate, seconds: f64) -> Frame { /// "01:01:01:12", /// ); /// assert_eq!(format_timecode(Frame(42), rate, TimeDisplay::Frames), "42"); +/// +/// // NTSC 29.97: frame 1800 is just past the first minute boundary, where +/// // frames 00 and 01 of the minute are skipped. +/// let ntsc = FrameRate::NTSC_2997; +/// assert_eq!(format_timecode(Frame(1800), ntsc, TimeDisplay::TimecodeDropFrame), "00:01:00;02"); /// ``` pub fn format_timecode(frame: Frame, rate: FrameRate, display: TimeDisplay) -> String { match display { @@ -307,9 +336,64 @@ pub fn format_timecode(frame: Frame, rate: FrameRate, display: TimeDisplay) -> S frames ) } + TimeDisplay::TimecodeDropFrame => { + if rate.den != 1001 { + // There is no drop-frame convention outside NTSC-derived + // rates; fall back to the ordinary non-drop string (including + // its `:` separator) so the two variants agree. + return format_timecode(frame, rate, TimeDisplay::Timecode); + } + let negative = frame.0 < 0; + let n = frame.0.unsigned_abs(); + let (nominal, adjusted) = drop_frame_adjust(n, rate); + let mut n = adjusted; + let frames = n % nominal; + n /= nominal; + let seconds = n % 60; + n /= 60; + let minutes = n % 60; + let hours = n / 60; + format!( + "{}{:02}:{:02}:{:02};{:02}", + if negative { "-" } else { "" }, + hours, + minutes, + seconds, + frames + ) + } } } +/// Applies the SMPTE drop-frame adjustment to a real frame count at an +/// NTSC-derived rate. +/// +/// Returns `(nominal_fps, adjusted_count)`, where `adjusted_count` is the +/// timecode frame count with the skipped frame numbers re-inserted. +fn drop_frame_adjust(frame: u64, rate: FrameRate) -> (u64, u64) { + debug_assert!( + rate.den == 1001, + "drop-frame adjustment is only defined for NTSC-derived rates (denominator 1001)" + ); + let nominal = rate.as_f64().round() as u64; + + // Skipped frame numbers per non-10th minute: two per 30 fps of nominal + // rate (2 at 29.97/23.976, 4 at 59.94, 8 at 119.88). + let drop = ((nominal as f64) * 2.0 / 30.0).round() as u64; + // Real frame counts per minute and per 10 minutes at this rate. + let frames_per_10_min = (rate.as_f64() * 600.0).round() as u64; + let frames_per_min = (rate.as_f64() * 60.0).round() as u64; + + let ten_minute_blocks = frame / frames_per_10_min; + let within_block = frame % frames_per_10_min; + + let mut adjusted = frame + drop * 9 * ten_minute_blocks; + if within_block > drop { + adjusted += drop * ((within_block - drop) / frames_per_min); + } + (nominal, adjusted) +} + /// What produced a [`SnapPoint`]. Used by the UI to pick an indicator style /// and by tests to assert snapping priority. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -446,6 +530,82 @@ mod tests { assert!((frame_to_seconds(frame, rate) - 10.0).abs() < 0.02); } + #[test] + fn drop_frame_timecode_skips_minute_boundary_frames() { + let rate = FrameRate::NTSC_2997; + let tc = |frame| format_timecode(Frame(frame), rate, TimeDisplay::TimecodeDropFrame); + // Within the first minute nothing is dropped. + assert_eq!(tc(0), "00:00:00;00"); + assert_eq!(tc(30), "00:00:01;00"); + assert_eq!(tc(1798), "00:00:59;28"); + assert_eq!(tc(1799), "00:00:59;29"); + // Frame 1800 is just past the first minute boundary; frames 00 and 01 + // of that minute are skipped, so the label jumps to ...;02. + assert_eq!(tc(1800), "00:01:00;02"); + } + + #[test] + fn drop_frame_timecode_preserves_tenth_minute() { + let rate = FrameRate::NTSC_2997; + let tc = |frame| format_timecode(Frame(frame), rate, TimeDisplay::TimecodeDropFrame); + // 10 minutes of real time at 29.97 is 17982 frames; no frames are + // dropped at the start of the tenth minute. + assert_eq!(tc(17982), "00:10:00;00"); + assert_eq!(tc(17984), "00:10:00;02"); + // 20 minutes: two 10-minute blocks. + assert_eq!(tc(17982 * 2), "00:20:00;00"); + } + + #[test] + fn drop_frame_timecode_aligns_with_wall_clock_at_one_hour() { + // One real hour at 29.97 = 107892 frames; drop-frame timecode reads + // exactly 01:00:00;00 (non-drop would read 01:00:02;12, the ~3.6s/h + // drift the convention exists to cancel). + let rate = FrameRate::NTSC_2997; + assert_eq!( + format_timecode(Frame(107892), rate, TimeDisplay::TimecodeDropFrame), + "01:00:00;00" + ); + } + + #[test] + fn drop_frame_timecode_at_59_94_drops_four_frames() { + let rate = FrameRate::new(60000, 1001); + let tc = |frame| format_timecode(Frame(frame), rate, TimeDisplay::TimecodeDropFrame); + assert_eq!(tc(0), "00:00:00;00"); + // Real frames per minute at 59.94: round(3596.4) = 3596. Just past + // the first minute the four skipped numbers (00..03) are visible. + assert_eq!(tc(3596), "00:00:59;56"); + assert_eq!(tc(3600), "00:01:00;04"); + // One real hour at 59.94 = round(59.94 * 3600) = 215784 frames. + assert_eq!(tc(215784), "01:00:00;00"); + } + + #[test] + fn drop_frame_timecode_falls_back_for_non_ntsc_rates() { + // 24 fps is not NTSC-derived (denominator 1): drop-frame output must + // be identical to non-drop output. + let rate = FrameRate::new(24, 1); + let frame = Frame(24 * 3600 + 24 * 60 + 24 + 12); + assert_eq!( + format_timecode(frame, rate, TimeDisplay::TimecodeDropFrame), + format_timecode(frame, rate, TimeDisplay::Timecode), + ); + assert_eq!( + format_timecode(frame, rate, TimeDisplay::TimecodeDropFrame), + "01:01:01:12", + ); + } + + #[test] + fn drop_frame_timecode_handles_negative_frames() { + let rate = FrameRate::NTSC_2997; + assert_eq!( + format_timecode(Frame(-1800), rate, TimeDisplay::TimecodeDropFrame), + "-00:01:00;02", + ); + } + #[test] fn snap_disabled_by_zero_threshold() { let points = [SnapPoint { diff --git a/crates/gpui_widgets/Cargo.toml b/crates/gpui_widgets/Cargo.toml index 11f6a3633d..13bce46679 100644 --- a/crates/gpui_widgets/Cargo.toml +++ b/crates/gpui_widgets/Cargo.toml @@ -19,6 +19,8 @@ thiserror.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] } +image.workspace = true +smallvec.workspace = true [target.'cfg(target_os = "macos")'.dev-dependencies] core-video.workspace = true diff --git a/crates/gpui_widgets/src/audio_meter.rs b/crates/gpui_widgets/src/audio_meter.rs index da8a43a197..ba9352de3e 100644 --- a/crates/gpui_widgets/src/audio_meter.rs +++ b/crates/gpui_widgets/src/audio_meter.rs @@ -16,6 +16,20 @@ const SEGMENTS: usize = 16; /// Peak decay per frame (fraction of full scale). const PEAK_DECAY: f32 = 0.01; +/// The orientation of an [`AudioLevelMeter`]. +/// +/// A horizontal meter stacks channels vertically and lights segments left to +/// right; a vertical meter (e.g. the 26px strip in the Oak transport bar) +/// places channels side by side and lights segments bottom to top. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MeterOrientation { + /// Channels stacked vertically, segments lit left to right. + #[default] + Horizontal, + /// Channels side by side, segments lit bottom to top. + Vertical, +} + /// Provides per-channel levels in `0..1` (linear or dB-normalized). pub trait AudioMeterDataSource: 'static { /// The current level of each channel, `0..1`. @@ -27,6 +41,7 @@ pub struct AudioLevelMeter { data: Entity, focus_handle: FocusHandle, peak: Vec, + orientation: MeterOrientation, } impl AudioLevelMeter { @@ -41,9 +56,21 @@ impl AudioLevelMeter { data, focus_handle: cx.focus_handle(), peak: Vec::new(), + orientation: MeterOrientation::Horizontal, } } + /// Set the orientation (builder-style, callable after `new`). + pub fn with_orientation(mut self, orientation: MeterOrientation) -> Self { + self.orientation = orientation; + self + } + + /// The current orientation. + pub fn orientation(&self) -> MeterOrientation { + self.orientation + } + /// The current per-channel levels. pub fn levels(&self, cx: &App) -> Vec { self.data.read(cx).levels() @@ -76,36 +103,68 @@ impl Render for AudioLevelMeter { .iter() .map(|level| meter_lit_segments(*level, SEGMENTS)) .collect(); + let orientation = self.orientation; canvas( move |_bounds, _window, _cx| (), move |bounds, (), window, _cx| { let width = f32::from(bounds.size.width); let height = f32::from(bounds.size.height); - let channel_h = if lit_counts.is_empty() { - height - } else { - height / lit_counts.len() as f32 - }; - let seg_w = width / SEGMENTS as f32; let lit_color = Hsla::from(colors.selected); let dim_color = Hsla::from(colors.border); let peak_color = Hsla::from(colors.text); - for (channel, &lit) in lit_counts.iter().enumerate() { - let y = bounds.top() + px(channel as f32 * channel_h); - for segment in 0..SEGMENTS { - let seg = Bounds::new( - point(bounds.left() + px(segment as f32 * seg_w), y), - size(px((seg_w - 1.0).max(1.0)), px((channel_h - 2.0).max(2.0))), - ); - window.paint_quad(fill(seg, if segment < lit { lit_color } else { dim_color })); + match orientation { + MeterOrientation::Horizontal => { + let channel_h = if lit_counts.is_empty() { + height + } else { + height / lit_counts.len() as f32 + }; + let seg_w = width / SEGMENTS as f32; + for (channel, &lit) in lit_counts.iter().enumerate() { + let y = bounds.top() + px(channel as f32 * channel_h); + for segment in 0..SEGMENTS { + let seg = Bounds::new( + point(bounds.left() + px(segment as f32 * seg_w), y), + size(px((seg_w - 1.0).max(1.0)), px((channel_h - 2.0).max(2.0))), + ); + window.paint_quad(fill(seg, if segment < lit { lit_color } else { dim_color })); + } + // Peak marker. + if let Some(peak) = peaks.get(channel) { + let x = bounds.left() + px((peak.clamp(0.0, 1.0) * width) - 1.0); + let marker = Bounds::new(point(x, y), size(px(2.0), px((channel_h - 2.0).max(2.0)))); + window.paint_quad(fill(marker, peak_color)); + } + } } - // Peak marker. - if let Some(peak) = peaks.get(channel) { - let x = bounds.left() + px((peak.clamp(0.0, 1.0) * width) - 1.0); - let marker = Bounds::new(point(x, y), size(px(2.0), px((channel_h - 2.0).max(2.0)))); - window.paint_quad(fill(marker, peak_color)); + MeterOrientation::Vertical => { + // Channels side by side; segments stack bottom to top, + // lit from the bottom like an equalizer column. + let channel_w = if lit_counts.is_empty() { + width + } else { + width / lit_counts.len() as f32 + }; + let seg_h = height / SEGMENTS as f32; + for (channel, &lit) in lit_counts.iter().enumerate() { + let x = bounds.left() + px(channel as f32 * channel_w); + for segment in 0..SEGMENTS { + let y = bounds.bottom() - px((segment + 1) as f32 * seg_h); + let seg = Bounds::new( + point(x, y), + size(px((channel_w - 2.0).max(2.0)), px((seg_h - 1.0).max(1.0))), + ); + window.paint_quad(fill(seg, if segment < lit { lit_color } else { dim_color })); + } + // Peak marker. + if let Some(peak) = peaks.get(channel) { + let y = bounds.bottom() - px(peak.clamp(0.0, 1.0) * height); + let marker = Bounds::new(point(x, y), size(px((channel_w - 2.0).max(2.0)), px(2.0))); + window.paint_quad(fill(marker, peak_color)); + } + } } } }, @@ -166,4 +225,50 @@ mod tests { // Peaks track the levels on the first update. assert!((peaks[0] - 0.8).abs() < 0.001); } + + #[gpui::test] + async fn vertical_meter_renders_in_a_narrow_strip(cx: &mut TestAppContext) { + // The Oak transport design needs a 26px-wide vertical strip: channels + // side by side, segments lit bottom to top. Render one at that exact + // size and exercise the paint path (the orientation default is + // horizontal, so this also covers the builder). + struct StripHost { + meter: Entity>, + } + impl Render for StripHost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .child(self.meter.clone()) + } + } + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(26.0), px(200.0)), |window, cx| { + let audio = cx.new(|_| MockAudio(vec![0.7])); + let meter = cx.new(|cx| { + AudioLevelMeter::new(5, audio, window, cx) + .with_orientation(MeterOrientation::Vertical) + }); + assert_eq!(meter.read(cx).orientation(), MeterOrientation::Vertical); + StripHost { meter } + }); + cx.run_until_parked(); + window + .update(cx, |host, _, cx| { + host.meter.update(cx, |meter, cx| meter.update(cx)); + }) + .unwrap(); + // Still valid after a vertical render + peak update. + assert!(window + .update(cx, |host, _, cx| host.meter.read(cx).orientation()) + .unwrap() + == MeterOrientation::Vertical); + } + + #[test] + fn meter_orientation_defaults_to_horizontal() { + assert_eq!(MeterOrientation::default(), MeterOrientation::Horizontal); + assert_ne!(MeterOrientation::Horizontal, MeterOrientation::Vertical); + } } diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index ff3b993f67..e8b0758539 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -33,3 +33,48 @@ pub mod spinbox; pub mod theme; pub mod value; pub mod viewer; + +#[cfg(test)] +mod tests { + use gpui::prelude::*; + use gpui::{AbsoluteLength, DefiniteLength, div, rems}; + + /// Fractional spacing helpers exist on the [`Styled`](gpui::Styled) trait + /// (via `gpui_macros::padding_style_methods!` / `margin_style_methods!`), + /// named after Tailwind's fractional scale: `0p5`, `1p5`, `2p5`, `3p5` + /// (i.e. 0.5/1.5/2.5/3.5 units of 4px = 2/6/10/14px). + /// + /// We deliberately do **not** add `_0_5`-style aliases (`py_0_5`, + /// `px_1_5`, ...): every fractional value they would name already exists + /// under the established `0p5`/`1p5`/`2p5`/`3p5` convention used + /// throughout gpui and gpui_widgets, and a second naming scheme for the + /// same helpers would only fragment the API surface. This test pins the + /// helpers (and their values) so a future refactor of the style macros + /// cannot silently drop them. + #[test] + fn fractional_spacing_helpers_exist() { + let mut padding = div().py_0p5().px_1p5(); + assert_eq!( + padding.style().padding.top, + Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.125)))) + ); + assert_eq!( + padding.style().padding.left, + Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.375)))) + ); + assert_eq!( + padding.style().padding.right, + Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.375)))) + ); + + let mut margin = div().my_2p5().pt_3p5(); + assert_eq!( + margin.style().margin.top, + Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.625))).into()) + ); + assert_eq!( + margin.style().padding.top, + Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.875))).into()) + ); + } +} diff --git a/crates/gpui_widgets/src/menu/mod.rs b/crates/gpui_widgets/src/menu/mod.rs index 08f8df2465..291342cb5c 100644 --- a/crates/gpui_widgets/src/menu/mod.rs +++ b/crates/gpui_widgets/src/menu/mod.rs @@ -121,6 +121,21 @@ impl MenuBar { self.open.is_some() } + /// Sets the checked state of the menu item with `id` across all entries + /// (searching submenus recursively), so a host can toggle a checkmark at + /// runtime without rebuilding the [`MenuBar`]. + /// + /// The checkmark appears on the next repaint (the renderer reads + /// `checked` per frame); pair with a `cx.notify()` after the call. + /// Returns whether an item with that id was found. + pub fn set_item_checked(&mut self, id: usize, checked: bool) -> bool { + let mut found = false; + for entry in &mut self.entries { + found |= entry.menu.set_item_checked(id, checked); + } + found + } + fn open_menu(&mut self, index: usize, position: Point, cx: &mut Context) { if self.open != Some(index) { self.open = Some(index); @@ -745,4 +760,30 @@ mod tests { }); assert!(closed); } + + #[gpui::test] + async fn runtime_set_item_checked_flips_the_checkmark(cx: &mut TestAppContext) { + let (cx, host) = make_bar(cx); + // Toggle item 11 ("Save") at runtime, by id. + let changed = cx.update(|_window, app| { + let bar = host.read(app).menu_bar.clone(); + bar.update(app, |bar, _cx| bar.set_item_checked(11, true)) + }); + assert!(changed, "item 11 exists and should be updated"); + let checked = cx.update(|_window, app| { + host.read(app).menu_bar.read(app).entries[0].menu.items[1].checked + }); + assert_eq!(checked, Some(true)); + + // Unknown ids are reported as not found and change nothing. + let changed = cx.update(|_window, app| { + let bar = host.read(app).menu_bar.clone(); + bar.update(app, |bar, _cx| bar.set_item_checked(12345, true)) + }); + assert!(!changed); + let checked = cx.update(|_window, app| { + host.read(app).menu_bar.read(app).entries[0].menu.items[0].checked + }); + assert_eq!(checked, None); + } } diff --git a/crates/gpui_widgets/src/menu/model.rs b/crates/gpui_widgets/src/menu/model.rs index a71e27480d..52abfbd138 100644 --- a/crates/gpui_widgets/src/menu/model.rs +++ b/crates/gpui_widgets/src/menu/model.rs @@ -54,6 +54,25 @@ impl MenuItem { self } + /// Set the checked state of an already-built item (runtime mutation). + /// + /// Unlike the construction-only [`with_checked`](Self::with_checked), this + /// lets a host flip a menu checkmark after the [`Menu`] has been handed + /// to a view — e.g. through [`Menu::set_item_checked`] on the menu held + /// by a [`MenuBar`](super::MenuBar) — without rebuilding the menu. The + /// renderers read `checked` on every frame, so the change shows up on the + /// next repaint. + pub fn set_checked(&mut self, checked: bool) -> &mut Self { + self.checked = Some(checked); + self + } + + /// Remove the checkmark from an already-built item (runtime mutation). + pub fn clear_checked(&mut self) -> &mut Self { + self.checked = None; + self + } + /// Attach a submenu. pub fn with_submenu(mut self, submenu: Menu) -> Self { self.submenu = Some(Box::new(submenu)); @@ -85,6 +104,28 @@ impl Menu { item.label.is_empty() } + /// Sets the checked state of the item with `id`, searching top-level + /// items and their submenus recursively. Returns whether an item with + /// that id was found and updated. + /// + /// Runtime counterpart to [`MenuItem::with_checked`]: hosts that hold a + /// live [`Menu`] (e.g. in a [`MenuBar`](super::MenuBar)) can toggle a + /// checkmark without rebuilding the menu. + pub fn set_item_checked(&mut self, id: usize, checked: bool) -> bool { + for item in &mut self.items { + if item.id == id { + item.set_checked(checked); + return true; + } + if let Some(submenu) = item.submenu.as_mut() { + if submenu.set_item_checked(id, checked) { + return true; + } + } + } + false + } + /// The next selectable index from `current`, moving `delta` steps /// (skipping separators and disabled items). `None` returns the first /// (or last) selectable item. Returns `None` if nothing is selectable. @@ -158,6 +199,34 @@ mod tests { assert_eq!(menu.items[4].checked, Some(true)); } + #[test] + fn set_item_checked_mutates_in_place() { + let mut menu = sample_menu(); + // Runtime toggle on a built item, found by id. + assert!(menu.set_item_checked(5, false)); + assert_eq!(menu.items[4].checked, Some(false)); + assert!(menu.set_item_checked(5, true)); + assert_eq!(menu.items[4].checked, Some(true)); + // Clear the checkmark entirely. + assert!(menu.items[4].clear_checked().checked.is_none()); + // Unknown ids report failure and change nothing. + assert!(!menu.set_item_checked(999, true)); + assert_eq!(menu.items[0].checked, None); + } + + #[test] + fn set_item_checked_reaches_nested_submenus() { + let sub = Menu::new(vec![MenuItem::new(10, "A"), MenuItem::new(11, "B")]); + let mut menu = Menu::new(vec![ + MenuItem::new(5, "Nested").with_submenu(sub), + MenuItem::new(6, "Top"), + ]); + assert!(menu.set_item_checked(11, true)); + assert_eq!(menu.items[0].submenu.as_ref().unwrap().items[1].checked, Some(true)); + // The top-level item is untouched. + assert_eq!(menu.items[1].checked, None); + } + #[test] fn cascade_nesting() { let sub = Menu::new(vec![MenuItem::new(10, "A"), MenuItem::new(11, "B")]); diff --git a/crates/gpui_widgets/src/viewer/mod.rs b/crates/gpui_widgets/src/viewer/mod.rs index ab20480718..366d482f2d 100644 --- a/crates/gpui_widgets/src/viewer/mod.rs +++ b/crates/gpui_widgets/src/viewer/mod.rs @@ -14,9 +14,11 @@ pub use transport::*; use gpui::timeline::{FrameRate, TimeDisplay, format_timecode}; use gpui::{ - App, AsyncWindowContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, - ObjectFit, Render, SurfaceSource, Window, colors::DefaultColors, div, prelude::*, px, surface, + AnyElement, App, AsyncWindowContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, + Focusable, ObjectFit, Render, RenderImage, SurfaceSource, Window, colors::DefaultColors, div, + img, prelude::*, px, surface, }; +use std::sync::Arc; /// A request emitted by the viewer. #[derive(Debug, Clone, PartialEq)] @@ -65,13 +67,29 @@ pub enum ViewerEvent { }, } +/// The picture source of a [`ViewerWidget`]. +/// +/// On macOS the fast path is a CoreVideo [`SurfaceSource`]; on platforms +/// without CVPixelBuffer (or when the engine only produces CPU frames) use +/// [`ViewerFrameSource::CpuFrame`]. +#[derive(Clone)] +pub enum ViewerFrameSource { + /// A platform surface: a CoreVideo pixel buffer on macOS, or a GPU + /// texture handle on Linux/FreeBSD. + Surface(SurfaceSource), + /// A CPU-side frame as raw bytes in a [`RenderImage`] (BGRA8, row-major, + /// top-to-bottom), uploaded through gpui's sprite atlas on every + /// platform — the path to use when no platform surface is available. + CpuFrame(Arc), +} + /// The viewer widget. pub struct ViewerWidget { control: usize, clock: Entity, frame_rate: FrameRate, transport: TransportState, - frame_source: Option, + frame_source: Option, focus_handle: FocusHandle, show_safe_frames: bool, zoom: bool, @@ -122,7 +140,19 @@ impl ViewerWidget { /// Set the picture source (the bridge's pixel buffer) and repaint. pub fn set_frame_source(&mut self, source: Option, cx: &mut Context) { - self.frame_source = source; + self.frame_source = source.map(ViewerFrameSource::Surface); + cx.notify(); + } + + /// Set the picture source to a CPU-side frame and repaint. + /// + /// This is the path for non-macOS platforms and engines that decode to + /// raw pixels instead of platform surfaces: hand in a + /// [`RenderImage`](gpui::RenderImage) whose bytes are BGRA8 (the same + /// format gpui's `img` element uses) and the viewer uploads it through + /// the sprite atlas. `None` clears the picture (showing the placeholder). + pub fn set_cpu_frame(&mut self, frame: Option>, cx: &mut Context) { + self.frame_source = frame.map(ViewerFrameSource::CpuFrame); cx.notify(); } @@ -174,11 +204,15 @@ impl Render for ViewerWidget { if let Some(source) = &self.frame_source { let fit = if self.zoom { ObjectFit::Cover } else { ObjectFit::Contain }; - picture = picture.child( - surface(source.clone()) - .size_full() - .object_fit(fit), - ); + let picture_element: AnyElement = match source { + ViewerFrameSource::Surface(surface_source) => { + surface(surface_source.clone()).size_full().object_fit(fit).into_any() + } + ViewerFrameSource::CpuFrame(image) => { + img(image.clone()).size_full().object_fit(fit).into_any() + } + }; + picture = picture.child(picture_element); } else { picture = picture.child( div() @@ -389,6 +423,55 @@ mod tests { assert_eq!(text, "00:01:40:00"); } + #[gpui::test] + async fn cpu_frame_source_renders_without_a_platform_surface(cx: &mut TestAppContext) { + // The CPU-frame path (for non-macOS platforms without CVPixelBuffer) + // accepts raw BGRA8 bytes in a RenderImage and renders through the + // sprite atlas — no SurfaceSource involved. + use gpui::RenderImage; + use image::{Frame, RgbaImage}; + + // A 2x2 opaque red frame, converted RGBA -> BGRA as gpui expects. + let mut rgba = RgbaImage::from_pixel(2, 2, image::Rgba([255, 0, 0, 255])); + for pixel in rgba.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + let frame = Arc::new(RenderImage::new(smallvec::SmallVec::from_elem( + Frame::new(rgba), + 1, + ))); + + let (cx, host) = make_host(cx); + cx.update(|window, app| { + host.read(app) + .viewer + .clone() + .update(app, |viewer, cx| viewer.set_cpu_frame(Some(frame), cx)); + window.draw(app); + }); + cx.run_until_parked(); + + let is_cpu = cx.read(|app| { + matches!( + host.read(app).viewer.read(app).frame_source, + Some(ViewerFrameSource::CpuFrame(_)) + ) + }); + assert!(is_cpu, "the frame source should be the CPU-frame variant"); + + // Clearing the CPU frame falls back to the placeholder. + cx.update(|window, app| { + host.read(app) + .viewer + .clone() + .update(app, |viewer, cx| viewer.set_cpu_frame(None, cx)); + window.draw(app); + }); + cx.run_until_parked(); + let is_none = cx.read(|app| host.read(app).viewer.read(app).frame_source.is_none()); + assert!(is_none); + } + fn make_host(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity) { cx.update(|cx| cx.init_colors()); let window = cx.open_window(size(px(640.0), px(420.0)), |window, cx| {