feat(editor-ui): hit-test Code panel actions

This commit is contained in:
Kayshen-X 2026-06-07 19:21:23 +08:00
parent 146cac371d
commit c4e439bfe4
2 changed files with 325 additions and 44 deletions

View file

@ -347,6 +347,17 @@ impl PropertyPanel {
// Multi-select inputs / toggles are inert in v1.
return None;
}
if matches!(self.tab, op_editor_core::PropertyTab::Code) {
// Same content origin the Code paint uses: panel left, the
// pinned tab-strip bottom (`+ TAB_HEIGHT`), panel width.
let cy0 = panel_rect.origin.y + crate::widgets::property_panel_inputs::TAB_HEIGHT;
let rects =
code_action_rects(panel_rect.origin.x, cy0, panel_rect.size.x, &self.codegen);
return rects
.into_iter()
.find(|(_, r)| rect_contains(*r, point))
.map(|(a, _)| PropertyPanelAction::Codegen(a));
}
if self.image_fill_popover_open {
if let Some(action) = sections::image_fill_popover_action_at(
self.scrolled_rect(panel_rect),
@ -473,7 +484,7 @@ fn rect_contains(r: Rect, p: Point2D) -> bool {
&& p.y <= r.origin.y + r.size.y
}
use crate::widgets::property_panel_code::paint_code_panel;
use crate::widgets::property_panel_code::{code_action_rects, paint_code_panel};
impl Widget for PropertyPanel {
fn id(&self) -> WidgetId {

View file

@ -8,12 +8,14 @@
use crate::theme::Theme;
use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::property_panel_action::CodegenAction;
use crate::widgets::property_panel_inputs::{
paint_section_label, to_jian_color, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP,
SECTION_HEADER_HEIGHT,
};
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect, TextLayout};
use op_editor_core::codegen::{ChunkStatus, CodegenPhase, CodegenState};
use op_editor_core::codegen::{ChunkStatus, CodegenPhase, CodegenState, Framework};
/// Height of a framework chip + the gap below the chip row.
const CHIP_HEIGHT: f32 = 26.0;
@ -37,6 +39,20 @@ fn origin() -> Point2D {
Point2D::new(0.0, 0.0)
}
/// Geometry for a full-width action button at `y`. Shared by the painter
/// and `code_action_rects` so a click lands on the drawn button.
fn full_button_rect(x: f32, y: f32, w: f32) -> Rect {
Rect {
origin: Point2D::new(x + PAD_X, y),
size: Point2D::new(w - PAD_X * 2.0, INPUT_HEIGHT),
}
}
/// y after a full-width button row + its trailing gap.
fn full_button_advance(y: f32) -> f32 {
y + INPUT_HEIGHT + 12.0
}
/// Paint a full-width action button at `y`. `filled` → primary fill +
/// primary-foreground label; otherwise a muted outline (border stroke)
/// + foreground label. Returns the y after the button + its gap.
@ -49,10 +65,7 @@ fn paint_full_button(
w: f32,
filled: bool,
) -> f32 {
let btn = Rect {
origin: Point2D::new(x + PAD_X, y),
size: Point2D::new(w - PAD_X * 2.0, INPUT_HEIGHT),
};
let btn = full_button_rect(x, y, w);
if filled {
cx.backend.fill_round_rect(btn, INPUT_RADIUS, theme.primary);
} else {
@ -73,7 +86,59 @@ fn paint_full_button(
btn.origin.x + (btn.size.x - tw) / 2.0,
btn.origin.y + 19.0,
);
y + INPUT_HEIGHT + 12.0
full_button_advance(y)
}
/// Backend-free advance estimate for a chip label at 13px, matching the
/// `RenderBackend::measure_text` trait-default heuristic (0.55 × size per
/// ASCII char, 1.0 × size per non-ASCII). Both the painter and the
/// hit-test walker measure chips through THIS helper — not
/// `cx.backend.measure_text` — so the chip wrapping math is identical
/// whether or not a real typeface is mounted. Framework wire tokens are
/// all ASCII, so the heuristic is exact here.
fn chip_label_width(label: &str) -> f32 {
label.chars().fold(0.0, |w, c| {
w + if c.is_ascii() { 13.0 * 0.55 } else { 13.0 }
})
}
/// Pure-geometry walker for the framework chip row — one `(Framework,
/// Rect)` per `Framework::ALL`, with the SAME wrapping math the painter
/// applies. `paint_framework_chips` draws these rects and
/// `code_action_rects` hit-tests them, so paint + click can't drift.
fn framework_chip_rects(x: f32, y: f32, w: f32) -> Vec<(Framework, Rect)> {
let left = x + PAD_X;
let right = x + w - PAD_X;
let mut chip_x = left;
let mut chip_y = y;
let mut out = Vec::with_capacity(Framework::ALL.len());
for fw in Framework::ALL {
let chip_w = chip_label_width(fw.as_wire()) + CHIP_PAD_X * 2.0;
// Wrap to a new row when this chip would overflow the usable
// width (but never wrap the very first chip on a row).
if chip_x > left && chip_x + chip_w > right {
chip_x = left;
chip_y += CHIP_HEIGHT + CHIP_GAP;
}
out.push((
fw,
Rect {
origin: Point2D::new(chip_x, chip_y),
size: Point2D::new(chip_w, CHIP_HEIGHT),
},
));
chip_x += chip_w + CHIP_GAP;
}
out
}
/// The y after the chip row + its trailing gap — the start of the
/// phase-specific body. Derived from the last chip's rect so it tracks
/// the (multi-row) wrapping exactly.
fn chips_body_top(x: f32, y: f32, w: f32) -> f32 {
let rects = framework_chip_rects(x, y, w);
let last_y = rects.last().map(|(_, r)| r.origin.y).unwrap_or(y);
last_y + CHIP_HEIGHT + SECTION_GAP
}
/// Paint the framework tab row: one small rounded-rect chip per
@ -88,25 +153,7 @@ fn paint_framework_chips(
y: f32,
w: f32,
) -> f32 {
use op_editor_core::codegen::Framework;
let left = x + PAD_X;
let right = x + w - PAD_X;
let mut chip_x = left;
let mut chip_y = y;
for fw in Framework::ALL {
let label = fw.as_wire();
let tw = cx.backend.measure_text(label, 13.0);
let chip_w = tw + CHIP_PAD_X * 2.0;
// Wrap to a new row when this chip would overflow the usable
// width (but never wrap the very first chip on a row).
if chip_x > left && chip_x + chip_w > right {
chip_x = left;
chip_y += CHIP_HEIGHT + CHIP_GAP;
}
let chip = Rect {
origin: Point2D::new(chip_x, chip_y),
size: Point2D::new(chip_w, CHIP_HEIGHT),
};
for (fw, chip) in framework_chip_rects(x, y, w) {
let active = state.framework == fw;
let (fill, text_color) = if active {
(theme.primary, theme.primary_foreground)
@ -116,14 +163,13 @@ fn paint_framework_chips(
cx.backend.fill_round_rect(chip, 6.0, fill);
draw_line(
cx,
label,
fw.as_wire(),
text_color,
chip.origin.x + CHIP_PAD_X,
chip.origin.y + 17.0,
);
chip_x += chip_w + CHIP_GAP;
}
chip_y + CHIP_HEIGHT + SECTION_GAP
chips_body_top(x, y, w)
}
/// Map a `ChunkStatus` to its trailing status glyph + tint. Pending /
@ -174,6 +220,16 @@ fn phase_glyph(theme: &Theme, done: Option<bool>) -> (Icon, Color) {
}
}
/// y of the Idle phase's first (Generate) button — past the hint row and
/// the optional error row. Shared by the painter and `code_action_rects`.
fn idle_generate_y(state: &CodegenState, y: f32) -> f32 {
let mut y = y + 28.0 + SECTION_GAP;
if state.error.is_some() {
y += PROGRESS_ROW_H;
}
y
}
/// Idle body: a hint + a primary "Generate <fw>" button + a secondary
/// "Export AI Bundle" button. Any error is surfaced above the buttons.
fn paint_idle_body(
@ -181,7 +237,7 @@ fn paint_idle_body(
theme: &Theme,
state: &CodegenState,
x: f32,
mut y: f32,
y: f32,
w: f32,
) -> f32 {
draw_icon(
@ -199,10 +255,17 @@ fn paint_idle_body(
x + PAD_X + 28.0,
y + 15.0,
);
y += 28.0 + SECTION_GAP;
let mut y = idle_generate_y(state, y);
if let Some(err) = state.error.as_ref() {
draw_line(cx, err, theme.destructive, x + PAD_X, y + 14.0);
y += PROGRESS_ROW_H;
// The error row sits one PROGRESS_ROW_H above the button, which
// `idle_generate_y` already accounted for — draw it there.
draw_line(
cx,
err,
theme.destructive,
x + PAD_X,
y - PROGRESS_ROW_H + 14.0,
);
}
let generate = format!("Generate {}", state.framework.as_wire());
y = paint_full_button(cx, theme, &generate, x, y, w, true);
@ -210,6 +273,14 @@ fn paint_idle_body(
y
}
/// y of the Generating phase's Cancel button — past the header,
/// planning, per-chunk, and assembly rows + the trailing section gap.
/// Shared by the painter and `code_action_rects`.
fn generating_cancel_y(state: &CodegenState, y: f32) -> f32 {
// header + planning + chunks + assembly rows, then SECTION_GAP.
y + PROGRESS_ROW_H * (3 + state.progress.chunks.len()) as f32 + SECTION_GAP
}
/// Generating body: a header, a planning row, one row per chunk, an
/// assembly row, and a full-width Cancel button.
fn paint_generating_body(
@ -290,6 +361,35 @@ fn paint_action_chip(cx: &mut PaintCx<'_>, theme: &Theme, icon: Icon, label: &st
);
}
/// y at which the Complete phase's 4-chip action row sits — accounts
/// for the optional degraded + assets notice rows and the code-preview
/// area above it. Shared by the painter and `code_action_rects`.
fn complete_action_row_y(state: &CodegenState, y: f32) -> f32 {
let mut y = y;
if state.degraded {
y += PROGRESS_ROW_H;
}
if !state.assets.is_empty() {
y += PROGRESS_ROW_H;
}
// The code-preview area advances by its height + the trailing gap;
// mirror `paint_code_area`'s return without painting.
y + CODE_AREA_H + 12.0
}
/// Geometry for the Complete phase's 4 action chips (Copy / Save /
/// Bundle / Regen), left-to-right across the usable width. Both the
/// painter and `code_action_rects` consume these rects.
fn action_chip_rects(x: f32, y: f32, w: f32) -> [Rect; 4] {
let usable = w - PAD_X * 2.0;
let gap = 8.0;
let btn_w = (usable - gap * 3.0) / 4.0;
std::array::from_fn(|i| Rect {
origin: Point2D::new(x + PAD_X + (btn_w + gap) * i as f32, y),
size: Point2D::new(btn_w, INPUT_HEIGHT),
})
}
/// Complete body: optional degraded warning + asset notice, the code
/// preview area, and an action row (Copy / Download / AI Bundle /
/// Regenerate).
@ -326,25 +426,25 @@ fn paint_complete_body(
}
y = paint_code_area(cx, theme, &state.code, x, y, w);
// Action row: 4 small buttons spread across the usable width.
let usable = w - PAD_X * 2.0;
let gap = 8.0;
let btn_w = (usable - gap * 3.0) / 4.0;
let actions = [
(Icon::Copy, "Copy"),
(Icon::Download, "Save"),
(Icon::Sparkles, "Bundle"),
(Icon::RefreshCw, "Regen"),
];
for (i, (icon, label)) in actions.iter().enumerate() {
let rect = Rect {
origin: Point2D::new(x + PAD_X + (btn_w + gap) * i as f32, y),
size: Point2D::new(btn_w, INPUT_HEIGHT),
};
let rects = action_chip_rects(x, y, w);
for ((icon, label), rect) in actions.iter().zip(rects) {
paint_action_chip(cx, theme, *icon, label, rect);
}
y + INPUT_HEIGHT + 12.0
}
/// y of the Error phase's Regenerate button — past the error-text row.
/// Shared by the painter and `code_action_rects`.
fn error_regenerate_y(y: f32) -> f32 {
y + PROGRESS_ROW_H + SECTION_GAP
}
/// Error body: the error text in the destructive color + a full-width
/// Regenerate button.
fn paint_error_body(
@ -352,13 +452,12 @@ fn paint_error_body(
theme: &Theme,
state: &CodegenState,
x: f32,
mut y: f32,
y: f32,
w: f32,
) -> f32 {
let msg = state.error.as_deref().unwrap_or("Generation failed");
draw_line(cx, msg, theme.destructive, x + PAD_X, y + 16.0);
y += PROGRESS_ROW_H + SECTION_GAP;
paint_full_button(cx, theme, "Regenerate", x, y, w, true)
paint_full_button(cx, theme, "Regenerate", x, error_regenerate_y(y), w, true)
}
/// Paint the full Code panel from `state`. Layout: a framework chip
@ -384,6 +483,57 @@ pub fn paint_code_panel(
}
}
/// Hit-test geometry for the Code panel — the clickable rects the panel
/// draws, in draw order. Takes the SAME `(x, y, w)` content origin the
/// host passes to `paint_code_panel` (panel left, tab-strip bottom,
/// panel width). Reuses the panel's shared geometry helpers
/// (`framework_chip_rects` / `full_button_rect` / `action_chip_rects`
/// and the per-phase `*_y` offsets) so a click always lands on what is
/// drawn — paint and hit-test cannot drift.
pub fn code_action_rects(
x: f32,
y: f32,
w: f32,
state: &CodegenState,
) -> Vec<(CodegenAction, Rect)> {
let mut out: Vec<(CodegenAction, Rect)> = Vec::new();
// Section label sits first, then the framework chip row.
let chips_y = y + SECTION_HEADER_HEIGHT;
for (fw, rect) in framework_chip_rects(x, chips_y, w) {
out.push((CodegenAction::SelectFramework(fw), rect));
}
// Phase-specific body starts after the chip row + its gap.
let body_y = chips_body_top(x, chips_y, w);
match state.phase {
CodegenPhase::Idle => {
let gen_y = idle_generate_y(state, body_y);
out.push((CodegenAction::Generate, full_button_rect(x, gen_y, w)));
let bundle_y = full_button_advance(gen_y);
out.push((
CodegenAction::ExportBundle,
full_button_rect(x, bundle_y, w),
));
}
CodegenPhase::Generating => {
let cancel_y = generating_cancel_y(state, body_y);
out.push((CodegenAction::Cancel, full_button_rect(x, cancel_y, w)));
}
CodegenPhase::Complete => {
let row_y = complete_action_row_y(state, body_y);
let [copy, save, bundle, regen] = action_chip_rects(x, row_y, w);
out.push((CodegenAction::Copy, copy));
out.push((CodegenAction::Download, save));
out.push((CodegenAction::ExportBundle, bundle));
out.push((CodegenAction::Regenerate, regen));
}
CodegenPhase::Error => {
let regen_y = error_regenerate_y(body_y);
out.push((CodegenAction::Regenerate, full_button_rect(x, regen_y, w)));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@ -475,4 +625,124 @@ mod tests {
};
assert!(paint(&error) > 0.0);
}
fn contains(r: Rect, p: Point2D) -> bool {
p.x >= r.origin.x
&& p.x <= r.origin.x + r.size.x
&& p.y >= r.origin.y
&& p.y <= r.origin.y + r.size.y
}
fn center(r: Rect) -> Point2D {
Point2D::new(r.origin.x + r.size.x / 2.0, r.origin.y + r.size.y / 2.0)
}
#[test]
fn code_action_rects_idle_has_generate_and_bundle() {
let mut s = op_editor_core::codegen::CodegenState::default(); // Idle, React
let rects = code_action_rects(0.0, 0.0, 280.0, &s);
assert!(rects
.iter()
.any(|(a, _)| matches!(a, CodegenAction::Generate)));
assert!(rects
.iter()
.any(|(a, _)| matches!(a, CodegenAction::ExportBundle)));
// all 8 framework chips present
let chips = rects
.iter()
.filter(|(a, _)| matches!(a, CodegenAction::SelectFramework(_)))
.count();
assert_eq!(chips, 8);
let _ = &mut s;
}
#[test]
fn code_action_rects_complete_has_copy_and_regen() {
// Struct-update form (not `default()` + field reassignment) so
// the pre-commit clippy `field_reassign_with_default` gate stays
// clean; assertions match the spec's Complete-phase test.
let s = CodegenState {
phase: CodegenPhase::Complete,
code: "x".into(),
..CodegenState::default()
};
let rects = code_action_rects(0.0, 0.0, 280.0, &s);
assert!(rects.iter().any(|(a, _)| matches!(a, CodegenAction::Copy)));
assert!(rects
.iter()
.any(|(a, _)| matches!(a, CodegenAction::Regenerate)));
}
#[test]
fn code_action_rects_generating_has_cancel_and_chips() {
let s = CodegenState {
phase: CodegenPhase::Generating,
..CodegenState::default()
};
let rects = code_action_rects(0.0, 0.0, 280.0, &s);
assert!(rects
.iter()
.any(|(a, _)| matches!(a, CodegenAction::Cancel)));
assert_eq!(
rects
.iter()
.filter(|(a, _)| matches!(a, CodegenAction::SelectFramework(_)))
.count(),
8
);
}
#[test]
fn code_action_rects_error_has_regenerate_only_button() {
let s = CodegenState {
phase: CodegenPhase::Error,
error: Some("boom".into()),
..CodegenState::default()
};
let rects = code_action_rects(0.0, 0.0, 280.0, &s);
let buttons: Vec<_> = rects
.iter()
.filter(|(a, _)| !matches!(a, CodegenAction::SelectFramework(_)))
.collect();
assert_eq!(buttons.len(), 1);
assert!(matches!(buttons[0].0, CodegenAction::Regenerate));
}
/// The geometry round-trips: a click at a button's centre hits that
/// button's rect and no other action's rect (the layout the host's
/// `hit_test_action` walks).
#[test]
fn code_action_rects_generate_center_round_trips() {
let s = CodegenState::default(); // Idle.
let rects = code_action_rects(0.0, 0.0, 280.0, &s);
let (_, gen_rect) = rects
.iter()
.find(|(a, _)| matches!(a, CodegenAction::Generate))
.expect("Generate rect present");
let p = center(*gen_rect);
// Exactly one action contains the Generate button's centre, and
// it is Generate (full-width button + chips don't overlap it).
let hits: Vec<_> = rects
.iter()
.filter(|(_, r)| contains(*r, p))
.map(|(a, _)| *a)
.collect();
assert_eq!(hits, vec![CodegenAction::Generate]);
}
/// The framework chips wrap onto multiple rows at the real panel
/// width (280 px), and every chip carries a positive-size rect.
#[test]
fn framework_chips_wrap_and_have_size() {
let chips = framework_chip_rects(0.0, 0.0, 280.0);
assert_eq!(chips.len(), 8);
for (_, r) in &chips {
assert!(r.size.x > 0.0 && r.size.y > 0.0);
}
// At 280px the 8 wire tokens don't fit on one row → at least
// two distinct chip rows.
let rows: std::collections::BTreeSet<i32> =
chips.iter().map(|(_, r)| r.origin.y as i32).collect();
assert!(rows.len() >= 2);
}
}