diff --git a/crates/op-editor-core/src/color_picker.rs b/crates/op-editor-core/src/color_picker.rs index f6c4a0a78..518d6e930 100644 --- a/crates/op-editor-core/src/color_picker.rs +++ b/crates/op-editor-core/src/color_picker.rs @@ -50,6 +50,19 @@ impl EditorState { } } + /// Write the anchor node's primary-fill opacity, in `[0.0, 1.0]`. + /// Editable-gated. Drives the Fill section's `100 %` input. + pub fn set_selected_fill_opacity(&mut self, opacity: f32) -> bool { + let sel = self.selection.anchor.clone(); + if !sel.is_real() || !self.is_editable(&sel) { + return false; + } + let Some(node) = find_node_mut(self.active_children_mut(), &sel) else { + return false; + }; + crate::fills::set_primary_fill_opacity(node, opacity) + } + /// Append a default drop-shadow effect to the anchor node. /// Editable-gated. Mirrors shell-core's /// `add_drop_shadow_to_selected`. diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index 7795fffcd..21062e4f0 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -462,6 +462,15 @@ pub enum VariableRowFocus { String(usize), } +/// Keyboard focus on an effect-parameter value (the Effects +/// section's editable X / Y / Blur / Spread / Radius numbers). +/// `effect` is the index of the effect on the selected node. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EffectParamFocus { + pub effect: usize, + pub field: crate::EffectField, +} + /// Editor-UI overlay + panel state — the widget-layer toggles, hover /// targets, menu / modal open flags and panel metrics that the ~30 /// editor widgets paint from. Faithful superset of the UI subset @@ -587,6 +596,10 @@ pub struct EditorUiState { pub axis_dropdown_open: Option, /// Editor focus for a non-color variable row (Number / String). pub variable_row_focus: Option, + /// Editor focus on an effect-parameter value (Effects section). + /// Shares `UiDraftState.property_input_draft` + caret like the + /// variable-row focus does. + pub effect_param_focus: Option, // --- Layer / page hover + context menu ------------------------- /// Currently-hovered LayerPanel row, or `None`. @@ -721,6 +734,7 @@ impl Default for EditorUiState { fill_type_picker_open: false, axis_dropdown_open: None, variable_row_focus: None, + effect_param_focus: None, hovered_layer_id: None, hovered_page_index: None, layer_context_menu: None, diff --git a/crates/op-editor-core/src/fills.rs b/crates/op-editor-core/src/fills.rs index ad89d465d..9e8276dd3 100644 --- a/crates/op-editor-core/src/fills.rs +++ b/crates/op-editor-core/src/fills.rs @@ -179,6 +179,47 @@ fn solid_fill(hex: String) -> PenFill { }) } +/// Opacity of the node's primary solid fill (1.0 when missing / +/// not stored — the canonical default a fresh fill paints with). +pub fn first_solid_fill_opacity(node: &PenNode) -> f32 { + node_fills(node) + .and_then(|f| { + f.iter().find_map(|fill| match fill { + PenFill::Solid(b) => Some(b.opacity.unwrap_or(1.0)), + _ => None, + }) + }) + .unwrap_or(1.0) +} + +/// Write the first `Solid` fill's `opacity` (clamped to `[0.0, 1.0]`). +/// When the node has no solid fill, a transparent-black one is +/// prepended so the opacity has a target. `false` when the variant +/// carries no `fill` field at all. +pub fn set_primary_fill_opacity(node: &mut PenNode, opacity: f32) -> bool { + let opacity = opacity.clamp(0.0, 1.0); + let Some(fills) = node_fills_mut(node) else { + return false; + }; + if let Some(slot) = fills.iter_mut().find_map(|f| match f { + PenFill::Solid(body) => Some(body), + _ => None, + }) { + slot.opacity = Some(opacity); + } else { + fills.insert( + 0, + PenFill::Solid(SolidFillBody { + color: "#000000".to_string(), + explain: None, + opacity: Some(opacity), + blend_mode: None, + }), + ); + } + true +} + /// Replace the first `Solid` fill's colour with `hex`, leaving any /// gradient / image fills untouched. When the node has no solid fill, /// a fresh one is prepended so it paints on top. `false` when the diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 9940e87f1..285373e3b 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -78,7 +78,10 @@ pub use editor_ui_state::{ MergeResolveState, PageRenameState, PropertyTab, RecentFile, ShapeChoice, ThemeMode, UpdateStatus, VariableRowFocus, }; -pub use fills::{first_fill_type, first_solid_fill_hex, first_solid_stroke_hex, node_effects}; +pub use fills::{ + first_fill_type, first_solid_fill_hex, first_solid_fill_opacity, first_solid_stroke_hex, + node_effects, +}; pub use geometry::{aggregate_bounds, own_bounds, union_aggregate_bounds, DocRect}; pub use history::{EditorSnapshot, History, HISTORY_CAP}; pub use jian_ops_schema::{DesignMdColor, DesignMdSpec, DesignMdTypography}; diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index a77040f86..0af6004f5 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -454,6 +454,12 @@ impl EditorState { | PropertyFocus::Opacity | PropertyFocus::FillHex | PropertyFocus::StrokeHex => {} + // Fill opacity is a percentage in the UI — convert to + // the canonical `[0.0, 1.0]` and route through the + // dedicated fill-opacity setter. + PropertyFocus::FillOpacity => { + let _ = self.set_selected_fill_opacity((value / 100.0).clamp(0.0, 1.0)); + } } true } diff --git a/crates/op-editor-core/src/ui_draft.rs b/crates/op-editor-core/src/ui_draft.rs index de41aee1a..b5eba4175 100644 --- a/crates/op-editor-core/src/ui_draft.rs +++ b/crates/op-editor-core/src/ui_draft.rs @@ -35,6 +35,8 @@ pub enum PropertyFocus { SizeH, Opacity, FillHex, + /// Fill section's `100 %` opacity input — percentage (0..100). + FillOpacity, StrokeHex, StrokeWidth, } @@ -137,6 +139,11 @@ pub struct UiDraftState { /// Draft for the focused property input; committed on Enter, /// discarded on Escape. pub property_input_draft: String, + /// Caret position (byte index into `property_input_draft`) for + /// the focused property input. Property drafts are ASCII, so a + /// byte index is also the char index. Typing inserts here and + /// Backspace deletes before it; ← / → move it. + pub property_caret_pos: usize, /// Caret-blink anchor (ms) for the focused property input — reset /// on focus and on every keystroke. pub property_caret_anchor_ms: u64, diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index fbeb43451..674c9de63 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -93,6 +93,9 @@ pub struct NodeSnapshot { /// Uniform corner radius in doc-px. pub corner_radius: f32, pub fill: Option, + /// Primary solid-fill opacity in `[0.0, 1.0]` — the Fill + /// section's `100 %` paints `fill_opacity * 100`. + pub fill_opacity: f32, pub stroke: Option, /// The node's visual effects, in paint order — drives the /// Effects section's rows + param inputs. @@ -211,6 +214,7 @@ impl NodeSnapshot { rotation_deg: 0.0, corner_radius: 0.0, fill: None, + fill_opacity: 1.0, stroke: None, // Multi-select shows no per-effect rows — the Effects // section paints just its header + the add affordance. @@ -253,6 +257,7 @@ impl NodeSnapshot { rotation_deg: base.rotation.unwrap_or(0.0) as f32, corner_radius, fill, + fill_opacity: op_editor_core::first_solid_fill_opacity(node), stroke, effects: op_editor_core::node_effects(node) .iter() @@ -296,6 +301,8 @@ pub struct PropertyPanel { /// is focused. The host fills this on click + mutates on /// keystroke; the panel paints it as the field's value. pub draft: String, + /// Caret byte-offset into `draft` (ASCII drafts → char index). + pub caret_pos: usize, /// Caret-blink anchor (ms since host start) for the focused /// input. Drives the same `jian_core::anim::blink_visible` /// helper the chat caret uses. @@ -331,6 +338,9 @@ pub struct PropertyPanel { /// Active UI locale — threaded into the Fill section so its /// type label / picker / body sub-labels translate. pub locale: op_editor_core::Locale, + /// Focused effect-parameter value, if any — drives the Effects + /// section's editable value boxes. + pub effect_param_focus: Option, } impl PropertyPanel { @@ -413,6 +423,11 @@ impl PropertyPanel { } else { state.ui.property_input_draft.clone() }, + caret_pos: if is_multi { + 0 + } else { + state.ui.property_caret_pos + }, caret_anchor_ms: state.ui.property_caret_anchor_ms, now_ms, flex_layout: ui.flex_layout, @@ -434,6 +449,12 @@ impl PropertyPanel { export_picker_hover: ui.export_picker_hover, scroll: ui.property_panel_scroll.max(0.0), locale: ui.locale, + // Inert in the multi-select aggregate view. + effect_param_focus: if is_multi { + None + } else { + ui.effect_param_focus + }, } } @@ -627,6 +648,7 @@ impl Widget for PropertyPanel { let edit_ctx = sections::EditContext { focus: self.focus, draft: self.draft.as_str(), + caret: self.caret_pos, caret_anchor_ms: self.caret_anchor_ms, now_ms: self.now_ms, }; @@ -728,6 +750,8 @@ impl Widget for PropertyPanel { &self.theme, &self.labels, &self.snapshot.effects, + &edit_ctx, + self.effect_param_focus, x, y, w, diff --git a/crates/op-editor-ui/src/widgets/property_panel_action.rs b/crates/op-editor-ui/src/widgets/property_panel_action.rs index bc42c94d0..d4c58b6e4 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_action.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_action.rs @@ -56,4 +56,12 @@ pub enum PropertyPanelAction { field: op_editor_core::EffectField, new_value: f32, }, + /// User clicked an effect parameter's value — host focuses it + /// for keyboard entry (`editor_ui.effect_param_focus`). `value` + /// is the current committed value, used to seed the draft. + FocusEffectParam { + effect: usize, + field: op_editor_core::EffectField, + value: f32, + }, } diff --git a/crates/op-editor-ui/src/widgets/property_panel_effects.rs b/crates/op-editor-ui/src/widgets/property_panel_effects.rs index e0e69cca5..ef6fcca95 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_effects.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_effects.rs @@ -7,23 +7,28 @@ use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::property_panel::EffectSummary; use crate::widgets::property_panel_inputs::{ - paint_section_divider, paint_section_label_with_add, to_jian_color, INPUT_HEIGHT, PAD_X, - SECTION_GAP, + paint_section_divider, paint_section_label_with_add, to_jian_color, INPUT_HEIGHT, INPUT_RADIUS, + PAD_X, SECTION_GAP, }; use crate::widgets::property_panel_layout::{ - effect_param_fields, EFFECT_PARAM_ROW_HEIGHT, EFFECT_ROW_HEIGHT, + effect_param_fields, effect_param_value_rect, EFFECT_PARAM_ROW_HEIGHT, EFFECT_ROW_HEIGHT, }; -use crate::widgets::property_panel_sections::PropertyLabels; +use crate::widgets::property_panel_sections::{EditContext, PropertyLabels}; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; +use op_editor_core::editor_ui_state::EffectParamFocus; // ── Effects section ─────────────────────────────────────────────── +// Paint-context + geometry args threaded through; a struct adds no gain. +#[allow(clippy::too_many_arguments)] pub fn paint_effects_section( cx: &mut PaintCx<'_>, theme: &Theme, labels: &PropertyLabels, effects: &[EffectSummary], + edit: &EditContext<'_>, + effect_focus: Option, x: f32, y: f32, width: f32, @@ -32,11 +37,28 @@ pub fn paint_effects_section( if effects.is_empty() { row_y += 8.0; } else { - for eff in effects { + for (ei, eff) in effects.iter().enumerate() { paint_effect_row(cx, theme, eff, x, row_y, width); row_y += EFFECT_ROW_HEIGHT; for &(field, label) in effect_param_fields(eff.kind) { - paint_effect_param_row(cx, theme, label, eff.param_value(field), x, row_y, width); + let focused = effect_focus == Some(EffectParamFocus { effect: ei, field }); + let caret = if focused && edit.caret_blink_on() { + Some(edit.caret.min(edit.draft.len())) + } else { + None + }; + paint_effect_param_row( + cx, + theme, + label, + eff.param_value(field), + focused, + edit.draft, + caret, + x, + row_y, + width, + ); row_y += EFFECT_PARAM_ROW_HEIGHT; } } @@ -45,14 +67,20 @@ pub fn paint_effects_section( row_y + SECTION_GAP } -/// Paint one effect-parameter row: `