fix: clear clippy -D warnings across the workspace

The op-* crate reorg never ran `cargo clippy -- -D warnings`, so the CI
lint gate failed. Fix every violation surgically: real fixes for
mechanical lints (needless_range_loop, derivable_impls, ptr_arg,
needless_borrow, doc_lazy_continuation, unused_imports, manual_find,
collapsible_match, never_loop, dead_code, complex types via type
aliases) and scoped `#[allow]` for intrusive ones (too_many_arguments
on paint helpers, result_large_err where ToolOutcome / PenNode payloads
are deliberately the Err type).
This commit is contained in:
Kayshen-X 2026-05-17 01:26:29 +08:00
parent 1ff061f41a
commit faafc99fb8
37 changed files with 341 additions and 232 deletions

View file

@ -68,7 +68,11 @@ impl Codegen for CssVariables {
// Group entries by their theme map signature so a "mode=dark"
// entry from variable A and another from B share one block.
use std::collections::BTreeMap;
let mut themed: BTreeMap<Vec<(String, String)>, Vec<(String, String)>> = BTreeMap::new();
// Maps a theme-map signature (sorted axis/value pairs) to the
// (variable-name, css-value) entries that share that signature.
type ThemeSignature = Vec<(String, String)>;
type ThemedBlock = Vec<(String, String)>;
let mut themed: BTreeMap<ThemeSignature, ThemedBlock> = BTreeMap::new();
for (name, def) in variables {
if let VariableValue::Themed(entries) = &def.value {
for e in entries {
@ -427,10 +431,12 @@ mod tests {
/// A rectangle at `(x,y)` sized `w×h` with an optional fill.
fn rect(id: &str, x: f64, y: f64, w: f64, h: f64, fill: Option<&str>) -> PenNode {
let mut container = jian_ops_schema::node::ContainerProps::default();
container.width = Some(SizingBehavior::Number(w));
container.height = Some(SizingBehavior::Number(h));
container.fill = fill.map(solid);
let container = jian_ops_schema::node::ContainerProps {
width: Some(SizingBehavior::Number(w)),
height: Some(SizingBehavior::Number(h)),
fill: fill.map(solid),
..Default::default()
};
PenNode::Rectangle(RectangleNode {
base: PenNodeBase {
id: id.into(),

View file

@ -6,6 +6,7 @@
//! - 2+ selected → union of the selection's aggregate bounds.
//! - 1 selected → parent container's aggregate bounds (a top-level
//! node has no useful reference and silently no-ops).
//!
//! Distribute requires 3+ nodes; fewer silently no-ops.
//!
//! All deltas go through [`crate::walkers::translate_subtree`], so
@ -146,8 +147,8 @@ fn apply_distribute(children: &mut [PenNode], editable: &[NodeId], action: Align
// descendant must not contribute two anchors.
let filtered: Vec<NodeId> = editable
.iter()
.cloned()
.filter(|id| !is_ancestor_in_set(children, id, editable))
.cloned()
.collect();
let mut sorted: Vec<(NodeId, DocRect)> = filtered
.iter()
@ -173,8 +174,8 @@ fn apply_distribute(children: &mut [PenNode], editable: &[NodeId], action: Align
let last_c = center(&sorted[n - 1].1);
let step = (last_c - first_c) / (n - 1) as f64;
let mut moved = false;
for i in 1..n - 1 {
let (id, cur) = (sorted[i].0.clone(), sorted[i].1);
for (i, entry) in sorted.iter().enumerate().take(n - 1).skip(1) {
let (id, cur) = (entry.0.clone(), entry.1);
let cur_c = center(&cur);
let target_c = first_c + step * i as f64;
let delta = target_c - cur_c;

View file

@ -222,8 +222,10 @@ mod tests {
#[test]
fn begin_send_pushes_user_plus_empty_assistant_and_raises_flag() {
let mut chat = ChatState::default();
chat.input = " design a login page ".into();
let mut chat = ChatState {
input: " design a login page ".into(),
..Default::default()
};
assert!(chat.begin_send());
assert_eq!(chat.messages.len(), 2);
assert_eq!(chat.messages[0].role, ChatRole::User);
@ -236,8 +238,10 @@ mod tests {
#[test]
fn begin_send_empty_input_no_ops() {
let mut chat = ChatState::default();
chat.input = " ".into();
let mut chat = ChatState {
input: " ".into(),
..Default::default()
};
assert!(!chat.begin_send());
assert!(chat.messages.is_empty());
assert!(chat.pending_send.is_none());
@ -245,8 +249,10 @@ mod tests {
#[test]
fn send_echo_appends_user_and_assistant() {
let mut chat = ChatState::default();
chat.input = "hi".into();
let mut chat = ChatState {
input: "hi".into(),
..Default::default()
};
chat.send();
assert_eq!(chat.messages.len(), 2);
assert_eq!(chat.messages[1].role, ChatRole::Assistant);

View file

@ -251,6 +251,9 @@ impl EditorState {
}
/// `InsertNode` — build + append a fresh leaf on the active page.
// Args mirror the `InsertNode` command fields one-for-one; bundling
// them into a struct would just shadow the DTO with no real gain.
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_insert_node(
&mut self,
kind: &str,
@ -285,6 +288,8 @@ impl EditorState {
}
/// `UpdateNode` — patch optional fields on an existing node.
// Args mirror the `UpdateNode` command fields one-for-one.
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_update_node(
&mut self,
node_id: &NodeId,

View file

@ -75,7 +75,7 @@ pub struct EditorState {
/// Editor-UI overlay + panel state — the widget-layer toggles, hover
/// targets, menu / modal open flags and panel metrics. With this
/// + `chat` + `components`, `EditorState` is a complete state
/// superset of shell-core's `Document` (Phase 6 Task 6.1a).
/// superset of shell-core's `Document` (Phase 6 Task 6.1a).
pub editor_ui: EditorUiState,
/// AI chat sub-state — message transcript, input draft, panel
/// anchor, model catalog. Mirrors shell-core's `Document.chat`.

View file

@ -6,7 +6,7 @@
use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt;
use crate::test_support::{frame, group, rect, sample, state_with, text};
use crate::test_support::{frame, group, rect, sample, state_with};
use crate::walkers::{find_node, ReorderDirection};
// --- Selection -------------------------------------------------------

View file

@ -6,8 +6,9 @@
//! (Task 4.7) is a re-export with no behaviour change.
/// Editor tool — what primary mouse drag does on the canvas.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tool {
#[default]
Select,
Rect,
Ellipse,
@ -58,9 +59,3 @@ impl Tool {
)
}
}
impl Default for Tool {
fn default() -> Self {
Tool::Select
}
}

View file

@ -107,6 +107,8 @@ pub fn extract_node(children: &mut Vec<PenNode>, target: &NodeId) -> Option<PenN
/// Insert `node` immediately before `anchor`. `Ok(())` on success;
/// `Err(node)` bounces the payload back on miss.
// `Err(PenNode)` is the bounced-back payload, not an error type.
#[allow(clippy::result_large_err)]
pub fn insert_before_in_children(
children: &mut Vec<PenNode>,
anchor: &NodeId,
@ -129,6 +131,8 @@ pub fn insert_before_in_children(
}
/// Insert `node` immediately after `anchor`. `Ok(())` / `Err(node)`.
// `Err(PenNode)` is the bounced-back payload, not an error type.
#[allow(clippy::result_large_err)]
pub fn insert_after_in_children(
children: &mut Vec<PenNode>,
anchor: &NodeId,
@ -152,8 +156,11 @@ pub fn insert_after_in_children(
/// Append `node` as the LAST child of `parent`. `Ok(())` / `Err(node)`.
/// Fails (bounces the payload) when `parent` is not a container.
// `Err(PenNode)` is the bounced-back payload, not an error type — boxing
// it would be a needless allocation on the hot insert path.
#[allow(clippy::result_large_err)]
pub fn append_into(
children: &mut Vec<PenNode>,
children: &mut [PenNode],
parent: &NodeId,
node: PenNode,
) -> Result<(), PenNode> {
@ -180,7 +187,7 @@ pub fn append_into(
/// Swap the node matching `target` with its next / prev sibling.
pub fn reorder_in_children(
children: &mut Vec<PenNode>,
children: &mut [PenNode],
target: &NodeId,
direction: ReorderDirection,
) -> bool {

View file

@ -594,7 +594,7 @@ fn cycle_active_axis_returns_false_for_empty_values() {
values: Vec::new(),
});
assert!(!tbl.cycle_active_axis_value("empty"));
assert!(tbl.active_theme.get("empty").is_none());
assert!(!tbl.active_theme.contains_key("empty"));
}
#[test]

View file

@ -161,12 +161,8 @@ impl<'a> AgentSettingsPanel<'a> {
return None;
}
let scrolled = Point2D::new(point.x, point.y + self.settings.scroll_y);
for i in 0..AgentProvider::ALL.len() {
if rect_contains(agent_card_rect_in(panel, i, &self.settings), scrolled) {
return Some(i);
}
}
None
(0..AgentProvider::ALL.len())
.find(|&i| rect_contains(agent_card_rect_in(panel, i, &self.settings), scrolled))
}
/// Total content height for the active tab. Host uses this to
@ -433,6 +429,8 @@ fn paint_section_header(
/// `right_inset` reserves space on the right edge for an
/// overlapping chrome element — currently the panel's close X
/// which sits over the top-of-content row.
// Paint-context + geometry args threaded through; a struct adds no gain.
#[allow(clippy::too_many_arguments)]
fn paint_section_header_inset(
cx: &mut PaintCx<'_>,
theme: &Theme,

View file

@ -7,6 +7,7 @@
//! - Align L / Center-H / Right
//! - Align Top / Center-V / Bottom
//! - Distribute H / Distribute V
//!
//! Anchored to the horizontal center of the canvas region, ~16 px
//! below the canvas top edge.
@ -32,6 +33,7 @@ pub const ALIGN_TOOLBAR_WIDTH: f32 =
/// column. Mirrors paint-side geometry: `TOOLBAR_INSET_X` (12 in
/// shell-native `widget_host/helpers.rs` and shell-web `widget_host.rs`)
/// + `TOOLBAR_WIDTH` (44) = 56 — leaves the tool column unobscured.
///
/// Has to be a shell-core local because shell-core can't depend on
/// either host crate's helpers. Keep in sync if either constant moves.
const VERTICAL_TOOLBAR_RESERVE: f32 = 56.0;

View file

@ -4,6 +4,7 @@
//! - Format radio group: PNG / JPEG / WEBP / SVG / PDF
//! - Scale radio group: 1× / 2× / 3×
//! - Cancel / Export buttons
//!
//! Selection writes back to `Document.ui.export_format` /
//! `export_scale`; Export dispatches `FileAction::ExportImage` so
//! the host's save dialog uses the chosen format + scale.

View file

@ -345,6 +345,8 @@ impl<'a> Widget for FileMenu<'a> {
}
}
// Paint-context + geometry args threaded through; a struct adds no gain.
#[allow(clippy::too_many_arguments)]
fn paint_row(
cx: &mut PaintCx<'_>,
theme: &Theme,

View file

@ -452,7 +452,7 @@ impl Widget for LayerPanel {
rect.origin.x,
y,
rect.size.x,
&self.pages_label,
self.pages_label,
);
// "+" add-page affordance, top-right of header row.
let plus_x = rect.origin.x + rect.size.x - ROW_PAD_X - 12.0;
@ -546,7 +546,7 @@ impl Widget for LayerPanel {
rect.origin.x,
y,
rect.size.x,
&self.layers_label,
self.layers_label,
);
y += SECTION_HEADER_HEIGHT;

View file

@ -78,6 +78,8 @@ pub(super) fn paint_drag_ghost(
/// background) with a subtle primary underline + blinking caret.
/// Tightened caret-to-text gap and uses `blink_visible` so the
/// caret pulses at the same cadence as the chat / property input.
// Paint-context + geometry args threaded through; a struct adds no gain.
#[allow(clippy::too_many_arguments)]
pub(super) fn paint_rename_input(
cx: &mut PaintCx<'_>,
theme: &Theme,

View file

@ -132,6 +132,8 @@ pub fn paint_fill_type_picker(
}
// ── Fill section ──────────────────────────────────────────────────
// Paint-context + geometry args threaded through; a struct adds no gain.
#[allow(clippy::too_many_arguments)]
pub fn paint_fill_section(
cx: &mut PaintCx<'_>,
theme: &Theme,
@ -144,7 +146,7 @@ pub fn paint_fill_section(
y: f32,
width: f32,
) -> f32 {
let mut y = paint_section_label_with_add(cx, theme, &labels.fill, x, y, width);
let mut y = paint_section_label_with_add(cx, theme, labels.fill, x, y, width);
let usable_w = width - PAD_X * 2.0;
let fill = snapshot.fill.unwrap_or(Color::WHITE);
let swatch_rect = Rect {

View file

@ -204,6 +204,8 @@ pub fn paint_input_with_icon(
paint_input_with_icon_focused(cx, theme, rect, icon, value, unit, false, false);
}
// Paint-context + geometry args threaded through; a struct adds no gain.
#[allow(clippy::too_many_arguments)]
pub fn paint_input_with_icon_focused(
cx: &mut PaintCx<'_>,
theme: &Theme,

View file

@ -150,12 +150,12 @@ pub fn paint_tab_strip(
use op_editor_core::PropertyTab;
let pad = 14.0;
let tab_y = y + 6.0;
let design_w = (cx.backend.measure_text(&labels.tab_design, 13.0) + 24.0).max(48.0);
let design_w = (cx.backend.measure_text(labels.tab_design, 13.0) + 24.0).max(48.0);
let design_rect = Rect {
origin: Point2D::new(x + pad, tab_y),
size: Point2D::new(design_w, 26.0),
};
let code_w = (cx.backend.measure_text(&labels.tab_code, 13.0) + 24.0).max(48.0);
let code_w = (cx.backend.measure_text(labels.tab_code, 13.0) + 24.0).max(48.0);
let code_rect = Rect {
origin: Point2D::new(design_rect.origin.x + design_rect.size.x + 6.0, tab_y),
size: Point2D::new(code_w, 26.0),
@ -176,7 +176,7 @@ pub fn paint_tab_strip(
theme.muted_foreground
};
let design_label = TextLayout::single_run(
&labels.tab_design,
labels.tab_design,
"system-ui",
13.0,
to_jian_color(design_color),
@ -187,7 +187,7 @@ pub fn paint_tab_strip(
Point2D::new(design_rect.origin.x + 12.0, design_rect.origin.y + 18.0),
);
let code_label = TextLayout::single_run(
&labels.tab_code,
labels.tab_code,
"system-ui",
13.0,
to_jian_color(code_color),
@ -260,13 +260,13 @@ pub fn paint_create_component(
1.4,
);
let label = TextLayout::single_run(
&labels.create_component,
labels.create_component,
"system-ui",
13.0,
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
let label_w = cx.backend.measure_text(&labels.create_component, 13.0);
let label_w = cx.backend.measure_text(labels.create_component, 13.0);
cx.backend.draw_text(
&label,
Point2D::new(
@ -279,6 +279,8 @@ pub fn paint_create_component(
// ── Position section ──────────────────────────────────────────────
// Paint-context + geometry args threaded through; a struct adds no gain.
#[allow(clippy::too_many_arguments)]
pub fn paint_position_section(
cx: &mut PaintCx<'_>,
theme: &Theme,
@ -289,7 +291,7 @@ pub fn paint_position_section(
y: f32,
width: f32,
) -> f32 {
let mut y = paint_section_label(cx, theme, &labels.position, x, y, width);
let mut y = paint_section_label(cx, theme, labels.position, x, y, width);
let usable_w = width - PAD_X * 2.0;
let half_w = (usable_w - 8.0) / 2.0;
let x_rect = Rect {
@ -370,7 +372,7 @@ pub fn paint_flex_section(
y: f32,
width: f32,
) -> f32 {
let mut y = paint_section_label(cx, theme, &labels.flex_layout, x, y, width);
let mut y = paint_section_label(cx, theme, labels.flex_layout, x, y, width);
// TS layout-section.tsx uses Columns3 / Rows3 / LayoutGrid for
// the three flex modes; LayoutGrid is the default-active mode
// (Free / 自由布局).
@ -418,6 +420,8 @@ pub fn paint_flex_section(
// ── Size section ──────────────────────────────────────────────────
// Paint-context + geometry args threaded through; a struct adds no gain.
#[allow(clippy::too_many_arguments)]
pub fn paint_size_section(
cx: &mut PaintCx<'_>,
theme: &Theme,
@ -429,7 +433,7 @@ pub fn paint_size_section(
y: f32,
width: f32,
) -> f32 {
let mut y = paint_section_label(cx, theme, &labels.size, x, y, width);
let mut y = paint_section_label(cx, theme, labels.size, x, y, width);
let usable_w = width - PAD_X * 2.0;
let half_w = (usable_w - 8.0) / 2.0;
let w_rect = Rect {
@ -468,7 +472,7 @@ pub fn paint_size_section(
x + PAD_X,
y,
half_w,
&labels.fill_width,
labels.fill_width,
flags.fill_width,
);
paint_check_row(
@ -477,7 +481,7 @@ pub fn paint_size_section(
x + PAD_X + half_w + 8.0,
y,
half_w,
&labels.fill_height,
labels.fill_height,
flags.fill_height,
);
y += row_h;
@ -487,7 +491,7 @@ pub fn paint_size_section(
x + PAD_X,
y,
half_w,
&labels.hug_width,
labels.hug_width,
flags.hug_width,
);
paint_check_row(
@ -496,7 +500,7 @@ pub fn paint_size_section(
x + PAD_X + half_w + 8.0,
y,
half_w,
&labels.hug_height,
labels.hug_height,
flags.hug_height,
);
y += row_h;
@ -506,7 +510,7 @@ pub fn paint_size_section(
x + PAD_X,
y,
usable_w,
&labels.clip_content,
labels.clip_content,
flags.clip_content,
);
y += row_h + 12.0;
@ -562,7 +566,7 @@ pub fn paint_layer_section(
y: f32,
width: f32,
) -> f32 {
let mut y = paint_section_label(cx, theme, &labels.layer, x, y, width);
let mut y = paint_section_label(cx, theme, labels.layer, x, y, width);
let usable_w = width - PAD_X * 2.0;
let row = Rect {
origin: Point2D::new(x + PAD_X, y),
@ -586,6 +590,8 @@ pub fn paint_layer_section(
// ── Stroke section ────────────────────────────────────────────────
// Paint-context + geometry args threaded through; a struct adds no gain.
#[allow(clippy::too_many_arguments)]
pub fn paint_stroke_section(
cx: &mut PaintCx<'_>,
theme: &Theme,
@ -596,7 +602,7 @@ pub fn paint_stroke_section(
y: f32,
width: f32,
) -> f32 {
let mut y = paint_section_label(cx, theme, &labels.stroke, x, y, width);
let mut y = paint_section_label(cx, theme, labels.stroke, x, y, width);
let usable_w = width - PAD_X * 2.0;
let stroke_color = snapshot.stroke.map(|s| s.color).unwrap_or(Color {
r: 0x37 as f32 / 255.0,
@ -695,7 +701,7 @@ pub fn paint_effects_section(
y: f32,
width: f32,
) -> f32 {
let y = paint_section_label_with_add(cx, theme, &labels.effects, x, y, width);
let y = paint_section_label_with_add(cx, theme, labels.effects, x, y, width);
let after = y + 8.0;
paint_section_divider(cx, theme, x, after, width);
after + SECTION_GAP
@ -703,6 +709,8 @@ pub fn paint_effects_section(
// ── Export section ────────────────────────────────────────────────
// Paint-context + geometry args threaded through; a struct adds no gain.
#[allow(clippy::too_many_arguments)]
pub fn paint_export_section(
cx: &mut PaintCx<'_>,
theme: &Theme,
@ -713,7 +721,7 @@ pub fn paint_export_section(
y: f32,
width: f32,
) -> f32 {
let mut y = paint_section_label(cx, theme, &labels.export, x, y, width);
let mut y = paint_section_label(cx, theme, labels.export, x, y, width);
let usable_w = width - PAD_X * 2.0;
let half_w = (usable_w - 8.0) / 2.0;
// Left pill: current scale. Right pill: current format. Click

View file

@ -18,7 +18,7 @@ use crate::theme::Theme;
use crate::widgets::editor_state_ext::theme_for;
use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
use crate::{Color, Point2D, Rect};
use crate::{Point2D, Rect};
use op_editor_core::EditorState;
use op_editor_core::Tool;
@ -380,14 +380,13 @@ fn paint_button(cx: &mut PaintCx<'_>, theme: &Theme, x: f32, y: f32, icon: Icon,
origin: Point2D::new(x, y),
size: Point2D::new(BUTTON_SIZE, BUTTON_SIZE),
};
let icon_color: Color;
if active {
let icon_color = if active {
cx.backend
.fill_round_rect(button_rect, BUTTON_RADIUS, theme.primary);
icon_color = theme.primary_foreground;
theme.primary_foreground
} else {
icon_color = theme.muted_foreground;
}
theme.muted_foreground
};
let icon_origin = Point2D::new(
x + (BUTTON_SIZE - ICON_SIZE) / 2.0,
y + (BUTTON_SIZE - ICON_SIZE) / 2.0,

View file

@ -2,8 +2,8 @@
//!
//! Mirrors `apps/web/src/components/editor/top-bar.tsx`: panel-toggle
//! + folder + brand on the left, file name centered, theme +
//! agent-status + i18n + fullscreen on the right. Click handling is
//! a P6 follow-up; Step 4 paints only.
//! agent-status + i18n + fullscreen on the right. Click handling is
//! a P6 follow-up; Step 4 paints only.
use crate::theme::Theme;
use crate::widgets::editor_state_ext::{theme_for, translate};

View file

@ -170,51 +170,6 @@ fn collect_top_level_blocks(s: &str) -> Option<Vec<&str>> {
None
}
/// Walk a clipboard-JSON payload and count the top-level entries in
/// the `"children": [ ... ]` array. Hand-rolled parser (no serde,
/// shell-core stays wasm32-light) — counts `{` openings at depth 1
/// inside the children array. Returns `None` when the children key
/// is absent or the brace tracker can't find a matching close.
fn count_top_level_children(s: &str) -> Option<usize> {
let needle = "\"children\"";
let key_at = s.find(needle)?;
let after = &s[key_at + needle.len()..];
let bracket = after.find('[')?;
let mut depth = 0i32;
let mut count = 0usize;
let mut in_string = false;
let mut escape = false;
for c in after[bracket..].chars().skip(1) {
if escape {
escape = false;
continue;
}
if c == '\\' {
escape = true;
continue;
}
if c == '"' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
match c {
'{' => {
if depth == 0 {
count += 1;
}
depth += 1;
}
'}' => depth -= 1,
']' if depth == 0 => return Some(count),
_ => {}
}
}
None
}
/// The canonical `PenNode` variant a Figma clipboard kind maps onto.
/// `PenNode` is a closed 12-variant enum with no `Other` escape
/// hatch, so unrecognised Figma kinds round-trip as `Frame` (the
@ -597,7 +552,9 @@ mod tests {
#[test]
fn figma_clipboard_node_to_node_builds_matching_pen_node_variant() {
let cases: [(&str, fn(&PenNode) -> bool); 8] = [
// (figma clipboard kind, predicate asserting the mapped variant).
type VariantCase = (&'static str, fn(&PenNode) -> bool);
let cases: [VariantCase; 8] = [
("RECTANGLE", |n| matches!(n, PenNode::Rectangle(_))),
("ELLIPSE", |n| matches!(n, PenNode::Ellipse(_))),
("LINE", |n| matches!(n, PenNode::Line(_))),

View file

@ -357,18 +357,10 @@ async fn discover_port(
/// after any of those tokens.
pub(crate) fn parse_listening_line(line: &str) -> Option<u16> {
let lower = line.to_ascii_lowercase();
let mut idx = 0;
while idx < lower.len() {
if let Some(pos) = lower[idx..].find("listening") {
idx += pos + "listening".len();
// Scan for the first digit run after this token.
let rest = &line[idx..];
return extract_port(rest);
} else {
break;
}
}
None
let pos = lower.find("listening")?;
let idx = pos + "listening".len();
// Scan for the first digit run after this token.
extract_port(&line[idx..])
}
/// Pull the actual port (not an IP octet) out of `rest`. Strategy:

View file

@ -71,7 +71,7 @@ impl BuiltInProvider {
/// Build a BuiltIn provider from a hand-rolled `Provider` impl.
/// Used by tests + future settings tabs that need a custom backend
/// (e.g., a local proxy or a mocked HTTP server). `system_prompt`
/// + `max_output_tokens` apply to every turn for the lifetime of
/// and `max_output_tokens` apply to every turn for the lifetime of
/// the provider; rebuild a new `BuiltInProvider` to change them.
#[allow(dead_code)]
pub fn from_provider(

View file

@ -206,7 +206,6 @@ fn build_command(binary: &str, args: &[String]) -> Command {
// we never depend on signal propagation for cleanup.
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
cmd

View file

@ -10,6 +10,7 @@
//! - Line : diagonal stroke across `bounds`
//! - Path : polyline through `node.points`
//! - Text : authored content at the resolved bounds
//!
//! Per-node rotation honoured via `Canvas::rotate`.
//!
//! The scene is layout-resolved: every `SceneNode::bounds` is the
@ -107,15 +108,68 @@ pub fn export_raster(
format: RasterFormat,
scale: f32,
) -> Result<(), String> {
let scale = if scale.is_finite() {
scale.clamp(0.5, 8.0)
} else {
2.0
};
let scale = clamp_scale(scale);
let Some(page) = scene.active_page() else {
return Err("no active page".into());
};
let bounds = page_bounds(page).ok_or("nothing to export")?;
render_raster(bounds, target, format, scale, |canvas| {
for node in &page.children {
paint_node(canvas, node);
}
})
}
/// Raster-export a single node + its subtree by id — the "export this
/// layer" path (TS parity: `exportLayerToRaster`). The surface is
/// cropped to the node's painted bounds via the same `collect_bounds`
/// traversal `export_raster` uses for the whole page. Errors when the
/// id is unknown on the active page or the node paints nothing.
pub fn export_node_raster(
scene: &LayoutScene,
node_id: &str,
target: &StdPath,
format: RasterFormat,
scale: f32,
) -> Result<(), String> {
let scale = clamp_scale(scale);
let Some(page) = scene.active_page() else {
return Err("no active page".into());
};
let node = page
.find(node_id)
.ok_or_else(|| format!("node {node_id} not found on the active page"))?;
let mut acc = BoundsAcc::new();
collect_bounds(node, glam::Affine2::IDENTITY, &mut acc);
let bounds = acc
.into_rect()
.ok_or_else(|| format!("node {node_id} paints nothing"))?;
render_raster(bounds, target, format, scale, |canvas| {
paint_node(canvas, node);
})
}
/// Clamp a caller-supplied export scale to the @0.5x..@8x range,
/// defaulting a non-finite value to @2x (TS export-dialog parity).
fn clamp_scale(scale: f32) -> f32 {
if scale.is_finite() {
scale.clamp(0.5, 8.0)
} else {
2.0
}
}
/// Shared surface-alloc + background-clear + encode + write path for
/// the whole-page and single-node raster exporters. `bounds` is the
/// painted-content rect (doc-space); `paint` draws into the canvas
/// after it has been scaled + translated so `bounds` sits at `MARGIN`.
fn render_raster(
bounds: Rect,
target: &StdPath,
format: RasterFormat,
scale: f32,
paint: impl FnOnce(&Canvas),
) -> Result<(), String> {
let width_px = ((bounds.size.x + MARGIN * 2.0) * scale).round() as i32;
let height_px = ((bounds.size.y + MARGIN * 2.0) * scale).round() as i32;
let info = skia_safe::ImageInfo::new(
@ -133,9 +187,7 @@ pub fn export_raster(
}
canvas.scale((scale, scale));
canvas.translate((MARGIN - bounds.origin.x, MARGIN - bounds.origin.y));
for node in &page.children {
paint_node(canvas, node);
}
paint(canvas);
let image = surface.image_snapshot();
let data = image
.encode(None, format.skia(), format.quality())
@ -815,4 +867,61 @@ mod tests {
assert_eq!(b.size.x, 200.0);
assert_eq!(b.size.y, 100.0);
}
#[test]
fn export_node_raster_crops_to_the_named_node() {
// Two side-by-side rects: a 100×50 at origin and a 40×40 far
// away. Exporting only the small node must produce a surface
// cropped to ITS bounds, not the page union.
let grey = Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
};
let scene = scene_with(vec![
filled_rect("big", 0.0, 0.0, 100.0, 50.0, grey),
filled_rect("small", 400.0, 400.0, 40.0, 40.0, grey),
]);
let tmp = std::env::temp_dir().join(format!("op-export-node-{}.png", std::process::id()));
let res = export_node_raster(&scene, "small", &tmp, RasterFormat::Png, 1.0);
assert!(res.is_ok(), "export_node_raster failed: {res:?}");
let bytes = std::fs::read(&tmp).unwrap();
assert_eq!(
&bytes[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
);
// The cropped surface is the 40×40 node + 2×MARGIN, far
// smaller than the ~440 px page union the whole-page export
// would have produced. PNG IHDR carries the dimensions as
// big-endian u32s at byte offsets 16 (width) and 20 (height).
let png_width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let png_height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
let expected = (40.0 + MARGIN * 2.0) as u32;
assert_eq!(png_width, expected);
assert_eq!(png_height, expected);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_node_raster_errors_on_unknown_id() {
let scene = scene_with(vec![filled_rect(
"n10",
0.0,
0.0,
10.0,
10.0,
Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
)]);
let tmp =
std::env::temp_dir().join(format!("op-export-node-miss-{}.png", std::process::id()));
let res = export_node_raster(&scene, "ghost", &tmp, RasterFormat::Png, 1.0);
assert!(res.is_err(), "expected Err on unknown id, got {res:?}");
assert!(res.unwrap_err().contains("not found"));
}
}

View file

@ -144,43 +144,6 @@ impl DesktopApp {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_only_redraw_without_visible_state_change_skips_present() {
let mut app = DesktopApp::new();
app.redraw_pending = true;
app.pending_cursor_move = Some((1200.0, 20.0));
assert!(!app.prepare_redraw());
assert!(!app.redraw_pending);
assert!(app.pending_cursor_move.is_none());
}
#[test]
fn consumed_press_dirties_existing_cursor_redraw_without_second_request() {
let mut app = DesktopApp::new();
app.redraw_pending = true;
assert!(!app.request_redraw(true));
assert!(app.prepare_redraw());
}
#[test]
fn cursor_redraw_still_paints_when_layer_hover_changes() {
let mut app = DesktopApp::new();
app.redraw_pending = true;
app.pending_cursor_move = Some((
20.0,
op_editor_ui::widgets::TOP_BAR_HEIGHT + 8.0 + 28.0 + 16.0,
));
assert!(app.prepare_redraw());
}
}
impl ApplicationHandler for DesktopApp {
fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
// When the WaitUntil deadline fires, the next redraw paints
@ -515,22 +478,19 @@ impl ApplicationHandler for DesktopApp {
self.request_redraw(true);
}
}
WindowEvent::Ime(ime) => {
// CJK composition: macOS / X11 / Wayland route the
// committed candidate string through here. We don't
// paint the preedit yet; only the final commit is
// pushed into the focused input.
if let winit::event::Ime::Commit(text) = ime {
let mut consumed = false;
for ch in text.chars() {
if self.host.apply_text(ch) {
consumed = true;
}
}
if consumed {
self.request_redraw(true);
// CJK composition: macOS / X11 / Wayland route the committed
// candidate string through here. We don't paint the preedit
// yet; only the final commit is pushed into the focused input.
WindowEvent::Ime(winit::event::Ime::Commit(text)) => {
let mut consumed = false;
for ch in text.chars() {
if self.host.apply_text(ch) {
consumed = true;
}
}
if consumed {
self.request_redraw(true);
}
}
WindowEvent::ModifiersChanged(mods) => {
let state = mods.state();
@ -811,3 +771,40 @@ fn main() {
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_only_redraw_without_visible_state_change_skips_present() {
let mut app = DesktopApp::new();
app.redraw_pending = true;
app.pending_cursor_move = Some((1200.0, 20.0));
assert!(!app.prepare_redraw());
assert!(!app.redraw_pending);
assert!(app.pending_cursor_move.is_none());
}
#[test]
fn consumed_press_dirties_existing_cursor_redraw_without_second_request() {
let mut app = DesktopApp::new();
app.redraw_pending = true;
assert!(!app.request_redraw(true));
assert!(app.prepare_redraw());
}
#[test]
fn cursor_redraw_still_paints_when_layer_hover_changes() {
let mut app = DesktopApp::new();
app.redraw_pending = true;
app.pending_cursor_move = Some((
20.0,
op_editor_ui::widgets::TOP_BAR_HEIGHT + 8.0 + 28.0 + 16.0,
));
assert!(app.prepare_redraw());
}
}

View file

@ -348,25 +348,28 @@ pub fn run_action(
// (runs jian's flex pass + `$ref` fill resolution).
let scene = op_pen_loader::editor_state_to_layout_scene(host.editor_state());
let scene = &scene;
// When exactly one node is selected, raster export
// crops to that layer (TS parity: exportLayerToRaster);
// otherwise the whole active page is exported. SVG /
// PDF always stay page-level.
let single_node: Option<String> = {
let st = host.editor_state();
if st.selection_count() == 1 && st.selection.anchor.is_real() {
Some(st.selection.anchor.as_str().to_string())
} else {
None
}
};
let raster = |rf: crate::export::RasterFormat| -> Result<(), String> {
match &single_node {
Some(id) => crate::export::export_node_raster(scene, id, &path, rf, scale),
None => crate::export::export_raster(scene, &path, rf, scale),
}
};
let result: Result<(), String> = match fmt {
Fmt::Png => crate::export::export_raster(
scene,
&path,
crate::export::RasterFormat::Png,
scale,
),
Fmt::Jpeg => crate::export::export_raster(
scene,
&path,
crate::export::RasterFormat::Jpeg,
scale,
),
Fmt::Webp => crate::export::export_raster(
scene,
&path,
crate::export::RasterFormat::Webp,
scale,
),
Fmt::Png => raster(crate::export::RasterFormat::Png),
Fmt::Jpeg => raster(crate::export::RasterFormat::Jpeg),
Fmt::Webp => raster(crate::export::RasterFormat::Webp),
Fmt::Svg => crate::export::export_svg(scene, &path),
Fmt::Pdf => crate::export_pdf::export_pdf(scene, &path),
};

View file

@ -112,7 +112,7 @@ pub struct NativeBackend {
const ROBOTO_TTF: &[u8] = include_bytes!("../../../op-host-web/assets/Roboto-Regular.ttf");
/// Union of every CJK codepoint that appears in the editor chrome
/// + settings modal. Pre-warmed at `NativeBackend::new` so the
/// and the settings modal. Pre-warmed at `NativeBackend::new` so the
/// first cross-tab paint doesn't synchronously call
/// `FontMgr::match_family_style_character` for ~50 fresh glyphs.
const PREWARM_CJK_CODEPOINTS: &str = "\

View file

@ -91,7 +91,7 @@ impl WidgetHostNative {
};
// shell-core hit-test returns shell-core `NodeId`s; translate
// to op-editor-core ids for storage on `editor_ui`.
let new_layer_ec = new_layer.as_ref().map(|id| id.clone());
let new_layer_ec = new_layer.clone();
let changed = new_layer_ec != self.editor_state.editor_ui.hovered_layer_id
|| new_page != self.editor_state.editor_ui.hovered_page_index;
if changed {

View file

@ -3,9 +3,9 @@
//! Per-OS dispatch:
//! - Linux : EGL pbuffer + `SharedSkiaContext` GL surface (non-ignored).
//! - macOS : invisible winit window + `SharedSkiaContext::new_desktop`
//! (non-ignored — runs on developer machine + macOS CI).
//! (non-ignored — runs on developer machine + macOS CI).
//! - Windows: `#[ignore]` per spec §8.1 (no GPU on standard Actions runner;
//! covered by manual smoke notes).
//! covered by manual smoke notes).
//!
//! All paths drive a single chrome `fill_rect`(red) through the live
//! pipeline and read pixels back via the FBO chain (spec §7.2 #3).

View file

@ -235,6 +235,9 @@ fn composition_update_no_selection_passthrough() {
}
#[test]
// The reversed `3..1` range is the deliberate test input — the mapper
// must reject a misordered selection and return `None`.
#[allow(clippy::reversed_empty_ranges)]
fn composition_update_misordered_selection_returns_none() {
let update = composition_update("hello".to_string(), Some(3..1));
match update.kind {

View file

@ -345,6 +345,9 @@ pub fn set_node_name_snapshot() -> SetNodeName {
/// Parse an optional `"true"` / `"false"` argument. `Ok(None)` when
/// the key is absent; `Err` on a non-boolean value.
// `ToolOutcome` is the shared MCP outcome type — boxing it broadly to
// shrink the `Err` variant would destabilize every tool signature.
#[allow(clippy::result_large_err)]
fn parse_opt_bool(args: &BTreeMap<String, String>, key: &str) -> Result<Option<bool>, ToolOutcome> {
match args.get(key) {
None => Ok(None),
@ -361,6 +364,8 @@ fn parse_opt_bool(args: &BTreeMap<String, String>, key: &str) -> Result<Option<b
/// Parse an optional finite-`f64` argument. `Ok(None)` when the key is
/// absent; `Err` on a non-numeric / non-finite value.
// `ToolOutcome` is the shared MCP outcome type — see `parse_opt_bool`.
#[allow(clippy::result_large_err)]
fn parse_opt_f64(args: &BTreeMap<String, String>, key: &str) -> Result<Option<f64>, ToolOutcome> {
match args.get(key) {
None => Ok(None),

View file

@ -36,6 +36,9 @@ pub fn set_active_page_snapshot() -> SetActivePage {
SetActivePage
}
// `ToolOutcome` is the shared MCP outcome type — boxing it broadly to
// shrink the `Err` variant would destabilize every tool signature.
#[allow(clippy::result_large_err)]
fn parse_u32_arg(args: &BTreeMap<String, String>, key: &str) -> Result<u32, ToolOutcome> {
let Some(raw) = args.get(key) else {
return Err(ToolOutcome::Err(
@ -327,6 +330,8 @@ impl McpTool for SetViewport {
"set_viewport"
}
fn call(&self, args: &BTreeMap<String, String>) -> ToolOutcome {
// `ToolOutcome` is the shared MCP outcome type — see `parse_u32_arg`.
#[allow(clippy::result_large_err)]
fn parse_opt_i32(
args: &BTreeMap<String, String>,
key: &str,
@ -457,7 +462,7 @@ impl McpTool for SetActiveTool {
const ALLOWED_TOOLS: &[&str] = &[
"select", "rect", "ellipse", "polygon", "line", "pen", "text", "frame", "hand",
];
if !ALLOWED_TOOLS.iter().any(|t| *t == tool.as_str()) {
if !ALLOWED_TOOLS.contains(&tool.as_str()) {
return ToolOutcome::Err(
ToolErrorCode::InvalidArgument,
format!(

View file

@ -483,8 +483,6 @@ fn extract_field<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let val = after_colon[colon..].trim_start();
let val_start = start + colon + (after_colon[colon..].len() - val.len());
// Read until next , or }.
let end_rel = val
.find(|c: char| c == ',' || c == '}')
.unwrap_or(val.len());
let end_rel = val.find([',', '}']).unwrap_or(val.len());
Some(line[val_start..val_start + end_rel].trim())
}

View file

@ -66,6 +66,9 @@ impl McpTool for NudgeSelected {
"nudge_selected"
}
fn call(&self, args: &BTreeMap<String, String>) -> ToolOutcome {
// `ToolOutcome` is the shared MCP outcome type — boxing it broadly
// to shrink the `Err` variant would destabilize every tool signature.
#[allow(clippy::result_large_err)]
fn parse_required_i32(
args: &BTreeMap<String, String>,
key: &str,

View file

@ -18,6 +18,9 @@ use super::{EditorCommand, McpTool, ToolErrorCode, ToolOutcome};
/// Parse a `node_id`-style argument into a `NodeId`. Node ids are
/// canonical `.op` schema strings — any non-empty string is valid; an
/// empty string (the NONE sentinel) is rejected.
// `ToolOutcome` is the shared MCP outcome type — boxing it broadly to
// shrink the `Err` variant would destabilize every tool signature.
#[allow(clippy::result_large_err)]
pub(super) fn parse_node_id(
args: &BTreeMap<String, String>,
key: &str,
@ -206,6 +209,8 @@ pub(super) const ALLOWED_KINDS: &[&str] = &[
"frame", "group", "rect", "ellipse", "polygon", "line", "text", "path",
];
// `ToolOutcome` is the shared MCP outcome type — see `parse_node_id`.
#[allow(clippy::result_large_err)]
fn parse_i32_arg(args: &BTreeMap<String, String>, key: &str) -> Result<i32, ToolOutcome> {
let Some(raw) = args.get(key) else {
return Err(ToolOutcome::Err(

View file

@ -8,34 +8,31 @@
//! inside the bundle and the wasm becomes loadable in browsers.
//!
//! Categories (counts after dedup against the actual import list):
//! - allocator: malloc / free / calloc / realloc / malloc_usable_size
//! → dlmalloc-rs
//! - libm: asinh / acosh / atanh / nextafterf / remainder
//! → libm crate
//! - libc string: memchr / wmemchr / strcmp / strcpy / strtoull
//! → hand-rolled byte-wise impls
//! - libc stdio: snprintf / vsnprintf / vfprintf
//! → C-side stubs (in `src/stdio_stub.c`) that route
//! into Rust extern `wasm_libc_shim_stdio_panic` and
//! panic with the symbol name. Skia's font printf
//! paths are NOT exercised on the raster +
//! custom_empty fontmgr pipeline (verified: 0 env.*
//! imports in the post-link bundle); a runtime panic
//! here would mean a new Skia call site landed and
//! needs a real impl, not a silent empty success.
//! - libc misc: abort / __errno_location
//! - C++ ABI: __cxa_atexit / __cxa_guard_acquire / __cxa_guard_release
//! / __cxa_pure_virtual
//! - allocator: malloc / free / calloc / realloc / malloc_usable_size
//! → dlmalloc-rs
//! - libm: asinh / acosh / atanh / nextafterf / remainder
//! → libm crate
//! - libc string: memchr / wmemchr / strcmp / strcpy / strtoull
//! → hand-rolled byte-wise impls
//! - libc stdio: snprintf / vsnprintf / vfprintf
//! → C-side stubs (in `src/stdio_stub.c`) that route into Rust extern
//! `wasm_libc_shim_stdio_panic` and panic with the symbol name. Skia's
//! font printf paths are NOT exercised on the raster + custom_empty
//! fontmgr pipeline (verified: 0 env.* imports in the post-link
//! bundle); a runtime panic here would mean a new Skia call site
//! landed and needs a real impl, not a silent empty success.
//! - libc misc: abort / __errno_location
//! - C++ ABI: __cxa_atexit / __cxa_guard_acquire / __cxa_guard_release
//! / __cxa_pure_virtual
//! - operator new/delete (`_Znwm`/`_Znam`/`_ZdlPv*`/`_ZdaPv*`)
//! → forward to malloc/free
//! - threads: sem_init / sem_destroy / sem_post / sem_wait
//! → no-op (single-threaded wasm)
//! → forward to malloc/free
//! - threads: sem_init / sem_destroy / sem_post / sem_wait
//! → no-op (single-threaded wasm)
//! - libcxx string / locale / iostream / shared_weak_count / to_string
//! → panic stubs; these symbols are linker-pulled by
//! templated code that the skia raster + empty
//! fontmgr pipeline does not exercise at runtime.
//! If any IS hit, the panic becomes a JS exception
//! via console_error_panic_hook.
//! → panic stubs; these symbols are linker-pulled by templated code
//! that the skia raster + empty fontmgr pipeline does not exercise at
//! runtime. If any IS hit, the panic becomes a JS exception via
//! console_error_panic_hook.
//!
//! IMPORTANT: this crate must NOT be a Cargo workspace member of any
//! native (non-wasm) build — its symbols intentionally collide with