From d992449a1cd008a1c4d8f32db0a2d7301f89b49a Mon Sep 17 00:00:00 2001 From: Fini Date: Mon, 13 Jul 2026 21:41:34 +0800 Subject: [PATCH] feat(editor): queued shells keep their skeleton, quietly Suppressing the queue left holes in the middle of the skeleton while the model was working the top - the skeleton-first effect is wanted, only one shell may look ACTIVE. The on-deck shell (first empty in fill order) keeps the radar sweep; every queued shell now paints a still wireframe - outline plus a whisper of wash, no band - so the whole page reads as planned-but-unwritten instead of half missing, and nothing downstream pretends to be under the cursor. --- .../src/widgets/canvas_agent_cursor_tests.rs | 18 ++-- .../src/widgets/canvas_generation_scan.rs | 61 ++++++++---- .../widgets/canvas_generation_scan_tests.rs | 92 ++++++++++++++++++- .../src/widgets/canvas_viewport.rs | 2 +- .../src/widgets/canvas_viewport_paint.rs | 56 +++++------ 5 files changed, 171 insertions(+), 58 deletions(-) diff --git a/crates/op-editor-ui/src/widgets/canvas_agent_cursor_tests.rs b/crates/op-editor-ui/src/widgets/canvas_agent_cursor_tests.rs index b8bba2fbd..ea4f22a2f 100644 --- a/crates/op-editor-ui/src/widgets/canvas_agent_cursor_tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_agent_cursor_tests.rs @@ -744,13 +744,13 @@ mod scan_gate_tests { "the header is first in fill order — it is on deck" ); assert!( - sets.suppressed.contains("bottom-nav"), - "the trailing nav shell waits INVISIBLY, even though it is its \ - container's first empty child" + sets.queued.contains("bottom-nav"), + "the trailing nav shell shows a QUIET skeleton and waits, even \ + though it is its container's first empty child" ); assert!( - sets.suppressed.contains("search"), - "a queued sibling of the deck stays hidden too" + sets.queued.contains("search"), + "a queued sibling of the deck keeps its skeleton, quietly" ); } @@ -782,14 +782,14 @@ mod scan_gate_tests { assert!(ids.contains("shell-a"), "the on-deck shell scans"); assert!( !ids.contains("shell-b"), - "a queued later shell stays plain until its turn" + "a queued later shell does not take the ACTIVE radar" ); assert!( - sets.suppressed.contains("shell-b"), - "the queued shell paints NOTHING (layout slot only)" + sets.queued.contains("shell-b"), + "the queued shell still shows its skeleton — as a quiet wireframe" ); assert!( - !sets.suppressed.contains("shell-a"), + !sets.queued.contains("shell-a"), "the on-deck shell is visible" ); } diff --git a/crates/op-editor-ui/src/widgets/canvas_generation_scan.rs b/crates/op-editor-ui/src/widgets/canvas_generation_scan.rs index 5a9193126..8c371a36e 100644 --- a/crates/op-editor-ui/src/widgets/canvas_generation_scan.rs +++ b/crates/op-editor-ui/src/widgets/canvas_generation_scan.rs @@ -35,16 +35,16 @@ pub(super) fn is_placeholder_section(node: &SceneNode) -> bool { node.kind == NodeKind::Frame && node.children.is_empty() } -/// Scan-eligible ids + QUEUED shells to suppress entirely. +/// The two generation states an empty shell can be in. pub(super) struct GenerationPaintSets { - /// Nodes allowed to paint the placeholder wash (the on-deck shells and - /// all worked content). + /// The shell being worked RIGHT NOW (first empty in fill order) plus all + /// worked content: the full radar treatment — wash + sweeping band. pub scan: HashSet, - /// Empty shells whose turn has NOT come: they keep their layout slot - /// but paint NOTHING — Pencil shows plain canvas where work has not - /// reached, not a stack of dark author-filled slabs (user report - /// 2026-07-12: "下面的黑块是什么?先隐藏?"). - pub suppressed: HashSet, + /// Shells still QUEUED: they keep their slot and show the skeleton, but + /// as a quiet static wireframe — no wash to read as a dark slab, no sweep + /// to claim they are being worked. The skeleton stays visible everywhere + /// (user: "骨架先行效果还是要的"); only ONE shell may look active. + pub queued: HashSet, } pub(super) fn generating_paint_sets( @@ -54,7 +54,7 @@ pub(super) fn generating_paint_sets( let indicators = indicators.filter(|value| value.run_active && !value.frames.is_empty())?; let mut sets = GenerationPaintSets { scan: HashSet::new(), - suppressed: HashSet::new(), + queued: HashSet::new(), }; for root in roots { if indicators.frames.contains_key(&root.id) { @@ -67,22 +67,21 @@ pub(super) fn generating_paint_sets( } fn collect_descendants(nodes: &[SceneNode], sets: &mut GenerationPaintSets, deck_taken: &mut bool) { - // Work-order gate: across the WHOLE generating root only the FIRST - // empty shell in document (pre-order) position is "on deck" and washes; - // every other empty shell waits its turn INVISIBLY (layout slot kept, - // paint suppressed). The gate was per-container at first, which lit the - // root's trailing bottom-nav shell while the model was narrating "fill - // the Header" — the fill order is document order, so the deck must be - // global to the root (user report 2026-07-12). Pencil never lights a - // section work has not reached, and a region must light up the moment - // it becomes the next target. + // Work-order gate: across the WHOLE generating root, the FIRST empty + // shell in document (pre-order) position is "on deck" and gets the active + // radar; every later empty shell still SHOWS its skeleton, but as a quiet + // wireframe. Two earlier shapes were both wrong: a per-container gate lit + // the root's trailing bottom-nav while the model was filling the header + // nested in the content wrapper, and suppressing the queue entirely left + // holes in the middle of the skeleton. Fill order is document order, so + // the deck is global — and exactly one shell may look active. for node in nodes { let empty_frame = node.kind == NodeKind::Frame && node.children.is_empty(); if empty_frame { if !*deck_taken { sets.scan.insert(node.id.clone()); } else { - sets.suppressed.insert(node.id.clone()); + sets.queued.insert(node.id.clone()); } *deck_taken = true; } else { @@ -92,6 +91,30 @@ fn collect_descendants(nodes: &[SceneNode], sets: &mut GenerationPaintSets, deck } } +/// A queued shell's skeleton: the wireframe outline and a whisper of wash — +/// present, clearly a placeholder, and unmistakably NOT the one being worked. +pub(super) fn paint_queued_skeleton( + cx: &mut PaintCx<'_>, + node: &SceneNode, + bounds: Rect, + zoom: f32, + accent: Color, +) { + if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 { + return; + } + let radius = node.corner_radius * zoom; + if radius > 0.5 { + cx.backend + .fill_round_rect(bounds, radius, accent.with_alpha(0.035)); + cx.backend + .stroke_round_rect(bounds, radius, accent.with_alpha(0.3), 1.0); + } else { + cx.backend.fill_rect(bounds, accent.with_alpha(0.035)); + cx.backend.stroke_rect(bounds, accent.with_alpha(0.3), 1.0); + } +} + pub(super) fn paint_generation_scan( cx: &mut PaintCx<'_>, node: &SceneNode, diff --git a/crates/op-editor-ui/src/widgets/canvas_generation_scan_tests.rs b/crates/op-editor-ui/src/widgets/canvas_generation_scan_tests.rs index d66e42259..2142b6004 100644 --- a/crates/op-editor-ui/src/widgets/canvas_generation_scan_tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_generation_scan_tests.rs @@ -1,5 +1,10 @@ -use super::canvas_generation_scan::{generating_paint_sets, is_placeholder_section, scan_phase}; +use super::canvas_generation_scan::{ + generating_paint_sets, is_placeholder_section, paint_generation_scan, paint_queued_skeleton, + scan_phase, SKELETON_BLUE, +}; use crate::layout_scene::{NodeKind, SceneNode}; +use crate::widgets::PaintCx; +use crate::{Color, Point2D, Rect, RenderBackend, TextLayout}; use op_editor_core::agent_indicators::{AgentIndicators, AgentTag}; #[test] @@ -58,3 +63,88 @@ fn generating_descendants_exclude_the_claimed_root_and_skip_idle_allocation() { assert!(ids.contains("section")); assert!(ids.contains("content")); } + +/// Counts what the two skeleton states actually paint. The ACTIVE shell +/// sweeps (many band segments); a QUEUED shell must show its wireframe with +/// no sweep at all — otherwise every queued shell looks like it is being +/// worked, which is the ordering confusion the deck gate exists to prevent. +#[derive(Default)] +struct SkeletonCountBackend { + fills: usize, + strokes: usize, +} + +impl RenderBackend for SkeletonCountBackend { + fn begin_frame(&mut self) {} + fn end_frame(&mut self) {} + fn fill_rect(&mut self, _: Rect, _: Color) { + self.fills += 1; + } + fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) { + self.strokes += 1; + } + fn draw_text(&mut self, _: &TextLayout, _: Point2D) {} + fn clip_rect(&mut self, _: Rect) {} + fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {} + fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) { + self.fills += 1; + } + fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) { + self.strokes += 1; + } + fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {} + fn fill_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: f32, _: Color) {} + fn fill_oval(&mut self, _: Rect, _: Color) {} + fn stroke_oval(&mut self, _: Rect, _: Color, _: f32) {} + fn fill_polygon(&mut self, _: &[Point2D], _: Color) {} + fn stroke_polygon(&mut self, _: &[Point2D], _: Color, _: f32) {} + fn save(&mut self) {} + fn restore(&mut self) {} + fn translate(&mut self, _: Point2D) {} + fn resize(&mut self, _: u32, _: u32) {} + fn dpi_scale(&self) -> f32 { + 1.0 + } +} + +#[test] +fn a_queued_shell_shows_its_wireframe_without_the_sweep() { + let shell = SceneNode::leaf("shell", NodeKind::Frame); + let bounds = Rect::xywh(0.0, 0.0, 300.0, 200.0); + + let mut active = SkeletonCountBackend::default(); + paint_generation_scan( + &mut PaintCx { + backend: &mut active, + }, + &shell, + bounds, + 1.0, + 500, + SKELETON_BLUE, + ); + + let mut queued = SkeletonCountBackend::default(); + paint_queued_skeleton( + &mut PaintCx { + backend: &mut queued, + }, + &shell, + bounds, + 1.0, + SKELETON_BLUE, + ); + + assert_eq!( + queued.strokes, 1, + "the queued shell keeps its skeleton outline" + ); + assert_eq!(queued.fills, 1, "one whisper of wash, no sweep band"); + assert!( + active.fills > queued.fills + 8, + "the on-deck shell sweeps a banded gradient ({} fills) while the queue \ + stays still ({} fills)", + active.fills, + queued.fills + ); +} diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport.rs b/crates/op-editor-ui/src/widgets/canvas_viewport.rs index 4e6d87735..43a6a5b9d 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport.rs @@ -712,7 +712,7 @@ impl<'a> Widget for CanvasViewport<'a> { generation_sets .as_ref() .map(|_| super::canvas_generation_scan::SKELETON_BLUE), - generation_sets.as_ref().map(|sets| &sets.suppressed), + generation_sets.as_ref().map(|sets| &sets.queued), ); paint_hits.merge_missing(child_hits); } diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs index 2eea2e00d..d067dded1 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -276,9 +276,10 @@ struct PaintNodeOptions<'a, 'generation> { now_ms: u64, generating_descendant_ids: Option<&'generation HashSet>, generation_accent: Option, - /// Queued empty shells: keep the layout slot, paint nothing (see - /// `canvas_generation_scan::GenerationPaintSets::suppressed`). - suppressed_shell_ids: Option<&'generation HashSet>, + /// Queued empty shells: the skeleton shows, but as a quiet wireframe — + /// only the on-deck shell gets the active radar (see + /// `canvas_generation_scan::GenerationPaintSets::queued`). + queued_shell_ids: Option<&'generation HashSet>, } use super::canvas_overlay_transform::OverlayTransform; @@ -417,7 +418,7 @@ pub(crate) fn paint_node_with_options_hiding<'a>( now_ms: u64, generating_descendant_ids: Option<&HashSet>, generation_accent: Option, - suppressed_shell_ids: Option<&HashSet>, + queued_shell_ids: Option<&HashSet>, ) -> PaintNodeHits<'a> { let options = PaintNodeOptions { viewport_origin, @@ -432,7 +433,7 @@ pub(crate) fn paint_node_with_options_hiding<'a>( now_ms, generating_descendant_ids, generation_accent, - suppressed_shell_ids, + queued_shell_ids, }; paint_node_inner(cx, node, &options, &mut Vec::new()) } @@ -498,15 +499,6 @@ fn paint_node_inner<'a>( if node.hidden || matches!(reveal_state, RevealPaintState::Pending) { return PaintNodeHits::default(); } - // Queued generation shell: its turn has not come — keep the layout - // slot, paint nothing (Pencil shows plain canvas where work has not - // reached, not dark author-filled slabs). - if options - .suppressed_shell_ids - .is_some_and(|ids| ids.contains(&node.id)) - { - return PaintNodeHits::default(); - } let world_rect = Rect { origin: Point2D::new( viewport_origin.x + node.bounds.origin.x * zoom, @@ -641,21 +633,29 @@ fn paint_node_inner<'a>( // panels; paint the minimal tab-bar visual over the frame // fill, then the children render normally below. paint_widget_visual(cx, node, world_rect, zoom); - if let (true, Some(accent)) = ( - super::canvas_generation_scan::is_placeholder_section(node) - && options + if let Some(accent) = options.generation_accent { + if super::canvas_generation_scan::is_placeholder_section(node) { + let on_deck = options .generating_descendant_ids - .is_some_and(|ids| ids.contains(&node.id)), - options.generation_accent, - ) { - super::canvas_generation_scan::paint_generation_scan( - cx, - node, - world_rect, - zoom, - options.now_ms, - accent, - ); + .is_some_and(|ids| ids.contains(&node.id)); + let queued = options + .queued_shell_ids + .is_some_and(|ids| ids.contains(&node.id)); + if on_deck { + super::canvas_generation_scan::paint_generation_scan( + cx, + node, + world_rect, + zoom, + options.now_ms, + accent, + ); + } else if queued { + super::canvas_generation_scan::paint_queued_skeleton( + cx, node, world_rect, zoom, accent, + ); + } + } } let clipped = push_clip_content(cx, node, world_rect, zoom); for child in node.children.iter().rev() {