diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index aa4c57eda..cd333fbd5 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -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 { diff --git a/crates/op-editor-ui/src/widgets/property_panel_code.rs b/crates/op-editor-ui/src/widgets/property_panel_code.rs index e2dadf1f2..a74704a7f 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_code.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_code.rs @@ -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) -> (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 " 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 = + chips.iter().map(|(_, r)| r.origin.y as i32).collect(); + assert!(rows.len() >= 2); + } }