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.
This commit is contained in:
Fini 2026-07-13 21:41:34 +08:00
parent 2649cf7335
commit d992449a1c
5 changed files with 171 additions and 58 deletions

View file

@ -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"
);
}

View file

@ -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<String>,
/// 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<String>,
/// 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<String>,
}
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,

View file

@ -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
);
}

View file

@ -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);
}

View file

@ -276,9 +276,10 @@ struct PaintNodeOptions<'a, 'generation> {
now_ms: u64,
generating_descendant_ids: Option<&'generation HashSet<String>>,
generation_accent: Option<Color>,
/// Queued empty shells: keep the layout slot, paint nothing (see
/// `canvas_generation_scan::GenerationPaintSets::suppressed`).
suppressed_shell_ids: Option<&'generation HashSet<String>>,
/// 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<String>>,
}
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<String>>,
generation_accent: Option<Color>,
suppressed_shell_ids: Option<&HashSet<String>>,
queued_shell_ids: Option<&HashSet<String>>,
) -> 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() {