diff --git a/crates/op-acp/src/client.rs b/crates/op-acp/src/client.rs index 760e50528..6df705a7e 100644 --- a/crates/op-acp/src/client.rs +++ b/crates/op-acp/src/client.rs @@ -13,8 +13,8 @@ use tokio::task::JoinHandle; use crate::jsonrpc::{dispatch_inbound, JsonRpcEngine}; use crate::protocol::{ - InitializeResult, NewSessionResult, SessionNotification, METHOD_INITIALIZE, - METHOD_SESSION_NEW, METHOD_SESSION_PROMPT, PROTOCOL_VERSION, + InitializeResult, NewSessionResult, SessionNotification, METHOD_INITIALIZE, METHOD_SESSION_NEW, + METHOD_SESSION_PROMPT, PROTOCOL_VERSION, }; use crate::transport::{read_frame, write_frame}; use crate::types::{AcpAgentConfig, AcpAgentInfo, AcpError, ConnectionType}; @@ -63,9 +63,7 @@ impl AcpConnection { let mut buf = BufReader::new(read); loop { match read_frame(&mut buf).await { - Ok(Some(value)) => { - dispatch_inbound(value, &pending, ¬if_tx, &reply_tx) - } + Ok(Some(value)) => dispatch_inbound(value, &pending, ¬if_tx, &reply_tx), // EOF or transport failure — stop reading. Ok(None) | Err(_) => break, } @@ -291,10 +289,7 @@ mod tests { /// A mock ACP agent: answers `initialize` / `session/new`, then on /// `session/prompt` streams one message chunk and returns. - async fn mock_agent( - read: impl AsyncRead + Unpin, - mut write: impl AsyncWrite + Unpin, - ) { + async fn mock_agent(read: impl AsyncRead + Unpin, mut write: impl AsyncWrite + Unpin) { let mut buf = BufReader::new(read); while let Ok(Some(frame)) = read_frame(&mut buf).await { let id = frame.get("id").cloned().unwrap_or(Value::Null); @@ -352,7 +347,9 @@ mod tests { let session = conn.new_session().await.expect("new_session"); assert_eq!(session, "sess-1"); - conn.prompt(&session, "design a button").await.expect("prompt"); + conn.prompt(&session, "design a button") + .await + .expect("prompt"); // The streamed chunk reached the notification channel. let note = notes.recv().await.expect("a session/update"); assert_eq!(note.session_id.as_deref(), Some("sess-1")); @@ -372,7 +369,10 @@ mod tests { // The reader drains pending requests on EOF, so the call // resolves to `Closed` at once rather than after the 30s // handshake timeout. - assert!(matches!(err, AcpError::Closed), "expected Closed, got {err:?}"); + assert!( + matches!(err, AcpError::Closed), + "expected Closed, got {err:?}" + ); assert!( started.elapsed() < Duration::from_secs(5), "must fail fast, not wait out the timeout" diff --git a/crates/op-acp/src/event_adapter.rs b/crates/op-acp/src/event_adapter.rs index 2c05c35b9..5b3b48325 100644 --- a/crates/op-acp/src/event_adapter.rs +++ b/crates/op-acp/src/event_adapter.rs @@ -53,8 +53,7 @@ pub fn session_update_to_delta(note: &SessionNotification) -> Option .. } => match status.as_deref() { Some("failed") => { - let msg = extract_tool_error(content) - .unwrap_or_else(|| raw_output.to_string()); + let msg = extract_tool_error(content).unwrap_or_else(|| raw_output.to_string()); Some(ChatDelta::Error(msg)) } // `completed` is not a stream terminator — the turn ends diff --git a/crates/op-acp/src/jsonrpc.rs b/crates/op-acp/src/jsonrpc.rs index 0a2310bfe..d3bdba105 100644 --- a/crates/op-acp/src/jsonrpc.rs +++ b/crates/op-acp/src/jsonrpc.rs @@ -66,8 +66,7 @@ impl JsonRpcEngine { self.pending.lock().unwrap().insert(id, tx); let req = JsonRpcRequest::new(id, method, params); - let frame = - serde_json::to_value(&req).map_err(|e| AcpError::Protocol(e.to_string()))?; + let frame = serde_json::to_value(&req).map_err(|e| AcpError::Protocol(e.to_string()))?; if self.out_tx.send(frame).is_err() { self.pending.lock().unwrap().remove(&id); return Err(AcpError::Closed); @@ -79,9 +78,7 @@ impl JsonRpcEngine { Ok(Err(_)) => Err(AcpError::Closed), Err(_) => { self.pending.lock().unwrap().remove(&id); - Err(AcpError::Transport(format!( - "request '{method}' timed out" - ))) + Err(AcpError::Transport(format!("request '{method}' timed out"))) } } } @@ -91,8 +88,8 @@ impl JsonRpcEngine { /// request and build the JSON-RPC result that selects it. Mirrors the /// TS `requestPermission` handler. pub fn auto_approve_permission(params: &Value) -> Value { - let parsed: RequestPermissionParams = - serde_json::from_value(params.clone()).unwrap_or(RequestPermissionParams { options: vec![] }); + let parsed: RequestPermissionParams = serde_json::from_value(params.clone()) + .unwrap_or(RequestPermissionParams { options: vec![] }); let chosen = parsed .options .iter() diff --git a/crates/op-acp/src/protocol.rs b/crates/op-acp/src/protocol.rs index 9829c4740..e06ab68ab 100644 --- a/crates/op-acp/src/protocol.rs +++ b/crates/op-acp/src/protocol.rs @@ -249,7 +249,10 @@ mod tests { classify_inbound(¬e), Inbound::Notification { .. } )); - assert!(matches!(classify_inbound(&serde_json::json!(5)), Inbound::Unknown)); + assert!(matches!( + classify_inbound(&serde_json::json!(5)), + Inbound::Unknown + )); } #[test] diff --git a/crates/op-acp/src/transport.rs b/crates/op-acp/src/transport.rs index 680bfac6b..e08eb7316 100644 --- a/crates/op-acp/src/transport.rs +++ b/crates/op-acp/src/transport.rs @@ -48,8 +48,7 @@ pub async fn write_frame( writer: &mut (impl AsyncWrite + Unpin), value: &Value, ) -> Result<(), AcpError> { - let mut bytes = - serde_json::to_vec(value).map_err(|e| AcpError::Protocol(e.to_string()))?; + let mut bytes = serde_json::to_vec(value).map_err(|e| AcpError::Protocol(e.to_string()))?; bytes.push(b'\n'); writer .write_all(&bytes) diff --git a/crates/op-ai-skills/src/frontmatter.rs b/crates/op-ai-skills/src/frontmatter.rs index 3b2076615..b217f6372 100644 --- a/crates/op-ai-skills/src/frontmatter.rs +++ b/crates/op-ai-skills/src/frontmatter.rs @@ -208,9 +208,7 @@ pub fn parse_skill_frontmatter(raw: &str) -> Option<(SkillMeta, String)> { .filter_map(|s| Phase::from_str(s)) .collect() } else { - Phase::from_str(&unquote(&e.value)) - .into_iter() - .collect() + Phase::from_str(&unquote(&e.value)).into_iter().collect() }; } "priority" => priority = unquote(&e.value).parse().unwrap_or(50), diff --git a/crates/op-ai-skills/src/loader.rs b/crates/op-ai-skills/src/loader.rs index dfe80b8e2..efe0f0b00 100644 --- a/crates/op-ai-skills/src/loader.rs +++ b/crates/op-ai-skills/src/loader.rs @@ -37,11 +37,7 @@ fn collect(dir: &Dir, out: &mut Vec) { collect(sub, out); } for file in dir.files() { - let is_md = file - .path() - .extension() - .map(|e| e == "md") - .unwrap_or(false); + let is_md = file.path().extension().map(|e| e == "md").unwrap_or(false); if !is_md { continue; } @@ -122,8 +118,8 @@ mod tests { // list. All three must parse to a non-empty keyword trigger // (a regression here silently disables the skill). for name in ["role-definitions", "copywriting", "cjk-typography"] { - let skill = get_skill_by_name(name) - .unwrap_or_else(|| panic!("{name} should be registered")); + let skill = + get_skill_by_name(name).unwrap_or_else(|| panic!("{name} should be registered")); match &skill.meta.trigger { crate::types::SkillTrigger::Keywords(kw) => { assert!(!kw.is_empty(), "{name} keyword trigger must not be empty"); diff --git a/crates/op-ai-skills/src/memory/document_context.rs b/crates/op-ai-skills/src/memory/document_context.rs index fc193a77a..54f046a93 100644 --- a/crates/op-ai-skills/src/memory/document_context.rs +++ b/crates/op-ai-skills/src/memory/document_context.rs @@ -132,7 +132,9 @@ mod tests { aesthetic: Some("minimal".into()), }), subtasks: Some(vec![ - PlanSubtask { label: "hero".into() }, + PlanSubtask { + label: "hero".into(), + }, PlanSubtask { label: "pricing".into(), }, @@ -141,7 +143,10 @@ mod tests { let out = extract_design_context(&base, &plan, "t1"); assert_eq!(out.updated_at, "t1"); assert_eq!(out.design_system.palette, vec!["#000", "#fff"]); - assert_eq!(out.design_system.typography.as_deref(), Some("Inter, Playfair")); + assert_eq!( + out.design_system.typography.as_deref(), + Some("Inter, Playfair") + ); assert_eq!(out.design_system.aesthetic.as_deref(), Some("minimal")); assert_eq!(out.structure.sections, vec!["hero", "pricing"]); } diff --git a/crates/op-ai-skills/src/resolve.rs b/crates/op-ai-skills/src/resolve.rs index d5b1228e6..72551875c 100644 --- a/crates/op-ai-skills/src/resolve.rs +++ b/crates/op-ai-skills/src/resolve.rs @@ -45,18 +45,13 @@ fn format_recent_history(history: &[crate::types::HistoryEntry]) -> String { } /// Resolve the skill set for `phase` against `user_message`. -pub fn resolve_skills( - phase: Phase, - user_message: &str, - options: &ResolveOptions, -) -> AgentContext { +pub fn resolve_skills(phase: Phase, user_message: &str, options: &ResolveOptions) -> AgentContext { let total_budget = options .budget_override .unwrap_or_else(|| phase.default_budget()); // Steps 1 + 2 — phase filter, then intent / flag match. - let phase_skills: Vec = - get_skills_by_phase(phase).into_iter().cloned().collect(); + let phase_skills: Vec = get_skills_by_phase(phase).into_iter().cloned().collect(); let matched = filter_by_intent(&phase_skills, user_message, &options.flags); // Per-phase memory loading (done before injection so history is @@ -139,10 +134,17 @@ mod tests { #[test] fn generation_resolve_stays_within_budget() { - let ctx = resolve_skills(Phase::Generation, "design a login form", &ResolveOptions::default()); + let ctx = resolve_skills( + Phase::Generation, + "design a login form", + &ResolveOptions::default(), + ); assert_eq!(ctx.budget_max, 8000); assert!(ctx.budget_used <= ctx.budget_max); - assert!(!ctx.skills.is_empty(), "generation phase should resolve skills"); + assert!( + !ctx.skills.is_empty(), + "generation phase should resolve skills" + ); } #[test] @@ -200,7 +202,11 @@ mod tests { #[test] fn budget_override_is_honored() { - let high = resolve_skills(Phase::Generation, "design something", &ResolveOptions::default()); + let high = resolve_skills( + Phase::Generation, + "design something", + &ResolveOptions::default(), + ); let opts = ResolveOptions { budget_override: Some(500), ..Default::default() diff --git a/crates/op-ai-skills/src/resolver.rs b/crates/op-ai-skills/src/resolver.rs index 8f18ce68f..52db2c617 100644 --- a/crates/op-ai-skills/src/resolver.rs +++ b/crates/op-ai-skills/src/resolver.rs @@ -55,9 +55,7 @@ pub fn match_trigger( .any(|kw| match_keyword(&msg, &kw.to_lowercase())) } // Every named flag must be present and `true`. - SkillTrigger::Flags(needed) => { - needed.iter().all(|f| flags.get(f).copied() == Some(true)) - } + SkillTrigger::Flags(needed) => needed.iter().all(|f| flags.get(f).copied() == Some(true)), } } @@ -167,11 +165,7 @@ mod tests { fn filter_keeps_matches_and_sorts_by_priority() { let skills = vec![ skill("late", SkillTrigger::Always, 90), - skill( - "kw", - SkillTrigger::Keywords(vec!["dashboard".into()]), - 10, - ), + skill("kw", SkillTrigger::Keywords(vec!["dashboard".into()]), 10), skill("early", SkillTrigger::Always, 5), ]; let out = filter_by_intent(&skills, "build a dashboard", &HashMap::new()); diff --git a/crates/op-ai-skills/src/style_guide/loader.rs b/crates/op-ai-skills/src/style_guide/loader.rs index db9f2fc9a..0321c43e8 100644 --- a/crates/op-ai-skills/src/style_guide/loader.rs +++ b/crates/op-ai-skills/src/style_guide/loader.rs @@ -61,11 +61,7 @@ pub fn style_guide_registry() -> &'static [ParsedStyleGuide] { let mut out = Vec::new(); if let Some(dir) = SKILLS.get_dir("style-guides") { for file in dir.files() { - let is_md = file - .path() - .extension() - .map(|e| e == "md") - .unwrap_or(false); + let is_md = file.path().extension().map(|e| e == "md").unwrap_or(false); if !is_md { continue; } @@ -165,7 +161,11 @@ mod tests { fn registry_loads_the_style_guides() { let reg = style_guide_registry(); // ~50 style guides ship with the corpus. - assert!(reg.len() >= 40, "style-guide registry too small: {}", reg.len()); + assert!( + reg.len() >= 40, + "style-guide registry too small: {}", + reg.len() + ); assert!(reg.iter().all(|g| !g.name.is_empty())); } diff --git a/crates/op-ai-skills/src/style_guide/mapping.rs b/crates/op-ai-skills/src/style_guide/mapping.rs index cf7767577..f79093195 100644 --- a/crates/op-ai-skills/src/style_guide/mapping.rs +++ b/crates/op-ai-skills/src/style_guide/mapping.rs @@ -48,22 +48,58 @@ pub fn build_style_mapping(from: &StyleGuideValues, to: &StyleGuideValues) -> Pr let mut out = PropertyReplacement::default(); // Fill colours — backgrounds, surfaces, accents. - push_color(&mut out.fill_color, &from.colors.background, &to.colors.background); - push_color(&mut out.fill_color, &from.colors.surface, &to.colors.surface); + push_color( + &mut out.fill_color, + &from.colors.background, + &to.colors.background, + ); + push_color( + &mut out.fill_color, + &from.colors.surface, + &to.colors.surface, + ); push_color(&mut out.fill_color, &from.colors.accent, &to.colors.accent); // Text colours. - push_color(&mut out.text_color, &from.colors.text_primary, &to.colors.text_primary); - push_color(&mut out.text_color, &from.colors.text_secondary, &to.colors.text_secondary); - push_color(&mut out.text_color, &from.colors.text_muted, &to.colors.text_muted); + push_color( + &mut out.text_color, + &from.colors.text_primary, + &to.colors.text_primary, + ); + push_color( + &mut out.text_color, + &from.colors.text_secondary, + &to.colors.text_secondary, + ); + push_color( + &mut out.text_color, + &from.colors.text_muted, + &to.colors.text_muted, + ); // Border / stroke colour. - push_color(&mut out.stroke_color, &from.colors.border, &to.colors.border); + push_color( + &mut out.stroke_color, + &from.colors.border, + &to.colors.border, + ); // Font families. - push_color(&mut out.font_family, &from.typography.display_font, &to.typography.display_font); - push_color(&mut out.font_family, &from.typography.body_font, &to.typography.body_font); - push_color(&mut out.font_family, &from.typography.data_font, &to.typography.data_font); + push_color( + &mut out.font_family, + &from.typography.display_font, + &to.typography.display_font, + ); + push_color( + &mut out.font_family, + &from.typography.body_font, + &to.typography.body_font, + ); + push_color( + &mut out.font_family, + &from.typography.data_font, + &to.typography.data_font, + ); // Corner radii (scalar px). for (f, t) in [ diff --git a/crates/op-ai-skills/src/style_guide/parser.rs b/crates/op-ai-skills/src/style_guide/parser.rs index 06975c7d2..82746c7f7 100644 --- a/crates/op-ai-skills/src/style_guide/parser.rs +++ b/crates/op-ai-skills/src/style_guide/parser.rs @@ -119,7 +119,10 @@ fn ordered(norm: &str, a: &str, b: &str) -> bool { fn extract_colors(sections: &HashMap) -> StyleColors { let empty = String::new(); - let color_section = sections.get("color system").unwrap_or(&empty).to_lowercase(); + let color_section = sections + .get("color system") + .unwrap_or(&empty) + .to_lowercase(); let background = hex_near(&color_section, |l| { l.contains("page background") || ordered(l, "root", "background") @@ -352,7 +355,10 @@ Button / Input: 8px #[test] fn extracts_typography_by_role() { let v = extract_style_guide_values(SAMPLE); - assert_eq!(v.typography.display_font.as_deref(), Some("Playfair Display")); + assert_eq!( + v.typography.display_font.as_deref(), + Some("Playfair Display") + ); assert_eq!(v.typography.body_font.as_deref(), Some("Inter")); assert_eq!(v.typography.data_font.as_deref(), Some("JetBrains Mono")); } diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs index fcb6ebb5e..fa28b64a6 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -192,10 +192,8 @@ impl<'a> AIChatPlaceholder<'a> { let height = crate::widgets::ai_chat_model_picker::picker_content_height( &self.state.available_models, ); - let toolbar_top = input_rect.origin.y - + INPUT_AREA_HEIGHT - + self.attachment_row_h() - + CONTROLS_ROW_HEIGHT; + let toolbar_top = + input_rect.origin.y + INPUT_AREA_HEIGHT + self.attachment_row_h() + CONTROLS_ROW_HEIGHT; let bottom = toolbar_top - 4.0; Rect { origin: Point2D::new(rect.origin.x + PAD, bottom - height), @@ -502,10 +500,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> { let attach_h = self.attachment_row_h(); if attach_h > 0.0 { let attach_rect = Rect { - origin: Point2D::new( - input_rect.origin.x, - input_rect.origin.y + INPUT_AREA_HEIGHT, - ), + origin: Point2D::new(input_rect.origin.x, input_rect.origin.y + INPUT_AREA_HEIGHT), size: Point2D::new(input_rect.size.x, attach_h), }; paint_attachment_row(cx, &self.theme, attach_rect, self.state); @@ -523,8 +518,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> { // Bottom toolbar — model picker on the left, send on the // right (mirrors the TS panel's bottom row). - let toolbar_y = - input_rect.origin.y + INPUT_AREA_HEIGHT + attach_h + CONTROLS_ROW_HEIGHT; + let toolbar_y = input_rect.origin.y + INPUT_AREA_HEIGHT + attach_h + CONTROLS_ROW_HEIGHT; let toolbar_center_y = toolbar_y + INPUT_TOOLBAR_HEIGHT / 2.0; // Model chip — brand logo of the selected model's provider // + its display name + a chevron. Click toggles the picker. @@ -583,8 +577,8 @@ impl<'a> Widget for AIChatPlaceholder<'a> { }; // A turn is sendable with text, with staged attachments, or // both (TS parity: an attachment-only message is valid). - let send_active = !self.state.input.trim().is_empty() - || !self.state.pending_attachments.is_empty(); + let send_active = + !self.state.input.trim().is_empty() || !self.state.pending_attachments.is_empty(); let (send_bg, icon_color) = if send_active { (self.theme.primary, self.theme.primary_foreground) } else { @@ -673,7 +667,8 @@ mod tests { /// Y-coordinate of the bottom toolbar's vertical center. fn toolbar_center_y() -> f32 { - AI_CHAT_HEIGHT - INPUT_BASE_HEIGHT + 1.0 + AI_CHAT_HEIGHT - INPUT_BASE_HEIGHT + + 1.0 + INPUT_AREA_HEIGHT + CONTROLS_ROW_HEIGHT + INPUT_TOOLBAR_HEIGHT / 2.0 diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_controls.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_controls.rs index ab5e08d1f..d724bf2bf 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_controls.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_controls.rs @@ -190,7 +190,12 @@ fn paint_chip(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rect, icon: Icon, label } /// Paint the controls strip — thinking / effort / attach. -pub fn paint_controls_row(cx: &mut PaintCx<'_>, theme: &Theme, controls_rect: Rect, state: &ChatState) { +pub fn paint_controls_row( + cx: &mut PaintCx<'_>, + theme: &Theme, + controls_rect: Rect, + state: &ChatState, +) { let layout = controls_layout(controls_rect); paint_chip( cx, @@ -314,10 +319,7 @@ mod tests { let rects = attachment_chip_rects(r, 4); // Click the third chip. let third = rects[2]; - let p = Point2D::new( - third.origin.x + 10.0, - third.origin.y + third.size.y / 2.0, - ); + let p = Point2D::new(third.origin.x + 10.0, third.origin.y + third.size.y / 2.0); assert_eq!( attachment_row_hit(r, p, 4), Some(AIChatHit::RemoveAttachment(2)) diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs index 350a0075d..1c7a8aeea 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -335,9 +335,10 @@ pub fn paint_node( // outline. let stroke = match node.stroke { Some(s) => Some((s.color, s.width * zoom)), - None if !filled => { - Some((node.fill.unwrap_or(crate::Color::BLACK), (1.5_f32).max(zoom))) - } + None if !filled => Some(( + node.fill.unwrap_or(crate::Color::BLACK), + (1.5_f32).max(zoom), + )), None => None, }; if let Some((color, width)) = stroke { diff --git a/crates/op-host-desktop/src/chat_acp.rs b/crates/op-host-desktop/src/chat_acp.rs index d4a6f2d35..a1f781efb 100644 --- a/crates/op-host-desktop/src/chat_acp.rs +++ b/crates/op-host-desktop/src/chat_acp.rs @@ -112,7 +112,9 @@ async fn run_acp_turn( Some(n) => n, None => { let _ = tx - .send(ChatDelta::Error("acp: notification channel unavailable".into())) + .send(ChatDelta::Error( + "acp: notification channel unavailable".into(), + )) .await; let _ = tx .send(ChatDelta::Done { diff --git a/crates/op-host-desktop/src/chat_attachment.rs b/crates/op-host-desktop/src/chat_attachment.rs index e6b7217a2..0d3e4e23a 100644 --- a/crates/op-host-desktop/src/chat_attachment.rs +++ b/crates/op-host-desktop/src/chat_attachment.rs @@ -123,9 +123,7 @@ pub fn write_temp_attachments(attachments: &[ChatAttachment]) -> io::Result Option<&'static str> { match mode { - ThinkingMode::Enabled => { - Some("Think step by step and reason carefully before answering.") - } + ThinkingMode::Enabled => Some("Think step by step and reason carefully before answering."), ThinkingMode::Disabled => { Some("Answer directly and concisely, without extended reasoning.") } diff --git a/crates/op-host-desktop/src/mcp_serve.rs b/crates/op-host-desktop/src/mcp_serve.rs index 7e7db7285..18c5e195d 100644 --- a/crates/op-host-desktop/src/mcp_serve.rs +++ b/crates/op-host-desktop/src/mcp_serve.rs @@ -173,9 +173,7 @@ pub fn run_cli_if_requested() -> bool { std::process::exit(2); }; let Ok(port) = port_arg.parse::() else { - eprintln!( - "openpencil-desktop --mcp-http: must be a u16, got {port_arg:?}" - ); + eprintln!("openpencil-desktop --mcp-http: must be a u16, got {port_arg:?}"); std::process::exit(2); }; let Some(path) = args.next() else { diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index b914fe42f..9906fc052 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -468,7 +468,10 @@ impl WidgetHostNative { } // ~7 screen-px grab radius, expressed in doc space. let r2 = 49.0 / (zoom * zoom); - for (handle, p) in handles { + // Reverse paint order so the topmost-painted handle wins — + // on a full-sweep ellipse the Start + Sweep handles coincide, + // and Sweep is painted last, so it must hit-test first. + for (handle, p) in handles.into_iter().rev() { let dx = doc_point.x - p.x; let dy = doc_point.y - p.y; if dx * dx + dy * dy <= r2 { diff --git a/crates/op-pen-loader/src/adapter.rs b/crates/op-pen-loader/src/adapter.rs index 03a8354b1..223c56f44 100644 --- a/crates/op-pen-loader/src/adapter.rs +++ b/crates/op-pen-loader/src/adapter.rs @@ -800,8 +800,11 @@ fn parse_hex(s: &str) -> Option<[f32; 4]> { fn short_src(src: &str) -> String { let s = src.rsplit('/').next().unwrap_or(src); - if s.len() > 24 { - format!("{}…", &s[..24]) + // Truncate by characters, not bytes — a `&s[..24]` byte slice + // panics when byte 24 lands inside a multi-byte UTF-8 char. + if s.chars().count() > 24 { + let head: String = s.chars().take(24).collect(); + format!("{head}…") } else { s.to_string() }