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:
parent
2649cf7335
commit
d992449a1c
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue