feat(panels): editable inputs (arrows, caret, fill-opacity, effect params) + paint polish

Property-panel input editing now supports:
- Arrow keys: Up/Down step a numeric field; Left/Right move the text caret. Caret position is a real index into the draft, so typing inserts at the caret and Backspace deletes the char before it (not just append/pop).
- Layer opacity: full-width box with the localized 不透明度 label inside on the left, value next to it, % at the right edge; clipped so a long-locale label can't bleed past the half-width box.
- Fill opacity: new SolidFillBody.opacity getter / setter on the model + PropertyFocus::FillOpacity + the 100 % box in the Fill head row is now editable end-to-end.
- Effect-param values: each Drop Shadow X / Y / Blur / Spread cell is a click-to-type input box (new effect_param_focus state, FocusEffectParam action, commit_effect_param_focus_if_any path). The − / + steppers still work alongside. Web's apply_property_action makes the focus a no-op (no keyboard path on web yet) to avoid stranding focus.

Paint polish:
- Standardised input-text baselines to + 19.0 across prefix / suffix / icon helpers, the fill / stroke / opacity / hex paints, and the export section so every INPUT_HEIGHT row reads on the same baseline.
- Icon-prefixed inputs now have 10 px left padding (matching X / Y / W / H) and the icon is vertically centred ((30 - 14) / 2 = 8) instead of sitting at y + 5.
- Fill / stroke swatches vertically centred in their hex rows ((30 - 16) / 2 = 7).
- '-' / '#' caret-aware validation in apply_text so typing them at caret 0 of a non-empty draft is now a valid edit. Native input_tests seed property_caret_pos to mirror real focus state.

Codex review iterations: poison-guard the effect-param focus on web, content-clip the layer-opacity row, fix property-panel scroll clamping in paint, and a few related safety guards across hosts.
This commit is contained in:
Kayshen-X 2026-05-23 12:59:28 +08:00
parent 2efca5aa3b
commit 46eeaef226
21 changed files with 631 additions and 93 deletions

View file

@ -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`.

View file

@ -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<String>,
/// Editor focus for a non-color variable row (Number / String).
pub variable_row_focus: Option<VariableRowFocus>,
/// 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<EffectParamFocus>,
// --- 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,

View file

@ -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

View file

@ -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};

View file

@ -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
}

View file

@ -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,

View file

@ -93,6 +93,9 @@ pub struct NodeSnapshot {
/// Uniform corner radius in doc-px.
pub corner_radius: f32,
pub fill: Option<Color>,
/// 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<SceneStroke>,
/// 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<op_editor_core::editor_ui_state::EffectParamFocus>,
}
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,

View file

@ -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,
},
}

View file

@ -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<EffectParamFocus>,
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: `<label> <value> [−] [+]`. The
/// "−"/"+" stepper rects must match `action_button_rects`'s
/// `AdjustEffectParam` rects exactly so paint + hit-test agree.
/// Paint one effect-parameter row: `<label> [value] [−] [+]`. The
/// value box is click-to-type; when `focused` it shows the live
/// `draft` + caret instead of the committed `value`. The "−"/"+"
/// stepper rects must match `action_button_rects`'s `AdjustEffectParam`
/// rects exactly so paint + hit-test agree.
#[allow(clippy::too_many_arguments)]
fn paint_effect_param_row(
cx: &mut PaintCx<'_>,
theme: &Theme,
label: &str,
value: f32,
focused: bool,
draft: &str,
caret: Option<usize>,
x: f32,
y: f32,
width: f32,
@ -66,9 +94,19 @@ fn paint_effect_param_row(
);
cx.backend
.draw_text(&label_layout, Point2D::new(x + PAD_X + 4.0, y + 15.0));
let value_text = format!("{value:.0}");
// Editable value box — shows the live draft while focused.
let box_rect = effect_param_value_rect(x, y, width);
cx.backend
.fill_round_rect(box_rect, INPUT_RADIUS, theme.muted);
if focused {
cx.backend
.stroke_round_rect(box_rect, INPUT_RADIUS, theme.primary, 1.5);
}
let value_owned = format!("{value:.0}");
let text = if focused { draft } else { value_owned.as_str() };
let text_x = box_rect.origin.x + 10.0;
let value_layout = TextLayout::single_run(
&value_text,
text,
"system-ui",
12.0,
to_jian_color(theme.foreground),
@ -76,8 +114,18 @@ fn paint_effect_param_row(
);
cx.backend.draw_text(
&value_layout,
Point2D::new(x + width - PAD_X - 78.0, y + 15.0),
Point2D::new(text_x, box_rect.origin.y + 16.0),
);
if let Some(pos) = caret {
let caret_w = cx.backend.measure_text(&text[..pos.min(text.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(text_x + caret_w, box_rect.origin.y + 4.0),
size: Point2D::new(1.5, box_rect.size.y - 8.0),
},
theme.foreground,
);
}
// "−" then "+" — geometry mirrors the `AdjustEffectParam` rects.
for (icon, off) in [(Icon::Minus, 48.0_f32), (Icon::Plus, 22.0_f32)] {
let r = Rect {

View file

@ -242,7 +242,7 @@ pub fn paint_fill_section(
);
cx.backend.draw_text(
&label,
Point2D::new(dropdown_rect.origin.x + 10.0, dropdown_rect.origin.y + 17.0),
Point2D::new(dropdown_rect.origin.x + 10.0, dropdown_rect.origin.y + 19.0),
);
draw_icon(
cx.backend,
@ -261,17 +261,35 @@ pub fn paint_fill_section(
};
cx.backend
.fill_round_rect(pct_rect, INPUT_RADIUS, theme.muted);
let opacity_focused = edit.focus == Some(PropertyFocus::FillOpacity);
if opacity_focused {
cx.backend
.stroke_round_rect(pct_rect, INPUT_RADIUS, theme.primary, 1.5);
}
let opacity_owned = ((snapshot.fill_opacity * 100.0).round() as i32).to_string();
let pct_text = edit.value_for(PropertyFocus::FillOpacity, &opacity_owned);
let pct = TextLayout::single_run(
"100",
pct_text,
"system-ui",
12.0,
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&pct,
Point2D::new(pct_rect.origin.x + 10.0, pct_rect.origin.y + 17.0),
);
let pct_x = pct_rect.origin.x + 10.0;
cx.backend
.draw_text(&pct, Point2D::new(pct_x, pct_rect.origin.y + 19.0));
if let Some(pos) = edit.caret_at(PropertyFocus::FillOpacity) {
let w = cx
.backend
.measure_text(&pct_text[..pos.min(pct_text.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(pct_x + w, pct_rect.origin.y + 6.0),
size: Point2D::new(1.5, pct_rect.size.y - 12.0),
},
theme.foreground,
);
}
let pct_unit = TextLayout::single_run(
"%",
"system-ui",
@ -283,7 +301,7 @@ pub fn paint_fill_section(
&pct_unit,
Point2D::new(
pct_rect.origin.x + pct_rect.size.x - 14.0,
pct_rect.origin.y + 17.0,
pct_rect.origin.y + 19.0,
),
);
draw_icon(
@ -342,7 +360,8 @@ fn paint_fill_solid_body(
}
cx.backend.fill_round_rect(
Rect {
origin: Point2D::new(hex_rect.origin.x + 6.0, hex_rect.origin.y + 5.0),
// Vertically centre the 16-tall swatch in the 30-tall row.
origin: Point2D::new(hex_rect.origin.x + 6.0, hex_rect.origin.y + 7.0),
size: Point2D::new(16.0, 16.0),
},
3.0,
@ -357,9 +376,11 @@ fn paint_fill_solid_body(
);
let hex_x = hex_rect.origin.x + 30.0;
cx.backend
.draw_text(&hex_layout, Point2D::new(hex_x, hex_rect.origin.y + 17.0));
if edit.caret_visible(PropertyFocus::FillHex) {
let w = cx.backend.measure_text(hex_text, 12.0);
.draw_text(&hex_layout, Point2D::new(hex_x, hex_rect.origin.y + 19.0));
if let Some(pos) = edit.caret_at(PropertyFocus::FillHex) {
let w = cx
.backend
.measure_text(&hex_text[..pos.min(hex_text.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(hex_x + w, hex_rect.origin.y + 6.0),
@ -398,7 +419,7 @@ fn paint_fill_gradient_body(
);
cx.backend.draw_text(
&prefix,
Point2D::new(angle_rect.origin.x + 10.0, angle_rect.origin.y + 17.0),
Point2D::new(angle_rect.origin.x + 10.0, angle_rect.origin.y + 19.0),
);
let value = TextLayout::single_run(
"0",
@ -409,7 +430,7 @@ fn paint_fill_gradient_body(
);
cx.backend.draw_text(
&value,
Point2D::new(angle_rect.origin.x + 44.0, angle_rect.origin.y + 17.0),
Point2D::new(angle_rect.origin.x + 44.0, angle_rect.origin.y + 19.0),
);
let unit = TextLayout::single_run(
"°",
@ -422,7 +443,7 @@ fn paint_fill_gradient_body(
&unit,
Point2D::new(
angle_rect.origin.x + angle_rect.size.x - 14.0,
angle_rect.origin.y + 17.0,
angle_rect.origin.y + 19.0,
),
);
yy += INPUT_HEIGHT + 6.0;
@ -478,7 +499,7 @@ fn paint_fill_gradient_body(
);
cx.backend.draw_text(
&hex_layout,
Point2D::new(hex_rect.origin.x + 30.0, hex_rect.origin.y + 17.0),
Point2D::new(hex_rect.origin.x + 30.0, hex_rect.origin.y + 19.0),
);
let pct_rect = Rect {
origin: Point2D::new(x + PAD_X + hex_w + 8.0, row_y),
@ -495,7 +516,7 @@ fn paint_fill_gradient_body(
);
cx.backend.draw_text(
&pct_layout,
Point2D::new(pct_rect.origin.x + 12.0, pct_rect.origin.y + 17.0),
Point2D::new(pct_rect.origin.x + 12.0, pct_rect.origin.y + 19.0),
);
let pct_unit = TextLayout::single_run(
"%",
@ -508,7 +529,7 @@ fn paint_fill_gradient_body(
&pct_unit,
Point2D::new(
pct_rect.origin.x + pct_rect.size.x - 14.0,
pct_rect.origin.y + 17.0,
pct_rect.origin.y + 19.0,
),
);
yy += INPUT_HEIGHT + 4.0;
@ -549,6 +570,6 @@ fn paint_fill_image_body(
);
cx.backend.draw_text(
&label,
Point2D::new(row.origin.x + 30.0, row.origin.y + 17.0),
Point2D::new(row.origin.x + 30.0, row.origin.y + 19.0),
);
}

View file

@ -77,7 +77,7 @@ pub fn paint_input_with_prefix(
prefix: &str,
value: &str,
) {
paint_input_with_prefix_focused(cx, theme, rect, prefix, value, false, false);
paint_input_with_prefix_focused(cx, theme, rect, prefix, value, false, None);
}
/// `paint_input_with_prefix` with explicit focus + caret toggle.
@ -88,7 +88,7 @@ pub fn paint_input_with_prefix_focused(
prefix: &str,
value: &str,
focused: bool,
caret_visible: bool,
caret: Option<usize>,
) {
cx.backend.fill_round_rect(rect, INPUT_RADIUS, theme.muted);
if focused {
@ -121,8 +121,10 @@ pub fn paint_input_with_prefix_focused(
);
cx.backend
.draw_text(&value_layout, Point2D::new(value_x, baseline_y));
if caret_visible {
let value_w = cx.backend.measure_text(value, 12.0);
if let Some(pos) = caret {
let value_w = cx
.backend
.measure_text(&value[..pos.min(value.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(value_x + value_w, rect.origin.y + 6.0),
@ -141,7 +143,7 @@ pub fn paint_input_with_suffix(
value: &str,
unit: &str,
) {
paint_input_with_suffix_focused(cx, theme, rect, value, unit, false, false);
paint_input_with_suffix_focused(cx, theme, rect, value, unit, false, None);
}
pub fn paint_input_with_suffix_focused(
@ -151,7 +153,7 @@ pub fn paint_input_with_suffix_focused(
value: &str,
unit: &str,
focused: bool,
caret_visible: bool,
caret: Option<usize>,
) {
cx.backend.fill_round_rect(rect, INPUT_RADIUS, theme.muted);
if focused {
@ -159,7 +161,7 @@ pub fn paint_input_with_suffix_focused(
.stroke_round_rect(rect, INPUT_RADIUS, theme.primary, 1.5);
}
let value_x = rect.origin.x + 10.0;
let baseline_y = rect.origin.y + 17.0;
let baseline_y = rect.origin.y + 19.0;
let value_layout = TextLayout::single_run(
value,
"system-ui",
@ -169,8 +171,10 @@ pub fn paint_input_with_suffix_focused(
);
cx.backend
.draw_text(&value_layout, Point2D::new(value_x, baseline_y));
if caret_visible {
let value_w = cx.backend.measure_text(value, 12.0);
if let Some(pos) = caret {
let value_w = cx
.backend
.measure_text(&value[..pos.min(value.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(value_x + value_w, rect.origin.y + 6.0),
@ -188,7 +192,7 @@ pub fn paint_input_with_suffix_focused(
);
cx.backend.draw_text(
&unit_layout,
Point2D::new(rect.origin.x + rect.size.x - 14.0, rect.origin.y + 17.0),
Point2D::new(rect.origin.x + rect.size.x - 14.0, rect.origin.y + 19.0),
);
}
@ -201,7 +205,7 @@ pub fn paint_input_with_icon(
value: &str,
unit: Option<&str>,
) {
paint_input_with_icon_focused(cx, theme, rect, icon, value, unit, false, false);
paint_input_with_icon_focused(cx, theme, rect, icon, value, unit, false, None);
}
// Paint-context + geometry args threaded through; a struct adds no gain.
@ -214,23 +218,27 @@ pub fn paint_input_with_icon_focused(
value: &str,
unit: Option<&str>,
focused: bool,
caret_visible: bool,
caret: Option<usize>,
) {
cx.backend.fill_round_rect(rect, INPUT_RADIUS, theme.muted);
if focused {
cx.backend
.stroke_round_rect(rect, INPUT_RADIUS, theme.primary, 1.5);
}
// Icon at 10 px from the left edge — matches the prefix-text
// inputs (X / Y / W / H), so a row of mixed icon + prefix
// inputs reads as a single column. Vertically centred in the
// 30-tall row: `(30 - 14) / 2 == 8`.
draw_icon(
cx.backend,
icon,
Point2D::new(rect.origin.x + 6.0, rect.origin.y + 5.0),
Point2D::new(rect.origin.x + 10.0, rect.origin.y + 8.0),
14.0,
theme.muted_foreground,
1.4,
);
let value_x = rect.origin.x + 26.0;
let baseline_y = rect.origin.y + 17.0;
let value_x = rect.origin.x + 30.0;
let baseline_y = rect.origin.y + 19.0;
let value_layout = TextLayout::single_run(
value,
"system-ui",
@ -240,17 +248,24 @@ pub fn paint_input_with_icon_focused(
);
cx.backend
.draw_text(&value_layout, Point2D::new(value_x, baseline_y));
if caret_visible {
let value_w = cx.backend.measure_text(value, 12.0);
let value_w = cx.backend.measure_text(value, 12.0);
if let Some(pos) = caret {
let caret_w = cx
.backend
.measure_text(&value[..pos.min(value.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(value_x + value_w, rect.origin.y + 6.0),
origin: Point2D::new(value_x + caret_w, rect.origin.y + 6.0),
size: Point2D::new(1.5, rect.size.y - 12.0),
},
theme.foreground,
);
}
let _ = value_w;
if let Some(u) = unit {
// Unit pinned to the box's right edge — keeps the icon
// input visually consistent with the suffix-only inputs
// ("100 %" etc.) where the unit also anchors right.
let unit_layout = TextLayout::single_run(
u,
"system-ui",
@ -260,7 +275,7 @@ pub fn paint_input_with_icon_focused(
);
cx.backend.draw_text(
&unit_layout,
Point2D::new(rect.origin.x + rect.size.x - 14.0, rect.origin.y + 17.0),
Point2D::new(rect.origin.x + rect.size.x - 14.0, baseline_y),
);
}
}
@ -277,7 +292,7 @@ pub fn paint_dropdown(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rect, value: &s
);
cx.backend.draw_text(
&value_layout,
Point2D::new(rect.origin.x + 12.0, rect.origin.y + 17.0),
Point2D::new(rect.origin.x + 12.0, rect.origin.y + 19.0),
);
draw_icon(
cx.backend,

View file

@ -173,6 +173,17 @@ pub fn effect_param_fields(kind: EffectKind) -> &'static [(EffectField, &'static
}
}
/// On-screen rect of an effect-parameter's editable value box — the
/// click target that focuses it for keyboard entry. Shared by the
/// action-rect walker (hit-test) and `paint_effect_param_row`
/// (paint) so the two never drift. `y` is the param row's top.
pub fn effect_param_value_rect(x: f32, y: f32, width: f32) -> Rect {
Rect {
origin: Point2D::new(x + width - PAD_X - 104.0, y + 3.0),
size: Point2D::new(52.0, INPUT_HEIGHT - 6.0),
}
}
/// Total height one effect block consumes — its header row plus one
/// row per editable parameter.
pub fn effect_block_height(kind: EffectKind) -> f32 {
@ -469,6 +480,15 @@ pub fn action_button_rects_with_fill_picker(
size: Point2D::new(22.0, INPUT_HEIGHT - 6.0),
},
));
// The value box — click to type a value directly.
out.push((
PropertyPanelAction::FocusEffectParam {
effect: ei,
field,
value: cur,
},
effect_param_value_rect(x0, py, w),
));
py += EFFECT_PARAM_ROW_HEIGHT;
}
y += effect_block_height(eff.kind);
@ -623,12 +643,12 @@ pub fn editable_input_rects(
}
if visible.opacity {
y += SECTION_HEADER_HEIGHT;
let half = usable_w / 2.0 - 4.0;
// Half-width Layer-opacity row — matches `paint_layer_section`.
rects.push((
PropertyFocus::Opacity,
Rect {
origin: Point2D::new(x0 + PAD_X, y),
size: Point2D::new(half, INPUT_HEIGHT),
size: Point2D::new(usable_w / 2.0 - 4.0, INPUT_HEIGHT),
},
));
y += INPUT_HEIGHT + 12.0;
@ -636,6 +656,16 @@ pub fn editable_input_rects(
}
if visible.fill {
y += SECTION_HEADER_HEIGHT;
// FillOpacity input — the head row's `100 %` box, sitting
// to the right of the fill-type dropdown. Geometry mirrors
// `paint_fill_section`'s `pct_rect`.
rects.push((
PropertyFocus::FillOpacity,
Rect {
origin: Point2D::new(x0 + w - PAD_X - 78.0, y),
size: Point2D::new(50.0, INPUT_HEIGHT),
},
));
y += INPUT_HEIGHT + 6.0;
if matches!(visible.fill_type, FillType::Solid) {
rects.push((

View file

@ -8,8 +8,8 @@ use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::property_panel::NodeSnapshot;
use crate::widgets::property_panel_inputs::{
format_color_hex, paint_input_with_icon_focused, paint_input_with_prefix_focused,
paint_input_with_suffix_focused, paint_section_divider, paint_section_label, to_jian_color,
HEADER_HEIGHT, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, TAB_HEIGHT,
paint_section_divider, paint_section_label, to_jian_color, HEADER_HEIGHT, INPUT_HEIGHT,
INPUT_RADIUS, PAD_X, SECTION_GAP, TAB_HEIGHT,
};
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect, TextLayout};
@ -37,6 +37,8 @@ pub enum PropertyPanelHit {
pub struct EditContext<'a> {
pub focus: Option<PropertyFocus>,
pub draft: &'a str,
/// Caret byte-index into `draft` (drafts are ASCII).
pub caret: usize,
pub caret_anchor_ms: u64,
pub now_ms: u64,
}
@ -117,10 +119,24 @@ impl<'a> EditContext<'a> {
}
}
/// Whether this field currently shows the caret.
pub fn caret_visible(&self, focus: PropertyFocus) -> bool {
self.focus == Some(focus)
/// Whether the caret blink is currently in its visible phase —
/// for editable surfaces (effect params) that don't key off a
/// `PropertyFocus`.
pub fn caret_blink_on(&self) -> bool {
jian_core::anim::blink_visible(self.now_ms, self.caret_anchor_ms, 500)
}
/// Caret byte-offset for `focus` when it is the focused field
/// and the blink is on — `None` otherwise. Drives caret paint;
/// the offset is clamped into the draft so a stale value is safe.
pub fn caret_at(&self, focus: PropertyFocus) -> Option<usize> {
if self.focus == Some(focus)
&& jian_core::anim::blink_visible(self.now_ms, self.caret_anchor_ms, 500)
{
Some(self.caret.min(self.draft.len()))
} else {
None
}
}
}
@ -217,8 +233,15 @@ pub fn paint_node_header(
y: f32,
_w: f32,
) -> f32 {
// Show the node's name so the header matches the LayerPanel
// row; fall back to the kind label for an unnamed node.
let title = if snapshot.name.is_empty() {
snapshot.kind.as_str()
} else {
snapshot.name.as_str()
};
let label = TextLayout::single_run(
&snapshot.kind,
title,
"system-ui",
14.0,
to_jian_color(theme.foreground),
@ -310,7 +333,7 @@ pub fn paint_position_section(
"X",
edit.value_for(PropertyFocus::PositionX, &x_value),
edit.focus == Some(PropertyFocus::PositionX),
edit.caret_visible(PropertyFocus::PositionX),
edit.caret_at(PropertyFocus::PositionX),
);
let y_value = snapshot.y.to_string();
paint_input_with_prefix_focused(
@ -320,7 +343,7 @@ pub fn paint_position_section(
"Y",
edit.value_for(PropertyFocus::PositionY, &y_value),
edit.focus == Some(PropertyFocus::PositionY),
edit.caret_visible(PropertyFocus::PositionY),
edit.caret_at(PropertyFocus::PositionY),
);
y += INPUT_HEIGHT + 6.0;
// Rotation input — TS uses RotateCw glyph; we render with an
@ -338,7 +361,7 @@ pub fn paint_position_section(
edit.value_for(PropertyFocus::Rotation, &rotation_value),
Some("°"),
edit.focus == Some(PropertyFocus::Rotation),
edit.caret_visible(PropertyFocus::Rotation),
edit.caret_at(PropertyFocus::Rotation),
);
// Corner radius (R) — editable input bound to Node::corner_radius
// via PropertyFocus::PositionR.
@ -354,7 +377,7 @@ pub fn paint_position_section(
"R",
edit.value_for(PropertyFocus::PositionR, &r_value),
edit.focus == Some(PropertyFocus::PositionR),
edit.caret_visible(PropertyFocus::PositionR),
edit.caret_at(PropertyFocus::PositionR),
);
y += INPUT_HEIGHT + 12.0;
paint_section_divider(cx, theme, x, y, width);
@ -452,7 +475,7 @@ pub fn paint_size_section(
"W",
edit.value_for(PropertyFocus::SizeW, &w_value),
edit.focus == Some(PropertyFocus::SizeW),
edit.caret_visible(PropertyFocus::SizeW),
edit.caret_at(PropertyFocus::SizeW),
);
let h_value = snapshot.height.to_string();
paint_input_with_prefix_focused(
@ -462,7 +485,7 @@ pub fn paint_size_section(
"H",
edit.value_for(PropertyFocus::SizeH, &h_value),
edit.focus == Some(PropertyFocus::SizeH),
edit.caret_visible(PropertyFocus::SizeH),
edit.caret_at(PropertyFocus::SizeH),
);
y += INPUT_HEIGHT + 10.0;
let row_h = 22.0;
@ -570,19 +593,75 @@ pub fn paint_layer_section(
let usable_w = width - PAD_X * 2.0;
let row = Rect {
origin: Point2D::new(x + PAD_X, y),
// Half-width box — the empty right half avoids the over-wide
// look of a row spanning the full panel for a single value.
size: Point2D::new(usable_w / 2.0 - 4.0, INPUT_HEIGHT),
};
let focused = edit.focus == Some(PropertyFocus::Opacity);
let value = edit.value_for(PropertyFocus::Opacity, "100");
paint_input_with_suffix_focused(
cx,
theme,
row,
value,
"%",
focused,
edit.caret_visible(PropertyFocus::Opacity),
// Left-aligned compound `<label> <value> <unit>` — measuring
// the label first so the value never collides with it, then
// packing the unit right after the value. Avoids the overlap
// a "value-centered" layout produces in narrow boxes when the
// label is a wide CJK string ("不透明度" alone is ~50 px).
cx.backend.fill_round_rect(row, INPUT_RADIUS, theme.muted);
if focused {
cx.backend
.stroke_round_rect(row, INPUT_RADIUS, theme.primary, 1.5);
}
// Clip the text paint to the row so a long localized label
// (e.g. ru "Непрозрачность") can't bleed past the half-width
// box into the neighbouring rail.
cx.backend.save();
cx.backend.clip_rect(row);
let prefix = labels.opacity;
let prefix_w = cx.backend.measure_text(prefix, 12.0);
let value_w = cx.backend.measure_text(value, 12.0);
let baseline_y = y + 19.0;
let prefix_x = row.origin.x + 10.0;
let prefix_layout = TextLayout::single_run(
prefix,
"system-ui",
12.0,
to_jian_color(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&prefix_layout, Point2D::new(prefix_x, baseline_y));
let value_x = prefix_x + prefix_w + 8.0;
let value_layout = TextLayout::single_run(
value,
"system-ui",
12.0,
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&value_layout, Point2D::new(value_x, baseline_y));
if let Some(pos) = edit.caret_at(PropertyFocus::Opacity) {
let w = cx
.backend
.measure_text(&value[..pos.min(value.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(value_x + w, y + 6.0),
size: Point2D::new(1.5, INPUT_HEIGHT - 12.0),
},
theme.foreground,
);
}
let unit_layout = TextLayout::single_run(
"%",
"system-ui",
12.0,
to_jian_color(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&unit_layout,
Point2D::new(value_x + value_w + 6.0, baseline_y),
);
cx.backend.restore();
y += INPUT_HEIGHT + 12.0;
paint_section_divider(cx, theme, x, y, width);
y + SECTION_GAP
@ -625,7 +704,9 @@ pub fn paint_stroke_section(
}
cx.backend.fill_round_rect(
Rect {
origin: Point2D::new(hex_rect.origin.x + 6.0, hex_rect.origin.y + 5.0),
// Vertically centre the 16-tall swatch in the 30-tall
// row: `(30 - 16) / 2 == 7`.
origin: Point2D::new(hex_rect.origin.x + 6.0, hex_rect.origin.y + 7.0),
size: Point2D::new(16.0, 16.0),
},
3.0,
@ -642,9 +723,11 @@ pub fn paint_stroke_section(
);
let hex_x = hex_rect.origin.x + 30.0;
cx.backend
.draw_text(&hex_layout, Point2D::new(hex_x, hex_rect.origin.y + 17.0));
if edit.caret_visible(PropertyFocus::StrokeHex) {
let w = cx.backend.measure_text(hex_text, 12.0);
.draw_text(&hex_layout, Point2D::new(hex_x, hex_rect.origin.y + 19.0));
if let Some(pos) = edit.caret_at(PropertyFocus::StrokeHex) {
let w = cx
.backend
.measure_text(&hex_text[..pos.min(hex_text.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(hex_x + w, hex_rect.origin.y + 6.0),
@ -675,9 +758,11 @@ pub fn paint_stroke_section(
);
let w_x = w_rect.origin.x + 12.0;
cx.backend
.draw_text(&w_layout, Point2D::new(w_x, w_rect.origin.y + 17.0));
if edit.caret_visible(PropertyFocus::StrokeWidth) {
let w = cx.backend.measure_text(w_text, 12.0);
.draw_text(&w_layout, Point2D::new(w_x, w_rect.origin.y + 19.0));
if let Some(pos) = edit.caret_at(PropertyFocus::StrokeWidth) {
let w = cx
.backend
.measure_text(&w_text[..pos.min(w_text.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(w_x + w, w_rect.origin.y + 6.0),

View file

@ -41,16 +41,24 @@ impl DesktopApp {
consumed = self.host.apply_escape();
}
Key::Named(NamedKey::ArrowUp) if !self.zoom_modifier && !settings_focused => {
consumed = self.host.apply_nudge(0.0, -nudge);
// A focused numeric property input steps its value;
// otherwise the arrow nudges the selection.
consumed =
self.host.apply_property_step(nudge) || self.host.apply_nudge(0.0, -nudge);
}
Key::Named(NamedKey::ArrowDown) if !self.zoom_modifier && !settings_focused => {
consumed = self.host.apply_nudge(0.0, nudge);
consumed =
self.host.apply_property_step(-nudge) || self.host.apply_nudge(0.0, nudge);
}
Key::Named(NamedKey::ArrowLeft) if !self.zoom_modifier && !settings_focused => {
consumed = self.host.apply_nudge(-nudge, 0.0);
// A focused property input moves its text caret;
// otherwise the arrow nudges the selection.
consumed =
self.host.apply_property_caret(false) || self.host.apply_nudge(-nudge, 0.0);
}
Key::Named(NamedKey::ArrowRight) if !self.zoom_modifier && !settings_focused => {
consumed = self.host.apply_nudge(nudge, 0.0);
consumed =
self.host.apply_property_caret(true) || self.host.apply_nudge(nudge, 0.0);
}
// Cmd/Ctrl+Alt+U/S/I/X — path boolean ops (Paper.js
// parity). Gated on `!settings_focused` so they

View file

@ -18,6 +18,7 @@ impl WidgetHostNative {
|| ui.text_editing.is_some()
|| ui.property_focus.is_some()
|| self.editor_state.editor_ui.variable_row_focus.is_some()
|| self.editor_state.editor_ui.effect_param_focus.is_some()
|| self.editor_state.editor_ui.agent_settings.focus.is_some()
|| self.editor_state.chat.focused
|| self.git_commit_focus_active()

View file

@ -45,6 +45,9 @@ fn escape_closes_one_overlay_per_press_in_priority_order() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PositionX);
host.editor_state_mut().ui.property_input_draft = "12".to_string();
// Focusing an input seeds the caret at the draft's end (the
// press path does this); mirror it so the state is faithful.
host.editor_state_mut().ui.property_caret_pos = 2;
host.editor_state_mut().editor_ui.locale_picker_open = true;
host.editor_state_mut().editor_ui.shape_picker_open = true;
host.editor_state_mut().editor_ui.fill_type_picker_open = true;
@ -95,9 +98,13 @@ fn backspace_with_property_draft_does_not_delete_selected() {
.set_single_selection(NodeId::new("n10"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PositionX);
host.editor_state_mut().ui.property_input_draft = "123".to_string();
// Caret at the draft's end, as a real focus seeds it — Backspace
// deletes the char *before* the caret.
host.editor_state_mut().ui.property_caret_pos = 3;
assert!(host.apply_backspace());
assert_eq!(host.editor_state().ui.property_input_draft, "12");
assert_eq!(host.editor_state().ui.property_caret_pos, 2);
// Selection must be untouched.
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n10"));
}

View file

@ -87,30 +87,58 @@ impl WidgetHostNative {
self.mark_dirty();
return true;
}
if self.editor_state.editor_ui.effect_param_focus.is_some() {
// Effect-param value box — numeric, caret-aware insert
// into the shared draft (same as a numeric property).
self.editor_state.ui.property_draft_select_all = false;
let draft = &self.editor_state.ui.property_input_draft;
let pos = self.editor_state.ui.property_caret_pos.min(draft.len());
let allowed = c.is_ascii_digit()
|| (c == '-' && pos == 0 && !draft.starts_with('-'))
|| (c == '.' && !draft.contains('.'));
if !allowed {
return false;
}
let draft = &mut self.editor_state.ui.property_input_draft;
draft.insert(pos, c);
self.editor_state.ui.property_caret_pos = pos + 1;
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
self.mark_dirty();
return true;
}
if let Some(focus) = self.editor_state.ui.property_focus {
self.editor_state.ui.property_draft_select_all = false;
let is_hex_focus = matches!(focus, PropertyFocus::FillHex | PropertyFocus::StrokeHex);
// Caret byte-index — drafts are ASCII so it is also the
// char index. `-` / `#` are gated on the caret being at
// the start, NOT on the draft being empty: typing `-` at
// the head of an existing `40` is a valid edit (`-40`).
let draft = &self.editor_state.ui.property_input_draft;
let pos = self.editor_state.ui.property_caret_pos.min(draft.len());
let allowed = if is_hex_focus {
self.editor_state.ui.property_input_draft.len() < 7
&& (c.is_ascii_hexdigit()
|| (c == '#' && self.editor_state.ui.property_input_draft.is_empty()))
draft.len() < 7
&& (c.is_ascii_hexdigit() || (c == '#' && pos == 0 && !draft.starts_with('#')))
} else {
c.is_ascii_digit()
|| (c == '-' && self.editor_state.ui.property_input_draft.is_empty())
|| (c == '-' && pos == 0 && !draft.starts_with('-'))
|| (c == '.'
&& matches!(
focus,
PropertyFocus::Opacity
| PropertyFocus::FillOpacity
| PropertyFocus::Rotation
| PropertyFocus::PositionR
| PropertyFocus::StrokeWidth
)
&& !self.editor_state.ui.property_input_draft.contains('.'))
&& !draft.contains('.'))
};
if !allowed {
return false;
}
self.editor_state.ui.property_input_draft.push(c);
// Insert at the caret and advance it.
let draft = &mut self.editor_state.ui.property_input_draft;
draft.insert(pos, c);
self.editor_state.ui.property_caret_pos = pos + 1;
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
self.mark_dirty();
return true;
@ -200,9 +228,16 @@ impl WidgetHostNative {
}
return false;
}
if self.editor_state.ui.property_focus.is_some() {
if self.editor_state.ui.property_focus.is_some()
|| self.editor_state.editor_ui.effect_param_focus.is_some()
{
self.editor_state.ui.property_draft_select_all = false;
if self.editor_state.ui.property_input_draft.pop().is_some() {
// Delete the char before the caret, then pull it back.
let draft = &mut self.editor_state.ui.property_input_draft;
let pos = self.editor_state.ui.property_caret_pos.min(draft.len());
if pos > 0 {
draft.remove(pos - 1);
self.editor_state.ui.property_caret_pos = pos - 1;
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
self.mark_dirty();
return true;
@ -291,6 +326,101 @@ impl WidgetHostNative {
dup
}
/// Up / Down arrow on a focused numeric property input — steps
/// the value by `delta` and commits it (like a `−` / `+`
/// stepper). Returns `false` when no numeric property input is
/// focused, so the caller falls back to nudging the selection.
pub fn apply_property_step(&mut self, delta: f32) -> bool {
use op_editor_core::ui_draft::PropertyFocus;
// Effect-parameter focus: step the value, commit via
// `SetEffectParam`, and reflect it back into the draft.
if let Some(ef) = self.editor_state.editor_ui.effect_param_focus {
let current: f32 = self
.editor_state
.ui
.property_input_draft
.trim()
.parse()
.unwrap_or(0.0);
let next = current + delta;
let id = self.editor_state.selection.anchor.clone();
if id.is_real() {
self.editor_state.commit_history();
let _ = self
.editor_state
.apply(op_editor_core::EditorCommand::SetEffectParam {
node_id: id,
index: ef.effect as u32,
field: ef.field,
value: next,
});
}
self.editor_state.ui.property_input_draft = if next.fract() == 0.0 {
format!("{}", next as i64)
} else {
format!("{next}")
};
self.editor_state.ui.property_caret_pos =
self.editor_state.ui.property_input_draft.len();
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
self.mark_dirty();
return true;
}
let Some(focus) = self.editor_state.ui.property_focus else {
return false;
};
// Hex colour fields aren't numerically steppable.
if matches!(focus, PropertyFocus::FillHex | PropertyFocus::StrokeHex) {
return false;
}
let current: f32 = self
.editor_state
.ui
.property_input_draft
.trim()
.parse()
.unwrap_or(0.0);
let next = current + delta;
let _ = self.editor_state.commit_property_edit(focus, next);
// Reflect the committed value back into the draft so the
// field shows it and a further step builds on the new value.
self.editor_state.ui.property_input_draft = if next.fract() == 0.0 {
format!("{}", next as i64)
} else {
format!("{next}")
};
self.editor_state.ui.property_caret_pos = self.editor_state.ui.property_input_draft.len();
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
self.mark_dirty();
true
}
/// Left / Right arrow on a focused property input — moves the
/// text caret one character. Returns `false` when no property
/// input is focused, so the caller falls back to node-nudge.
pub fn apply_property_caret(&mut self, forward: bool) -> bool {
if self.editor_state.ui.property_focus.is_none()
&& self.editor_state.editor_ui.effect_param_focus.is_none()
{
return false;
}
let len = self.editor_state.ui.property_input_draft.len();
let pos = self.editor_state.ui.property_caret_pos.min(len);
let next = if forward {
(pos + 1).min(len)
} else {
pos.saturating_sub(1)
};
if next != self.editor_state.ui.property_caret_pos {
self.editor_state.ui.property_caret_pos = next;
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
self.mark_dirty();
}
// Consumed regardless — an arrow over a focused input must
// never fall through to nudging the selected node.
true
}
/// Arrow-key nudge — translate selection by (dx, dy) doc px.
pub fn apply_nudge(&mut self, dx: f32, dy: f32) -> bool {
if self.input_active() {
@ -369,6 +499,10 @@ impl WidgetHostNative {
self.commit_variable_row_focus_if_any();
return true;
}
if self.editor_state.editor_ui.effect_param_focus.is_some() {
self.commit_effect_param_focus_if_any();
return true;
}
if self.editor_state.ui.property_focus.is_some() {
self.commit_property_focus_if_any();
return true;
@ -463,6 +597,18 @@ impl WidgetHostNative {
self.mark_dirty();
return true;
}
if self
.editor_state
.editor_ui
.effect_param_focus
.take()
.is_some()
{
self.editor_state.ui.property_input_draft.clear();
self.editor_state.ui.property_draft_select_all = false;
self.mark_dirty();
return true;
}
if self.editor_state.ui.property_focus.take().is_some() {
self.editor_state.ui.property_input_draft.clear();
self.editor_state.ui.property_draft_select_all = false;

View file

@ -424,6 +424,9 @@ impl WidgetHostNative {
// shell-core `PropertyFocus` → op-editor-core.
self.editor_state.ui.property_focus = Some(focus);
self.editor_state.ui.property_input_draft = initial;
// Caret starts at the end of the seeded draft.
self.editor_state.ui.property_caret_pos =
self.editor_state.ui.property_input_draft.len();
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
self.editor_state.ui.property_draft_select_all = false;
self.editor_state.chat.focused = false;

View file

@ -142,6 +142,7 @@ pub(in crate::widget_host) fn property_focus_initial(
F::Rotation => (panel.snapshot.rotation_deg.round() as i32).to_string(),
F::PositionR => (panel.snapshot.corner_radius.round() as i32).to_string(),
F::Opacity => "100".to_string(),
F::FillOpacity => ((panel.snapshot.fill_opacity * 100.0).round() as i32).to_string(),
F::FillHex => panel
.snapshot
.fill

View file

@ -114,6 +114,26 @@ impl WidgetHostNative {
});
}
}
A::FocusEffectParam {
effect,
field,
value,
} => {
// Any prior input was committed by the press path's
// `commit_property_focus_if_any`; seed this param's
// draft from its current value, caret at the end.
let ui = &mut self.editor_state.ui;
ui.property_input_draft = if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
format!("{value}")
};
ui.property_caret_pos = ui.property_input_draft.len();
ui.property_caret_anchor_ms = self.now_ms;
ui.property_draft_select_all = false;
self.editor_state.editor_ui.effect_param_focus =
Some(op_editor_core::editor_ui_state::EffectParamFocus { effect, field });
}
}
self.mark_dirty();
}
@ -338,9 +358,38 @@ impl WidgetHostNative {
self.mark_dirty();
}
/// Commit a pending effect-parameter edit (Effects section's
/// editable value box). Parses the shared draft and writes it
/// via `SetEffectParam`; a non-numeric draft is discarded.
pub(in crate::widget_host) fn commit_effect_param_focus_if_any(&mut self) {
let Some(focus) = self.editor_state.editor_ui.effect_param_focus.take() else {
return;
};
self.editor_state.ui.property_draft_select_all = false;
let draft = std::mem::take(&mut self.editor_state.ui.property_input_draft);
if let Ok(value) = draft.trim().parse::<f32>() {
if value.is_finite() {
let id = self.editor_state.selection.anchor.clone();
if id.is_real() {
self.editor_state.commit_history();
let _ =
self.editor_state
.apply(op_editor_core::EditorCommand::SetEffectParam {
node_id: id,
index: focus.effect as u32,
field: focus.field,
value,
});
}
}
}
self.mark_dirty();
}
pub(in crate::widget_host) fn commit_property_focus_if_any(&mut self) {
// Commit any pending variable-row edit first.
// Commit any pending variable-row / effect-param edit first.
self.commit_variable_row_focus_if_any();
self.commit_effect_param_focus_if_any();
let Some(focus) = self.editor_state.ui.property_focus.take() else {
return;
};

View file

@ -108,6 +108,14 @@ impl WidgetHost {
});
}
}
A::FocusEffectParam { .. } => {
// No-op on web: the web host has no keyboard path for
// property / effect-param text inputs (`apply_text`
// has no such branch), so setting `effect_param_focus`
// here would strand the focus with no way to type,
// commit, or Escape out. The `−` / `+` steppers
// (`AdjustEffectParam`) remain the web edit path.
}
}
self.mark_dirty();
}