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)
This commit is contained in:
Generated
+2
@@ -2766,6 +2766,8 @@ dependencies = [
|
||||
"gpui",
|
||||
"gpui_elements",
|
||||
"gpui_platform",
|
||||
"image",
|
||||
"smallvec",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
|
||||
@@ -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<NodePath, (Entity<TabBar>, 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<NodePath, (Entity<SplitHandle>, 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<SplitHandle>, 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<Self>,
|
||||
) -> Entity<SplitHandle> {
|
||||
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<SplitHandleDrag>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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::<SplitHandleDrag>(
|
||||
cx.listener(move |this, event: &DragMoveEvent<SplitHandleDrag>, _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::<SplitHandleDrag>(
|
||||
@@ -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<NodePath> = 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);
|
||||
|
||||
+345
-60
@@ -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<f32>,
|
||||
/// The children, in layout order. Never empty, never a single child,
|
||||
/// and never contains a nested `Split` with the same `direction`.
|
||||
children: Vec<DockNode>,
|
||||
@@ -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<DockNode> = 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<f32> {
|
||||
/// 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<Vec<f32>> {
|
||||
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<f32>,
|
||||
/// See [`DockNode::Split::children`].
|
||||
children: Vec<SerializedNode>,
|
||||
},
|
||||
@@ -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<SerializedNode> = 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<DockNode> = 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<f32>, 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<f32>) {
|
||||
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<String> {
|
||||
Some(format!("panel-{}", id.raw()))
|
||||
}
|
||||
fn build_panel(&self, _key: &str, _window: &mut Window, _cx: &mut App) -> Option<PanelHandle> {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Pixels>,
|
||||
}
|
||||
@@ -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<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<D: AudioMeterDataSource> {
|
||||
data: Entity<D>,
|
||||
focus_handle: FocusHandle,
|
||||
peak: Vec<f32>,
|
||||
orientation: MeterOrientation,
|
||||
}
|
||||
|
||||
impl<D: AudioMeterDataSource> AudioLevelMeter<D> {
|
||||
@@ -41,9 +56,21 @@ impl<D: AudioMeterDataSource> AudioLevelMeter<D> {
|
||||
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<f32> {
|
||||
self.data.read(cx).levels()
|
||||
@@ -76,36 +103,68 @@ impl<D: AudioMeterDataSource> Render for AudioLevelMeter<D> {
|
||||
.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<AudioLevelMeter<MockAudio>>,
|
||||
}
|
||||
impl Render for StripHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Pixels>, cx: &mut Context<Self>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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<RenderImage>),
|
||||
}
|
||||
|
||||
/// The viewer widget.
|
||||
pub struct ViewerWidget<C: PlaybackClock> {
|
||||
control: usize,
|
||||
clock: Entity<C>,
|
||||
frame_rate: FrameRate,
|
||||
transport: TransportState,
|
||||
frame_source: Option<SurfaceSource>,
|
||||
frame_source: Option<ViewerFrameSource>,
|
||||
focus_handle: FocusHandle,
|
||||
show_safe_frames: bool,
|
||||
zoom: bool,
|
||||
@@ -122,7 +140,19 @@ impl<C: PlaybackClock> ViewerWidget<C> {
|
||||
|
||||
/// Set the picture source (the bridge's pixel buffer) and repaint.
|
||||
pub fn set_frame_source(&mut self, source: Option<SurfaceSource>, cx: &mut Context<Self>) {
|
||||
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<Arc<RenderImage>>, cx: &mut Context<Self>) {
|
||||
self.frame_source = frame.map(ViewerFrameSource::CpuFrame);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -174,11 +204,15 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
|
||||
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<Host>) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(420.0)), |window, cx| {
|
||||
|
||||
Reference in New Issue
Block a user