feat(panels): improve native property controls and icons
Some checks failed
Rust check (native) / ubuntu-latest / 1.94 (push) Failing after 2s
Rust check (native) / cargo-deny (native) (push) Failing after 1s
Rust check (native) / diagnostics golden drift (push) Failing after 2s
Rust multi-platform build / linux-x86_64 (push) Failing after 1s
Rust multi-platform build / wasm32-unknown-unknown / op-host-web (compile guard) (push) Failing after 2s
Rust multi-platform build / android-aarch64 (cargo check only) (push) Failing after 2s
Rust multi-platform build / android-x86_64 (cargo check only) (push) Failing after 2s
WASM bundle check (kickoff §1.2) / cargo check --target wasm32-unknown-unknown (push) Failing after 2s
WASM bundle check (kickoff §1.2) / cargo-deny --target wasm32-unknown-unknown check bans (push) Failing after 1s
Rust check (native) / macos-latest / 1.94 (push) Has been cancelled
Rust check (native) / windows-latest / 1.94 (push) Has been cancelled
Rust multi-platform build / linux-aarch64 (push) Has been cancelled
Rust multi-platform build / macos-aarch64 (push) Has been cancelled
Rust multi-platform build / windows-x86_64 (push) Has been cancelled
Rust multi-platform build / macos-x86_64 (push) Has been cancelled
Rust multi-platform build / windows-aarch64 (push) Has been cancelled
Rust multi-platform build / ios-aarch64 (cargo check only) (push) Has been cancelled
Rust multi-platform build / ios-aarch64-sim (cargo check only) (push) Has been cancelled

This commit is contained in:
Kayshen-X 2026-05-24 23:30:00 +08:00
parent 84e2b5f2bf
commit b0b52a7842
66 changed files with 5634 additions and 1000 deletions

2
Cargo.lock generated
View file

@ -2832,6 +2832,8 @@ dependencies = [
"jian-ops-schema", "jian-ops-schema",
"op-editor-core", "op-editor-core",
"op-i18n", "op-i18n",
"serde",
"serde_json",
] ]
[[package]] [[package]]

View file

@ -1,6 +1,7 @@
import { defineEventHandler, getQuery, setResponseHeaders } from 'h3'; import { defineEventHandler, getQuery, setResponseHeaders } from 'h3';
import simpleIconsData from '@iconify-json/simple-icons/icons.json'; import simpleIconsData from '@iconify-json/simple-icons/icons.json';
import lucideData from '@iconify-json/lucide/icons.json'; import lucideData from '@iconify-json/lucide/icons.json';
import featherData from '@iconify-json/feather/icons.json';
interface IconResult { interface IconResult {
d: string; d: string;
@ -18,6 +19,7 @@ type IconifySet = {
const simpleIcons = simpleIconsData as unknown as IconifySet; const simpleIcons = simpleIconsData as unknown as IconifySet;
const lucideIcons = lucideData as unknown as IconifySet; const lucideIcons = lucideData as unknown as IconifySet;
const featherIcons = featherData as unknown as IconifySet;
// In-memory cache: normalized name → result (null = confirmed miss) // In-memory cache: normalized name → result (null = confirmed miss)
const iconCache = new Map<string, IconResult | null>(); const iconCache = new Map<string, IconResult | null>();
@ -26,7 +28,7 @@ const iconCache = new Map<string, IconResult | null>();
* GET /api/ai/icon?name=google * GET /api/ai/icon?name=google
* *
* Resolves icon names to SVG path data using locally bundled icon sets. * Resolves icon names to SVG path data using locally bundled icon sets.
* Search order: lucide simple-icons (brand icons) * Search order: simple-icons lucide feather
* No external network requests instant, offline-capable. * No external network requests instant, offline-capable.
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -164,6 +166,12 @@ function resolveIcon(name: string): IconResult | null {
if (result) return result; if (result) return result;
} }
// 3. Try Feather for legacy/generated icon names.
for (const n of candidates) {
const result = lookupLocal(featherIcons, 'feather', n);
if (result) return result;
}
return null; return null;
} }

View file

@ -6,6 +6,7 @@ import { ICONIFY_API_URL } from '@/constants/app';
const ICONIFY_API = ICONIFY_API_URL; const ICONIFY_API = ICONIFY_API_URL;
const DEBOUNCE_MS = 250; const DEBOUNCE_MS = 250;
const SEARCH_LIMIT = 60;
function getIconColor(): string { function getIconColor(): string {
const isLight = const isLight =
@ -18,6 +19,10 @@ function parseIconId(id: string) {
return { collection: id.slice(0, idx), name: id.slice(idx + 1) }; return { collection: id.slice(0, idx), name: id.slice(idx + 1) };
} }
function mergeIconIds(prev: string[], next: string[]) {
return [...new Set([...prev, ...next])];
}
export interface IconPickerPosition { export interface IconPickerPosition {
top: number; top: number;
right: number; right: number;
@ -53,6 +58,8 @@ export default function IconPickerDialog({
const [collectionLoading, setCollectionLoading] = useState(false); const [collectionLoading, setCollectionLoading] = useState(false);
// Global search (no collection context) // Global search (no collection context)
const [searchIcons, setSearchIcons] = useState<string[]>([]); const [searchIcons, setSearchIcons] = useState<string[]>([]);
const [searchTotal, setSearchTotal] = useState<number | null>(null);
const [searchStart, setSearchStart] = useState(0);
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false);
const [searched, setSearched] = useState(false); const [searched, setSearched] = useState(false);
const [fetching, setFetching] = useState<string | null>(null); const [fetching, setFetching] = useState<string | null>(null);
@ -104,11 +111,15 @@ export default function IconPickerDialog({
// Focus + pre-fill on open // Focus + pre-fill on open
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setQuery(initialQuery ?? ''); const nextQuery = initialQuery ?? '';
setQuery(nextQuery);
if (nextQuery.trim()) doGlobalSearch(nextQuery);
setTimeout(() => inputRef.current?.focus(), 30); setTimeout(() => inputRef.current?.focus(), 30);
} else { } else {
setQuery(''); setQuery('');
setSearchIcons([]); setSearchIcons([]);
setSearchTotal(null);
setSearchStart(0);
setSearched(false); setSearched(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -143,36 +154,60 @@ export default function IconPickerDialog({
}; };
}, [open, onClose]); }, [open, onClose]);
// Global search (used when no collection is loaded) const fetchSearch = async (q: string, start = 0, append = false) => {
const trimmed = q.trim();
if (!trimmed) return;
setSearchLoading(true);
try {
const prefix = activeCollection ? `&prefix=${encodeURIComponent(activeCollection)}` : '';
const res = await fetch(
`${ICONIFY_API}/search?query=${encodeURIComponent(trimmed)}&limit=${SEARCH_LIMIT}&start=${start}${prefix}`,
);
if (!res.ok) throw new Error();
const data = await res.json();
const icons = data.icons ?? [];
setSearchIcons((prev) => (append ? mergeIconIds(prev, icons) : icons));
setSearchTotal(data.total ?? icons.length);
setSearchStart((data.start ?? start) + icons.length);
} catch {
if (!append) setSearchIcons([]);
setSearchTotal(null);
} finally {
setSearchLoading(false);
setSearched(true);
}
};
const doGlobalSearch = (q: string) => { const doGlobalSearch = (q: string) => {
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
if (!q.trim()) { if (!q.trim()) {
setSearchIcons([]); setSearchIcons([]);
setSearchTotal(null);
setSearchStart(0);
setSearchLoading(false); setSearchLoading(false);
setSearched(false); setSearched(false);
return; return;
} }
setSearchIcons([]);
setSearchTotal(null);
setSearchStart(0);
setSearchLoading(true); setSearchLoading(true);
timerRef.current = setTimeout(async () => { timerRef.current = setTimeout(() => void fetchSearch(q, 0, false), DEBOUNCE_MS);
try {
const res = await fetch(
`${ICONIFY_API}/search?query=${encodeURIComponent(q.trim())}&limit=120`,
);
if (!res.ok) throw new Error();
const data = await res.json();
setSearchIcons(data.icons ?? []);
} catch {
setSearchIcons([]);
} finally {
setSearchLoading(false);
setSearched(true);
}
}, DEBOUNCE_MS);
}; };
const handleQueryChange = (val: string) => { const handleQueryChange = (val: string) => {
setQuery(val); setQuery(val);
if (!activeCollection) doGlobalSearch(val); doGlobalSearch(val);
};
useEffect(() => {
if (open && query.trim()) doGlobalSearch(query);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCollection]);
const handleLoadMore = () => {
if (!trimmedQuery || searchLoading) return;
void fetchSearch(trimmedQuery, searchStart, true);
}; };
const handleSelect = async (iconId: string) => { const handleSelect = async (iconId: string) => {
@ -197,7 +232,15 @@ export default function IconPickerDialog({
let displayIcons: string[]; let displayIcons: string[];
let isLoading: boolean; let isLoading: boolean;
if (activeCollection) { if (trimmedQuery) {
displayIcons =
searchIcons.length || searched
? searchIcons
: activeCollection
? allIcons.filter((id) => (id.split(':')[1] ?? '').includes(trimmedQuery))
: [];
isLoading = searchLoading && searchIcons.length === 0;
} else if (activeCollection) {
displayIcons = trimmedQuery displayIcons = trimmedQuery
? allIcons.filter((id) => (id.split(':')[1] ?? '').includes(trimmedQuery)) ? allIcons.filter((id) => (id.split(':')[1] ?? '').includes(trimmedQuery))
: allIcons; : allIcons;
@ -209,9 +252,13 @@ export default function IconPickerDialog({
const countLabel = activeCollection const countLabel = activeCollection
? t('icon.iconsCount', { ? t('icon.iconsCount', {
count: trimmedQuery ? displayIcons.length : (totalCount ?? displayIcons.length), count: trimmedQuery
? (searchTotal ?? displayIcons.length)
: (totalCount ?? displayIcons.length),
}) })
: null; : null;
const canLoadMore =
!!trimmedQuery && !searchLoading && searchTotal !== null && searchStart < searchTotal;
// Compute popover position: anchored to the left of the property panel // Compute popover position: anchored to the left of the property panel
const PANEL_WIDTH = 256; // property panel w-64 const PANEL_WIDTH = 256; // property panel w-64
@ -268,39 +315,50 @@ export default function IconPickerDialog({
<Loader2 size={18} className="animate-spin text-muted-foreground" /> <Loader2 size={18} className="animate-spin text-muted-foreground" />
</div> </div>
) : displayIcons.length > 0 ? ( ) : displayIcons.length > 0 ? (
<div className="grid grid-cols-6 gap-0.5"> <>
{displayIcons.map((iconId) => { <div className="grid grid-cols-6 gap-0.5">
const { collection, name } = parseIconId(iconId); {displayIcons.map((iconId) => {
const isFetching = fetching === iconId; const { collection, name } = parseIconId(iconId);
const isCurrent = iconId === currentIconId; const isFetching = fetching === iconId;
return ( const isCurrent = iconId === currentIconId;
<button return (
key={iconId} <button
title={iconId} key={iconId}
onClick={() => handleSelect(iconId)} title={iconId}
disabled={isFetching} onClick={() => handleSelect(iconId)}
className={`w-10 h-10 flex items-center justify-center rounded transition-colors disabled:opacity-50 ${ disabled={isFetching}
isCurrent className={`w-10 h-10 flex items-center justify-center rounded transition-colors disabled:opacity-50 ${
? 'bg-primary/15 ring-1 ring-inset ring-primary' isCurrent
: 'hover:bg-accent cursor-pointer' ? 'bg-primary/15 ring-1 ring-inset ring-primary'
}`} : 'hover:bg-accent cursor-pointer'
> }`}
{isFetching ? ( >
<Loader2 size={14} className="animate-spin text-muted-foreground" /> {isFetching ? (
) : ( <Loader2 size={14} className="animate-spin text-muted-foreground" />
<img ) : (
src={`${ICONIFY_API}/${collection}/${name}.svg?height=18&color=${getIconColor()}`} <img
alt={name} src={`${ICONIFY_API}/${collection}/${name}.svg?height=18&color=${getIconColor()}`}
width={18} alt={name}
height={18} width={18}
loading="lazy" height={18}
/> loading="lazy"
)} />
</button> )}
); </button>
})} );
</div> })}
) : searched && !activeCollection ? ( </div>
{canLoadMore && (
<button
type="button"
onClick={handleLoadMore}
className="mt-2 h-8 w-full rounded bg-secondary text-xs text-foreground hover:bg-accent"
>
{t('git.history.loadMore')}
</button>
)}
</>
) : searched && trimmedQuery ? (
<p className="text-xs text-muted-foreground text-center py-8">{t('icon.noIconsFound')}</p> <p className="text-xs text-muted-foreground text-center py-8">{t('icon.noIconsFound')}</p>
) : !activeCollection && !trimmedQuery ? ( ) : !activeCollection && !trimmedQuery ? (
<p className="text-xs text-muted-foreground text-center py-8">{t('icon.typeToSearch')}</p> <p className="text-xs text-muted-foreground text-center py-8">{t('icon.typeToSearch')}</p>

View file

@ -64,11 +64,13 @@ pub enum VariableScalarPayload {
/// - `Keyword(String)` — a string keyword (textAlign, textGrowth, /// - `Keyword(String)` — a string keyword (textAlign, textGrowth,
/// alignItems, justifyContent, and sizing keywords like "fit_content"). /// alignItems, justifyContent, and sizing keywords like "fit_content").
/// - `NumberArray(Vec<f64>)` — padding as a number array. /// - `NumberArray(Vec<f64>)` — padding as a number array.
/// - `Bool(bool)` — boolean layout flags such as `clipContent`.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum LayoutPropValue { pub enum LayoutPropValue {
Number(f64), Number(f64),
Keyword(String), Keyword(String),
NumberArray(Vec<f64>), NumberArray(Vec<f64>),
Bool(bool),
} }
/// Per-item descriptor for [`EditorCommand::BatchInsert`]. Same shape as /// Per-item descriptor for [`EditorCommand::BatchInsert`]. Same shape as

View file

@ -4,11 +4,13 @@
#![cfg(test)] #![cfg(test)]
use crate::command::EditorCommand; use crate::command::{EditorCommand, LayoutPropValue};
use crate::node_id::NodeId; use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt; use crate::pen_node_ext::PenNodeExt;
use crate::test_support::{ellipse, rect, state_with}; use crate::test_support::{ellipse, rect, state_with, text};
use crate::walkers::find_node; use crate::walkers::find_node;
use jian_ops_schema::node::container::{AlignItems, JustifyContent, LayoutMode, Padding};
use jian_ops_schema::node::text::{TextAlign, TextAlignVertical, TextGrowth};
use jian_ops_schema::node::PenNode; use jian_ops_schema::node::PenNode;
fn id(s: &str) -> NodeId { fn id(s: &str) -> NodeId {
@ -227,3 +229,88 @@ fn set_effect_param_rejects_field_effect_mismatch() {
value: 5.0, value: 5.0,
})); }));
} }
// --- SetNodeLayoutProp ----------------------------------------------
#[test]
fn set_node_layout_prop_writes_container_layout_fields() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
for (property, value) in [
("layout", LayoutPropValue::Keyword("horizontal".into())),
(
"justifyContent",
LayoutPropValue::Keyword("space_between".into()),
),
("alignItems", LayoutPropValue::Keyword("center".into())),
("gap", LayoutPropValue::Number(12.0)),
(
"padding",
LayoutPropValue::NumberArray(vec![1.0, 2.0, 3.0, 4.0]),
),
("clipContent", LayoutPropValue::Bool(true)),
] {
assert!(s.apply(EditorCommand::SetNodeLayoutProp {
node_id: id("n1"),
property: property.into(),
value,
}));
}
match find_node(s.active_children(), &id("n1")).unwrap() {
PenNode::Rectangle(r) => {
assert_eq!(r.container.layout, Some(LayoutMode::Horizontal));
assert_eq!(
r.container.justify_content,
Some(JustifyContent::SpaceBetween)
);
assert_eq!(r.container.align_items, Some(AlignItems::Center));
assert_eq!(
r.container.gap,
Some(jian_ops_schema::node::base::NumberOrExpression::Number(
12.0
))
);
assert_eq!(
r.container.padding,
Some(Padding::LtrB([1.0, 2.0, 3.0, 4.0]))
);
assert_eq!(r.container.clip_content, Some(true));
}
other => panic!("expected rect, got {other:?}"),
}
}
#[test]
fn set_node_layout_prop_writes_text_specific_fields() {
let mut s = state_with(vec![text("t1", "t", 0.0, 0.0, 100.0, 40.0, "hello")]);
for (property, value) in [
("fontFamily", LayoutPropValue::Keyword("Inter".into())),
("textAlign", LayoutPropValue::Keyword("justify".into())),
(
"textAlignVertical",
LayoutPropValue::Keyword("middle".into()),
),
(
"textGrowth",
LayoutPropValue::Keyword("fixed-width-height".into()),
),
("lineHeight", LayoutPropValue::Number(1.4)),
("letterSpacing", LayoutPropValue::Number(2.0)),
] {
assert!(s.apply(EditorCommand::SetNodeLayoutProp {
node_id: id("t1"),
property: property.into(),
value,
}));
}
match find_node(s.active_children(), &id("t1")).unwrap() {
PenNode::Text(t) => {
assert_eq!(t.font_family.as_deref(), Some("Inter"));
assert_eq!(t.text_align, Some(TextAlign::Justify));
assert_eq!(t.text_align_vertical, Some(TextAlignVertical::Middle));
assert_eq!(t.text_growth, Some(TextGrowth::FixedWidthHeight));
assert_eq!(t.line_height, Some(1.4));
assert_eq!(t.letter_spacing, Some(2.0));
}
other => panic!("expected text, got {other:?}"),
}
}

View file

@ -1,8 +1,9 @@
//! `SetNodeLayoutProp` command application — sets layout / text //! `SetNodeLayoutProp` command application — sets layout / text
//! properties that do not have their own typed `EditorCommand` variants. //! properties that do not have their own typed `EditorCommand` variants.
//! //!
//! Covers: `gap`, `padding`, `letterSpacing`, `lineHeight`, `opacity`, //! Covers: `layout`, `gap`, `padding`, `letterSpacing`, `lineHeight`,
//! `textAlign`, `textGrowth`, `alignItems`, `justifyContent`, and //! `opacity`, `fontFamily`, `textAlign`, `textAlignVertical`,
//! `textGrowth`, `alignItems`, `justifyContent`, `clipContent`, and
//! sizing keywords for `width` / `height` (`"fit_content"` / //! sizing keywords for `width` / `height` (`"fit_content"` /
//! `"fill_container"`). //! `"fill_container"`).
//! //!
@ -15,8 +16,8 @@ use crate::pen_node_ext::PenNodeExt;
use crate::state::EditorState; use crate::state::EditorState;
use crate::walkers::find_node_mut; use crate::walkers::find_node_mut;
use jian_ops_schema::node::base::NumberOrExpression; use jian_ops_schema::node::base::NumberOrExpression;
use jian_ops_schema::node::container::{AlignItems, JustifyContent, Padding}; use jian_ops_schema::node::container::{AlignItems, JustifyContent, LayoutMode, Padding};
use jian_ops_schema::node::text::{TextAlign, TextGrowth}; use jian_ops_schema::node::text::{TextAlign, TextAlignVertical, TextGrowth};
use jian_ops_schema::node::PenNode; use jian_ops_schema::node::PenNode;
use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword}; use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
@ -44,6 +45,17 @@ fn parse_align_items(s: &str) -> Option<AlignItems> {
} }
} }
// ── helper: parse layout keyword ─────────────────────────────────────────────
fn parse_layout_mode(s: &str) -> Option<LayoutMode> {
match s {
"none" => Some(LayoutMode::None),
"vertical" => Some(LayoutMode::Vertical),
"horizontal" => Some(LayoutMode::Horizontal),
_ => None,
}
}
// ── helper: parse text_align keyword ───────────────────────────────────────── // ── helper: parse text_align keyword ─────────────────────────────────────────
fn parse_text_align(s: &str) -> Option<TextAlign> { fn parse_text_align(s: &str) -> Option<TextAlign> {
@ -51,6 +63,18 @@ fn parse_text_align(s: &str) -> Option<TextAlign> {
"left" => Some(TextAlign::Left), "left" => Some(TextAlign::Left),
"center" => Some(TextAlign::Center), "center" => Some(TextAlign::Center),
"right" => Some(TextAlign::Right), "right" => Some(TextAlign::Right),
"justify" => Some(TextAlign::Justify),
_ => None,
}
}
// ── helper: parse text_align_vertical keyword ────────────────────────────────
fn parse_text_align_vertical(s: &str) -> Option<TextAlignVertical> {
match s {
"top" => Some(TextAlignVertical::Top),
"middle" => Some(TextAlignVertical::Middle),
"bottom" => Some(TextAlignVertical::Bottom),
_ => None, _ => None,
} }
} }
@ -93,7 +117,7 @@ fn build_padding(value: &LayoutPropValue) -> Option<Padding> {
4 => Some(Padding::LtrB([arr[0], arr[1], arr[2], arr[3]])), 4 => Some(Padding::LtrB([arr[0], arr[1], arr[2], arr[3]])),
_ => None, _ => None,
}, },
LayoutPropValue::Keyword(_) => None, LayoutPropValue::Keyword(_) | LayoutPropValue::Bool(_) => None,
} }
} }
@ -129,6 +153,14 @@ impl EditorState {
return false; return false;
} }
} }
"layout" => {
let LayoutPropValue::Keyword(s) = value else {
return false;
};
if parse_layout_mode(s).is_none() {
return false;
}
}
"justifyContent" => { "justifyContent" => {
let LayoutPropValue::Keyword(s) = value else { let LayoutPropValue::Keyword(s) = value else {
return false; return false;
@ -153,6 +185,22 @@ impl EditorState {
return false; return false;
} }
} }
"textAlignVertical" => {
let LayoutPropValue::Keyword(s) = value else {
return false;
};
if parse_text_align_vertical(s).is_none() {
return false;
}
}
"fontFamily" => {
let LayoutPropValue::Keyword(s) = value else {
return false;
};
if s.trim().is_empty() {
return false;
}
}
"textGrowth" => { "textGrowth" => {
let LayoutPropValue::Keyword(s) = value else { let LayoutPropValue::Keyword(s) = value else {
return false; return false;
@ -171,6 +219,11 @@ impl EditorState {
return false; return false;
} }
} }
"clipContent" => {
if !matches!(value, LayoutPropValue::Bool(_)) {
return false;
}
}
_ => return false, _ => return false,
} }
@ -190,6 +243,13 @@ impl EditorState {
let pad = build_padding(value).expect("validated above"); let pad = build_padding(value).expect("validated above");
set_container_padding(node, pad) set_container_padding(node, pad)
} }
"layout" => {
let LayoutPropValue::Keyword(s) = value else {
return false;
};
let mode = parse_layout_mode(s).expect("validated above");
set_container_layout(node, mode)
}
"justifyContent" => { "justifyContent" => {
let LayoutPropValue::Keyword(s) = value else { let LayoutPropValue::Keyword(s) = value else {
return false; return false;
@ -223,6 +283,19 @@ impl EditorState {
let ta = parse_text_align(s).expect("validated above"); let ta = parse_text_align(s).expect("validated above");
set_text_align(node, ta) set_text_align(node, ta)
} }
"textAlignVertical" => {
let LayoutPropValue::Keyword(s) = value else {
return false;
};
let ta = parse_text_align_vertical(s).expect("validated above");
set_text_align_vertical(node, ta)
}
"fontFamily" => {
let LayoutPropValue::Keyword(s) = value else {
return false;
};
set_text_font_family(node, s.trim().to_string())
}
"textGrowth" => { "textGrowth" => {
let LayoutPropValue::Keyword(s) = value else { let LayoutPropValue::Keyword(s) = value else {
return false; return false;
@ -251,6 +324,12 @@ impl EditorState {
let sb = parse_sizing_keyword(s).expect("validated above"); let sb = parse_sizing_keyword(s).expect("validated above");
set_node_height(node, sb) set_node_height(node, sb)
} }
"clipContent" => {
let LayoutPropValue::Bool(v) = value else {
return false;
};
set_container_clip_content(node, *v)
}
_ => false, _ => false,
} }
} }
@ -277,6 +356,24 @@ fn set_container_gap(node: &mut PenNode, gap: f64) -> bool {
} }
} }
fn set_container_layout(node: &mut PenNode, mode: LayoutMode) -> bool {
match node {
PenNode::Frame(n) => {
n.container.layout = Some(mode);
true
}
PenNode::Group(n) => {
n.container.layout = Some(mode);
true
}
PenNode::Rectangle(n) => {
n.container.layout = Some(mode);
true
}
_ => false,
}
}
fn set_container_padding(node: &mut PenNode, pad: Padding) -> bool { fn set_container_padding(node: &mut PenNode, pad: Padding) -> bool {
match node { match node {
PenNode::Frame(n) => { PenNode::Frame(n) => {
@ -295,6 +392,24 @@ fn set_container_padding(node: &mut PenNode, pad: Padding) -> bool {
} }
} }
fn set_container_clip_content(node: &mut PenNode, value: bool) -> bool {
match node {
PenNode::Frame(n) => {
n.container.clip_content = Some(value);
true
}
PenNode::Group(n) => {
n.container.clip_content = Some(value);
true
}
PenNode::Rectangle(n) => {
n.container.clip_content = Some(value);
true
}
_ => false,
}
}
fn set_container_justify(node: &mut PenNode, jc: JustifyContent) -> bool { fn set_container_justify(node: &mut PenNode, jc: JustifyContent) -> bool {
match node { match node {
PenNode::Frame(n) => { PenNode::Frame(n) => {
@ -361,6 +476,26 @@ fn set_text_align(node: &mut PenNode, ta: TextAlign) -> bool {
} }
} }
fn set_text_align_vertical(node: &mut PenNode, ta: TextAlignVertical) -> bool {
match node {
PenNode::Text(t) => {
t.text_align_vertical = Some(ta);
true
}
_ => false,
}
}
fn set_text_font_family(node: &mut PenNode, family: String) -> bool {
match node {
PenNode::Text(t) => {
t.font_family = Some(family);
true
}
_ => false,
}
}
fn set_text_growth(node: &mut PenNode, tg: TextGrowth) -> bool { fn set_text_growth(node: &mut PenNode, tg: TextGrowth) -> bool {
match node { match node {
PenNode::Text(t) => { PenNode::Text(t) => {
@ -385,6 +520,18 @@ fn set_node_width(node: &mut PenNode, sb: SizingBehavior) -> bool {
n.container.width = Some(sb); n.container.width = Some(sb);
true true
} }
PenNode::Ellipse(n) => {
n.width = Some(sb);
true
}
PenNode::Polygon(n) => {
n.width = Some(sb);
true
}
PenNode::Path(n) => {
n.width = Some(sb);
true
}
PenNode::Text(n) => { PenNode::Text(n) => {
n.width = Some(sb); n.width = Some(sb);
true true
@ -393,6 +540,14 @@ fn set_node_width(node: &mut PenNode, sb: SizingBehavior) -> bool {
n.width = Some(sb); n.width = Some(sb);
true true
} }
PenNode::Image(n) => {
n.width = Some(sb);
true
}
PenNode::IconFont(n) => {
n.width = Some(sb);
true
}
_ => false, _ => false,
} }
} }
@ -411,6 +566,18 @@ fn set_node_height(node: &mut PenNode, sb: SizingBehavior) -> bool {
n.container.height = Some(sb); n.container.height = Some(sb);
true true
} }
PenNode::Ellipse(n) => {
n.height = Some(sb);
true
}
PenNode::Polygon(n) => {
n.height = Some(sb);
true
}
PenNode::Path(n) => {
n.height = Some(sb);
true
}
PenNode::Text(n) => { PenNode::Text(n) => {
n.height = Some(sb); n.height = Some(sb);
true true
@ -419,6 +586,14 @@ fn set_node_height(node: &mut PenNode, sb: SizingBehavior) -> bool {
n.height = Some(sb); n.height = Some(sb);
true true
} }
PenNode::Image(n) => {
n.height = Some(sb);
true
}
PenNode::IconFont(n) => {
n.height = Some(sb);
true
}
_ => false, _ => false,
} }
} }

View file

@ -40,6 +40,10 @@ fn write_corner_radius(node: &mut PenNode, radius: f64) -> bool {
n.container.corner_radius = Some(CornerRadius::Uniform(radius)); n.container.corner_radius = Some(CornerRadius::Uniform(radius));
true true
} }
PenNode::Image(n) => {
n.corner_radius = Some(CornerRadius::Uniform(radius));
true
}
PenNode::Ellipse(n) => { PenNode::Ellipse(n) => {
n.corner_radius = Some(radius); n.corner_radius = Some(radius);
true true

View file

@ -492,8 +492,18 @@ pub struct EditorUiState {
pub shape_tool: Tool, pub shape_tool: Tool,
/// Whether the Toolbar's Icon action picker is open. /// Whether the Toolbar's Icon action picker is open.
pub icon_picker_open: bool, pub icon_picker_open: bool,
/// True when the icon picker should replace the selected icon
/// instead of inserting a new icon at the canvas centre.
pub icon_picker_replace_selection: bool,
/// Top-left corner of the floating Icon picker in logical px.
/// `None` until first dragged/opened, then reused across opens.
pub icon_picker_panel_pos: Option<(f32, f32)>,
/// Live text filter for the native Lucide icon picker. /// Live text filter for the native Lucide icon picker.
pub icon_picker_search: String, pub icon_picker_search: String,
/// Remote Iconify search results appended by the desktop host.
pub icon_picker_remote: crate::icon_picker_state::IconPickerRemoteState,
/// Queued "load more" request drained asynchronously by desktop.
pub icon_picker_load_more_request: Option<crate::icon_picker_state::IconifyLoadMoreRequest>,
// --- AI chat model picker -------------------------------------- // --- AI chat model picker --------------------------------------
/// AI chat model-picker dropdown open. /// AI chat model-picker dropdown open.
@ -534,6 +544,8 @@ pub struct EditorUiState {
pub fill_type_picker_open: bool, pub fill_type_picker_open: bool,
/// Whether the image-fill editor popover is open. /// Whether the image-fill editor popover is open.
pub image_fill_popover_open: bool, pub image_fill_popover_open: bool,
/// Whether the text font-family picker is open.
pub font_family_picker_open: bool,
/// Active-theme axis whose value picker is open; `None` = closed. /// Active-theme axis whose value picker is open; `None` = closed.
pub axis_dropdown_open: Option<String>, pub axis_dropdown_open: Option<String>,
/// Editor focus for a non-color variable row (Number / String). /// Editor focus for a non-color variable row (Number / String).
@ -661,7 +673,11 @@ impl Default for EditorUiState {
toolbar_hover: None, toolbar_hover: None,
shape_tool: Tool::Rect, shape_tool: Tool::Rect,
icon_picker_open: false, icon_picker_open: false,
icon_picker_replace_selection: false,
icon_picker_panel_pos: None,
icon_picker_search: String::new(), icon_picker_search: String::new(),
icon_picker_remote: crate::icon_picker_state::IconPickerRemoteState::default(),
icon_picker_load_more_request: None,
chat_model_picker_open: false, chat_model_picker_open: false,
chat_model_picker_scroll: 0.0, chat_model_picker_scroll: 0.0,
chat_model_picker_hover: None, chat_model_picker_hover: None,
@ -678,6 +694,7 @@ impl Default for EditorUiState {
size_clip_content: false, size_clip_content: false,
fill_type_picker_open: false, fill_type_picker_open: false,
image_fill_popover_open: false, image_fill_popover_open: false,
font_family_picker_open: false,
axis_dropdown_open: None, axis_dropdown_open: None,
variable_row_focus: None, variable_row_focus: None,
effect_param_focus: None, effect_param_focus: None,

View file

@ -1,27 +1,10 @@
//! Fill / stroke / effect read-write helpers for `PenNode`. //! Fill / stroke / effect read-write helpers for `PenNode`.
//! //!
//! shell-core's flat `Node` carried `fill: Option<Color>` and a //! The canonical model stores rich `Vec<PenFill>` payloads; the
//! `stroke: Option<Stroke>` — a single literal colour per channel. //! property panel mostly edits a primary colour/fill/stroke/effect
//! The canonical `PenNode` is richer: every paintable variant carries //! surface. These helpers preserve non-target fills and gradient/image
//! `fill: Option<Vec<PenFill>>` (where each `PenFill` is a tagged //! bodies instead of flattening the node into shell-core's old scalar
//! `Solid` / gradient / `Image` body with hex `String` colours), and //! colour model.
//! the stroke colour lives inside `PenStroke::fill` as its own
//! `Vec<PenFill>`.
//!
//! The colour-picker + property-panel mutators only ever care about
//! "the node's primary solid colour" — a single hex. This module is
//! the shim that reads / writes exactly that:
//!
//! - [`first_solid_fill_hex`] — read the first `Solid` fill's hex.
//! - [`set_primary_fill_hex`] — replace the first `Solid` fill (or
//! prepend one) with a new hex, keeping any non-solid fills.
//! - the stroke parallel ([`first_solid_stroke_hex`] /
//! [`set_primary_stroke_hex`]).
//! - [`push_drop_shadow`] — append a default drop-shadow effect.
//!
//! Gradient / image fills are preserved verbatim — a hex write only
//! ever touches the *first solid* entry, mirroring shell-core's
//! single-colour behaviour without flattening the canonical model.
use crate::editor_ui_state::{FillType, ImageAdjustmentField, ImageFillMode}; use crate::editor_ui_state::{FillType, ImageAdjustmentField, ImageFillMode};
use jian_ops_schema::node::PenNode; use jian_ops_schema::node::PenNode;
@ -416,6 +399,41 @@ pub fn set_primary_gradient_stop_offset(node: &mut PenNode, index: usize, frac:
true true
} }
fn primary_gradient_stops_mut(node: &mut PenNode) -> Option<&mut Vec<GradientStop>> {
if node_fills(node).map(|f| f.is_empty()).unwrap_or(true) {
return None;
}
let fills = node_fills_mut(node)?;
match fills.first_mut()? {
PenFill::LinearGradient(b) => Some(&mut b.stops),
PenFill::RadialGradient(b) => Some(&mut b.stops),
_ => None,
}
}
pub fn add_primary_gradient_stop(node: &mut PenNode) -> bool {
let Some(stops) = primary_gradient_stops_mut(node) else {
return false;
};
let last_offset = stops.last().map(|s| s.offset).unwrap_or(0.5);
stops.push(GradientStop {
offset: (last_offset + 0.1).min(1.0),
color: "#888888".to_string(),
});
true
}
pub fn remove_primary_gradient_stop(node: &mut PenNode, index: usize) -> bool {
let Some(stops) = primary_gradient_stops_mut(node) else {
return false;
};
if stops.len() <= 2 || index >= stops.len() {
return false;
}
stops.remove(index);
true
}
/// Replace the first `Solid` fill's colour with `hex`, leaving any /// Replace the first `Solid` fill's colour with `hex`, leaving any
/// gradient / image fills untouched. When the node has no solid fill, /// gradient / image fills untouched. When the node has no solid fill,
/// a fresh one is prepended so it paints on top. `false` when the /// a fresh one is prepended so it paints on top. `false` when the
@ -589,6 +607,14 @@ pub fn set_primary_fill_type(node: &mut PenNode, kind: FillType) -> bool {
true true
} }
pub fn clear_primary_fills(node: &mut PenNode) -> bool {
let Some(fills) = node_fills_mut(node) else {
return false;
};
fills.clear();
true
}
/// Stroke parallel to [`set_primary_fill_hex`]. Creates a default /// Stroke parallel to [`set_primary_fill_hex`]. Creates a default
/// 1-px stroke when the node has none, so a colour write always /// 1-px stroke when the node has none, so a colour write always
/// lands a visible stroke. `false` for variants without a stroke. /// lands a visible stroke. `false` for variants without a stroke.

View file

@ -13,6 +13,7 @@ use crate::fills::{set_primary_fill_hex, set_primary_stroke_hex};
use crate::node_id::NodeId; use crate::node_id::NodeId;
use crate::state::EditorState; use crate::state::EditorState;
use crate::tool::Tool; use crate::tool::Tool;
use crate::walkers::find_node_mut;
use jian_ops_schema::node::{IconFontNode, PathNode, PenNode, PenNodeBase, PenPathAnchor}; use jian_ops_schema::node::{IconFontNode, PathNode, PenNode, PenNodeBase, PenPathAnchor};
impl EditorState { impl EditorState {
@ -250,6 +251,54 @@ impl EditorState {
Some(id) Some(id)
} }
/// Replace the selected icon node with another Lucide glyph.
/// `icon_font` nodes update their name/family directly. Path
/// icons update `iconId` and optionally replace their SVG `d`
/// data when the host can provide local path data.
pub fn replace_selected_icon(
&mut self,
icon_name: &str,
family: &str,
svg_path_d: Option<&str>,
) -> bool {
let icon_name = icon_name.trim();
let family = family.trim();
let sel = self.selection.anchor.clone();
if icon_name.is_empty() || family.is_empty() || !sel.is_real() || !self.is_editable(&sel) {
return false;
}
let can_replace = match self.selected_node() {
Some(PenNode::IconFont(_)) => true,
Some(PenNode::Path(n)) => n.icon_id.is_some(),
_ => false,
};
if !can_replace {
return false;
}
self.commit_history();
let Some(node) = find_node_mut(self.active_children_mut(), &sel) else {
return false;
};
let icon_id = format!("{family}:{icon_name}");
match node {
PenNode::IconFont(n) => {
n.icon_font_name = icon_name.to_string();
n.icon_font_family = Some(family.to_string());
n.base.name = Some(icon_id);
true
}
PenNode::Path(n) if n.icon_id.is_some() => {
n.icon_id = Some(icon_id.clone());
n.base.name = Some(icon_id);
if let Some(d) = svg_path_d {
n.d = Some(d.to_string());
}
true
}
_ => false,
}
}
/// Replace the selected node's primary fill with an Image fill /// Replace the selected node's primary fill with an Image fill
/// rooted at `src` (typically a `data:` URL). Existing colour / /// rooted at `src` (typically a `data:` URL). Existing colour /
/// gradient is overwritten; non-fillable variants reject silently. /// gradient is overwritten; non-fillable variants reject silently.
@ -263,6 +312,10 @@ impl EditorState {
let Some(node) = crate::walkers::find_node_mut(self.active_children_mut(), &sel) else { let Some(node) = crate::walkers::find_node_mut(self.active_children_mut(), &sel) else {
return false; return false;
}; };
if let PenNode::Image(image) = node {
image.src = src.to_string();
return true;
}
let Some(fills) = crate::fills::node_fills_mut(node) else { let Some(fills) = crate::fills::node_fills_mut(node) else {
return false; return false;
}; };

View file

@ -0,0 +1,33 @@
//! Native icon-picker remote search state.
//!
//! The widget layer queues requests here; desktop drains them on a
//! background worker and writes loaded Iconify results back into this
//! cache. Keeping the state in core lets native and web hosts share
//! the same UI contract without putting network code in widgets.
#[derive(Debug, Clone, PartialEq)]
pub struct IconPickerRemoteIcon {
pub collection: String,
pub name: String,
pub width: f32,
pub height: f32,
pub style: String,
pub d: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IconifyLoadMoreRequest {
pub query: String,
pub start: usize,
pub limit: usize,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct IconPickerRemoteState {
pub query: String,
pub icons: Vec<IconPickerRemoteIcon>,
pub total: usize,
pub next_start: usize,
pub loading: bool,
pub error: Option<String>,
}

View file

@ -0,0 +1,69 @@
//! Image-node property helpers shared by the panel snapshot and mutators.
use crate::editor_ui_state::{ImageAdjustmentField, ImageFillMode};
use crate::fills::ImageFillSummary;
use jian_ops_schema::node::PenNode;
/// Summary of a real `ImageNode` using the same UI payload as image
/// fills, so the native image editor popover can edit both surfaces.
pub fn image_node_summary(node: &PenNode) -> Option<ImageFillSummary> {
let PenNode::Image(image) = node else {
return None;
};
let trimmed_url = image.src.trim();
Some(ImageFillSummary {
mode: ImageFillMode::from_image_node_schema(image.object_fit.as_ref()),
has_image: !trimmed_url.is_empty(),
image_url: (!trimmed_url.is_empty()).then(|| image.src.clone()),
exposure: image.exposure.unwrap_or(0.0) as f32,
contrast: image.contrast.unwrap_or(0.0) as f32,
saturation: image.saturation.unwrap_or(0.0) as f32,
temperature: image.temperature.unwrap_or(0.0) as f32,
tint: image.tint.unwrap_or(0.0) as f32,
highlights: image.highlights.unwrap_or(0.0) as f32,
shadows: image.shadows.unwrap_or(0.0) as f32,
})
}
pub fn set_image_node_mode(node: &mut PenNode, mode: ImageFillMode) -> bool {
let PenNode::Image(image) = node else {
return false;
};
image.object_fit = Some(mode.to_image_node_schema());
true
}
pub fn set_image_node_adjustment(
node: &mut PenNode,
field: ImageAdjustmentField,
value: f32,
) -> bool {
let PenNode::Image(image) = node else {
return false;
};
let value = value.clamp(-100.0, 100.0) as f64;
match field {
ImageAdjustmentField::Exposure => image.exposure = Some(value),
ImageAdjustmentField::Contrast => image.contrast = Some(value),
ImageAdjustmentField::Saturation => image.saturation = Some(value),
ImageAdjustmentField::Temperature => image.temperature = Some(value),
ImageAdjustmentField::Tint => image.tint = Some(value),
ImageAdjustmentField::Highlights => image.highlights = Some(value),
ImageAdjustmentField::Shadows => image.shadows = Some(value),
}
true
}
pub fn reset_image_node_adjustments(node: &mut PenNode) -> bool {
let PenNode::Image(image) = node else {
return false;
};
image.exposure = Some(0.0);
image.contrast = Some(0.0);
image.saturation = Some(0.0);
image.temperature = Some(0.0);
image.tint = Some(0.0);
image.highlights = Some(0.0);
image.shadows = Some(0.0);
true
}

View file

@ -26,12 +26,15 @@ pub mod geometry;
pub mod grouping; pub mod grouping;
pub mod history; pub mod history;
pub mod host_support; pub mod host_support;
pub mod icon_picker_state;
pub mod image_node_props;
pub mod mutators; pub mod mutators;
pub mod node_id; pub mod node_id;
pub mod page_mutators; pub mod page_mutators;
pub mod path_bounds; pub mod path_bounds;
pub mod pen; pub mod pen;
pub mod pen_node_ext; pub mod pen_node_ext;
pub mod property_edit_mutators;
pub mod property_panel_state; pub mod property_panel_state;
pub mod rename; pub mod rename;
pub mod render_backend; pub mod render_backend;
@ -92,6 +95,8 @@ pub use fills::{
}; };
pub use geometry::{aggregate_bounds, own_bounds, union_aggregate_bounds, DocRect}; pub use geometry::{aggregate_bounds, own_bounds, union_aggregate_bounds, DocRect};
pub use history::{EditorSnapshot, History, HISTORY_CAP}; pub use history::{EditorSnapshot, History, HISTORY_CAP};
pub use icon_picker_state::{IconPickerRemoteIcon, IconPickerRemoteState, IconifyLoadMoreRequest};
pub use image_node_props::image_node_summary;
pub use jian_ops_schema::{DesignMdColor, DesignMdSpec, DesignMdTypography}; pub use jian_ops_schema::{DesignMdColor, DesignMdSpec, DesignMdTypography};
pub use node_id::NodeId; pub use node_id::NodeId;
pub use pen_node_ext::PenNodeExt; pub use pen_node_ext::PenNodeExt;

View file

@ -18,7 +18,6 @@ use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt; use crate::pen_node_ext::PenNodeExt;
use crate::selection::SelectionState; use crate::selection::SelectionState;
use crate::state::EditorState; use crate::state::EditorState;
use crate::ui_draft::PropertyFocus;
use crate::walkers::{self, find_node, find_node_mut, reorder_in_children, ReorderDirection}; use crate::walkers::{self, find_node, find_node_mut, reorder_in_children, ReorderDirection};
use jian_ops_schema::node::PenNode; use jian_ops_schema::node::PenNode;
use std::collections::HashSet; use std::collections::HashSet;
@ -429,179 +428,6 @@ impl EditorState {
} }
} }
/// Apply a parsed numeric property edit to the anchor node.
/// True on a real, editable selection.
pub fn commit_property_edit(&mut self, focus: PropertyFocus, value: f32) -> bool {
let sel = self.selection.anchor.clone();
if !sel.is_real() || !self.is_editable(&sel) {
return false;
}
match focus {
PropertyFocus::PositionR => return self.cmd_set_node_corner_radius(&sel, value),
PropertyFocus::PolygonSides => {
if !value.is_finite() {
return false;
}
return self.cmd_set_polygon_count(&sel, value.round().clamp(3.0, 100.0) as u32);
}
PropertyFocus::EllipseStart => {
if !value.is_finite() {
return false;
}
return self.cmd_set_ellipse_arc(
&sel,
Some(value.clamp(0.0, 360.0) as f64),
None,
None,
);
}
PropertyFocus::EllipseSweep => {
if !value.is_finite() {
return false;
}
return self.cmd_set_ellipse_arc(
&sel,
None,
Some(value.clamp(0.0, 360.0) as f64),
None,
);
}
PropertyFocus::EllipseInnerRadius => {
if !value.is_finite() {
return false;
}
return self.cmd_set_ellipse_arc(
&sel,
None,
None,
Some((value.clamp(0.0, 99.0) / 100.0) as f64),
);
}
_ => {}
}
let Some(node) = find_node_mut(self.active_children_mut(), &sel) else {
return false;
};
let v = value as f64;
match focus {
PropertyFocus::PositionX => node.base_mut().x = Some(v),
PropertyFocus::PositionY => node.base_mut().y = Some(v),
PropertyFocus::SizeW => node.set_width_px(v.max(0.0)),
PropertyFocus::SizeH => node.set_height_px(v.max(0.0)),
PropertyFocus::Rotation => {
// Property panel ships degrees; schema stores degrees.
node.base_mut().rotation = Some(v);
}
PropertyFocus::PositionR => unreachable!("corner radius handled before node borrow"),
PropertyFocus::PolygonSides
| PropertyFocus::EllipseStart
| PropertyFocus::EllipseSweep
| PropertyFocus::EllipseInnerRadius => {
unreachable!("shape-specific properties handled before node borrow")
}
// Hex + opacity edits route through
// dedicated setters (not a single base-field write).
PropertyFocus::StrokeWidth
| 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));
}
PropertyFocus::GradientAngle => {
let _ = crate::fills::set_primary_gradient_angle(node, value);
}
PropertyFocus::GradientStopOffset(index) => {
// Percent → fraction. Clamp happens inside the setter.
let _ = crate::fills::set_primary_gradient_stop_offset(node, index, value / 100.0);
}
// Hex focuses route through the dedicated colour setters
// (a typed-in hex is parsed by the host before commit), so
// the numeric `commit_property_edit` arm is a no-op for them.
PropertyFocus::GradientStopHex(_) => {}
}
true
}
/// Write a parsed hex colour to the selected gradient stop. Used
/// by the host's hex-input commit path; mirrors
/// `set_selected_color` for solid fills. `false` when the first
/// fill isn't a gradient, the index is out of range, or the
/// selection isn't editable.
pub fn set_selected_gradient_stop_hex(&mut self, index: usize, hex: &str) -> 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_gradient_stop_hex(node, index, hex)
}
/// Write the picker's fill-type choice to the anchor node. The
/// canonical model has no scalar `fill_type` field — the kind is
/// encoded by the first `PenFill`'s variant, so this replaces the
/// node's first fill with a fresh body of the chosen type
/// (preserving its colour). Editable-gated so locked / hidden
/// nodes can't be mutated. `true` when the edit lands; `false`
/// when nothing editable is selected or the variant carries no
/// `fill` field. Faithful port of shell-core's
/// `Document::set_selected_fill_type`.
pub fn set_selected_fill_type(&mut self, fill_type: crate::FillType) -> 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_type(node, fill_type)
}
/// Set the selected node's primary image-fill fit mode.
pub fn set_selected_image_fill_mode(&mut self, mode: crate::ImageFillMode) -> 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_image_fill_mode(node, mode)
}
/// Set one selected-node primary image-fill adjustment.
pub fn set_selected_image_adjustment(
&mut self,
field: crate::ImageAdjustmentField,
value: 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_image_adjustment(node, field, value)
}
/// Reset all selected-node primary image-fill adjustments.
pub fn reset_selected_image_adjustments(&mut self) -> 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::reset_primary_image_adjustments(node)
}
// --- Tree ops ---------------------------------------------------- // --- Tree ops ----------------------------------------------------
/// Remove every editable node in the selection set from its /// Remove every editable node in the selection set from its

View file

@ -0,0 +1,286 @@
//! Property-panel edit mutators split out of `mutators.rs`.
use crate::pen_node_ext::PenNodeExt;
use crate::state::EditorState;
use crate::ui_draft::PropertyFocus;
use crate::walkers::find_node_mut;
use jian_ops_schema::node::container::Padding;
use jian_ops_schema::node::PenNode;
impl EditorState {
/// Apply a parsed numeric property edit to the anchor node.
/// True on a real, editable selection.
pub fn commit_property_edit(&mut self, focus: PropertyFocus, value: f32) -> bool {
let sel = self.selection.anchor.clone();
if !sel.is_real() || !self.is_editable(&sel) {
return false;
}
match focus {
PropertyFocus::PositionR => return self.cmd_set_node_corner_radius(&sel, value),
PropertyFocus::PolygonSides => {
if !value.is_finite() {
return false;
}
return self.cmd_set_polygon_count(&sel, value.round().clamp(3.0, 100.0) as u32);
}
PropertyFocus::EllipseStart => {
if !value.is_finite() {
return false;
}
return self.cmd_set_ellipse_arc(
&sel,
Some(value.clamp(0.0, 360.0) as f64),
None,
None,
);
}
PropertyFocus::EllipseSweep => {
if !value.is_finite() {
return false;
}
return self.cmd_set_ellipse_arc(
&sel,
None,
Some(value.clamp(0.0, 360.0) as f64),
None,
);
}
PropertyFocus::EllipseInnerRadius => {
if !value.is_finite() {
return false;
}
return self.cmd_set_ellipse_arc(
&sel,
None,
None,
Some((value.clamp(0.0, 99.0) / 100.0) as f64),
);
}
PropertyFocus::FontSize => return self.cmd_set_node_font_size(&sel, value.max(1.0)),
PropertyFocus::FontWeight => {
if !value.is_finite() {
return false;
}
return self
.cmd_set_node_font_weight(&sel, value.round().clamp(1.0, 1000.0) as u16);
}
PropertyFocus::LineHeight => {
if !value.is_finite() {
return false;
}
return self.cmd_set_node_layout_prop(
&sel,
"lineHeight",
&crate::LayoutPropValue::Number((value / 100.0) as f64),
);
}
PropertyFocus::LetterSpacing => {
if !value.is_finite() {
return false;
}
return self.cmd_set_node_layout_prop(
&sel,
"letterSpacing",
&crate::LayoutPropValue::Number(value as f64),
);
}
PropertyFocus::LayoutGap => {
if !value.is_finite() {
return false;
}
return self.cmd_set_node_layout_prop(
&sel,
"gap",
&crate::LayoutPropValue::Number(value.max(0.0) as f64),
);
}
PropertyFocus::PaddingTop
| PropertyFocus::PaddingRight
| PropertyFocus::PaddingBottom
| PropertyFocus::PaddingLeft => {
if !value.is_finite() {
return false;
}
let mut values = self
.selected_node()
.map(container_padding_values)
.unwrap_or([0.0; 4]);
let idx = match focus {
PropertyFocus::PaddingTop => 0,
PropertyFocus::PaddingRight => 1,
PropertyFocus::PaddingBottom => 2,
PropertyFocus::PaddingLeft => 3,
_ => unreachable!(),
};
values[idx] = value.max(0.0) as f64;
return self.cmd_set_node_layout_prop(
&sel,
"padding",
&crate::LayoutPropValue::NumberArray(values.to_vec()),
);
}
_ => {}
}
let Some(node) = find_node_mut(self.active_children_mut(), &sel) else {
return false;
};
let v = value as f64;
match focus {
PropertyFocus::PositionX => node.base_mut().x = Some(v),
PropertyFocus::PositionY => node.base_mut().y = Some(v),
PropertyFocus::SizeW => node.set_width_px(v.max(0.0)),
PropertyFocus::SizeH => node.set_height_px(v.max(0.0)),
PropertyFocus::Rotation => node.base_mut().rotation = Some(v),
PropertyFocus::PositionR => unreachable!("corner radius handled before node borrow"),
PropertyFocus::PolygonSides
| PropertyFocus::EllipseStart
| PropertyFocus::EllipseSweep
| PropertyFocus::EllipseInnerRadius
| PropertyFocus::FontSize
| PropertyFocus::FontWeight
| PropertyFocus::LineHeight
| PropertyFocus::LetterSpacing
| PropertyFocus::LayoutGap
| PropertyFocus::PaddingTop
| PropertyFocus::PaddingRight
| PropertyFocus::PaddingBottom
| PropertyFocus::PaddingLeft => {
unreachable!("shape-specific properties handled before node borrow")
}
PropertyFocus::StrokeWidth
| PropertyFocus::Opacity
| PropertyFocus::FillHex
| PropertyFocus::StrokeHex => {}
PropertyFocus::FillOpacity => {
let _ = self.set_selected_fill_opacity((value / 100.0).clamp(0.0, 1.0));
}
PropertyFocus::GradientAngle => {
let _ = crate::fills::set_primary_gradient_angle(node, value);
}
PropertyFocus::GradientStopOffset(index) => {
let _ = crate::fills::set_primary_gradient_stop_offset(node, index, value / 100.0);
}
PropertyFocus::GradientStopHex(_) => {}
}
true
}
pub fn set_selected_gradient_stop_hex(&mut self, index: usize, hex: &str) -> 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_gradient_stop_hex(node, index, hex)
}
pub fn add_selected_gradient_stop(&mut self) -> 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::add_primary_gradient_stop(node)
}
pub fn remove_selected_gradient_stop(&mut self, index: usize) -> 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::remove_primary_gradient_stop(node, index)
}
pub fn set_selected_fill_type(&mut self, fill_type: crate::FillType) -> 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_type(node, fill_type)
}
pub fn clear_selected_fills(&mut self) -> 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::clear_primary_fills(node)
}
pub fn set_selected_image_fill_mode(&mut self, mode: crate::ImageFillMode) -> 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;
};
if crate::image_node_props::set_image_node_mode(node, mode) {
true
} else {
crate::fills::set_primary_image_fill_mode(node, mode)
}
}
pub fn set_selected_image_adjustment(
&mut self,
field: crate::ImageAdjustmentField,
value: 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;
};
if crate::image_node_props::set_image_node_adjustment(node, field, value) {
true
} else {
crate::fills::set_primary_image_adjustment(node, field, value)
}
}
pub fn reset_selected_image_adjustments(&mut self) -> 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;
};
if crate::image_node_props::reset_image_node_adjustments(node) {
true
} else {
crate::fills::reset_primary_image_adjustments(node)
}
}
}
fn container_padding_values(node: &PenNode) -> [f64; 4] {
let padding = match node {
PenNode::Frame(n) => n.container.padding.as_ref(),
PenNode::Group(n) => n.container.padding.as_ref(),
PenNode::Rectangle(n) => n.container.padding.as_ref(),
_ => None,
};
match padding {
Some(Padding::Uniform(v)) => [*v, *v, *v, *v],
Some(Padding::XY(v)) => [v[0], v[1], v[0], v[1]],
Some(Padding::LtrB(v)) => *v,
Some(Padding::Expression(_)) | None => [0.0; 4],
}
}

View file

@ -56,6 +56,26 @@ impl ImageFillMode {
| None => Self::Fill, | None => Self::Fill,
} }
} }
pub fn to_image_node_schema(self) -> jian_ops_schema::node::image::ImageFitMode {
match self {
Self::Fill => jian_ops_schema::node::image::ImageFitMode::Fill,
Self::Fit => jian_ops_schema::node::image::ImageFitMode::Fit,
Self::Crop => jian_ops_schema::node::image::ImageFitMode::Crop,
Self::Tile => jian_ops_schema::node::image::ImageFitMode::Tile,
}
}
pub fn from_image_node_schema(
value: Option<&jian_ops_schema::node::image::ImageFitMode>,
) -> Self {
match value {
Some(jian_ops_schema::node::image::ImageFitMode::Fit) => Self::Fit,
Some(jian_ops_schema::node::image::ImageFitMode::Crop) => Self::Crop,
Some(jian_ops_schema::node::image::ImageFitMode::Tile) => Self::Tile,
Some(jian_ops_schema::node::image::ImageFitMode::Fill) | None => Self::Fill,
}
}
} }
/// Image-fill adjustment sliders. Values are clamped to `[-100, 100]`. /// Image-fill adjustment sliders. Values are clamped to `[-100, 100]`.

View file

@ -126,6 +126,24 @@ fn set_selected_image_adjustment_clamps_and_resets() {
} }
} }
#[test]
fn replace_selected_icon_updates_icon_font_name_without_closing_over_color() {
let mut s = sample();
let id = s
.insert_icon_font_node_at("search", "lucide", 50.0, 50.0)
.expect("insert icon");
assert!(s.replace_selected_icon("home", "lucide", None));
let node = find_node(s.active_children(), &id).unwrap();
let jian_ops_schema::node::PenNode::IconFont(icon) = node else {
panic!("expected icon_font node");
};
assert_eq!(icon.icon_font_name, "home");
assert_eq!(icon.icon_font_family.as_deref(), Some("lucide"));
assert!(icon.fill.is_some(), "replacement preserves display color");
}
// --- Delete ---------------------------------------------------------- // --- Delete ----------------------------------------------------------
#[test] #[test]

View file

@ -33,6 +33,13 @@ pub enum PropertyFocus {
PositionR, PositionR,
SizeW, SizeW,
SizeH, SizeH,
/// Container flex-layout gap in document px.
LayoutGap,
/// Container padding values, shown in top/right/bottom/left order.
PaddingTop,
PaddingRight,
PaddingBottom,
PaddingLeft,
Opacity, Opacity,
FillHex, FillHex,
/// Fill section's `100 %` opacity input — percentage (0..100). /// Fill section's `100 %` opacity input — percentage (0..100).
@ -57,6 +64,14 @@ pub enum PropertyFocus {
EllipseSweep, EllipseSweep,
/// Ellipse donut-hole radius, shown as a percentage. /// Ellipse donut-hole radius, shown as a percentage.
EllipseInnerRadius, EllipseInnerRadius,
/// Text font size in document px.
FontSize,
/// Text font weight, numeric 1..=1000.
FontWeight,
/// Text line height, shown as a percentage.
LineHeight,
/// Text letter spacing in document px.
LetterSpacing,
StrokeHex, StrokeHex,
StrokeWidth, StrokeWidth,
} }
@ -83,12 +98,20 @@ impl PropertyFocus {
| PropertyFocus::PositionR | PropertyFocus::PositionR
| PropertyFocus::Opacity | PropertyFocus::Opacity
| PropertyFocus::FillOpacity | PropertyFocus::FillOpacity
| PropertyFocus::LayoutGap
| PropertyFocus::PaddingTop
| PropertyFocus::PaddingRight
| PropertyFocus::PaddingBottom
| PropertyFocus::PaddingLeft
| PropertyFocus::StrokeWidth | PropertyFocus::StrokeWidth
| PropertyFocus::GradientAngle | PropertyFocus::GradientAngle
| PropertyFocus::GradientStopOffset(_) | PropertyFocus::GradientStopOffset(_)
| PropertyFocus::EllipseStart | PropertyFocus::EllipseStart
| PropertyFocus::EllipseSweep | PropertyFocus::EllipseSweep
| PropertyFocus::EllipseInnerRadius | PropertyFocus::EllipseInnerRadius
| PropertyFocus::FontSize
| PropertyFocus::LineHeight
| PropertyFocus::LetterSpacing
) )
} }
} }

View file

@ -31,3 +31,5 @@ op-editor-core = { path = "../op-editor-core" }
# placeholder. Workspace pinned via the same `base64` op-host-desktop # placeholder. Workspace pinned via the same `base64` op-host-desktop
# uses for the file picker. # uses for the file picker.
base64 = "0.22" base64 = "0.22"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

File diff suppressed because one or more lines are too long

View file

@ -209,6 +209,21 @@ pub struct SceneAnchor {
pub point_type: ScenePointType, pub point_type: ScenePointType,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SceneTextAlign {
Left,
Center,
Right,
Justify,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SceneTextVerticalAlign {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct SceneNode { pub struct SceneNode {
/// Stable node id (the `.op` schema id). Identity for hit-test / /// Stable node id (the `.op` schema id). Identity for hit-test /
@ -247,10 +262,20 @@ pub struct SceneNode {
/// Text content — `Some` for Text nodes (and the lucide glyph /// Text content — `Some` for Text nodes (and the lucide glyph
/// name for `icon_font`). `None` for non-text kinds. /// name for `icon_font`). `None` for non-text kinds.
pub text: Option<String>, pub text: Option<String>,
/// CSS font-family stack for text nodes. Empty = renderer default.
pub font_family: String,
/// Text size in doc-px. `0.0` = the painter's default (13 px). /// Text size in doc-px. `0.0` = the painter's default (13 px).
pub font_size: f32, pub font_size: f32,
/// CSS-style font weight (100-900). `0` = default (400). /// CSS-style font weight (100-900). `0` = default (400).
pub font_weight: u16, pub font_weight: u16,
/// Line-height multiplier (`1.2` = 120%). `0.0` = renderer default.
pub line_height: f32,
/// Extra tracking in doc-px between glyphs.
pub letter_spacing: f32,
/// Horizontal text alignment inside `bounds`.
pub text_align: SceneTextAlign,
/// Vertical text alignment inside `bounds`.
pub text_vertical_align: SceneTextVerticalAlign,
/// Whether the painter wraps the text to `bounds.size.x`. /// Whether the painter wraps the text to `bounds.size.x`.
pub text_wrap: bool, pub text_wrap: bool,
/// Polyline / path geometry in absolute doc-space coords — /// Polyline / path geometry in absolute doc-space coords —
@ -356,8 +381,13 @@ impl SceneNode {
gradient: None, gradient: None,
stroke: None, stroke: None,
text: None, text: None,
font_family: String::new(),
font_size: 0.0, font_size: 0.0,
font_weight: 0, font_weight: 0,
line_height: 0.0,
letter_spacing: 0.0,
text_align: SceneTextAlign::Left,
text_vertical_align: SceneTextVerticalAlign::Top,
text_wrap: false, text_wrap: false,
points: Vec::new(), points: Vec::new(),
path_anchors: Vec::new(), path_anchors: Vec::new(),

View file

@ -37,6 +37,7 @@ pub mod theme;
pub mod widgets; pub mod widgets;
// Re-export the primary API for the hosts / widgets / tests. // Re-export the primary API for the hosts / widgets / tests.
pub use layout_scene::{SceneTextAlign, SceneTextVerticalAlign};
pub use op_editor_core::render_backend::{ pub use op_editor_core::render_backend::{
Color, ImageAdjustments, ImageDrawMode, Point2D, Rect, RenderBackend, TextLayout, Color, ImageAdjustments, ImageDrawMode, Point2D, Rect, RenderBackend, TextLayout,
}; };

View file

@ -269,6 +269,7 @@ pub fn paint_node(
} }
NodeKind::Other(tag) if tag == "icon_font" => crate::widgets::icons::paint_icon_font_node( NodeKind::Other(tag) if tag == "icon_font" => crate::widgets::icons::paint_icon_font_node(
cx.backend, cx.backend,
node.font_family.as_str(),
node.text.as_deref().unwrap_or(""), node.text.as_deref().unwrap_or(""),
world_rect, world_rect,
node.fill, node.fill,
@ -521,34 +522,75 @@ fn paint_text_node(
13.0 13.0
}; };
let font_size = base_size * zoom; let font_size = base_size * zoom;
let baseline_y = world_rect.origin.y + (base_size + 1.0) * zoom; let weight = if node.font_weight > 0 {
node.font_weight
} else {
400
};
let family = if node.font_family.trim().is_empty() {
"system-ui"
} else {
node.font_family.as_str()
};
let line_height = if node.line_height > 0.0 {
node.line_height
} else {
1.2
};
let letter_spacing = node.letter_spacing * zoom;
let lines: Vec<String> = if node.text_wrap {
wrap_text(cx.backend, text, font_size, world_rect.size.x, weight)
} else {
text.split('\n').map(str::to_string).collect()
};
if !text.is_empty() { if !text.is_empty() {
let weight = if node.font_weight > 0 {
node.font_weight
} else {
400
};
let jc = jian_core::scene::Color::rgba(ch(ink.r), ch(ink.g), ch(ink.b), ch(ink.a)); let jc = jian_core::scene::Color::rgba(ch(ink.r), ch(ink.g), ch(ink.b), ch(ink.a));
let line_h = base_size * 1.35 * zoom; let line_h = base_size * line_height * zoom;
let mut ly = baseline_y; let text_h = if lines.is_empty() {
let lines: Vec<String> = if node.text_wrap { 0.0
wrap_text(cx.backend, text, font_size, world_rect.size.x, weight)
} else { } else {
text.split('\n').map(str::to_string).collect() font_size + line_h * (lines.len().saturating_sub(1) as f32)
}; };
for line in lines { let first_baseline_y = match node.text_vertical_align {
cx.backend.draw_text( crate::layout_scene::SceneTextVerticalAlign::Middle => {
&TextLayout::single_run(&line, "system-ui", font_size, jc, Point2D::new(0.0, 0.0)) world_rect.origin.y + ((world_rect.size.y - text_h).max(0.0) / 2.0) + font_size
.with_font_weight(weight), }
Point2D::new(world_rect.origin.x, ly), crate::layout_scene::SceneTextVerticalAlign::Bottom => {
world_rect.origin.y + (world_rect.size.y - text_h).max(0.0) + font_size
}
crate::layout_scene::SceneTextVerticalAlign::Top => world_rect.origin.y + font_size,
};
for (idx, line) in lines.iter().enumerate() {
let line_w = measure_line_width(cx.backend, line, font_size, weight, letter_spacing);
let x = match node.text_align {
crate::layout_scene::SceneTextAlign::Center => {
world_rect.origin.x + (world_rect.size.x - line_w).max(0.0) / 2.0
}
crate::layout_scene::SceneTextAlign::Right => {
world_rect.origin.x + (world_rect.size.x - line_w).max(0.0)
}
crate::layout_scene::SceneTextAlign::Left
| crate::layout_scene::SceneTextAlign::Justify => world_rect.origin.x,
};
let y = first_baseline_y + idx as f32 * line_h;
draw_text_line(
cx.backend,
line,
family,
font_size,
weight,
jc,
Point2D::new(x, y),
letter_spacing,
); );
ly += line_h;
} }
} }
// Caret while editing — sits at the end of the text. // Caret while editing — sits at the end of the text.
if let Some(c) = edit_caret { if let Some(c) = edit_caret {
if c.editing == node.id && jian_core::anim::blink_visible(c.now_ms, c.anchor_ms, 500) { if c.editing == node.id && jian_core::anim::blink_visible(c.now_ms, c.anchor_ms, 500) {
let text_w = cx.backend.measure_text(text, font_size); let caret_line = lines.last().map(String::as_str).unwrap_or("");
let text_w =
measure_line_width(cx.backend, caret_line, font_size, weight, letter_spacing);
let caret = Rect { let caret = Rect {
origin: Point2D::new( origin: Point2D::new(
world_rect.origin.x + text_w, world_rect.origin.x + text_w,
@ -561,6 +603,49 @@ fn paint_text_node(
} }
} }
fn measure_line_width(
backend: &mut dyn crate::RenderBackend,
line: &str,
font_size: f32,
weight: u16,
letter_spacing: f32,
) -> f32 {
let base = backend.measure_text_weighted(line, font_size, weight);
let extra = line.chars().count().saturating_sub(1) as f32 * letter_spacing;
base + extra
}
#[allow(clippy::too_many_arguments)]
fn draw_text_line(
backend: &mut dyn crate::RenderBackend,
line: &str,
family: &str,
font_size: f32,
weight: u16,
color: jian_core::scene::Color,
origin: Point2D,
letter_spacing: f32,
) {
if letter_spacing.abs() < f32::EPSILON {
backend.draw_text(
&TextLayout::single_run(line, family, font_size, color, Point2D::new(0.0, 0.0))
.with_font_weight(weight),
origin,
);
return;
}
let mut x = origin.x;
for ch in line.chars() {
let s = ch.to_string();
backend.draw_text(
&TextLayout::single_run(&s, family, font_size, color, Point2D::new(0.0, 0.0))
.with_font_weight(weight),
Point2D::new(x, origin.y),
);
x += backend.measure_text_weighted(&s, font_size, weight) + letter_spacing;
}
}
#[cfg(test)] #[cfg(test)]
mod arc_tests { mod arc_tests {
use super::arc_polygon; use super::arc_polygon;
@ -568,11 +653,9 @@ mod arc_tests {
#[test] #[test]
fn pie_polygon_starts_at_centre() { fn pie_polygon_starts_at_centre() {
// 100×100 rect at origin → centre (50, 50).
let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 90.0, 0.0); let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 90.0, 0.0);
assert_eq!(poly[0].x, 50.0); assert_eq!(poly[0].x, 50.0);
assert_eq!(poly[0].y, 50.0); assert_eq!(poly[0].y, 50.0);
// First arc point at 0° = +X edge → (100, 50).
assert!((poly[1].x - 100.0).abs() < 0.01); assert!((poly[1].x - 100.0).abs() < 0.01);
assert!((poly[1].y - 50.0).abs() < 0.01); assert!((poly[1].y - 50.0).abs() < 0.01);
} }
@ -580,9 +663,7 @@ mod arc_tests {
#[test] #[test]
fn donut_polygon_has_outer_and_inner_rings() { fn donut_polygon_has_outer_and_inner_rings() {
let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 360.0, 0.5); let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 360.0, 0.5);
// segs for 360° = 90; outer (segs+1) + inner (segs+1) points.
assert_eq!(poly.len(), 2 * (90 + 1)); assert_eq!(poly.len(), 2 * (90 + 1));
// An inner-ring point sits at half the radius from centre.
let last = poly[poly.len() - 1]; let last = poly[poly.len() - 1];
let dist = ((last.x - 50.0).powi(2) + (last.y - 50.0).powi(2)).sqrt(); let dist = ((last.x - 50.0).powi(2) + (last.y - 50.0).powi(2)).sqrt();
assert!((dist - 25.0).abs() < 0.5, "inner radius ~25, got {dist}"); assert!((dist - 25.0).abs() < 0.5, "inner radius ~25, got {dist}");
@ -590,7 +671,6 @@ mod arc_tests {
#[test] #[test]
fn quarter_sweep_end_point_at_90_degrees() { fn quarter_sweep_end_point_at_90_degrees() {
// start 0°, sweep 90° → last outer point at +Y edge (50, 100).
let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 90.0, 0.0); let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 90.0, 0.0);
let last = poly[poly.len() - 1]; let last = poly[poly.len() - 1];
assert!((last.x - 50.0).abs() < 0.01); assert!((last.x - 50.0).abs() < 0.01);
@ -598,6 +678,79 @@ mod arc_tests {
} }
} }
#[cfg(test)]
mod text_tests {
use super::paint_text_node;
use crate::layout_scene::{NodeKind, SceneNode, SceneTextAlign, SceneTextVerticalAlign};
use crate::widgets::PaintCx;
use crate::{Color, ImageDrawMode, Point2D, Rect, RenderBackend, TextLayout};
#[derive(Default)]
struct TextCaptureBackend {
origins: Vec<Point2D>,
families: Vec<String>,
}
impl RenderBackend for TextCaptureBackend {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, _: Rect, _: Color) {}
fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
fn draw_text(&mut self, layout: &TextLayout, origin: Point2D) {
self.origins.push(origin);
if let Some(run) = layout.runs().first() {
self.families.push(run.font_family.clone());
}
}
fn clip_rect(&mut self, _: Rect) {}
fn save(&mut self) {}
fn restore(&mut self) {}
fn translate(&mut self, _: Point2D) {}
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {}
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {}
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {}
fn draw_image(&mut self, _: Rect, _: u64, _: &[u8]) {}
fn draw_image_with_mode(&mut self, _: Rect, _: u64, _: &[u8], _: ImageDrawMode) {}
fn resize(&mut self, _: u32, _: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
fn measure_text_weighted(&mut self, text: &str, font_size: f32, _: u16) -> f32 {
text.chars().count() as f32 * font_size * 0.5
}
}
#[test]
fn text_node_paint_honors_typography_alignment() {
let mut node = SceneNode::leaf("t", NodeKind::Text);
node.bounds = Rect::xywh(0.0, 0.0, 200.0, 80.0);
node.text = Some("Hi".to_string());
node.font_family = "Georgia".to_string();
node.font_size = 20.0;
node.line_height = 1.0;
node.text_align = SceneTextAlign::Center;
node.text_vertical_align = SceneTextVerticalAlign::Middle;
let mut backend = TextCaptureBackend::default();
let mut cx = PaintCx {
backend: &mut backend,
};
paint_text_node(&mut cx, &node, node.bounds, 1.0, &None);
assert_eq!(backend.families, vec!["Georgia".to_string()]);
let origin = backend.origins[0];
assert!(
origin.x > 80.0,
"center-aligned text should move away from the left edge"
);
assert!(
origin.y > 40.0,
"middle-aligned text should move down from the top baseline"
);
}
}
#[cfg(test)] #[cfg(test)]
mod path_tests { mod path_tests {
use super::flatten_path; use super::flatten_path;
@ -618,7 +771,6 @@ mod path_tests {
let mut n = SceneNode::leaf("p", NodeKind::Path); let mut n = SceneNode::leaf("p", NodeKind::Path);
n.points = vec![Point2D::new(0.0, 0.0), Point2D::new(10.0, 0.0)]; n.points = vec![Point2D::new(0.0, 0.0), Point2D::new(10.0, 0.0)];
n.path_anchors = vec![anchor(0.0, 0.0, None), anchor(10.0, 0.0, None)]; n.path_anchors = vec![anchor(0.0, 0.0, None), anchor(10.0, 0.0, None)];
// No handles → straight polyline == points.
assert_eq!(flatten_path(&n), n.points); assert_eq!(flatten_path(&n), n.points);
} }
@ -631,17 +783,14 @@ mod path_tests {
anchor(100.0, 0.0, None), anchor(100.0, 0.0, None),
]; ];
let poly = flatten_path(&n); let poly = flatten_path(&n);
// 1 start point + 16 tessellation steps for the cubic.
assert_eq!(poly.len(), 17); assert_eq!(poly.len(), 17);
assert_eq!(poly[0], Point2D::new(0.0, 0.0)); assert_eq!(poly[0], Point2D::new(0.0, 0.0));
assert_eq!(poly[poly.len() - 1], Point2D::new(100.0, 0.0)); assert_eq!(poly[poly.len() - 1], Point2D::new(100.0, 0.0));
// Mid-curve bows toward the +Y handle.
assert!(poly[8].y > 1.0, "curve bows toward the handle"); assert!(poly[8].y > 1.0, "curve bows toward the handle");
} }
#[test] #[test]
fn bounds_kept_so_helper_is_pure() { fn bounds_kept_so_helper_is_pure() {
// flatten_path must not mutate the node.
let mut n = SceneNode::leaf("p", NodeKind::Path); let mut n = SceneNode::leaf("p", NodeKind::Path);
n.bounds = Rect::xywh(1.0, 2.0, 3.0, 4.0); n.bounds = Rect::xywh(1.0, 2.0, 3.0, 4.0);
let _ = flatten_path(&n); let _ = flatten_path(&n);

View file

@ -0,0 +1,281 @@
//! Bundled Iconify catalog plus lightweight SVG-body parsing.
use std::collections::HashMap;
use std::sync::OnceLock;
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IconRenderStyle {
Stroke,
Fill,
}
#[derive(Debug, Deserialize)]
struct Catalog {
icons: Vec<IconCatalogEntry>,
}
#[derive(Debug, Deserialize)]
pub struct IconCatalogEntry {
pub collection: String,
pub name: String,
pub width: f32,
pub height: f32,
pub style: IconRenderStyle,
pub d: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedIconBody {
pub width: f32,
pub height: f32,
pub style: IconRenderStyle,
pub d: String,
}
const CATALOG_JSON: &str = include_str!("../../assets/iconify-catalog.json");
pub fn lookup_icon(collection: &str, name: &str) -> Option<&'static IconCatalogEntry> {
let key = format!("{}:{}", collection.trim(), name.trim());
catalog_index()
.get(&key)
.and_then(|idx| catalog().get(*idx))
}
pub fn search_icons(query: &str, limit: usize) -> Vec<&'static IconCatalogEntry> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return catalog().iter().take(limit).collect();
}
let mut out = Vec::new();
for icon in catalog() {
if icon.name == query || format!("{}:{}", icon.collection, icon.name) == query {
out.push(icon);
if out.len() >= limit {
return out;
}
}
}
for icon in catalog() {
if (icon.name != query && matches_query(icon, &query))
|| format!("{}:{}", icon.collection, icon.name) == query
{
out.push(icon);
if out.len() >= limit {
break;
}
}
}
out
}
pub fn parse_iconify_body(body: &str, width: f32, height: f32) -> Option<ParsedIconBody> {
let mut paths = paths_from_body(body);
if paths.is_empty() {
return None;
}
for d in paths.iter_mut().skip(1) {
if d.starts_with('m') {
d.replace_range(0..1, "M");
}
}
Some(ParsedIconBody {
width,
height,
style: style_from_body(body),
d: paths.join(" "),
})
}
fn catalog() -> &'static [IconCatalogEntry] {
static CATALOG: OnceLock<Catalog> = OnceLock::new();
&CATALOG
.get_or_init(|| serde_json::from_str(CATALOG_JSON).expect("bundled icon catalog is valid"))
.icons
}
fn catalog_index() -> &'static HashMap<String, usize> {
static INDEX: OnceLock<HashMap<String, usize>> = OnceLock::new();
INDEX.get_or_init(|| {
catalog()
.iter()
.enumerate()
.map(|(idx, icon)| (format!("{}:{}", icon.collection, icon.name), idx))
.collect()
})
}
fn matches_query(icon: &IconCatalogEntry, query: &str) -> bool {
icon.name.contains(query)
|| icon.collection.contains(query)
|| format!("{}:{}", icon.collection, icon.name).contains(query)
}
fn paths_from_body(body: &str) -> Vec<String> {
let mut paths = Vec::new();
for tag in tags(body, "path") {
if invisible(tag) {
continue;
}
if let Some(d) = attr(tag, "d") {
paths.push(d);
}
}
for tag in tags(body, "line") {
paths.push(format!(
"M{} {}L{} {}",
num(attr(tag, "x1").as_deref(), 0.0),
num(attr(tag, "y1").as_deref(), 0.0),
num(attr(tag, "x2").as_deref(), 0.0),
num(attr(tag, "y2").as_deref(), 0.0)
));
}
for tag in tags(body, "circle") {
let cx = num(attr(tag, "cx").as_deref(), 0.0);
let cy = num(attr(tag, "cy").as_deref(), 0.0);
let r = num(attr(tag, "r").as_deref(), 0.0);
paths.push(format!(
"M{} {}A{} {} 0 1 0 {} {}A{} {} 0 1 0 {} {}Z",
cx - r,
cy,
r,
r,
cx + r,
cy,
r,
r,
cx - r,
cy
));
}
for tag in tags(body, "ellipse") {
let cx = num(attr(tag, "cx").as_deref(), 0.0);
let cy = num(attr(tag, "cy").as_deref(), 0.0);
let rx = num(attr(tag, "rx").as_deref(), 0.0);
let ry = num(attr(tag, "ry").as_deref(), 0.0);
paths.push(format!(
"M{} {}A{} {} 0 1 0 {} {}A{} {} 0 1 0 {} {}Z",
cx - rx,
cy,
rx,
ry,
cx + rx,
cy,
rx,
ry,
cx - rx,
cy
));
}
for tag in tags(body, "rect") {
paths.push(rect_path(
num(attr(tag, "x").as_deref(), 0.0),
num(attr(tag, "y").as_deref(), 0.0),
num(attr(tag, "width").as_deref(), 0.0),
num(attr(tag, "height").as_deref(), 0.0),
num(attr(tag, "rx").or_else(|| attr(tag, "ry")).as_deref(), 0.0),
));
}
for tag in tags(body, "polyline") {
if let Some(points) = attr(tag, "points").and_then(|p| points_path(&p, false)) {
paths.push(points);
}
}
for tag in tags(body, "polygon") {
if let Some(points) = attr(tag, "points").and_then(|p| points_path(&p, true)) {
paths.push(points);
}
}
paths
}
fn tags<'a>(body: &'a str, name: &str) -> Vec<&'a str> {
let needle = format!("<{name}");
let mut out = Vec::new();
let mut rest = body;
while let Some(start) = rest.find(&needle) {
rest = &rest[start..];
let Some(end) = rest.find('>') else {
break;
};
out.push(&rest[..=end]);
rest = &rest[end + 1..];
}
out
}
fn attr(tag: &str, name: &str) -> Option<String> {
let needle = format!(r#"{name}=""#);
let start = tag.find(&needle)? + needle.len();
let end = tag[start..].find('"')?;
Some(tag[start..start + end].to_string())
}
fn num(value: Option<&str>, fallback: f32) -> f32 {
value
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite())
.unwrap_or(fallback)
}
fn invisible(tag: &str) -> bool {
attr(tag, "fill").as_deref() == Some("none") && attr(tag, "stroke").as_deref() == Some("none")
}
fn style_from_body(body: &str) -> IconRenderStyle {
let has_stroke = body.contains("stroke=")
|| body.contains("stroke-width=")
|| body.contains("stroke-linecap=")
|| body.contains(r#"fill="none""#);
let has_fill =
body.contains(r#"fill="currentColor""#) || body.contains(r#"fill='currentColor'"#);
if has_stroke && !has_fill {
IconRenderStyle::Stroke
} else {
IconRenderStyle::Fill
}
}
fn rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> String {
if r <= 0.0 {
return format!("M{x} {y}H{}V{}H{x}Z", x + w, y + h);
}
let rr = r.min(w / 2.0).min(h / 2.0);
format!(
"M{} {y}H{}A{rr} {rr} 0 0 1 {} {}V{}A{rr} {rr} 0 0 1 {} {}H{}A{rr} {rr} 0 0 1 {x} {}V{}A{rr} {rr} 0 0 1 {} {y}Z",
x + rr,
x + w - rr,
x + w,
y + rr,
y + h - rr,
x + w - rr,
y + h,
x + rr,
y + h - rr,
y + rr,
x + rr
)
}
fn points_path(points: &str, close: bool) -> Option<String> {
let nums: Vec<f32> = points
.split(|c: char| c.is_whitespace() || c == ',')
.filter_map(|p| p.parse::<f32>().ok())
.collect();
if nums.len() < 4 {
return None;
}
let mut out = String::from("M");
for (idx, pair) in nums.chunks_exact(2).enumerate() {
if idx > 0 {
out.push('L');
}
out.push_str(&format!("{} {}", pair[0], pair[1]));
}
if close {
out.push('Z');
}
Some(out)
}

View file

@ -1,19 +1,16 @@
//! Native Lucide icon picker used by the Toolbar shape dropdown. //! Native Iconify picker used by the toolbar and icon property row.
//!
//! This is deliberately small compared with the TS Iconify dialog:
//! it lists the Lucide glyphs the Rust renderer already knows how to
//! paint as `icon_font` nodes. Search is owned by the host keyboard
//! router while the panel is open.
use crate::theme::Theme; use crate::theme::Theme;
use crate::widgets::editor_state_ext::theme_for; use crate::widgets::editor_state_ext::theme_for;
use crate::widgets::{draw_icon, Icon, PaintCx}; use crate::widgets::icon_catalog::{search_icons, IconCatalogEntry, IconRenderStyle};
use crate::widgets::{
draw_icon, draw_icon_catalog_entry, draw_icon_data, Icon, IconPathData, PaintCx,
};
use crate::{Color, Point2D, Rect, TextLayout}; use crate::{Color, Point2D, Rect, TextLayout};
use op_editor_core::{EditorState, Locale}; use op_editor_core::{EditorState, IconPickerRemoteIcon, Locale};
pub const ICON_PICKER_PANEL_W: f32 = 320.0; pub const ICON_PICKER_PANEL_W: f32 = 320.0;
pub const ICON_PICKER_PANEL_H: f32 = 420.0; pub const ICON_PICKER_PANEL_H: f32 = 420.0;
const PAD: f32 = 14.0; const PAD: f32 = 14.0;
const HEADER_H: f32 = 40.0; const HEADER_H: f32 = 40.0;
const SEARCH_H: f32 = 42.0; const SEARCH_H: f32 = 42.0;
@ -22,84 +19,37 @@ const ROW_H: f32 = 34.0;
const ICON_SIZE: f32 = 17.0; const ICON_SIZE: f32 = 17.0;
const ROW_PAD_X: f32 = 10.0; const ROW_PAD_X: f32 = 10.0;
const CHAR_W: f32 = 6.0; const CHAR_W: f32 = 6.0;
const LOCAL_LIMIT: usize = 120;
pub const ICONIFY_LOAD_MORE_LIMIT: usize = 48;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum IconPickerHit { pub enum IconPickerHit {
Close, Close,
SelectIcon(String), DragHeader,
SelectIcon { collection: String, name: String },
LoadMore,
Inside, Inside,
} }
struct IconPickerItem { enum IconRow<'a> {
name: &'static str, Local(&'static IconCatalogEntry),
label: &'static str, Remote(&'a IconPickerRemoteIcon),
icon: Icon,
} }
const ICONS: &[IconPickerItem] = &[ impl IconRow<'_> {
item("search", "Search", Icon::Search), fn collection(&self) -> &str {
item("home", "Home", Icon::Home), match self {
item("user", "User", Icon::User), IconRow::Local(i) => &i.collection,
item("settings", "Settings", Icon::Settings), IconRow::Remote(i) => &i.collection,
item("bell", "Bell", Icon::Bell), }
item("mail", "Mail", Icon::Mail), }
item("calendar", "Calendar", Icon::Calendar),
item("clock", "Clock", Icon::Clock),
item("heart", "Heart", Icon::Heart),
item("star", "Star", Icon::Star),
item("check", "Check", Icon::Check),
item("x", "X", Icon::Close),
item("plus", "Plus", Icon::Plus),
item("minus", "Minus", Icon::Minus),
item("arrow-right", "Arrow Right", Icon::ArrowRight),
item("arrow-left", "Arrow Left", Icon::ArrowLeft),
item("chevron-left", "Chevron Left", Icon::ChevronLeft),
item("chevron-right", "Chevron Right", Icon::ChevronRight),
item("chevron-down", "Chevron Down", Icon::ChevronDown),
item("more-horizontal", "More Horizontal", Icon::MoreHorizontal),
item("more-vertical", "More Vertical", Icon::MoreVertical),
item("trash-2", "Trash", Icon::Trash),
item("copy", "Copy", Icon::Copy),
item("pencil", "Pencil", Icon::Pencil),
item("download", "Download", Icon::Download),
item("save", "Save", Icon::Save),
item("image", "Image", Icon::Image),
item("camera", "Camera", Icon::Camera),
item("video", "Video", Icon::Video),
item("music", "Music", Icon::Music),
item("phone", "Phone", Icon::Phone),
item("map-pin", "Map Pin", Icon::MapPin),
item("info", "Info", Icon::Info),
item("alert-circle", "Alert Circle", Icon::AlertCircle),
item("help-circle", "Help Circle", Icon::HelpCircle),
item("message-circle", "Message Circle", Icon::MessageCircle),
item("message-square", "Message Square", Icon::MessageSquare),
item("shopping-cart", "Shopping Cart", Icon::ShoppingCart),
item("shopping-bag", "Shopping Bag", Icon::ShoppingBag),
item("credit-card", "Credit Card", Icon::CreditCard),
item("send", "Send", Icon::Send),
item("rocket", "Rocket", Icon::Rocket),
item("activity", "Activity", Icon::Activity),
item("trending-up", "Trending Up", Icon::TrendingUp),
item("trending-down", "Trending Down", Icon::TrendingDown),
item("bar-chart-2", "Bar Chart", Icon::BarChart2),
item("layout-dashboard", "Dashboard", Icon::LayoutDashboard),
item("users", "Users", Icon::Users),
item("package", "Package", Icon::Package),
item("zap", "Zap", Icon::Zap),
item("sliders-horizontal", "Sliders", Icon::SlidersHorizontal),
item("lock", "Lock", Icon::Lock),
item("unlock", "Unlock", Icon::LockOpen),
item("eye", "Eye", Icon::Eye),
item("eye-off", "Eye Off", Icon::EyeOff),
item("github", "GitHub", Icon::Github),
item("globe", "Globe", Icon::Globe),
item("terminal", "Terminal", Icon::Terminal),
item("bot", "Bot", Icon::Bot),
];
const fn item(name: &'static str, label: &'static str, icon: Icon) -> IconPickerItem { fn name(&self) -> &str {
IconPickerItem { name, label, icon } match self {
IconRow::Local(i) => &i.name,
IconRow::Remote(i) => &i.name,
}
}
} }
pub struct IconPickerPanel<'a> { pub struct IconPickerPanel<'a> {
@ -124,20 +74,45 @@ impl<'a> IconPickerPanel<'a> {
crate::i18n::translate(self.locale, key) crate::i18n::translate(self.locale, key)
} }
fn filtered(&self) -> Vec<&'static IconPickerItem> { fn query(&self) -> String {
let query = self self.state
.state
.editor_ui .editor_ui
.icon_picker_search .icon_picker_search
.trim() .trim()
.to_lowercase(); .to_lowercase()
if query.is_empty() { }
return ICONS.iter().collect();
fn rows(&self, limit: usize) -> Vec<IconRow<'a>> {
let query = self.query();
let mut rows: Vec<IconRow<'a>> = search_icons(&query, LOCAL_LIMIT)
.into_iter()
.map(IconRow::Local)
.collect();
let remote = &self.state.editor_ui.icon_picker_remote;
if !query.is_empty() && remote.query == query {
for icon in &remote.icons {
if !rows
.iter()
.any(|row| row.collection() == icon.collection && row.name() == icon.name)
{
rows.push(IconRow::Remote(icon));
}
}
} }
ICONS rows.truncate(limit);
.iter() rows
.filter(|item| item.name.contains(&query) || item.label.to_lowercase().contains(&query)) }
.collect()
fn has_load_more(&self) -> bool {
let query = self.query();
if query.is_empty() {
return false;
}
let remote = &self.state.editor_ui.icon_picker_remote;
if remote.loading {
return true;
}
remote.query != query || remote.next_start < remote.total
} }
fn close_rect(panel: Rect) -> Rect { fn close_rect(panel: Rect) -> Rect {
@ -172,15 +147,25 @@ impl<'a> IconPickerPanel<'a> {
if rect_contains(Self::close_rect(panel), point) { if rect_contains(Self::close_rect(panel), point) {
return Some(IconPickerHit::Close); return Some(IconPickerHit::Close);
} }
if point.y <= panel.origin.y + HEADER_H {
return Some(IconPickerHit::DragHeader);
}
let list_top = Self::list_top(panel); let list_top = Self::list_top(panel);
if point.y >= list_top { if point.y >= list_top {
let row = ((point.y - list_top) / ROW_H) as usize; let row = ((point.y - list_top) / ROW_H) as usize;
let items = self.filtered();
let capacity = Self::visible_row_capacity(panel); let capacity = Self::visible_row_capacity(panel);
if row < capacity { let has_more = self.has_load_more();
if let Some(item) = items.get(row) { let item_cap = capacity.saturating_sub(usize::from(has_more));
return Some(IconPickerHit::SelectIcon(item.name.to_string())); let items = self.rows(item_cap);
} if row < items.len() {
let item = &items[row];
return Some(IconPickerHit::SelectIcon {
collection: item.collection().to_string(),
name: item.name().to_string(),
});
}
if has_more && row == items.len() && !self.state.editor_ui.icon_picker_remote.loading {
return Some(IconPickerHit::LoadMore);
} }
} }
Some(IconPickerHit::Inside) Some(IconPickerHit::Inside)
@ -190,7 +175,26 @@ impl<'a> IconPickerPanel<'a> {
cx.backend.fill_round_rect(panel, 8.0, self.theme.popover); cx.backend.fill_round_rect(panel, 8.0, self.theme.popover);
cx.backend cx.backend
.stroke_round_rect(panel, 8.0, self.theme.border, 1.0); .stroke_round_rect(panel, 8.0, self.theme.border, 1.0);
self.paint_header(cx, panel);
self.paint_search(cx, panel);
let rows = Self::visible_row_capacity(panel);
let has_more = self.has_load_more();
let item_cap = rows.saturating_sub(usize::from(has_more));
let filtered = self.rows(item_cap);
if filtered.is_empty() && !has_more {
self.paint_empty(cx, panel);
return;
}
for (idx, item) in filtered.iter().enumerate() {
self.paint_row(cx, panel, idx, item);
}
if has_more && rows > 0 {
self.paint_load_more(cx, panel, filtered.len());
}
}
fn paint_header(&self, cx: &mut PaintCx<'_>, panel: Rect) {
let title = TextLayout::single_run( let title = TextLayout::single_run(
self.t("icon.title"), self.t("icon.title"),
"system-ui", "system-ui",
@ -202,7 +206,6 @@ impl<'a> IconPickerPanel<'a> {
&title, &title,
Point2D::new(panel.origin.x + PAD, panel.origin.y + 25.0), Point2D::new(panel.origin.x + PAD, panel.origin.y + 25.0),
); );
let close = Self::close_rect(panel); let close = Self::close_rect(panel);
draw_icon( draw_icon(
cx.backend, cx.backend,
@ -212,7 +215,9 @@ impl<'a> IconPickerPanel<'a> {
self.theme.muted_foreground, self.theme.muted_foreground,
1.4, 1.4,
); );
}
fn paint_search(&self, cx: &mut PaintCx<'_>, panel: Rect) {
let search = Self::search_rect(panel); let search = Self::search_rect(panel);
cx.backend.fill_round_rect(search, 6.0, self.theme.muted); cx.backend.fill_round_rect(search, 6.0, self.theme.muted);
draw_icon( draw_icon(
@ -223,72 +228,120 @@ impl<'a> IconPickerPanel<'a> {
self.theme.muted_foreground, self.theme.muted_foreground,
1.4, 1.4,
); );
let query = self.state.editor_ui.icon_picker_search.trim(); let raw_query = self.state.editor_ui.icon_picker_search.trim();
let search_text = if query.is_empty() { let search_text = if raw_query.is_empty() {
self.t("icon.searchIcons") self.t("icon.searchIcons")
} else { } else {
query raw_query
}; };
let search_color = if query.is_empty() { let color = if raw_query.is_empty() {
self.theme.muted_foreground self.theme.muted_foreground
} else { } else {
self.theme.foreground self.theme.foreground
}; };
let search_layout = TextLayout::single_run( let layout = TextLayout::single_run(
search_text, search_text,
"system-ui", "system-ui",
12.0, 12.0,
to_jian(search_color), to_jian(color),
Point2D::new(0.0, 0.0), Point2D::new(0.0, 0.0),
); );
cx.backend.draw_text( cx.backend.draw_text(
&search_layout, &layout,
Point2D::new(search.origin.x + 30.0, search.origin.y + 18.0), Point2D::new(search.origin.x + 30.0, search.origin.y + 18.0),
); );
}
let filtered = self.filtered(); fn paint_empty(&self, cx: &mut PaintCx<'_>, panel: Rect) {
if filtered.is_empty() { let empty = TextLayout::single_run(
let empty = TextLayout::single_run( self.t("icon.noIconsFound"),
self.t("icon.noIconsFound"), "system-ui",
"system-ui", 12.0,
12.0, to_jian(self.theme.muted_foreground),
to_jian(self.theme.muted_foreground), Point2D::new(0.0, 0.0),
Point2D::new(0.0, 0.0), );
); cx.backend.draw_text(
cx.backend.draw_text( &empty,
&empty, Point2D::new(panel.origin.x + PAD, Self::list_top(panel) + 28.0),
Point2D::new(panel.origin.x + PAD, Self::list_top(panel) + 28.0), );
); }
return;
}
let rows = Self::visible_row_capacity(panel); fn paint_row(&self, cx: &mut PaintCx<'_>, panel: Rect, idx: usize, item: &IconRow<'_>) {
for (idx, item) in filtered.into_iter().take(rows).enumerate() { let y = Self::list_top(panel) + idx as f32 * ROW_H;
let y = Self::list_top(panel) + idx as f32 * ROW_H; let row = Rect {
let row = Rect { origin: Point2D::new(panel.origin.x + 6.0, y),
origin: Point2D::new(panel.origin.x + 6.0, y), size: Point2D::new(panel.size.x - 12.0, ROW_H),
size: Point2D::new(panel.size.x - 12.0, ROW_H), };
}; cx.backend.fill_round_rect(row, 6.0, self.theme.popover);
cx.backend.fill_round_rect(row, 6.0, self.theme.popover); let icon_pos = Point2D::new(row.origin.x + ROW_PAD_X, y + (ROW_H - ICON_SIZE) / 2.0);
draw_icon( match item {
IconRow::Local(icon) => draw_icon_catalog_entry(
cx.backend, cx.backend,
item.icon, icon,
Point2D::new(row.origin.x + ROW_PAD_X, y + (ROW_H - ICON_SIZE) / 2.0), icon_pos,
ICON_SIZE, ICON_SIZE,
self.theme.foreground, self.theme.foreground,
1.5, 1.5,
); ),
let label = truncate(item.label, ((row.size.x - 54.0) / CHAR_W) as usize); IconRow::Remote(icon) => draw_icon_data(
let text = TextLayout::single_run( cx.backend,
&label, IconPathData {
"system-ui", d: &icon.d,
12.0, style: remote_style(&icon.style),
to_jian(self.theme.foreground), viewbox: icon.width.max(icon.height),
Point2D::new(0.0, 0.0), },
); icon_pos,
cx.backend ICON_SIZE,
.draw_text(&text, Point2D::new(row.origin.x + 38.0, y + 21.0)); self.theme.foreground,
1.5,
),
} }
let label = truncate(
&format!("{}:{}", item.collection(), item.name()),
((row.size.x - 54.0) / CHAR_W) as usize,
);
let text = TextLayout::single_run(
&label,
"system-ui",
12.0,
to_jian(self.theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&text, Point2D::new(row.origin.x + 38.0, y + 21.0));
}
fn paint_load_more(&self, cx: &mut PaintCx<'_>, panel: Rect, idx: usize) {
let y = Self::list_top(panel) + idx as f32 * ROW_H;
let row = Rect {
origin: Point2D::new(panel.origin.x + 6.0, y + 2.0),
size: Point2D::new(panel.size.x - 12.0, ROW_H - 4.0),
};
cx.backend.fill_round_rect(row, 6.0, self.theme.muted);
let label = if self.state.editor_ui.icon_picker_remote.loading {
"..."
} else {
self.t("git.history.loadMore")
};
let text = TextLayout::single_run(
label,
"system-ui",
12.0,
to_jian(self.theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&text,
Point2D::new(row.origin.x + ROW_PAD_X, row.origin.y + 20.0),
);
}
}
fn remote_style(style: &str) -> IconRenderStyle {
if style == "stroke" {
IconRenderStyle::Stroke
} else {
IconRenderStyle::Fill
} }
} }

View file

@ -518,15 +518,58 @@ pub fn draw_icon(
} }
} }
/// Paint a canonical `icon_font` node — lucide glyph by name, pub struct IconPathData<'a> {
/// scaled into `rect` with aspect preserved. Mirrors TS pub d: &'a str,
/// `drawIconFont` (packages/pen-renderer/src/node-renderer.ts). pub style: super::icon_catalog::IconRenderStyle,
/// Unknown names stroke a small dot at the centre — same shape pub viewbox: f32,
/// as the TS `FALLBACK_ICON_D` (`M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0`) }
/// so the user sees an honest "unknown glyph" mark instead of
/// a solid block. pub fn draw_icon_data(
backend: &mut dyn RenderBackend,
data: IconPathData<'_>,
top_left: Point2D,
size: f32,
color: Color,
stroke_width: f32,
) {
match data.style {
super::icon_catalog::IconRenderStyle::Stroke => {
backend.stroke_svg_path(data.d, top_left, size, color, stroke_width);
}
super::icon_catalog::IconRenderStyle::Fill => {
backend.fill_svg_path(data.d, top_left, size, data.viewbox.max(1.0), color);
}
}
}
pub fn draw_icon_catalog_entry(
backend: &mut dyn RenderBackend,
icon: &super::icon_catalog::IconCatalogEntry,
top_left: Point2D,
size: f32,
color: Color,
stroke_width: f32,
) {
draw_icon_data(
backend,
IconPathData {
d: &icon.d,
style: icon.style,
viewbox: icon.width.max(icon.height),
},
top_left,
size,
color,
stroke_width,
);
}
/// Paint a canonical `icon_font` node by Iconify collection + name,
/// scaled into `rect` with aspect preserved. Unknown names stroke a
/// small dot at the centre so the user sees an honest fallback mark.
pub fn paint_icon_font_node( pub fn paint_icon_font_node(
backend: &mut dyn RenderBackend, backend: &mut dyn RenderBackend,
family: &str,
name: &str, name: &str,
rect: crate::Rect, rect: crate::Rect,
fill: Option<Color>, fill: Option<Color>,
@ -546,8 +589,19 @@ pub fn paint_icon_font_node(
rect.origin.y + (rect.size.y - size) / 2.0, rect.origin.y + (rect.size.y - size) / 2.0,
); );
let stroke_width = (size / 24.0 * 2.0).max(1.0); let stroke_width = (size / 24.0 * 2.0).max(1.0);
if let Some(icon) = Icon::from_name(name) { let family = if family.trim().is_empty() {
draw_icon(backend, icon, top_left, size, color, stroke_width); "lucide"
} else {
family.trim()
};
if let Some(icon) = super::icon_catalog::lookup_icon(family, name) {
draw_icon_catalog_entry(backend, icon, top_left, size, color, stroke_width);
} else if family == "lucide" {
if let Some(icon) = Icon::from_name(name) {
draw_icon(backend, icon, top_left, size, color, stroke_width);
} else {
backend.stroke_svg_path(FALLBACK_ICON_D, top_left, size, color, stroke_width);
}
} else { } else {
backend.stroke_svg_path(FALLBACK_ICON_D, top_left, size, color, stroke_width); backend.stroke_svg_path(FALLBACK_ICON_D, top_left, size, color, stroke_width);
} }
@ -559,171 +613,3 @@ pub fn paint_icon_font_node(
const FALLBACK_ICON_D: &str = "M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"; const FALLBACK_ICON_D: &str = "M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0";
use super::icons_data::*; use super::icons_data::*;
#[cfg(test)]
mod tests {
use super::*;
use crate::{Color, Point2D, Rect, TextLayout};
#[derive(Default)]
struct CountingBackend {
paths: usize,
}
impl RenderBackend for CountingBackend {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, _: Rect, _: Color) {}
fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
fn draw_text(&mut self, _: &TextLayout, _: Point2D) {}
fn clip_rect(&mut self, _: Rect) {}
fn save(&mut self) {}
fn restore(&mut self) {}
fn translate(&mut self, _: Point2D) {}
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {}
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {}
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {
self.paths += 1;
}
fn resize(&mut self, _: u32, _: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
}
fn paint_one(icon: Icon) -> CountingBackend {
let mut b = CountingBackend::default();
draw_icon(
&mut b,
icon,
Point2D::new(0.0, 0.0),
16.0,
Color::WHITE,
1.5,
);
b
}
#[test]
fn plus_renders_two_paths() {
let b = paint_one(Icon::Plus);
assert_eq!(b.paths, 2);
}
#[test]
fn minus_renders_one_path() {
let b = paint_one(Icon::Minus);
assert_eq!(b.paths, 1);
}
#[test]
fn sun_renders_disc_plus_eight_rays() {
let b = paint_one(Icon::Sun);
assert_eq!(b.paths, 9);
}
#[test]
fn every_variant_paints_at_least_one_primitive() {
for icon in [
Icon::Cursor,
Icon::Square,
Icon::ChevronDown,
Icon::Type,
Icon::Frame,
Icon::Hand,
Icon::Undo,
Icon::Redo,
Icon::Braces,
Icon::BookOpen,
Icon::Plus,
Icon::Minus,
Icon::Search,
Icon::Sun,
Icon::Globe,
Icon::Maximize,
Icon::Hash,
Icon::PanelLeft,
Icon::FolderOpen,
Icon::Sparkles,
Icon::Close,
Icon::ChevronUp,
Icon::MessageSquare,
Icon::LayoutGrid,
Icon::Rows3,
Icon::Columns3,
Icon::RotateCw,
Icon::Diamond,
Icon::Component,
Icon::Unlink,
Icon::Check,
Icon::ArrowUpRight,
] {
let b = paint_one(icon);
assert!(b.paths > 0, "{:?} drew nothing", icon);
}
}
#[test]
fn first_party_icon_font_names_all_resolve() {
// Every `iconFontName` value emitted by the TS element-builders
// — both literal `iconFontName: '...'` AND values fed through
// builder defaults / param indirection (e.g. callout severity
// maps, input-with-action's `action_icon` default, toolbar-v1
// formatting glyphs) — must resolve via `Icon::from_name`.
// Scanned across `packages/pen-core/src/element-builders/` on
// 2026-05-13; extend this list when new names land.
for name in [
// Direct iconFontName literals
"calendar",
"check",
"chevron-down",
"chevron-left",
"chevron-right",
"clock",
"map-pin",
"more-vertical",
"play",
"search",
"star",
"x",
// Builder defaults / indirection
"arrow-right",
"check-circle",
"alert-triangle",
"alert-octagon",
"sticky-note",
"bar-chart-2",
"bold",
"italic",
"underline",
"shopping-cart",
"shopping-bag",
"message-circle",
"rocket",
"menu",
"credit-card",
// pencil-demo.op fixture sweep (2026-05-13) — covers
// 56 occurrences that previously fell through.
"trending-up",
"trending-down",
"compass",
"refresh-cw",
"layout-dashboard",
"users",
"package",
"zap",
"sliders-horizontal",
"activity",
"loader",
"focus",
"chart-line",
"settings-2",
] {
assert!(
Icon::from_name(name).is_some(),
"first-party iconFontName {:?} fell through to placeholder",
name
);
}
}
}

View file

@ -0,0 +1,189 @@
use crate::widgets::icons::{draw_icon, paint_icon_font_node, Icon};
use crate::{Color, Point2D, Rect, RenderBackend, TextLayout};
#[derive(Default)]
struct CountingBackend {
paths: usize,
fills: usize,
}
impl RenderBackend for CountingBackend {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, _: Rect, _: Color) {}
fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
fn draw_text(&mut self, _: &TextLayout, _: Point2D) {}
fn clip_rect(&mut self, _: Rect) {}
fn save(&mut self) {}
fn restore(&mut self) {}
fn translate(&mut self, _: Point2D) {}
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {}
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {}
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {
self.paths += 1;
}
fn fill_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: f32, _: Color) {
self.fills += 1;
}
fn resize(&mut self, _: u32, _: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
}
fn paint_one(icon: Icon) -> CountingBackend {
let mut b = CountingBackend::default();
draw_icon(
&mut b,
icon,
Point2D::new(0.0, 0.0),
16.0,
Color::WHITE,
1.5,
);
b
}
#[test]
fn plus_renders_two_paths() {
let b = paint_one(Icon::Plus);
assert_eq!(b.paths, 2);
}
#[test]
fn minus_renders_one_path() {
let b = paint_one(Icon::Minus);
assert_eq!(b.paths, 1);
}
#[test]
fn sun_renders_disc_plus_eight_rays() {
let b = paint_one(Icon::Sun);
assert_eq!(b.paths, 9);
}
#[test]
fn every_variant_paints_at_least_one_primitive() {
for icon in [
Icon::Cursor,
Icon::Square,
Icon::ChevronDown,
Icon::Type,
Icon::Frame,
Icon::Hand,
Icon::Undo,
Icon::Redo,
Icon::Braces,
Icon::BookOpen,
Icon::Plus,
Icon::Minus,
Icon::Search,
Icon::Sun,
Icon::Globe,
Icon::Maximize,
Icon::Hash,
Icon::PanelLeft,
Icon::FolderOpen,
Icon::Sparkles,
Icon::Close,
Icon::ChevronUp,
Icon::MessageSquare,
Icon::LayoutGrid,
Icon::Rows3,
Icon::Columns3,
Icon::RotateCw,
Icon::Diamond,
Icon::Component,
Icon::Unlink,
Icon::Check,
Icon::ArrowUpRight,
] {
let b = paint_one(icon);
assert!(b.paths > 0, "{:?} drew nothing", icon);
}
}
#[test]
fn first_party_icon_font_names_all_resolve() {
for name in [
"calendar",
"check",
"chevron-down",
"chevron-left",
"chevron-right",
"clock",
"map-pin",
"more-vertical",
"play",
"search",
"star",
"x",
"arrow-right",
"check-circle",
"alert-triangle",
"alert-octagon",
"sticky-note",
"bar-chart-2",
"bold",
"italic",
"underline",
"shopping-cart",
"shopping-bag",
"message-circle",
"rocket",
"menu",
"credit-card",
"trending-up",
"trending-down",
"compass",
"refresh-cw",
"layout-dashboard",
"users",
"package",
"zap",
"sliders-horizontal",
"activity",
"loader",
"focus",
"chart-line",
"settings-2",
] {
assert!(
Icon::from_name(name).is_some(),
"first-party iconFontName {:?} fell through to placeholder",
name
);
}
}
#[test]
fn bundled_iconify_catalog_contains_requested_collections() {
use crate::widgets::icon_catalog::{lookup_icon, IconRenderStyle};
assert_eq!(
lookup_icon("lucide", "airplay").map(|i| i.style),
Some(IconRenderStyle::Stroke)
);
assert_eq!(
lookup_icon("feather", "airplay").map(|i| i.style),
Some(IconRenderStyle::Stroke)
);
assert_eq!(
lookup_icon("simple-icons", "github").map(|i| i.style),
Some(IconRenderStyle::Fill)
);
}
#[test]
fn icon_font_node_paints_simple_icon_as_fill_path() {
let mut b = CountingBackend::default();
paint_icon_font_node(
&mut b,
"simple-icons",
"github",
Rect::xywh(0.0, 0.0, 24.0, 24.0),
Some(Color::WHITE),
);
assert_eq!(b.fills, 1);
assert_eq!(b.paths, 0);
}

View file

@ -46,7 +46,12 @@ pub mod property_panel_code;
pub mod property_panel_effects; pub mod property_panel_effects;
pub mod property_panel_export; pub mod property_panel_export;
pub mod property_panel_fill; pub mod property_panel_fill;
pub mod property_panel_flex;
pub mod property_panel_icon;
#[cfg(test)]
mod property_panel_icon_tests;
pub mod property_panel_image_fill; pub mod property_panel_image_fill;
pub mod property_panel_image_node;
mod property_panel_image_preview; mod property_panel_image_preview;
pub mod property_panel_input_layout; pub mod property_panel_input_layout;
pub mod property_panel_inputs; pub mod property_panel_inputs;
@ -56,6 +61,8 @@ pub mod property_panel_sections;
pub mod property_panel_snapshot; pub mod property_panel_snapshot;
#[cfg(test)] #[cfg(test)]
mod property_panel_tests; mod property_panel_tests;
pub mod property_panel_text;
pub mod property_panel_visibility;
pub mod toolbar; pub mod toolbar;
// Step 3 — center canvas that renders document nodes as actual // Step 3 — center canvas that renders document nodes as actual
@ -69,7 +76,10 @@ pub mod canvas_viewport_paint;
pub mod editor_state_ext; pub mod editor_state_ext;
// Step 4 — icon glyph drawer for editor chrome (lucide-flavored line art). // Step 4 — icon glyph drawer for editor chrome (lucide-flavored line art).
pub mod icon_catalog;
pub mod icons; pub mod icons;
#[cfg(test)]
mod icons_tests;
// Lucide d-string data — extracted as a sibling so `icons.rs` stays // Lucide d-string data — extracted as a sibling so `icons.rs` stays
// under the 800-line cap as more first-party glyphs are added. // under the 800-line cap as more first-party glyphs are added.
mod icons_data; mod icons_data;
@ -126,7 +136,7 @@ pub use canvas_viewport::{
selection_handle_at_point, ArcHandle, CanvasViewport, SelectionHandle, selection_handle_at_point, ArcHandle, CanvasViewport, SelectionHandle,
}; };
pub use icons::{draw_icon, Icon}; pub use icons::{draw_icon, draw_icon_catalog_entry, draw_icon_data, Icon, IconPathData};
pub use ai_chat_panel::{ pub use ai_chat_panel::{
AIChatHit, AIChatPlaceholder, AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, AIChatHit, AIChatPlaceholder, AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH,
@ -141,7 +151,8 @@ pub use design_md_panel::{DesignMdHit, DesignMdPanel, DESIGN_MD_PANEL_H, DESIGN_
pub use export_dialog::{ExportDialog, ExportDialogHit, ExportFormat}; pub use export_dialog::{ExportDialog, ExportDialogHit, ExportFormat};
pub use git_panel::{GitPanel, GitPanelHit, GIT_PANEL_INSET, GIT_PANEL_WIDTH}; pub use git_panel::{GitPanel, GitPanelHit, GIT_PANEL_INSET, GIT_PANEL_WIDTH};
pub use icon_picker_panel::{ pub use icon_picker_panel::{
IconPickerHit, IconPickerPanel, ICON_PICKER_PANEL_H, ICON_PICKER_PANEL_W, IconPickerHit, IconPickerPanel, ICONIFY_LOAD_MORE_LIMIT, ICON_PICKER_PANEL_H,
ICON_PICKER_PANEL_W,
}; };
pub use locale_picker::{LocalePicker, LOCALE_PICKER_WIDTH}; pub use locale_picker::{LocalePicker, LOCALE_PICKER_WIDTH};
pub use shape_picker::{ShapeChoice, ShapePicker, SHAPE_PICKER_WIDTH}; pub use shape_picker::{ShapeChoice, ShapePicker, SHAPE_PICKER_WIDTH};

View file

@ -38,7 +38,10 @@ pub const PROPERTY_PANEL_WIDTH: f32 = 280.0;
// out for the 800-line ceiling); re-exported so every existing // out for the 800-line ceiling); re-exported so every existing
// `widgets::PropertyPanelAction` / `property_panel::PropertyPanelAction` // `widgets::PropertyPanelAction` / `property_panel::PropertyPanelAction`
// path is unchanged. // path is unchanged.
pub use crate::widgets::property_panel_action::PropertyPanelAction; pub use crate::widgets::property_panel_action::{
FontFamilyChoice, LayoutAlignValue, LayoutJustifyValue, PropertyPanelAction, TextAlignValue,
TextGrowthValue, TextVerticalAlignValue,
};
// `SectionCapabilities` lives in `property_panel_layout.rs` // `SectionCapabilities` lives in `property_panel_layout.rs`
// alongside `VisibleSections` (the section-visibility mask it // alongside `VisibleSections` (the section-visibility mask it
@ -80,6 +83,7 @@ pub struct PropertyPanel {
pub fill_type: op_editor_core::FillType, pub fill_type: op_editor_core::FillType,
pub fill_type_picker_open: bool, pub fill_type_picker_open: bool,
pub image_fill_popover_open: bool, pub image_fill_popover_open: bool,
pub font_family_picker_open: bool,
/// True for multi-select aggregate (inputs inert, "N items"). /// True for multi-select aggregate (inputs inert, "N items").
pub is_multi: bool, pub is_multi: bool,
/// Active header tab — toggled by Cmd+Shift+C. /// Active header tab — toggled by Cmd+Shift+C.
@ -168,6 +172,14 @@ impl PropertyPanel {
is_multi: bool, is_multi: bool,
) -> Self { ) -> Self {
let ui = &state.editor_ui; let ui = &state.editor_ui;
let flex_layout = snapshot.flex_layout;
let size_flags = sections::SizeFlags {
fill_width: snapshot.size_fill_width,
fill_height: snapshot.size_fill_height,
hug_width: snapshot.size_hug_width,
hug_height: snapshot.size_hug_height,
clip_content: snapshot.size_clip_content,
};
Self { Self {
id: WidgetId::new(2000), id: WidgetId::new(2000),
snapshot, snapshot,
@ -194,17 +206,12 @@ impl PropertyPanel {
}, },
caret_anchor_ms: state.ui.property_caret_anchor_ms, caret_anchor_ms: state.ui.property_caret_anchor_ms,
now_ms, now_ms,
flex_layout: ui.flex_layout, flex_layout,
size_flags: sections::SizeFlags { size_flags,
fill_width: ui.size_fill_width,
fill_height: ui.size_fill_height,
hug_width: ui.size_hug_width,
hug_height: ui.size_hug_height,
clip_content: ui.size_clip_content,
},
fill_type, fill_type,
fill_type_picker_open: ui.fill_type_picker_open, fill_type_picker_open: ui.fill_type_picker_open,
image_fill_popover_open: ui.image_fill_popover_open, image_fill_popover_open: ui.image_fill_popover_open,
font_family_picker_open: ui.font_family_picker_open,
is_multi, is_multi,
tab: ui.property_tab, tab: ui.property_tab,
export_format: ui.export_format, export_format: ui.export_format,
@ -271,9 +278,18 @@ impl PropertyPanel {
fn visible_sections(&self) -> sections::VisibleSections { fn visible_sections(&self) -> sections::VisibleSections {
let caps = self.capabilities(); let caps = self.capabilities();
sections::VisibleSections { sections::VisibleSections {
create_component: caps.create_component && self.snapshot.can_create_component,
flex_layout: caps.flex_layout, flex_layout: caps.flex_layout,
flex_layout_mode: self.snapshot.flex_layout,
layout_justify: self.snapshot.layout_justify,
layout_align: self.snapshot.layout_align,
size_options: caps.size_options, size_options: caps.size_options,
clip_content: self.snapshot.can_clip_content,
text: caps.text && self.snapshot.text.is_some(),
icon: self.snapshot.icon.is_some(),
image: caps.image && self.snapshot.is_image_node,
opacity: caps.opacity, opacity: caps.opacity,
corner_radius: self.snapshot.has_corner_radius,
polygon_sides: self.snapshot.polygon_sides.is_some(), polygon_sides: self.snapshot.polygon_sides.is_some(),
ellipse_arc: self.snapshot.ellipse_arc.is_some(), ellipse_arc: self.snapshot.ellipse_arc.is_some(),
fill: caps.fill, fill: caps.fill,
@ -312,6 +328,7 @@ impl PropertyPanel {
self.visible_sections(), self.visible_sections(),
&self.snapshot.effects, &self.snapshot.effects,
self.fill_type_picker_open, self.fill_type_picker_open,
self.font_family_picker_open,
self.export_scale_picker_open, self.export_scale_picker_open,
self.export_format_picker_open, self.export_format_picker_open,
); );
@ -344,6 +361,7 @@ impl PropertyPanel {
self.visible_sections(), self.visible_sections(),
&self.snapshot.effects, &self.snapshot.effects,
self.fill_type_picker_open, self.fill_type_picker_open,
self.font_family_picker_open,
self.export_scale_picker_open, self.export_scale_picker_open,
self.export_format_picker_open, self.export_format_picker_open,
) )
@ -483,23 +501,28 @@ impl Widget for PropertyPanel {
// matching the layout walker's `+= TAB_HEIGHT` step. // matching the layout walker's `+= TAB_HEIGHT` step.
let mut y = tab_bottom - scroll; let mut y = tab_bottom - scroll;
y = sections::paint_node_header(cx, &self.theme, &self.snapshot, x, y, w); y = sections::paint_node_header(cx, &self.theme, &self.snapshot, x, y, w);
y = sections::paint_create_component(cx, &self.theme, &self.labels, x, y, w); if caps.create_component && self.snapshot.can_create_component {
y = sections::paint_create_component(cx, &self.theme, &self.labels, x, y, w);
}
y = sections::paint_position_section( y = sections::paint_position_section(
cx, cx,
&self.theme, &self.theme,
&self.snapshot, &self.snapshot,
&edit_ctx, &edit_ctx,
&self.labels, &self.labels,
self.snapshot.has_corner_radius,
x, x,
y, y,
w, w,
); );
if caps.flex_layout { if caps.flex_layout {
y = sections::paint_flex_section( y = crate::widgets::property_panel_flex::paint_flex_section(
cx, cx,
&self.theme, &self.theme,
&self.snapshot,
&edit_ctx,
&self.labels, &self.labels,
self.flex_layout, self.locale,
x, x,
y, y,
w, w,
@ -513,6 +536,41 @@ impl Widget for PropertyPanel {
&edit_ctx, &edit_ctx,
&self.labels, &self.labels,
self.size_flags, self.size_flags,
self.snapshot.can_clip_content,
x,
y,
w,
);
}
if self.snapshot.icon.is_some() {
y = crate::widgets::property_panel_icon::paint_icon_section(
cx,
&self.theme,
&self.snapshot,
self.locale,
x,
y,
w,
);
}
if caps.text && self.snapshot.text.is_some() {
y = crate::widgets::property_panel_text::paint_text_section(
cx,
&self.theme,
&self.snapshot,
&edit_ctx,
self.locale,
x,
y,
w,
);
}
if caps.image && self.snapshot.is_image_node {
y = crate::widgets::property_panel_image_node::paint_image_node_section(
cx,
&self.theme,
&self.snapshot,
self.locale,
x, x,
y, y,
w, w,
@ -594,6 +652,17 @@ impl Widget for PropertyPanel {
self.locale, self.locale,
); );
} }
if caps.text && self.font_family_picker_open {
if let Some(text) = self.snapshot.text.as_ref() {
crate::widgets::property_panel_text::paint_font_family_picker(
cx,
&self.theme,
scrolled,
self.visible_sections(),
&text.font_family,
);
}
}
// Export-section inline select popups — painted last so the // Export-section inline select popups — painted last so the
// scale / format dropdown overlays sit above every section. // scale / format dropdown overlays sit above every section.
if caps.export && (self.export_scale_picker_open || self.export_format_picker_open) { if caps.export && (self.export_scale_picker_open || self.export_format_picker_open) {
@ -625,7 +694,8 @@ impl PropertyPanel {
/// right rail. Hosts call this late in their composition pass so /// right rail. Hosts call this late in their composition pass so
/// the image-fill popover is above floating canvas controls. /// the image-fill popover is above floating canvas controls.
pub fn paint_overlays(&self, cx: &mut PaintCx<'_>, rect: Rect) { pub fn paint_overlays(&self, cx: &mut PaintCx<'_>, rect: Rect) {
if !self.capabilities().fill || !self.image_fill_popover_open { let caps = self.capabilities();
if !(caps.fill || caps.image) || !self.image_fill_popover_open {
return; return;
} }
let scroll = self.effective_scroll(rect); let scroll = self.effective_scroll(rect);

View file

@ -3,6 +3,103 @@
//! keep that file under the 800-line ceiling; re-exported from //! keep that file under the 800-line ceiling; re-exported from
//! `property_panel` so `widgets::PropertyPanelAction` is unchanged. //! `property_panel` so `widgets::PropertyPanelAction` is unchanged.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextAlignValue {
Left,
Center,
Right,
Justify,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextVerticalAlignValue {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextGrowthValue {
Auto,
FixedWidth,
FixedWidthHeight,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FontFamilyChoice {
Inter,
Poppins,
Roboto,
Montserrat,
OpenSans,
Lato,
Raleway,
DmSans,
PlayfairDisplay,
Nunito,
SourceSans3,
Arial,
Helvetica,
Georgia,
CourierNew,
}
impl FontFamilyChoice {
pub const ALL: [Self; 15] = [
Self::Inter,
Self::Poppins,
Self::Roboto,
Self::Montserrat,
Self::OpenSans,
Self::Lato,
Self::Raleway,
Self::DmSans,
Self::PlayfairDisplay,
Self::Nunito,
Self::SourceSans3,
Self::Arial,
Self::Helvetica,
Self::Georgia,
Self::CourierNew,
];
pub fn family(self) -> &'static str {
match self {
Self::Inter => "Inter",
Self::Poppins => "Poppins",
Self::Roboto => "Roboto",
Self::Montserrat => "Montserrat",
Self::OpenSans => "Open Sans",
Self::Lato => "Lato",
Self::Raleway => "Raleway",
Self::DmSans => "DM Sans",
Self::PlayfairDisplay => "Playfair Display",
Self::Nunito => "Nunito",
Self::SourceSans3 => "Source Sans 3",
Self::Arial => "Arial",
Self::Helvetica => "Helvetica",
Self::Georgia => "Georgia",
Self::CourierNew => "Courier New",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutAlignValue {
Start,
Center,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutJustifyValue {
Start,
Center,
End,
SpaceBetween,
SpaceAround,
}
/// Button / checkbox actions in the property panel that don't /// Button / checkbox actions in the property panel that don't
/// map to a text input. The host dispatches these in `apply_press` /// map to a text input. The host dispatches these in `apply_press`
/// after the text-input hit-test misses. /// after the text-input hit-test misses.
@ -16,6 +113,12 @@ pub enum PropertyPanelAction {
ToggleSizeHugWidth, ToggleSizeHugWidth,
ToggleSizeHugHeight, ToggleSizeHugHeight,
ToggleSizeClipContent, ToggleSizeClipContent,
SetLayoutAlign(LayoutAlignValue),
SetLayoutJustify(LayoutJustifyValue),
SetLayoutAlignment {
justify: LayoutJustifyValue,
align: LayoutAlignValue,
},
/// User clicked the header "Create Component" affordance. /// User clicked the header "Create Component" affordance.
CreateComponent, CreateComponent,
/// User clicked the Fill section's fill-type dropdown — host /// User clicked the Fill section's fill-type dropdown — host
@ -23,6 +126,14 @@ pub enum PropertyPanelAction {
ToggleFillTypePicker, ToggleFillTypePicker,
/// User picked a fill type from the dropdown. /// User picked a fill type from the dropdown.
SetFillType(op_editor_core::FillType), SetFillType(op_editor_core::FillType),
/// User clicked the Fill section header "+".
AddFill,
/// User clicked the Fill section row remove button.
RemoveFill,
/// User clicked the gradient-stops header "+".
AddGradientStop,
/// User clicked a gradient stop row remove button.
RemoveGradientStop(usize),
/// User clicked a colour swatch (Fill or Stroke section). Host /// User clicked a colour swatch (Fill or Stroke section). Host
/// opens the floating colour picker tied to that target. /// opens the floating colour picker tied to that target.
OpenColorPicker(op_editor_core::ColorTarget), OpenColorPicker(op_editor_core::ColorTarget),
@ -89,4 +200,12 @@ pub enum PropertyPanelAction {
}, },
/// User clicked the image adjustment reset affordance. /// User clicked the image adjustment reset affordance.
ResetImageAdjustments, ResetImageAdjustments,
/// User clicked the Icon section's icon/library row — host opens
/// the native Lucide picker in replace-selection mode.
OpenSelectedIconPicker,
SetTextAlign(TextAlignValue),
SetTextVerticalAlign(TextVerticalAlignValue),
SetTextGrowth(TextGrowthValue),
ToggleFontFamilyPicker,
SetFontFamily(FontFamilyChoice),
} }

View file

@ -115,6 +115,7 @@ pub fn paint_export_picker(
visible, visible,
effects, effects,
false, false,
false,
scale_open, scale_open,
format_open, format_open,
); );

View file

@ -14,7 +14,7 @@ use crate::widgets::property_panel_inputs::{
HEADER_HEIGHT, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, HEADER_HEIGHT, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT,
TAB_HEIGHT, TAB_HEIGHT,
}; };
use crate::widgets::property_panel_layout::{fill_body_height, VisibleSections}; use crate::widgets::property_panel_layout::{fill_body_height_with_stops, VisibleSections};
use crate::widgets::property_panel_sections::{EditContext, PropertyLabels}; use crate::widgets::property_panel_sections::{EditContext, PropertyLabels};
use crate::widgets::PaintCx; use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect, TextLayout}; use crate::{Color, Point2D, Rect, TextLayout};
@ -57,23 +57,35 @@ pub fn paint_fill_type_picker(
let mut y = panel_rect.origin.y; let mut y = panel_rect.origin.y;
y += TAB_HEIGHT; y += TAB_HEIGHT;
y += HEADER_HEIGHT; y += HEADER_HEIGHT;
y += 8.0 + 36.0 + 12.0; if visible.create_component {
y += 8.0 + 36.0 + 12.0;
}
// Position section. // Position section.
y += SECTION_HEADER_HEIGHT; y += SECTION_HEADER_HEIGHT;
y += INPUT_HEIGHT + 6.0; y += INPUT_HEIGHT + 6.0;
y += INPUT_HEIGHT + 12.0; y += INPUT_HEIGHT + 12.0;
y += SECTION_GAP; y += SECTION_GAP;
if visible.flex_layout { if visible.flex_layout {
y += SECTION_HEADER_HEIGHT; y += crate::widgets::property_panel_flex::flex_section_height(visible.flex_layout_mode);
y += 32.0 + 12.0;
y += SECTION_GAP;
} }
if visible.size_options { if visible.size_options {
y += SECTION_HEADER_HEIGHT; y += SECTION_HEADER_HEIGHT;
y += INPUT_HEIGHT + 10.0; y += INPUT_HEIGHT + 10.0;
y += 22.0 * 3.0; y += 22.0 * if visible.clip_content { 3.0 } else { 2.0 };
y += 12.0 + SECTION_GAP; y += 12.0 + SECTION_GAP;
} }
if visible.icon {
y += crate::widgets::property_panel_icon::icon_section_height();
}
if visible.text {
y += crate::widgets::property_panel_text::text_section_height();
y += SECTION_GAP;
}
if visible.image {
y += SECTION_HEADER_HEIGHT;
y += INPUT_HEIGHT + 34.0;
y += SECTION_GAP;
}
if visible.opacity { if visible.opacity {
y += SECTION_HEADER_HEIGHT; y += SECTION_HEADER_HEIGHT;
y += INPUT_HEIGHT + 12.0 + SECTION_GAP; y += INPUT_HEIGHT + 12.0 + SECTION_GAP;
@ -331,7 +343,7 @@ pub fn paint_fill_section(
paint_fill_image_body(cx, theme, snapshot, locale, x, y, width); paint_fill_image_body(cx, theme, snapshot, locale, x, y, width);
} }
} }
y += fill_body_height(fill_type) - 6.0 + 12.0; y += fill_body_height_with_stops(fill_type, snapshot.gradient_stops.len()) - 6.0 + 12.0;
paint_section_divider(cx, theme, x, y, width); paint_section_divider(cx, theme, x, y, width);
y + SECTION_GAP y + SECTION_GAP
} }
@ -490,9 +502,12 @@ fn paint_fill_gradient_body(
); );
yy += SECTION_HEADER_HEIGHT; yy += SECTION_HEADER_HEIGHT;
let pct_w = 56.0; let pct_w = 56.0;
let show_remove = snapshot.gradient_stops.len() > 2;
let remove_w = if show_remove { 26.0 } else { 0.0 };
let remove_gap = if show_remove { 6.0 } else { 0.0 };
for (index, stop) in snapshot.gradient_stops.iter().enumerate() { for (index, stop) in snapshot.gradient_stops.iter().enumerate() {
let row_y = yy; let row_y = yy;
let hex_w = usable_w - pct_w - 8.0; let hex_w = usable_w - pct_w - 8.0 - remove_w - remove_gap;
let hex_rect = Rect { let hex_rect = Rect {
origin: Point2D::new(x + PAD_X, row_y), origin: Point2D::new(x + PAD_X, row_y),
size: Point2D::new(hex_w, INPUT_HEIGHT), size: Point2D::new(hex_w, INPUT_HEIGHT),
@ -590,6 +605,19 @@ fn paint_fill_gradient_body(
pct_rect.origin.y + 19.0, pct_rect.origin.y + 19.0,
), ),
); );
if show_remove {
draw_icon(
cx.backend,
Icon::Close,
Point2D::new(
pct_rect.origin.x + pct_rect.size.x + 10.0,
row_y + (INPUT_HEIGHT - 14.0) / 2.0,
),
14.0,
theme.muted_foreground,
1.4,
);
}
yy += INPUT_HEIGHT + 4.0; yy += INPUT_HEIGHT + 4.0;
} }
let _ = yy; let _ = yy;

View file

@ -0,0 +1,541 @@
//! Flex-layout section paint and geometry helpers.
use crate::theme::Theme;
use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::property_panel::{
LayoutAlignValue, LayoutJustifyValue, NodeSnapshot, PropertyPanelAction,
};
use crate::widgets::property_panel_inputs::{
paint_input_with_prefix_focused, paint_section_divider, paint_section_label, to_jian_color,
INPUT_HEIGHT, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT,
};
use crate::widgets::property_panel_sections::{EditContext, PropertyLabels};
use crate::widgets::PaintCx;
use crate::{Point2D, Rect, TextLayout};
use op_editor_core::{FlexLayout, PropertyFocus};
const DIR_BUTTON_W: f32 = 56.0;
const DIR_BUTTON_H: f32 = 32.0;
const DIR_GAP: f32 = 8.0;
const ADVANCED_TOP_GAP: f32 = 10.0;
const SUB_LABEL_H: f32 = 18.0;
const GRID_CELL_W: f32 = 34.0;
const GRID_CELL_H: f32 = 22.0;
const GRID_GAP: f32 = 3.0;
const GAP_BUTTON_H: f32 = 24.0;
const PADDING_ROW_GAP: f32 = 6.0;
fn alignment_grid_h() -> f32 {
GRID_CELL_H * 3.0 + GRID_GAP * 2.0
}
fn gap_column_h() -> f32 {
INPUT_HEIGHT + 4.0 + GAP_BUTTON_H * 3.0
}
fn alignment_block_body_h() -> f32 {
alignment_grid_h().max(gap_column_h())
}
pub fn flex_section_height(active: FlexLayout) -> f32 {
let base = SECTION_HEADER_HEIGHT + DIR_BUTTON_H + 12.0;
if active == FlexLayout::Free {
base + SECTION_GAP
} else {
base + ADVANCED_TOP_GAP
+ SUB_LABEL_H
+ alignment_block_body_h()
+ 8.0
+ SUB_LABEL_H
+ INPUT_HEIGHT * 2.0
+ PADDING_ROW_GAP
+ 12.0
+ SECTION_GAP
}
}
#[allow(clippy::too_many_arguments)]
pub fn paint_flex_section(
cx: &mut PaintCx<'_>,
theme: &Theme,
snapshot: &NodeSnapshot,
edit: &EditContext<'_>,
labels: &PropertyLabels,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
) -> f32 {
let mut y = paint_section_label(cx, theme, labels.flex_layout, x, y, width);
paint_direction_buttons(cx, theme, snapshot.flex_layout, x, y);
y += DIR_BUTTON_H + 12.0;
if snapshot.flex_layout != FlexLayout::Free {
y += ADVANCED_TOP_GAP;
y = paint_alignment_and_gap(cx, theme, snapshot, edit, locale, x, y, width);
y = paint_padding_inputs(cx, theme, snapshot, edit, locale, x, y, width);
}
paint_section_divider(cx, theme, x, y, width);
y + SECTION_GAP
}
pub fn push_flex_input_rects(
rects: &mut Vec<(PropertyFocus, Rect)>,
x0: f32,
y: f32,
width: f32,
active: FlexLayout,
) {
if active == FlexLayout::Free {
return;
}
let usable_w = width - PAD_X * 2.0;
let mut y = y + SECTION_HEADER_HEIGHT + DIR_BUTTON_H + 12.0 + ADVANCED_TOP_GAP;
let grid_w = GRID_CELL_W * 3.0 + GRID_GAP * 2.0;
let gap_x = x0 + PAD_X + grid_w + 16.0;
let gap_w = usable_w - grid_w - 16.0;
y += SUB_LABEL_H;
rects.push((
PropertyFocus::LayoutGap,
Rect {
origin: Point2D::new(gap_x, y),
size: Point2D::new(gap_w, INPUT_HEIGHT),
},
));
y += alignment_block_body_h() + 8.0;
y += SUB_LABEL_H;
let half_w = (usable_w - 8.0) / 2.0;
let focuses = [
PropertyFocus::PaddingTop,
PropertyFocus::PaddingRight,
PropertyFocus::PaddingBottom,
PropertyFocus::PaddingLeft,
];
for (i, focus) in focuses.into_iter().enumerate() {
let row = i / 2;
let col = i % 2;
rects.push((
focus,
Rect {
origin: Point2D::new(
x0 + PAD_X + col as f32 * (half_w + 8.0),
y + row as f32 * (INPUT_HEIGHT + PADDING_ROW_GAP),
),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
));
}
}
pub fn push_flex_action_rects(
out: &mut Vec<(PropertyPanelAction, Rect)>,
x0: f32,
y: f32,
width: f32,
active: FlexLayout,
justify: LayoutJustifyValue,
) {
let row_x = x0 + PAD_X;
let modes = [
FlexLayout::Free,
FlexLayout::Vertical,
FlexLayout::Horizontal,
];
for (i, mode) in modes.iter().enumerate() {
out.push((
PropertyPanelAction::SetFlexLayout(*mode),
Rect {
origin: Point2D::new(row_x + i as f32 * (DIR_BUTTON_W + DIR_GAP), y),
size: Point2D::new(DIR_BUTTON_W, DIR_BUTTON_H),
},
));
}
if active == FlexLayout::Free {
return;
}
let usable_w = width - PAD_X * 2.0;
let grid_w = GRID_CELL_W * 3.0 + GRID_GAP * 2.0;
let grid_y = y + DIR_BUTTON_H + 12.0 + ADVANCED_TOP_GAP + SUB_LABEL_H;
let is_space = matches!(
justify,
LayoutJustifyValue::SpaceBetween | LayoutJustifyValue::SpaceAround
);
for row in 0..3 {
for col in 0..3 {
let justify_value = if active == FlexLayout::Vertical {
position_to_justify(row)
} else {
position_to_justify(col)
};
let align_value = if active == FlexLayout::Vertical {
position_to_align(col)
} else {
position_to_align(row)
};
let action = if is_space {
PropertyPanelAction::SetLayoutAlign(align_value)
} else {
PropertyPanelAction::SetLayoutAlignment {
justify: justify_value,
align: align_value,
}
};
out.push((
action,
Rect {
origin: Point2D::new(
x0 + PAD_X + col as f32 * (GRID_CELL_W + GRID_GAP),
grid_y + row as f32 * (GRID_CELL_H + GRID_GAP),
),
size: Point2D::new(GRID_CELL_W, GRID_CELL_H),
},
));
}
}
let gap_x = x0 + PAD_X + grid_w + 16.0;
let gap_w = usable_w - grid_w - 16.0;
let mut gap_y = grid_y + INPUT_HEIGHT + 4.0;
for (action, _) in [
(
PropertyPanelAction::SetLayoutJustify(LayoutJustifyValue::Start),
"numeric",
),
(
PropertyPanelAction::SetLayoutJustify(LayoutJustifyValue::SpaceBetween),
"between",
),
(
PropertyPanelAction::SetLayoutJustify(LayoutJustifyValue::SpaceAround),
"around",
),
] {
out.push((
action,
Rect {
origin: Point2D::new(gap_x, gap_y),
size: Point2D::new(gap_w, GAP_BUTTON_H),
},
));
gap_y += GAP_BUTTON_H;
}
}
fn paint_direction_buttons(
cx: &mut PaintCx<'_>,
theme: &Theme,
active: FlexLayout,
x: f32,
y: f32,
) {
let modes = [
(FlexLayout::Free, Icon::LayoutGrid),
(FlexLayout::Vertical, Icon::Rows3),
(FlexLayout::Horizontal, Icon::Columns3),
];
for (i, (mode, icon)) in modes.iter().enumerate() {
let rect = Rect {
origin: Point2D::new(x + PAD_X + i as f32 * (DIR_BUTTON_W + DIR_GAP), y),
size: Point2D::new(DIR_BUTTON_W, DIR_BUTTON_H),
};
let is_active = *mode == active;
if is_active {
cx.backend.fill_round_rect(rect, 6.0, theme.primary);
} else {
cx.backend.fill_round_rect(rect, 6.0, theme.muted);
cx.backend.stroke_round_rect(rect, 6.0, theme.border, 1.0);
}
let icon_color = if is_active {
theme.primary_foreground
} else {
theme.muted_foreground
};
draw_icon(
cx.backend,
*icon,
Point2D::new(
rect.origin.x + (DIR_BUTTON_W - 18.0) / 2.0,
rect.origin.y + 7.0,
),
18.0,
icon_color,
1.4,
);
}
}
#[allow(clippy::too_many_arguments)]
fn paint_alignment_and_gap(
cx: &mut PaintCx<'_>,
theme: &Theme,
snapshot: &NodeSnapshot,
edit: &EditContext<'_>,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
) -> f32 {
let usable_w = width - PAD_X * 2.0;
let grid_w = GRID_CELL_W * 3.0 + GRID_GAP * 2.0;
paint_sub_label(
cx,
theme,
op_i18n::translate(locale, "layout.alignment"),
x + PAD_X,
y,
);
paint_sub_label(
cx,
theme,
op_i18n::translate(locale, "layout.gap"),
x + PAD_X + grid_w + 16.0,
y,
);
let grid_y = y + SUB_LABEL_H;
paint_alignment_grid(cx, theme, snapshot, x + PAD_X, grid_y);
let gap_x = x + PAD_X + grid_w + 16.0;
let gap_w = usable_w - grid_w - 16.0;
let gap_text = format_panel_number(snapshot.layout_gap);
paint_input_with_prefix_focused(
cx,
theme,
Rect {
origin: Point2D::new(gap_x, grid_y),
size: Point2D::new(gap_w, INPUT_HEIGHT),
},
"G",
edit.value_for(PropertyFocus::LayoutGap, &gap_text),
edit.focus == Some(PropertyFocus::LayoutGap),
edit.caret_at(PropertyFocus::LayoutGap),
);
let mut yy = grid_y + INPUT_HEIGHT + 4.0;
yy = paint_gap_mode_button(
cx,
theme,
locale,
gap_x,
yy,
gap_w,
snapshot.layout_justify == LayoutJustifyValue::Start,
"layout.gap",
);
yy = paint_gap_mode_button(
cx,
theme,
locale,
gap_x,
yy,
gap_w,
snapshot.layout_justify == LayoutJustifyValue::SpaceBetween,
"layout.spaceBetween",
);
let _ = paint_gap_mode_button(
cx,
theme,
locale,
gap_x,
yy,
gap_w,
snapshot.layout_justify == LayoutJustifyValue::SpaceAround,
"layout.spaceAround",
);
grid_y + alignment_block_body_h() + 8.0
}
#[allow(clippy::too_many_arguments)]
fn paint_padding_inputs(
cx: &mut PaintCx<'_>,
theme: &Theme,
snapshot: &NodeSnapshot,
edit: &EditContext<'_>,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
) -> f32 {
paint_sub_label(
cx,
theme,
op_i18n::translate(locale, "padding.title"),
x + PAD_X,
y,
);
let usable_w = width - PAD_X * 2.0;
let half_w = (usable_w - 8.0) / 2.0;
let mut yy = y + SUB_LABEL_H;
let rows = [
(PropertyFocus::PaddingTop, "T", snapshot.layout_padding.top),
(
PropertyFocus::PaddingRight,
"R",
snapshot.layout_padding.right,
),
(
PropertyFocus::PaddingBottom,
"B",
snapshot.layout_padding.bottom,
),
(
PropertyFocus::PaddingLeft,
"L",
snapshot.layout_padding.left,
),
];
for pair in rows.chunks(2) {
for (col, (focus, label, value)) in pair.iter().enumerate() {
let value = format_panel_number(*value);
paint_input_with_prefix_focused(
cx,
theme,
Rect {
origin: Point2D::new(x + PAD_X + col as f32 * (half_w + 8.0), yy),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
label,
edit.value_for(*focus, &value),
edit.focus == Some(*focus),
edit.caret_at(*focus),
);
}
yy += INPUT_HEIGHT + PADDING_ROW_GAP;
}
yy - PADDING_ROW_GAP + 12.0
}
fn paint_alignment_grid(
cx: &mut PaintCx<'_>,
theme: &Theme,
snapshot: &NodeSnapshot,
x: f32,
y: f32,
) {
let is_space = matches!(
snapshot.layout_justify,
LayoutJustifyValue::SpaceBetween | LayoutJustifyValue::SpaceAround
);
let is_vertical = snapshot.flex_layout == FlexLayout::Vertical;
let bg = Rect {
origin: Point2D::new(x - 6.0, y - 6.0),
size: Point2D::new(
GRID_CELL_W * 3.0 + GRID_GAP * 2.0 + 12.0,
GRID_CELL_H * 3.0 + GRID_GAP * 2.0 + 12.0,
),
};
cx.backend.fill_round_rect(bg, 6.0, theme.muted);
for row in 0..3 {
for col in 0..3 {
let justify = if is_vertical {
position_to_justify(row)
} else {
position_to_justify(col)
};
let align = if is_vertical {
position_to_align(col)
} else {
position_to_align(row)
};
let active = if is_space {
snapshot.layout_align == align
} else {
snapshot.layout_justify == justify && snapshot.layout_align == align
};
let cell = Rect {
origin: Point2D::new(
x + col as f32 * (GRID_CELL_W + GRID_GAP),
y + row as f32 * (GRID_CELL_H + GRID_GAP),
),
size: Point2D::new(GRID_CELL_W, GRID_CELL_H),
};
let dot = if active {
Rect {
origin: Point2D::new(cell.origin.x + 12.0, cell.origin.y + 6.0),
size: Point2D::new(10.0, 10.0),
}
} else {
Rect {
origin: Point2D::new(cell.origin.x + 15.0, cell.origin.y + 9.0),
size: Point2D::new(4.0, 4.0),
}
};
cx.backend.fill_round_rect(
dot,
if active { 2.0 } else { 4.0 },
if active {
theme.primary
} else {
theme.muted_foreground
},
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn paint_gap_mode_button(
cx: &mut PaintCx<'_>,
theme: &Theme,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
active: bool,
label_key: &'static str,
) -> f32 {
let rect = Rect {
origin: Point2D::new(x, y),
size: Point2D::new(width, GAP_BUTTON_H),
};
if active {
cx.backend.fill_round_rect(rect, 5.0, theme.primary);
}
let color = if active {
theme.primary_foreground
} else {
theme.muted_foreground
};
let label = TextLayout::single_run(
op_i18n::translate(locale, label_key),
"system-ui",
10.0,
to_jian_color(color),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&label,
Point2D::new(rect.origin.x + 8.0, rect.origin.y + 16.0),
);
y + GAP_BUTTON_H
}
fn paint_sub_label(cx: &mut PaintCx<'_>, theme: &Theme, label: &str, x: f32, y: f32) {
let layout = TextLayout::single_run(
label,
"system-ui",
10.0,
to_jian_color(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(&layout, Point2D::new(x, y + 13.0));
}
fn position_to_justify(index: usize) -> LayoutJustifyValue {
match index {
0 => LayoutJustifyValue::Start,
1 => LayoutJustifyValue::Center,
_ => LayoutJustifyValue::End,
}
}
fn position_to_align(index: usize) -> LayoutAlignValue {
match index {
0 => LayoutAlignValue::Start,
1 => LayoutAlignValue::Center,
_ => LayoutAlignValue::End,
}
}
fn format_panel_number(value: f32) -> String {
if value.fract().abs() < f32::EPSILON {
format!("{}", value.round() as i32)
} else {
format!("{value:.2}")
}
}

View file

@ -0,0 +1,147 @@
//! Icon section for path/icon_font selections.
use crate::theme::Theme;
use crate::widgets::icon_catalog::lookup_icon;
use crate::widgets::icons::{draw_icon, draw_icon_catalog_entry, Icon};
use crate::widgets::property_panel::PropertyPanelAction;
use crate::widgets::property_panel_inputs::{
paint_dropdown, paint_section_divider, paint_section_label, to_jian_color, INPUT_HEIGHT, PAD_X,
SECTION_GAP, SECTION_HEADER_HEIGHT,
};
use crate::widgets::PaintCx;
use crate::{Point2D, Rect, TextLayout};
pub fn icon_section_height() -> f32 {
SECTION_HEADER_HEIGHT + INPUT_HEIGHT * 2.0 + 6.0 + 12.0 + SECTION_GAP
}
pub fn push_icon_action_rects(
out: &mut Vec<(PropertyPanelAction, Rect)>,
x: f32,
y: f32,
width: f32,
) {
let usable_w = width - PAD_X * 2.0;
let row_y = y + SECTION_HEADER_HEIGHT;
for offset in [0.0, INPUT_HEIGHT + 6.0] {
out.push((
PropertyPanelAction::OpenSelectedIconPicker,
Rect {
origin: Point2D::new(x + PAD_X, row_y + offset),
size: Point2D::new(usable_w, INPUT_HEIGHT),
},
));
}
}
pub fn paint_icon_section(
cx: &mut PaintCx<'_>,
theme: &Theme,
snapshot: &crate::widgets::property_panel::NodeSnapshot,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
) -> f32 {
let Some(icon) = snapshot.icon.as_ref() else {
return y;
};
let mut y = paint_section_label(
cx,
theme,
translate(locale, "icon.title", "Icon"),
x,
y,
width,
);
let row = Rect {
origin: Point2D::new(x + PAD_X, y),
size: Point2D::new(width - PAD_X * 2.0, INPUT_HEIGHT),
};
paint_icon_name_row(cx, theme, row, icon.family.as_str(), icon.name.as_str());
y += INPUT_HEIGHT + 6.0;
let library = Rect {
origin: Point2D::new(x + PAD_X, y),
size: Point2D::new(width - PAD_X * 2.0, INPUT_HEIGHT),
};
paint_dropdown(cx, theme, library, display_family(icon.family.as_str()));
y += INPUT_HEIGHT + 12.0;
paint_section_divider(cx, theme, x, y, width);
y + SECTION_GAP
}
fn paint_icon_name_row(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rect, family: &str, name: &str) {
cx.backend.fill_round_rect(
rect,
crate::widgets::property_panel_inputs::INPUT_RADIUS,
theme.muted,
);
if let Some(icon) = lookup_icon(family, name) {
draw_icon_catalog_entry(
cx.backend,
icon,
Point2D::new(rect.origin.x + 9.0, rect.origin.y + 7.0),
16.0,
theme.muted_foreground,
1.5,
);
} else if let Some(icon) = Icon::from_name(name) {
draw_icon(
cx.backend,
icon,
Point2D::new(rect.origin.x + 9.0, rect.origin.y + 7.0),
16.0,
theme.muted_foreground,
1.5,
);
} else {
draw_icon(
cx.backend,
Icon::ImagePlus,
Point2D::new(rect.origin.x + 9.0, rect.origin.y + 7.0),
16.0,
theme.muted_foreground,
1.5,
);
}
let label = TextLayout::single_run(
name,
"system-ui",
12.0,
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&label,
Point2D::new(rect.origin.x + 34.0, rect.origin.y + 19.0),
);
draw_icon(
cx.backend,
Icon::ChevronDown,
Point2D::new(rect.origin.x + rect.size.x - 22.0, rect.origin.y + 5.0),
16.0,
theme.muted_foreground,
1.4,
);
}
fn display_family(family: &str) -> &str {
if family.eq_ignore_ascii_case("lucide") {
"Lucide"
} else {
family
}
}
fn translate(
locale: op_editor_core::Locale,
key: &'static str,
fallback: &'static str,
) -> &'static str {
let translated = crate::i18n::translate(locale, key);
if translated == key {
fallback
} else {
translated
}
}

View file

@ -0,0 +1,108 @@
//! Focused tests for icon/size behavior that is easier to keep
//! separate from the broad property-panel snapshot suite.
use super::property_panel::{PropertyPanel, PropertyPanelAction, SectionCapabilities};
use super::property_panel_sections as sections;
use crate::{Point2D, Rect};
use op_editor_core::{EditorState, NodeId};
fn state_from(src: &str) -> EditorState {
let doc = jian_ops_schema::load_str(src)
.expect("property-panel fixture parses")
.value;
EditorState::from_document(doc)
}
fn visible_for(panel: &PropertyPanel) -> sections::VisibleSections {
let caps = SectionCapabilities::for_kind(&panel.snapshot.kind_variant);
sections::VisibleSections {
create_component: caps.create_component && panel.snapshot.can_create_component,
flex_layout: caps.flex_layout,
flex_layout_mode: panel.snapshot.flex_layout,
layout_justify: panel.snapshot.layout_justify,
layout_align: panel.snapshot.layout_align,
size_options: caps.size_options,
clip_content: panel.snapshot.can_clip_content,
text: caps.text && panel.snapshot.text.is_some(),
icon: panel.snapshot.icon.is_some(),
image: caps.image && panel.snapshot.is_image_node,
opacity: caps.opacity,
corner_radius: panel.snapshot.has_corner_radius,
polygon_sides: panel.snapshot.polygon_sides.is_some(),
ellipse_arc: panel.snapshot.ellipse_arc.is_some(),
fill: caps.fill,
stroke: caps.stroke,
effects: caps.effects,
export: caps.export,
fill_type: panel.fill_type,
gradient_stop_count: panel.snapshot.gradient_stops.len(),
}
}
#[test]
fn text_size_section_does_not_emit_clip_content_action() {
let mut state = EditorState::sample();
state.set_single_selection(NodeId::new("n11"));
let panel = PropertyPanel::for_selection(&state).expect("text panel");
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(280.0, 1200.0),
};
let actions = sections::action_button_rects_with_fill_picker(
rect,
visible_for(&panel),
&panel.snapshot.effects,
false,
false,
false,
false,
);
assert!(
actions
.iter()
.all(|(action, _)| !matches!(action, PropertyPanelAction::ToggleSizeClipContent)),
"Text nodes do not support clipContent, so the panel must not expose a dead checkbox"
);
}
#[test]
fn icon_font_selection_exposes_icon_picker_action() {
let mut state = state_from(
r##"{ "version": "0.8.0", "children": [
{"type":"icon_font","id":"icon","name":"Search",
"x":40,"y":40,"width":24,"height":24,
"iconFontName":"search","iconFontFamily":"lucide"}
]}"##,
);
state.set_single_selection(NodeId::new("icon"));
let panel = PropertyPanel::for_selection(&state).expect("icon panel");
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(280.0, 1200.0),
};
let action_rect = sections::action_button_rects_with_fill_picker(
rect,
visible_for(&panel),
&panel.snapshot.effects,
false,
false,
false,
false,
)
.into_iter()
.find(|(action, _)| matches!(action, PropertyPanelAction::OpenSelectedIconPicker))
.map(|(_, r)| r)
.expect("icon section emits picker action");
let center = Point2D::new(
action_rect.origin.x + action_rect.size.x / 2.0,
action_rect.origin.y + action_rect.size.y / 2.0,
);
assert_eq!(
panel.hit_test_action(rect, center),
Some(PropertyPanelAction::OpenSelectedIconPicker)
);
}

View file

@ -43,10 +43,10 @@ fn rect_contains(r: Rect, p: Point2D) -> bool {
} }
fn image_body_rect(panel_rect: Rect, visible: VisibleSections) -> Option<Rect> { fn image_body_rect(panel_rect: Rect, visible: VisibleSections) -> Option<Rect> {
if !visible.fill || visible.fill_type != op_editor_core::FillType::Image { if !visible.image && (!visible.fill || visible.fill_type != op_editor_core::FillType::Image) {
return None; return None;
} }
action_button_rects_with_fill_picker(panel_rect, visible, &[], false, false, false) action_button_rects_with_fill_picker(panel_rect, visible, &[], false, false, false, false)
.into_iter() .into_iter()
.find_map(|(action, rect)| { .find_map(|(action, rect)| {
matches!(action, PropertyPanelAction::ToggleImageFillPopover).then_some(rect) matches!(action, PropertyPanelAction::ToggleImageFillPopover).then_some(rect)

View file

@ -0,0 +1,82 @@
//! Image-node specific section for the native property panel.
use crate::theme::Theme;
use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::property_panel::NodeSnapshot;
use crate::widgets::property_panel_image_preview::paint_image_preview;
use crate::widgets::property_panel_inputs::{
paint_section_divider, paint_section_label, to_jian_color, INPUT_HEIGHT, INPUT_RADIUS, PAD_X,
SECTION_GAP,
};
use crate::widgets::PaintCx;
use crate::{Point2D, Rect, TextLayout};
#[allow(clippy::too_many_arguments)]
pub fn paint_image_node_section(
cx: &mut PaintCx<'_>,
theme: &Theme,
snapshot: &NodeSnapshot,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
) -> f32 {
let mut y = paint_section_label(
cx,
theme,
op_i18n::translate(locale, "image.title"),
x,
y,
width,
);
let usable_w = width - PAD_X * 2.0;
let row = Rect {
origin: Point2D::new(x + PAD_X, y),
size: Point2D::new(usable_w, INPUT_HEIGHT),
};
cx.backend.fill_round_rect(row, INPUT_RADIUS, theme.muted);
cx.backend
.stroke_round_rect(row, INPUT_RADIUS, theme.border, 1.0);
let summary = snapshot.image_fill.as_ref();
let thumb = Rect {
origin: Point2D::new(row.origin.x + 6.0, row.origin.y + 5.0),
size: Point2D::new(20.0, 20.0),
};
let painted = summary
.and_then(|summary| {
summary
.image_url
.as_deref()
.map(|src| paint_image_preview(cx, thumb, src, summary))
})
.unwrap_or(false);
if !painted {
draw_icon(
cx.backend,
Icon::ImagePlus,
Point2D::new(thumb.origin.x, thumb.origin.y),
18.0,
theme.muted_foreground,
1.4,
);
}
let label_key = summary
.map(|summary| summary.mode.label_key())
.unwrap_or("image.fill");
let label = TextLayout::single_run(
op_i18n::translate(locale, label_key),
"system-ui",
12.0,
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&label,
Point2D::new(row.origin.x + 32.0, row.origin.y + 19.0),
);
y += INPUT_HEIGHT + 34.0;
paint_section_divider(cx, theme, x, y, width);
y + SECTION_GAP
}

View file

@ -22,7 +22,9 @@ fn push_gradient_stop_rects(
stop_count: usize, stop_count: usize,
) { ) {
let pct_w = 56.0; let pct_w = 56.0;
let hex_w = usable_w - pct_w - 8.0; let remove_w = if stop_count > 2 { 26.0 } else { 0.0 };
let remove_gap = if stop_count > 2 { 6.0 } else { 0.0 };
let hex_w = usable_w - pct_w - 8.0 - remove_w - remove_gap;
for index in 0..stop_count { for index in 0..stop_count {
rects.push(( rects.push((
PropertyFocus::GradientStopHex(index), PropertyFocus::GradientStopHex(index),
@ -56,7 +58,9 @@ pub fn editable_input_rects(
let mut y = panel_rect.origin.y; let mut y = panel_rect.origin.y;
y += TAB_HEIGHT; y += TAB_HEIGHT;
y += HEADER_HEIGHT; y += HEADER_HEIGHT;
y += 8.0 + 36.0 + 12.0; if visible.create_component {
y += 8.0 + 36.0 + 12.0;
}
y += SECTION_HEADER_HEIGHT; y += SECTION_HEADER_HEIGHT;
let x_rect = Rect { let x_rect = Rect {
origin: Point2D::new(x0 + PAD_X, y), origin: Point2D::new(x0 + PAD_X, y),
@ -71,23 +75,30 @@ pub fn editable_input_rects(
origin: Point2D::new(x0 + PAD_X, y), origin: Point2D::new(x0 + PAD_X, y),
size: Point2D::new(half_w, INPUT_HEIGHT), size: Point2D::new(half_w, INPUT_HEIGHT),
}; };
let radius_rect = Rect { let radius_rect = visible.corner_radius.then_some(Rect {
origin: Point2D::new(x0 + PAD_X + half_w + 8.0, y), origin: Point2D::new(x0 + PAD_X + half_w + 8.0, y),
size: Point2D::new(half_w, INPUT_HEIGHT), size: Point2D::new(half_w, INPUT_HEIGHT),
}; });
y += INPUT_HEIGHT + 12.0; y += INPUT_HEIGHT + 12.0;
y += SECTION_GAP; y += SECTION_GAP;
if visible.flex_layout {
y += SECTION_HEADER_HEIGHT;
y += 32.0 + 12.0;
y += SECTION_GAP;
}
let mut rects = vec![ let mut rects = vec![
(PropertyFocus::PositionX, x_rect), (PropertyFocus::PositionX, x_rect),
(PropertyFocus::PositionY, y_rect), (PropertyFocus::PositionY, y_rect),
(PropertyFocus::Rotation, rotation_rect), (PropertyFocus::Rotation, rotation_rect),
(PropertyFocus::PositionR, radius_rect),
]; ];
if let Some(radius_rect) = radius_rect {
rects.push((PropertyFocus::PositionR, radius_rect));
}
if visible.flex_layout {
crate::widgets::property_panel_flex::push_flex_input_rects(
&mut rects,
x0,
y,
w,
visible.flex_layout_mode,
);
y += crate::widgets::property_panel_flex::flex_section_height(visible.flex_layout_mode);
}
if visible.size_options { if visible.size_options {
y += SECTION_HEADER_HEIGHT; y += SECTION_HEADER_HEIGHT;
rects.push(( rects.push((
@ -106,10 +117,23 @@ pub fn editable_input_rects(
)); ));
y += INPUT_HEIGHT + 10.0; y += INPUT_HEIGHT + 10.0;
let check_h = 22.0; let check_h = 22.0;
y += check_h * 3.0; y += check_h * if visible.clip_content { 3.0 } else { 2.0 };
y += 12.0; y += 12.0;
y += SECTION_GAP; y += SECTION_GAP;
} }
if visible.icon {
y += crate::widgets::property_panel_icon::icon_section_height();
}
if visible.text {
crate::widgets::property_panel_text::push_text_input_rects(&mut rects, x0, y, usable_w);
y += crate::widgets::property_panel_text::text_section_height();
y += SECTION_GAP;
}
if visible.image {
y += SECTION_HEADER_HEIGHT;
y += INPUT_HEIGHT + 34.0;
y += SECTION_GAP;
}
if visible.opacity { if visible.opacity {
y += SECTION_HEADER_HEIGHT; y += SECTION_HEADER_HEIGHT;
rects.push(( rects.push((

View file

@ -11,157 +11,13 @@ use crate::widgets::property_panel_inputs::{
HEADER_HEIGHT, INPUT_HEIGHT, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, TAB_HEIGHT, HEADER_HEIGHT, INPUT_HEIGHT, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, TAB_HEIGHT,
}; };
use crate::{Point2D, Rect}; use crate::{Point2D, Rect};
use op_editor_core::{EffectField, FillType, FlexLayout}; use op_editor_core::{EffectField, FillType};
pub(crate) use crate::widgets::property_panel_visibility::SectionCapabilities;
pub use crate::widgets::property_panel_visibility::VisibleSections;
pub use crate::widgets::property_panel_input_layout::editable_input_rects; pub use crate::widgets::property_panel_input_layout::editable_input_rects;
/// Per-NodeKind toggles for which property-panel sections render.
/// Mirrors the TS app's behaviour where a Line node hides the
/// fill picker, a Frame hides Text properties, etc. Sections that
/// always apply (Position / Layer / Export) aren't gated here.
/// Lives here alongside `VisibleSections` — the mask it feeds.
#[derive(Debug, Clone, Copy)]
pub(crate) struct SectionCapabilities {
pub(crate) flex_layout: bool,
pub(crate) size_options: bool,
pub(crate) opacity: bool,
pub(crate) fill: bool,
pub(crate) stroke: bool,
pub(crate) effects: bool,
pub(crate) export: bool,
}
impl SectionCapabilities {
/// Capability mask for the multi-select aggregate snapshot.
/// Keeps Size (so the union W/H actually paint), hides
/// fill/stroke (no aggregation in v1), keeps Layer/Effects/
/// Export (paint safely with the zeroed snapshot fields).
pub(crate) fn for_multi() -> Self {
Self {
flex_layout: false,
size_options: true,
opacity: true,
fill: false,
stroke: false,
effects: true,
export: true,
}
}
pub(crate) fn for_kind(kind: &crate::layout_scene::NodeKind) -> Self {
use crate::layout_scene::NodeKind as K;
match kind {
// Frame: full chrome — it can host children, take auto-
// layout, fill / stroke / effects / export.
K::Frame => Self {
flex_layout: true,
size_options: true,
opacity: true,
fill: true,
stroke: true,
effects: true,
export: true,
},
// Group: structural — no fill / stroke, no flex slot
// (children own layout). Opacity + export still apply.
K::Group | K::Other(_) => Self {
flex_layout: false,
size_options: false,
opacity: true,
fill: false,
stroke: false,
effects: true,
export: true,
},
// Rect / Ellipse / Polygon: full leaf — every paint
// section applies; no flex (no children).
K::Rect | K::Ellipse | K::Polygon => Self {
flex_layout: false,
size_options: true,
opacity: true,
fill: true,
stroke: true,
effects: true,
export: true,
},
// Line / Path: only outline — fill doesn't apply.
K::Line | K::Path => Self {
flex_layout: false,
size_options: true,
opacity: true,
fill: false,
stroke: true,
effects: true,
export: true,
},
// Text: stroke is rare for text, but fill = ink colour.
K::Text => Self {
flex_layout: false,
size_options: true,
opacity: true,
fill: true,
stroke: false,
effects: true,
export: true,
},
}
}
}
/// Whether each section currently paints — drives the layout
/// walk so when per-kind filtering hides a section, the rects
/// that follow shift up.
#[derive(Debug, Clone, Copy)]
pub struct VisibleSections {
pub flex_layout: bool,
pub size_options: bool,
/// `Opacity` from the Layer section.
pub opacity: bool,
/// Polygon side-count input in the Layer section.
pub polygon_sides: bool,
/// Ellipse start/sweep/inner-radius inputs in the Layer section.
pub ellipse_arc: bool,
/// `FillHex` from the Fill section.
pub fill: bool,
/// `StrokeHex` + `StrokeWidth` from the Stroke section.
pub stroke: bool,
/// Effects section paints (header + add chip + one block per
/// effect). Tracked because the export-rect walker needs to know
/// whether it consumed vertical space ahead of the Export
/// section. The per-effect geometry is driven by the `effects`
/// slice the walker takes alongside this struct.
pub effects: bool,
/// Export section paints — its scale / format dropdown rects
/// emit only when this is true.
pub export: bool,
/// Active fill type — affects fill-section body height so
/// the walk past Fill stays aligned with paint when the user
/// flips Solid / Gradient / Image.
pub fill_type: FillType,
/// Number of stops in the primary gradient body — drives
/// gradient-section row count so paint + hit-test agree on how
/// far the section reaches and which stop a click hits. `0` for
/// non-gradient fills.
pub gradient_stop_count: usize,
}
impl VisibleSections {
/// Every section visible — matches the legacy unfiltered layout.
pub const ALL: Self = Self {
flex_layout: true,
size_options: true,
opacity: true,
polygon_sides: false,
ellipse_arc: false,
fill: true,
stroke: true,
effects: true,
export: true,
fill_type: FillType::Solid,
gradient_stop_count: 0,
};
}
/// Height (px) of an effect card's title row — `投影` label on /// Height (px) of an effect card's title row — `投影` label on
/// the left + remove `—` icon on the right. /// the left + remove `—` icon on the right.
pub const EFFECT_TITLE_ROW_HEIGHT: f32 = INPUT_HEIGHT; pub const EFFECT_TITLE_ROW_HEIGHT: f32 = INPUT_HEIGHT;
@ -331,7 +187,7 @@ pub fn action_button_rects(
visible: VisibleSections, visible: VisibleSections,
effects: &[EffectSummary], effects: &[EffectSummary],
) -> Vec<(PropertyPanelAction, Rect)> { ) -> Vec<(PropertyPanelAction, Rect)> {
action_button_rects_with_fill_picker(panel_rect, visible, effects, false, false, false) action_button_rects_with_fill_picker(panel_rect, visible, effects, false, false, false, false)
} }
/// Height of one row in an Export-section inline select popup. /// Height of one row in an Export-section inline select popup.
@ -347,8 +203,9 @@ pub fn property_panel_content_height(
visible: VisibleSections, visible: VisibleSections,
effects: &[EffectSummary], effects: &[EffectSummary],
) -> f32 { ) -> f32 {
let actions = let actions = action_button_rects_with_fill_picker(
action_button_rects_with_fill_picker(panel_rect, visible, effects, false, false, false); panel_rect, visible, effects, false, false, false, false,
);
let inputs = editable_input_rects(panel_rect, visible); let inputs = editable_input_rects(panel_rect, visible);
let bottom = actions let bottom = actions
.iter() .iter()
@ -370,6 +227,7 @@ pub fn action_button_rects_with_fill_picker(
visible: VisibleSections, visible: VisibleSections,
effects: &[EffectSummary], effects: &[EffectSummary],
fill_picker_open: bool, fill_picker_open: bool,
font_family_picker_open: bool,
export_scale_picker_open: bool, export_scale_picker_open: bool,
export_format_picker_open: bool, export_format_picker_open: bool,
) -> Vec<(PropertyPanelAction, Rect)> { ) -> Vec<(PropertyPanelAction, Rect)> {
@ -382,14 +240,16 @@ pub fn action_button_rects_with_fill_picker(
let mut y = panel_rect.origin.y; let mut y = panel_rect.origin.y;
y += TAB_HEIGHT; y += TAB_HEIGHT;
y += HEADER_HEIGHT; y += HEADER_HEIGHT;
out.push(( if visible.create_component {
PropertyPanelAction::CreateComponent, out.push((
Rect { PropertyPanelAction::CreateComponent,
origin: Point2D::new(x0 + PAD_X, y + 8.0), Rect {
size: Point2D::new(usable_w, 36.0), origin: Point2D::new(x0 + PAD_X, y + 8.0),
}, size: Point2D::new(usable_w, 36.0),
)); },
y += 8.0 + 36.0 + 12.0; ));
y += 8.0 + 36.0 + 12.0;
}
// Position section. // Position section.
y += SECTION_HEADER_HEIGHT; y += SECTION_HEADER_HEIGHT;
y += INPUT_HEIGHT + 6.0; y += INPUT_HEIGHT + 6.0;
@ -398,26 +258,16 @@ pub fn action_button_rects_with_fill_picker(
if visible.flex_layout { if visible.flex_layout {
y += SECTION_HEADER_HEIGHT; y += SECTION_HEADER_HEIGHT;
let btn_w = 56.0; crate::widgets::property_panel_flex::push_flex_action_rects(
let gap = 8.0; &mut out,
let row_x = x0 + PAD_X; x0,
let modes = [ y,
FlexLayout::Free, w,
FlexLayout::Vertical, visible.flex_layout_mode,
FlexLayout::Horizontal, visible.layout_justify,
]; );
for (i, mode) in modes.iter().enumerate() { y += crate::widgets::property_panel_flex::flex_section_height(visible.flex_layout_mode)
let bx = row_x + i as f32 * (btn_w + gap); - SECTION_HEADER_HEIGHT;
out.push((
PropertyPanelAction::SetFlexLayout(*mode),
Rect {
origin: Point2D::new(bx, y),
size: Point2D::new(btn_w, 32.0),
},
));
}
y += 32.0 + 12.0;
y += SECTION_GAP;
} }
if visible.size_options { if visible.size_options {
@ -454,14 +304,50 @@ pub fn action_button_rects_with_fill_picker(
}, },
)); ));
y += row_h; y += row_h;
if visible.clip_content {
out.push((
PropertyPanelAction::ToggleSizeClipContent,
Rect {
origin: Point2D::new(x0 + PAD_X, y),
size: Point2D::new(usable_w, row_h),
},
));
y += row_h;
}
y += 12.0;
y += SECTION_GAP;
}
if visible.icon {
crate::widgets::property_panel_icon::push_icon_action_rects(&mut out, x0, y, w);
y += crate::widgets::property_panel_icon::icon_section_height();
}
if visible.text {
out.extend(crate::widgets::property_panel_text::text_action_rects(
x0, y, usable_w,
));
if font_family_picker_open {
out.extend(
crate::widgets::property_panel_text::font_family_picker_action_rects(
x0, y, usable_w,
),
);
}
y += crate::widgets::property_panel_text::text_section_height();
y += SECTION_GAP;
}
if visible.image {
y += SECTION_HEADER_HEIGHT;
out.push(( out.push((
PropertyPanelAction::ToggleSizeClipContent, PropertyPanelAction::ToggleImageFillPopover,
Rect { Rect {
origin: Point2D::new(x0 + PAD_X, y), origin: Point2D::new(x0 + PAD_X, y),
size: Point2D::new(usable_w, row_h), size: Point2D::new(usable_w, INPUT_HEIGHT),
}, },
)); ));
y += row_h + 12.0; y += INPUT_HEIGHT + 34.0;
y += SECTION_GAP; y += SECTION_GAP;
} }
@ -470,6 +356,13 @@ pub fn action_button_rects_with_fill_picker(
y += SECTION_GAP; y += SECTION_GAP;
} }
if visible.fill { if visible.fill {
out.push((
PropertyPanelAction::AddFill,
Rect {
origin: Point2D::new(x0 + w - PAD_X - 22.0, y),
size: Point2D::new(28.0, SECTION_HEADER_HEIGHT),
},
));
y += SECTION_HEADER_HEIGHT; y += SECTION_HEADER_HEIGHT;
// The head-row swatch is display-only — the colour picker // The head-row swatch is display-only — the colour picker
// opens from the hex-row swatch below (added further down), // opens from the hex-row swatch below (added further down),
@ -517,6 +410,13 @@ pub fn action_button_rects_with_fill_picker(
}, },
)); ));
} }
out.push((
PropertyPanelAction::RemoveFill,
Rect {
origin: Point2D::new(x0 + w - PAD_X - 22.0, y - INPUT_HEIGHT - 6.0),
size: Point2D::new(28.0, INPUT_HEIGHT),
},
));
if visible.fill_type == FillType::Image { if visible.fill_type == FillType::Image {
// Whole image-fill row opens the TS-parity image editor // Whole image-fill row opens the TS-parity image editor
// popover. The popover's upload well opens the file picker. // popover. The popover's upload well opens the file picker.
@ -540,6 +440,13 @@ pub fn action_button_rects_with_fill_picker(
if visible.fill_type == FillType::LinearGradient { if visible.fill_type == FillType::LinearGradient {
stop_y += INPUT_HEIGHT + 6.0; // Angle row sits above the stops header. stop_y += INPUT_HEIGHT + 6.0; // Angle row sits above the stops header.
} }
out.push((
PropertyPanelAction::AddGradientStop,
Rect {
origin: Point2D::new(x0 + w - PAD_X - 22.0, stop_y),
size: Point2D::new(28.0, SECTION_HEADER_HEIGHT),
},
));
stop_y += SECTION_HEADER_HEIGHT; // 色标 header stop_y += SECTION_HEADER_HEIGHT; // 色标 header
for index in 0..visible.gradient_stop_count { for index in 0..visible.gradient_stop_count {
out.push(( out.push((
@ -551,6 +458,15 @@ pub fn action_button_rects_with_fill_picker(
size: Point2D::new(28.0, INPUT_HEIGHT), size: Point2D::new(28.0, INPUT_HEIGHT),
}, },
)); ));
if visible.gradient_stop_count > 2 {
out.push((
PropertyPanelAction::RemoveGradientStop(index),
Rect {
origin: Point2D::new(x0 + w - PAD_X - 22.0, stop_y),
size: Point2D::new(28.0, INPUT_HEIGHT),
},
));
}
stop_y += INPUT_HEIGHT + 4.0; stop_y += INPUT_HEIGHT + 4.0;
} }
} }
@ -688,9 +604,14 @@ pub fn action_button_rects_with_fill_picker(
} }
} }
if export_format_picker_open { if export_format_picker_open {
let count = op_editor_core::ExportFormat::ALL.len() as f32; let formats = [
op_editor_core::ExportFormat::Png,
op_editor_core::ExportFormat::Jpeg,
op_editor_core::ExportFormat::Webp,
];
let count = formats.len() as f32;
let first_row_y = format_rect.origin.y - 4.0 - 6.0 - count * EXPORT_PICKER_ROW_H; let first_row_y = format_rect.origin.y - 4.0 - 6.0 - count * EXPORT_PICKER_ROW_H;
for (i, fmt) in op_editor_core::ExportFormat::ALL.into_iter().enumerate() { for (i, fmt) in formats.into_iter().enumerate() {
out.push(( out.push((
PropertyPanelAction::SetExportFormat(fmt), PropertyPanelAction::SetExportFormat(fmt),
Rect { Rect {

View file

@ -324,6 +324,7 @@ pub fn paint_position_section(
snapshot: &NodeSnapshot, snapshot: &NodeSnapshot,
edit: &EditContext<'_>, edit: &EditContext<'_>,
labels: &PropertyLabels, labels: &PropertyLabels,
show_radius: bool,
x: f32, x: f32,
y: f32, y: f32,
width: f32, width: f32,
@ -377,22 +378,24 @@ pub fn paint_position_section(
edit.focus == Some(PropertyFocus::Rotation), edit.focus == Some(PropertyFocus::Rotation),
edit.caret_at(PropertyFocus::Rotation), edit.caret_at(PropertyFocus::Rotation),
); );
// Corner radius (R) — editable input bound to Node::corner_radius if show_radius {
// via PropertyFocus::PositionR. // Corner radius (R) — editable input bound to Node::corner_radius
let r_rect = Rect { // via PropertyFocus::PositionR.
origin: Point2D::new(x + PAD_X + half_w + 8.0, y), let r_rect = Rect {
size: Point2D::new(half_w, INPUT_HEIGHT), origin: Point2D::new(x + PAD_X + half_w + 8.0, y),
}; size: Point2D::new(half_w, INPUT_HEIGHT),
let r_value = format!("{}", snapshot.corner_radius.round() as i32); };
paint_input_with_prefix_focused( let r_value = format!("{}", snapshot.corner_radius.round() as i32);
cx, paint_input_with_prefix_focused(
theme, cx,
r_rect, theme,
"R", r_rect,
edit.value_for(PropertyFocus::PositionR, &r_value), "R",
edit.focus == Some(PropertyFocus::PositionR), edit.value_for(PropertyFocus::PositionR, &r_value),
edit.caret_at(PropertyFocus::PositionR), edit.focus == Some(PropertyFocus::PositionR),
); edit.caret_at(PropertyFocus::PositionR),
);
}
y += INPUT_HEIGHT + 12.0; y += INPUT_HEIGHT + 12.0;
paint_section_divider(cx, theme, x, y, width); paint_section_divider(cx, theme, x, y, width);
y + SECTION_GAP y + SECTION_GAP
@ -466,6 +469,7 @@ pub fn paint_size_section(
edit: &EditContext<'_>, edit: &EditContext<'_>,
labels: &PropertyLabels, labels: &PropertyLabels,
flags: SizeFlags, flags: SizeFlags,
show_clip_content: bool,
x: f32, x: f32,
y: f32, y: f32,
width: f32, width: f32,
@ -541,16 +545,19 @@ pub fn paint_size_section(
flags.hug_height, flags.hug_height,
); );
y += row_h; y += row_h;
paint_check_row( if show_clip_content {
cx, paint_check_row(
theme, cx,
x + PAD_X, theme,
y, x + PAD_X,
usable_w, y,
labels.clip_content, usable_w,
flags.clip_content, labels.clip_content,
); flags.clip_content,
y += row_h + 12.0; );
y += row_h;
}
y += 12.0;
paint_section_divider(cx, theme, x, y, width); paint_section_divider(cx, theme, x, y, width);
y + SECTION_GAP y + SECTION_GAP
} }

View file

@ -1,8 +1,16 @@
//! Snapshot extraction for the right-rail `PropertyPanel`. //! Snapshot extraction for the right-rail `PropertyPanel`.
use crate::layout_scene::{NodeKind, SceneStroke}; use crate::layout_scene::{NodeKind, SceneStroke};
use crate::widgets::property_panel_action::{
LayoutAlignValue, LayoutJustifyValue, TextAlignValue, TextGrowthValue, TextVerticalAlignValue,
};
use crate::Color; use crate::Color;
use jian_ops_schema::node::base::NumberOrExpression;
use jian_ops_schema::node::container::LayoutMode;
use jian_ops_schema::node::container::{AlignItems, JustifyContent, Padding};
use jian_ops_schema::node::text::{FontWeight, TextAlign, TextAlignVertical, TextGrowth};
use jian_ops_schema::node::PenNode; use jian_ops_schema::node::PenNode;
use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
use op_editor_core::pen_node_ext::PenNodeExt; use op_editor_core::pen_node_ext::PenNodeExt;
use op_editor_core::EditorState; use op_editor_core::EditorState;
@ -56,6 +64,22 @@ pub struct NodeSnapshot {
pub polygon_sides: Option<u32>, pub polygon_sides: Option<u32>,
/// Ellipse arc controls, only present for Ellipse selections. /// Ellipse arc controls, only present for Ellipse selections.
pub ellipse_arc: Option<EllipseArcSummary>, pub ellipse_arc: Option<EllipseArcSummary>,
pub flex_layout: op_editor_core::FlexLayout,
pub layout_justify: LayoutJustifyValue,
pub layout_align: LayoutAlignValue,
pub layout_gap: f32,
pub layout_padding: LayoutPaddingSummary,
pub size_fill_width: bool,
pub size_fill_height: bool,
pub size_hug_width: bool,
pub size_hug_height: bool,
pub size_clip_content: bool,
pub can_clip_content: bool,
pub has_corner_radius: bool,
pub can_create_component: bool,
pub is_image_node: bool,
pub icon: Option<IconSummary>,
pub text: Option<TextSummary>,
pub fill: Option<Color>, pub fill: Option<Color>,
/// Primary solid-fill opacity in `[0.0, 1.0]` — the Fill /// Primary solid-fill opacity in `[0.0, 1.0]` — the Fill
/// section's `100 %` paints `fill_opacity * 100`. /// section's `100 %` paints `fill_opacity * 100`.
@ -89,6 +113,53 @@ pub struct EllipseArcSummary {
pub inner_percent: f32, pub inner_percent: f32,
} }
#[derive(Debug, Clone, PartialEq)]
pub struct TextSummary {
pub font_family: String,
pub font_size: f32,
pub font_weight: u16,
pub line_height_percent: f32,
pub letter_spacing: f32,
pub align: TextAlignValue,
pub vertical_align: TextVerticalAlignValue,
pub growth: TextGrowthValue,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IconSummary {
pub family: String,
pub name: String,
pub icon_id: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutPaddingSummary {
pub top: f32,
pub right: f32,
pub bottom: f32,
pub left: f32,
}
impl LayoutPaddingSummary {
pub const ZERO: Self = Self {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
};
pub fn value_for(self, focus: op_editor_core::PropertyFocus) -> Option<f32> {
use op_editor_core::PropertyFocus as F;
match focus {
F::PaddingTop => Some(self.top),
F::PaddingRight => Some(self.right),
F::PaddingBottom => Some(self.bottom),
F::PaddingLeft => Some(self.left),
_ => None,
}
}
}
/// One gradient stop summary for the Fill section. /// One gradient stop summary for the Fill section.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GradientStopSummary { pub struct GradientStopSummary {
@ -228,6 +299,22 @@ impl NodeSnapshot {
corner_radius: 0.0, corner_radius: 0.0,
polygon_sides: None, polygon_sides: None,
ellipse_arc: None, ellipse_arc: None,
flex_layout: op_editor_core::FlexLayout::Free,
layout_justify: LayoutJustifyValue::Start,
layout_align: LayoutAlignValue::Start,
layout_gap: 0.0,
layout_padding: LayoutPaddingSummary::ZERO,
size_fill_width: false,
size_fill_height: false,
size_hug_width: false,
size_hug_height: false,
size_clip_content: false,
can_clip_content: false,
has_corner_radius: false,
can_create_component: false,
is_image_node: false,
icon: None,
text: None,
fill: None, fill: None,
fill_opacity: 1.0, fill_opacity: 1.0,
stroke: None, stroke: None,
@ -276,12 +363,29 @@ impl NodeSnapshot {
corner_radius, corner_radius,
polygon_sides: polygon_sides_of(node), polygon_sides: polygon_sides_of(node),
ellipse_arc: ellipse_arc_of(node), ellipse_arc: ellipse_arc_of(node),
flex_layout: flex_layout_of(node),
layout_justify: layout_justify_of(node),
layout_align: layout_align_of(node),
layout_gap: layout_gap_of(node),
layout_padding: layout_padding_of(node),
size_fill_width: sizing_is(node_width_sizing(node), SizingKeyword::FillContainer),
size_fill_height: sizing_is(node_height_sizing(node), SizingKeyword::FillContainer),
size_hug_width: sizing_is(node_width_sizing(node), SizingKeyword::FitContent),
size_hug_height: sizing_is(node_height_sizing(node), SizingKeyword::FitContent),
size_clip_content: clip_content_of(node),
can_clip_content: can_clip_content(node),
has_corner_radius: has_corner_radius(node),
can_create_component: can_create_component(node),
is_image_node: matches!(node, PenNode::Image(_)),
icon: icon_summary_of(node),
text: text_summary_of(node),
fill, fill,
fill_opacity: op_editor_core::first_solid_fill_opacity(node), fill_opacity: op_editor_core::first_solid_fill_opacity(node),
stroke, stroke,
gradient_angle: gradient_angle_of(node), gradient_angle: gradient_angle_of(node),
gradient_stops: gradient_stops_of(node), gradient_stops: gradient_stops_of(node),
image_fill: op_editor_core::first_image_fill_summary(node), image_fill: op_editor_core::first_image_fill_summary(node)
.or_else(|| op_editor_core::image_node_summary(node)),
effects: op_editor_core::node_effects(node) effects: op_editor_core::node_effects(node)
.iter() .iter()
.map(EffectSummary::from_pen_effect) .map(EffectSummary::from_pen_effect)
@ -309,6 +413,250 @@ fn ellipse_arc_of(node: &PenNode) -> Option<EllipseArcSummary> {
} }
} }
fn container_layout(node: &PenNode) -> Option<&LayoutMode> {
match node {
PenNode::Frame(n) => n.container.layout.as_ref(),
PenNode::Group(n) => n.container.layout.as_ref(),
PenNode::Rectangle(n) => n.container.layout.as_ref(),
_ => None,
}
}
fn flex_layout_of(node: &PenNode) -> op_editor_core::FlexLayout {
match container_layout(node) {
Some(LayoutMode::Vertical) => op_editor_core::FlexLayout::Vertical,
Some(LayoutMode::Horizontal) => op_editor_core::FlexLayout::Horizontal,
Some(LayoutMode::None) | None => op_editor_core::FlexLayout::Free,
}
}
fn layout_justify_of(node: &PenNode) -> LayoutJustifyValue {
let value = match node {
PenNode::Frame(n) => n.container.justify_content.as_ref(),
PenNode::Group(n) => n.container.justify_content.as_ref(),
PenNode::Rectangle(n) => n.container.justify_content.as_ref(),
_ => None,
};
match value.unwrap_or(&JustifyContent::Start) {
JustifyContent::Start => LayoutJustifyValue::Start,
JustifyContent::Center => LayoutJustifyValue::Center,
JustifyContent::End => LayoutJustifyValue::End,
JustifyContent::SpaceBetween => LayoutJustifyValue::SpaceBetween,
JustifyContent::SpaceAround => LayoutJustifyValue::SpaceAround,
}
}
fn layout_align_of(node: &PenNode) -> LayoutAlignValue {
let value = match node {
PenNode::Frame(n) => n.container.align_items.as_ref(),
PenNode::Group(n) => n.container.align_items.as_ref(),
PenNode::Rectangle(n) => n.container.align_items.as_ref(),
_ => None,
};
match value.unwrap_or(&AlignItems::Start) {
AlignItems::Start => LayoutAlignValue::Start,
AlignItems::Center => LayoutAlignValue::Center,
AlignItems::End => LayoutAlignValue::End,
}
}
fn layout_gap_of(node: &PenNode) -> f32 {
let gap = match node {
PenNode::Frame(n) => n.container.gap.as_ref(),
PenNode::Group(n) => n.container.gap.as_ref(),
PenNode::Rectangle(n) => n.container.gap.as_ref(),
_ => None,
};
match gap {
Some(NumberOrExpression::Number(v)) => *v as f32,
Some(NumberOrExpression::Expression(_)) | None => 0.0,
}
}
fn layout_padding_of(node: &PenNode) -> LayoutPaddingSummary {
let padding = match node {
PenNode::Frame(n) => n.container.padding.as_ref(),
PenNode::Group(n) => n.container.padding.as_ref(),
PenNode::Rectangle(n) => n.container.padding.as_ref(),
_ => None,
};
match padding {
Some(Padding::Uniform(v)) => {
let v = *v as f32;
LayoutPaddingSummary {
top: v,
right: v,
bottom: v,
left: v,
}
}
Some(Padding::XY(v)) => LayoutPaddingSummary {
top: v[0] as f32,
right: v[1] as f32,
bottom: v[0] as f32,
left: v[1] as f32,
},
Some(Padding::LtrB(v)) => LayoutPaddingSummary {
top: v[0] as f32,
right: v[1] as f32,
bottom: v[2] as f32,
left: v[3] as f32,
},
Some(Padding::Expression(_)) | None => LayoutPaddingSummary::ZERO,
}
}
fn node_width_sizing(node: &PenNode) -> Option<&SizingBehavior> {
match node {
PenNode::Frame(n) => n.container.width.as_ref(),
PenNode::Group(n) => n.container.width.as_ref(),
PenNode::Rectangle(n) => n.container.width.as_ref(),
PenNode::Ellipse(n) => n.width.as_ref(),
PenNode::Polygon(n) => n.width.as_ref(),
PenNode::Path(n) => n.width.as_ref(),
PenNode::Text(n) => n.width.as_ref(),
PenNode::TextInput(n) => n.width.as_ref(),
PenNode::Image(n) => n.width.as_ref(),
PenNode::IconFont(n) => n.width.as_ref(),
PenNode::Line(_) | PenNode::Ref(_) => None,
}
}
fn node_height_sizing(node: &PenNode) -> Option<&SizingBehavior> {
match node {
PenNode::Frame(n) => n.container.height.as_ref(),
PenNode::Group(n) => n.container.height.as_ref(),
PenNode::Rectangle(n) => n.container.height.as_ref(),
PenNode::Ellipse(n) => n.height.as_ref(),
PenNode::Polygon(n) => n.height.as_ref(),
PenNode::Path(n) => n.height.as_ref(),
PenNode::Text(n) => n.height.as_ref(),
PenNode::TextInput(n) => n.height.as_ref(),
PenNode::Image(n) => n.height.as_ref(),
PenNode::IconFont(n) => n.height.as_ref(),
PenNode::Line(_) | PenNode::Ref(_) => None,
}
}
fn sizing_is(sizing: Option<&SizingBehavior>, keyword: SizingKeyword) -> bool {
matches!(sizing, Some(SizingBehavior::Keyword(k)) if *k == keyword)
}
fn clip_content_of(node: &PenNode) -> bool {
match node {
PenNode::Frame(n) => n.container.clip_content.unwrap_or(false),
PenNode::Group(n) => n.container.clip_content.unwrap_or(false),
PenNode::Rectangle(n) => n.container.clip_content.unwrap_or(false),
_ => false,
}
}
fn can_clip_content(node: &PenNode) -> bool {
matches!(
node,
PenNode::Frame(_) | PenNode::Group(_) | PenNode::Rectangle(_)
)
}
fn icon_summary_of(node: &PenNode) -> Option<IconSummary> {
match node {
PenNode::IconFont(n) => {
let family = n
.icon_font_family
.clone()
.unwrap_or_else(|| "lucide".to_string());
Some(IconSummary {
icon_id: format!("{}:{}", family, n.icon_font_name),
family,
name: n.icon_font_name.clone(),
})
}
PenNode::Path(n) => {
let icon_id = n.icon_id.as_ref()?;
let (family, name) = icon_id
.split_once(':')
.map(|(family, name)| (family.to_string(), name.to_string()))
.unwrap_or_else(|| ("lucide".to_string(), icon_id.clone()));
Some(IconSummary {
family,
name,
icon_id: icon_id.clone(),
})
}
_ => None,
}
}
fn has_corner_radius(node: &PenNode) -> bool {
matches!(
node,
PenNode::Frame(_)
| PenNode::Rectangle(_)
| PenNode::Ellipse(_)
| PenNode::Polygon(_)
| PenNode::Image(_)
)
}
fn can_create_component(node: &PenNode) -> bool {
matches!(
node,
PenNode::Frame(_) | PenNode::Group(_) | PenNode::Rectangle(_) | PenNode::Ref(_)
)
}
fn text_summary_of(node: &PenNode) -> Option<TextSummary> {
let PenNode::Text(t) = node else {
return None;
};
Some(TextSummary {
font_family: t
.font_family
.clone()
.unwrap_or_else(|| "Inter, sans-serif".to_string()),
font_size: t.font_size.unwrap_or(16.0) as f32,
font_weight: font_weight_value(t.font_weight.as_ref()),
line_height_percent: (t.line_height.unwrap_or(1.2) * 100.0) as f32,
letter_spacing: t.letter_spacing.unwrap_or(0.0) as f32,
align: match t.text_align.as_ref().unwrap_or(&TextAlign::Left) {
TextAlign::Left => TextAlignValue::Left,
TextAlign::Center => TextAlignValue::Center,
TextAlign::Right => TextAlignValue::Right,
TextAlign::Justify => TextAlignValue::Justify,
},
vertical_align: match t
.text_align_vertical
.as_ref()
.unwrap_or(&TextAlignVertical::Top)
{
TextAlignVertical::Top => TextVerticalAlignValue::Top,
TextAlignVertical::Middle => TextVerticalAlignValue::Middle,
TextAlignVertical::Bottom => TextVerticalAlignValue::Bottom,
},
growth: match t.text_growth.as_ref().unwrap_or(&TextGrowth::FixedWidth) {
TextGrowth::Auto => TextGrowthValue::Auto,
TextGrowth::FixedWidth => TextGrowthValue::FixedWidth,
TextGrowth::FixedWidthHeight => TextGrowthValue::FixedWidthHeight,
},
})
}
fn font_weight_value(weight: Option<&FontWeight>) -> u16 {
match weight {
Some(FontWeight::Number(n)) => (*n).clamp(1, 1000) as u16,
Some(FontWeight::Keyword(s)) => match s.as_str() {
"thin" => 100,
"light" => 300,
"medium" => 500,
"semibold" => 600,
"bold" => 700,
"black" => 900,
_ => 400,
},
None => 400,
}
}
/// LinearGradient `angle` for the node's first fill, when it has /// LinearGradient `angle` for the node's first fill, when it has
/// one. Falls back to `0.0` (canonical default, bottom→top) when /// one. Falls back to `0.0` (canonical default, bottom→top) when
/// the body omits an explicit angle. `None` for non-linear primary /// the body omits an explicit angle. `None` for non-linear primary
@ -351,11 +699,16 @@ fn container_corner_radius(node: &PenNode) -> f32 {
PenNode::Frame(n) => n.container.corner_radius.as_ref(), PenNode::Frame(n) => n.container.corner_radius.as_ref(),
PenNode::Group(n) => n.container.corner_radius.as_ref(), PenNode::Group(n) => n.container.corner_radius.as_ref(),
PenNode::Rectangle(n) => n.container.corner_radius.as_ref(), PenNode::Rectangle(n) => n.container.corner_radius.as_ref(),
PenNode::Image(n) => n.corner_radius.as_ref(),
_ => None, _ => None,
}; };
match cr { match cr {
Some(CornerRadius::Uniform(r)) => *r as f32, Some(CornerRadius::Uniform(r)) => *r as f32,
Some(CornerRadius::PerCorner(c)) => c[0] as f32, Some(CornerRadius::PerCorner(c)) => c[0] as f32,
None => 0.0, None => match node {
PenNode::Ellipse(n) => n.corner_radius.unwrap_or(0.0) as f32,
PenNode::Polygon(n) => n.corner_radius.unwrap_or(0.0) as f32,
_ => 0.0,
},
} }
} }

View file

@ -71,9 +71,18 @@ fn group_snapshot_aggregates_child_bounds() {
fn visible_for(panel: &PropertyPanel) -> sections::VisibleSections { fn visible_for(panel: &PropertyPanel) -> sections::VisibleSections {
let caps = SectionCapabilities::for_kind(&panel.snapshot.kind_variant); let caps = SectionCapabilities::for_kind(&panel.snapshot.kind_variant);
sections::VisibleSections { sections::VisibleSections {
create_component: caps.create_component && panel.snapshot.can_create_component,
flex_layout: caps.flex_layout, flex_layout: caps.flex_layout,
flex_layout_mode: panel.snapshot.flex_layout,
layout_justify: panel.snapshot.layout_justify,
layout_align: panel.snapshot.layout_align,
size_options: caps.size_options, size_options: caps.size_options,
clip_content: panel.snapshot.can_clip_content,
text: caps.text && panel.snapshot.text.is_some(),
icon: panel.snapshot.icon.is_some(),
image: caps.image && panel.snapshot.is_image_node,
opacity: caps.opacity, opacity: caps.opacity,
corner_radius: panel.snapshot.has_corner_radius,
polygon_sides: panel.snapshot.polygon_sides.is_some(), polygon_sides: panel.snapshot.polygon_sides.is_some(),
ellipse_arc: panel.snapshot.ellipse_arc.is_some(), ellipse_arc: panel.snapshot.ellipse_arc.is_some(),
fill: caps.fill, fill: caps.fill,
@ -175,6 +184,7 @@ fn hit_test_action_export_section_returns_picker_toggles() {
false, false,
false, false,
false, false,
false,
); );
// The Export section emits a scale-dropdown + a format-dropdown // The Export section emits a scale-dropdown + a format-dropdown
// toggle rect — clicking neither opens the Export modal. // toggle rect — clicking neither opens the Export modal.
@ -212,6 +222,99 @@ fn hit_test_action_export_section_returns_picker_toggles() {
); );
} }
#[test]
fn flex_advanced_rows_do_not_overlap_gap_modes() {
let mut state = state_from(
r##"{ "version": "0.8.0", "children": [
{"type":"frame","id":"f","name":"Frame",
"x":40,"y":40,"width":360,"height":240,
"layout":"horizontal","gap":0,
"children":[]}
]}"##,
);
state.set_single_selection(NodeId::new("f"));
let panel = PropertyPanel::for_selection(&state).expect("frame panel");
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(280.0, 1200.0),
};
let visible = visible_for(&panel);
let actions = sections::action_button_rects_with_fill_picker(
rect,
visible,
&panel.snapshot.effects,
false,
false,
false,
false,
);
let last_gap_mode = actions
.iter()
.find(|(action, _)| {
matches!(
action,
PropertyPanelAction::SetLayoutJustify(
super::property_panel::LayoutJustifyValue::SpaceAround
)
)
})
.map(|(_, r)| *r)
.expect("space-around hit rect");
let padding_top = sections::editable_input_rects(rect, visible)
.into_iter()
.find(|(focus, _)| *focus == op_editor_core::PropertyFocus::PaddingTop)
.map(|(_, r)| r)
.expect("padding top input rect");
assert!(
padding_top.origin.y >= last_gap_mode.origin.y + last_gap_mode.size.y + 18.0,
"padding inputs must start below the full gap-mode column"
);
}
#[test]
fn font_family_picker_rows_are_clickable() {
let mut state = EditorState::sample();
state.set_single_selection(NodeId::new("n11"));
state.editor_ui.font_family_picker_open = true;
let panel = PropertyPanel::for_selection(&state).expect("text panel");
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(280.0, 1200.0),
};
let rects = sections::action_button_rects_with_fill_picker(
rect,
visible_for(&panel),
&panel.snapshot.effects,
false,
true,
false,
false,
);
let georgia = rects
.iter()
.find(|(action, _)| {
matches!(
action,
PropertyPanelAction::SetFontFamily(
super::property_panel::FontFamilyChoice::Georgia
)
)
})
.map(|(_, r)| *r)
.expect("Georgia font row");
let center = Point2D::new(
georgia.origin.x + georgia.size.x / 2.0,
georgia.origin.y + georgia.size.y / 2.0,
);
assert!(matches!(
panel.hit_test_action(rect, center),
Some(PropertyPanelAction::SetFontFamily(
super::property_panel::FontFamilyChoice::Georgia
))
));
}
#[test] #[test]
fn export_scale_picker_open_emits_option_rows() { fn export_scale_picker_open_emits_option_rows() {
let mut state = EditorState::sample(); let mut state = EditorState::sample();
@ -229,6 +332,7 @@ fn export_scale_picker_open_emits_option_rows() {
visible_for(&panel), visible_for(&panel),
&panel.snapshot.effects, &panel.snapshot.effects,
false, false,
false,
true, true,
false, false,
); );
@ -440,6 +544,7 @@ fn image_fill_body_click_opens_the_image_popover() {
false, false,
false, false,
false, false,
false,
); );
let body = rects let body = rects
.iter() .iter()

View file

@ -0,0 +1,621 @@
//! Text-specific property section for the native right panel.
use crate::theme::Theme;
use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::property_panel::{
FontFamilyChoice, NodeSnapshot, PropertyPanelAction, TextAlignValue, TextGrowthValue,
TextVerticalAlignValue,
};
use crate::widgets::property_panel_inputs::{
paint_input_with_prefix_focused, paint_section_divider, paint_section_label, to_jian_color,
INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT,
};
use crate::widgets::property_panel_sections::EditContext;
use crate::widgets::PaintCx;
use crate::{Point2D, Rect, TextLayout};
use op_editor_core::PropertyFocus;
const FAMILY_ROW_GAP: f32 = 6.0;
const ALIGN_LABEL_H: f32 = 18.0;
const BUTTON_H: f32 = 28.0;
const TEXT_LAYOUT_BLOCK_H: f32 = SECTION_HEADER_HEIGHT + BUTTON_H + 12.0;
pub fn text_section_height() -> f32 {
TEXT_LAYOUT_BLOCK_H
+ SECTION_HEADER_HEIGHT
+ INPUT_HEIGHT
+ FAMILY_ROW_GAP
+ INPUT_HEIGHT
+ 6.0
+ INPUT_HEIGHT
+ 8.0
+ ALIGN_LABEL_H
+ BUTTON_H
+ 6.0
+ ALIGN_LABEL_H
+ BUTTON_H
+ 12.0
}
pub fn push_text_input_rects(
rects: &mut Vec<(PropertyFocus, Rect)>,
x0: f32,
y: f32,
usable_w: f32,
) {
let half_w = (usable_w - 8.0) / 2.0;
let mut y = y + TEXT_LAYOUT_BLOCK_H + SECTION_HEADER_HEIGHT + INPUT_HEIGHT + FAMILY_ROW_GAP;
rects.push((
PropertyFocus::FontWeight,
Rect {
origin: Point2D::new(x0 + PAD_X, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
));
rects.push((
PropertyFocus::FontSize,
Rect {
origin: Point2D::new(x0 + PAD_X + half_w + 8.0, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
));
y += INPUT_HEIGHT + 6.0;
rects.push((
PropertyFocus::LineHeight,
Rect {
origin: Point2D::new(x0 + PAD_X, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
));
rects.push((
PropertyFocus::LetterSpacing,
Rect {
origin: Point2D::new(x0 + PAD_X + half_w + 8.0, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
));
}
pub fn text_action_rects(x0: f32, y: f32, usable_w: f32) -> Vec<(PropertyPanelAction, Rect)> {
let mut out = Vec::new();
let growth_w = (usable_w - 2.0 * 6.0) / 3.0;
let growth_y = y + SECTION_HEADER_HEIGHT;
let growth_actions = [
(
PropertyPanelAction::SetTextGrowth(TextGrowthValue::Auto),
0_usize,
),
(
PropertyPanelAction::SetTextGrowth(TextGrowthValue::FixedWidth),
1_usize,
),
(
PropertyPanelAction::SetTextGrowth(TextGrowthValue::FixedWidthHeight),
2_usize,
),
];
for (action, i) in growth_actions {
out.push((
action,
Rect {
origin: Point2D::new(x0 + PAD_X + i as f32 * (growth_w + 6.0), growth_y),
size: Point2D::new(growth_w, BUTTON_H),
},
));
}
out.push((
PropertyPanelAction::ToggleFontFamilyPicker,
Rect {
origin: Point2D::new(x0 + PAD_X, y + TEXT_LAYOUT_BLOCK_H + SECTION_HEADER_HEIGHT),
size: Point2D::new(usable_w, INPUT_HEIGHT),
},
));
let mut y = y
+ TEXT_LAYOUT_BLOCK_H
+ SECTION_HEADER_HEIGHT
+ INPUT_HEIGHT
+ FAMILY_ROW_GAP
+ INPUT_HEIGHT
+ 6.0
+ INPUT_HEIGHT
+ 8.0
+ ALIGN_LABEL_H;
let h_buttons = [
(PropertyPanelAction::SetTextAlign(TextAlignValue::Left), 0),
(PropertyPanelAction::SetTextAlign(TextAlignValue::Center), 1),
(PropertyPanelAction::SetTextAlign(TextAlignValue::Right), 2),
(
PropertyPanelAction::SetTextAlign(TextAlignValue::Justify),
3,
),
];
let h_w = (usable_w - 3.0 * 6.0) / 4.0;
for (action, i) in h_buttons {
out.push((
action,
Rect {
origin: Point2D::new(x0 + PAD_X + i as f32 * (h_w + 6.0), y),
size: Point2D::new(h_w, BUTTON_H),
},
));
}
y += BUTTON_H + 6.0 + ALIGN_LABEL_H;
let v_buttons = [
(
PropertyPanelAction::SetTextVerticalAlign(TextVerticalAlignValue::Top),
0,
),
(
PropertyPanelAction::SetTextVerticalAlign(TextVerticalAlignValue::Middle),
1,
),
(
PropertyPanelAction::SetTextVerticalAlign(TextVerticalAlignValue::Bottom),
2,
),
];
let v_w = (usable_w - 2.0 * 6.0) / 3.0;
for (action, i) in v_buttons {
out.push((
action,
Rect {
origin: Point2D::new(x0 + PAD_X + i as f32 * (v_w + 6.0), y),
size: Point2D::new(v_w, BUTTON_H),
},
));
}
out
}
pub fn font_family_picker_action_rects(
x0: f32,
y: f32,
usable_w: f32,
) -> Vec<(PropertyPanelAction, Rect)> {
let family_y = y + TEXT_LAYOUT_BLOCK_H + SECTION_HEADER_HEIGHT + INPUT_HEIGHT + 4.0;
FontFamilyChoice::ALL
.into_iter()
.enumerate()
.map(|(i, choice)| {
(
PropertyPanelAction::SetFontFamily(choice),
Rect {
origin: Point2D::new(x0 + PAD_X, family_y + i as f32 * 28.0),
size: Point2D::new(usable_w, 28.0),
},
)
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub fn paint_text_section(
cx: &mut PaintCx<'_>,
theme: &Theme,
snapshot: &NodeSnapshot,
edit: &EditContext<'_>,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
) -> f32 {
let Some(text) = snapshot.text.as_ref() else {
return y;
};
let mut y = paint_section_label(
cx,
theme,
op_i18n::translate(locale, "textLayout.title"),
x,
y,
width,
);
y = paint_text_growth_row(cx, theme, locale, x, y, width, text.growth);
y += 12.0;
let mut y = paint_section_label(
cx,
theme,
op_i18n::translate(locale, "text.typography"),
x,
y,
width,
);
let usable_w = width - PAD_X * 2.0;
let family_rect = Rect {
origin: Point2D::new(x + PAD_X, y),
size: Point2D::new(usable_w, INPUT_HEIGHT),
};
cx.backend
.fill_round_rect(family_rect, INPUT_RADIUS, theme.muted);
let family = TextLayout::single_run(
&text.font_family,
"system-ui",
12.0,
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&family,
Point2D::new(family_rect.origin.x + 10.0, family_rect.origin.y + 19.0),
);
draw_icon(
cx.backend,
Icon::ChevronDown,
Point2D::new(
family_rect.origin.x + family_rect.size.x - 18.0,
family_rect.origin.y + 8.0,
),
14.0,
theme.muted_foreground,
1.5,
);
y += INPUT_HEIGHT + FAMILY_ROW_GAP;
let half_w = (usable_w - 8.0) / 2.0;
let weight = text.font_weight.to_string();
paint_input_with_prefix_focused(
cx,
theme,
Rect {
origin: Point2D::new(x + PAD_X, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
"W",
edit.value_for(PropertyFocus::FontWeight, &weight),
edit.focus == Some(PropertyFocus::FontWeight),
edit.caret_at(PropertyFocus::FontWeight),
);
let font_size = format_panel_number(text.font_size);
paint_input_with_prefix_focused(
cx,
theme,
Rect {
origin: Point2D::new(x + PAD_X + half_w + 8.0, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
"S",
edit.value_for(PropertyFocus::FontSize, &font_size),
edit.focus == Some(PropertyFocus::FontSize),
edit.caret_at(PropertyFocus::FontSize),
);
y += INPUT_HEIGHT + 6.0;
let line_height = format_panel_number(text.line_height_percent);
paint_input_with_prefix_focused(
cx,
theme,
Rect {
origin: Point2D::new(x + PAD_X, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
"LH",
edit.value_for(PropertyFocus::LineHeight, &line_height),
edit.focus == Some(PropertyFocus::LineHeight),
edit.caret_at(PropertyFocus::LineHeight),
);
let letter_spacing = format_panel_number(text.letter_spacing);
paint_input_with_prefix_focused(
cx,
theme,
Rect {
origin: Point2D::new(x + PAD_X + half_w + 8.0, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
"LS",
edit.value_for(PropertyFocus::LetterSpacing, &letter_spacing),
edit.focus == Some(PropertyFocus::LetterSpacing),
edit.caret_at(PropertyFocus::LetterSpacing),
);
y += INPUT_HEIGHT + 8.0;
y = paint_horizontal_align_row(cx, theme, locale, x, y, width, text.align);
y = paint_vertical_align_row(cx, theme, locale, x, y + 6.0, width, text.vertical_align);
y += 12.0;
paint_section_divider(cx, theme, x, y, width);
y + SECTION_GAP
}
pub fn paint_font_family_picker(
cx: &mut PaintCx<'_>,
theme: &Theme,
panel_rect: Rect,
visible: crate::widgets::property_panel_layout::VisibleSections,
active_family: &str,
) {
let x0 = panel_rect.origin.x;
let w = panel_rect.size.x;
let usable_w = w - PAD_X * 2.0;
let Some(text_y) = text_section_top(panel_rect, visible) else {
return;
};
let rows = font_family_picker_action_rects(x0, text_y, usable_w);
if rows.is_empty() {
return;
}
let first = rows.first().map(|(_, r)| *r).unwrap();
let last = rows.last().map(|(_, r)| *r).unwrap();
let pop = Rect {
origin: Point2D::new(first.origin.x, first.origin.y - 6.0),
size: Point2D::new(
first.size.x,
last.origin.y + last.size.y - first.origin.y + 12.0,
),
};
cx.backend.fill_round_rect(pop, 8.0, theme.popover);
cx.backend.stroke_round_rect(pop, 8.0, theme.border, 1.0);
let active = display_font_family(active_family);
for (action, row) in rows {
let PropertyPanelAction::SetFontFamily(choice) = action else {
continue;
};
let is_active = choice.family() == active;
if is_active {
cx.backend
.fill_round_rect(row, 6.0, theme.row_selected_primary);
}
let label = TextLayout::single_run(
choice.family(),
choice.family(),
12.0,
to_jian_color(if is_active {
theme.primary
} else {
theme.foreground
}),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&label,
Point2D::new(row.origin.x + 10.0, row.origin.y + 19.0),
);
if is_active {
draw_icon(
cx.backend,
Icon::Check,
Point2D::new(row.origin.x + row.size.x - 22.0, row.origin.y + 7.0),
14.0,
theme.primary,
1.6,
);
}
}
}
fn text_section_top(
panel_rect: Rect,
visible: crate::widgets::property_panel_layout::VisibleSections,
) -> Option<f32> {
if !visible.text {
return None;
}
let mut y = panel_rect.origin.y;
y += crate::widgets::property_panel_inputs::TAB_HEIGHT;
y += crate::widgets::property_panel_inputs::HEADER_HEIGHT;
if visible.create_component {
y += 8.0 + 36.0 + 12.0;
}
y += SECTION_HEADER_HEIGHT;
y += INPUT_HEIGHT + 6.0;
y += INPUT_HEIGHT + 12.0;
y += SECTION_GAP;
if visible.flex_layout {
y += crate::widgets::property_panel_flex::flex_section_height(visible.flex_layout_mode);
}
if visible.size_options {
y += SECTION_HEADER_HEIGHT;
y += INPUT_HEIGHT + 10.0;
y += 22.0 * if visible.clip_content { 3.0 } else { 2.0 };
y += 12.0 + SECTION_GAP;
}
if visible.icon {
y += crate::widgets::property_panel_icon::icon_section_height();
}
Some(y)
}
fn paint_text_growth_row(
cx: &mut PaintCx<'_>,
theme: &Theme,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
active: TextGrowthValue,
) -> f32 {
let usable_w = width - PAD_X * 2.0;
let gap = 6.0;
let button_w = (usable_w - gap * 2.0) / 3.0;
let specs = [
(TextGrowthValue::Auto, "textLayout.autoWidth"),
(TextGrowthValue::FixedWidth, "textLayout.autoHeight"),
(TextGrowthValue::FixedWidthHeight, "textLayout.fixed"),
];
for (i, (value, key)) in specs.iter().enumerate() {
let rect = Rect {
origin: Point2D::new(x + PAD_X + i as f32 * (button_w + gap), y),
size: Point2D::new(button_w, BUTTON_H),
};
let is_active = *value == active;
if is_active {
cx.backend.fill_round_rect(rect, 6.0, theme.primary);
} else {
cx.backend.fill_round_rect(rect, 6.0, theme.muted);
}
let color = if is_active {
theme.primary_foreground
} else {
theme.muted_foreground
};
let label = op_i18n::translate(locale, key);
let layout = TextLayout::single_run(
label,
"system-ui",
10.0,
to_jian_color(color),
Point2D::new(0.0, 0.0),
);
let label_w = cx.backend.measure_text(label, 10.0);
cx.backend.draw_text(
&layout,
Point2D::new(
rect.origin.x + (rect.size.x - label_w) / 2.0,
rect.origin.y + 18.0,
),
);
}
y + BUTTON_H
}
fn paint_horizontal_align_row(
cx: &mut PaintCx<'_>,
theme: &Theme,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
active: TextAlignValue,
) -> f32 {
paint_align_row(
cx,
theme,
locale,
x,
y,
width,
"text.horizontal",
&H_ALIGN_SPECS,
active,
)
}
fn paint_vertical_align_row(
cx: &mut PaintCx<'_>,
theme: &Theme,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
active: TextVerticalAlignValue,
) -> f32 {
paint_align_row(
cx,
theme,
locale,
x,
y,
width,
"text.vertical",
&V_ALIGN_SPECS,
active,
)
}
#[allow(clippy::too_many_arguments)]
fn paint_align_row<T: Copy + PartialEq>(
cx: &mut PaintCx<'_>,
theme: &Theme,
locale: op_editor_core::Locale,
x: f32,
y: f32,
width: f32,
label_key: &'static str,
specs: &[AlignButtonSpec<T>],
active: T,
) -> f32 {
let label = TextLayout::single_run(
op_i18n::translate(locale, label_key),
"system-ui",
11.0,
to_jian_color(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&label, Point2D::new(x + PAD_X, y + 13.0));
let y = y + ALIGN_LABEL_H;
let gap = 6.0;
let usable_w = width - PAD_X * 2.0;
let button_w = (usable_w - gap * (specs.len().saturating_sub(1) as f32)) / specs.len() as f32;
for (i, spec) in specs.iter().enumerate() {
let rect = Rect {
origin: Point2D::new(x + PAD_X + i as f32 * (button_w + gap), y),
size: Point2D::new(button_w, BUTTON_H),
};
let is_active = spec.value == active;
if is_active {
cx.backend.fill_round_rect(rect, 6.0, theme.primary);
} else {
cx.backend.fill_round_rect(rect, 6.0, theme.muted);
cx.backend.stroke_round_rect(rect, 6.0, theme.border, 1.0);
}
draw_icon(
cx.backend,
spec.icon,
Point2D::new(rect.origin.x + (button_w - 16.0) / 2.0, rect.origin.y + 6.0),
16.0,
if is_active {
theme.primary_foreground
} else {
theme.muted_foreground
},
1.4,
);
}
y + BUTTON_H
}
#[derive(Clone, Copy)]
struct AlignButtonSpec<T> {
value: T,
icon: Icon,
}
const H_ALIGN_SPECS: [AlignButtonSpec<TextAlignValue>; 4] = [
AlignButtonSpec {
value: TextAlignValue::Left,
icon: Icon::AlignLeft,
},
AlignButtonSpec {
value: TextAlignValue::Center,
icon: Icon::AlignCenterH,
},
AlignButtonSpec {
value: TextAlignValue::Right,
icon: Icon::AlignRight,
},
AlignButtonSpec {
value: TextAlignValue::Justify,
icon: Icon::AlignCenterH,
},
];
const V_ALIGN_SPECS: [AlignButtonSpec<TextVerticalAlignValue>; 3] = [
AlignButtonSpec {
value: TextVerticalAlignValue::Top,
icon: Icon::AlignTop,
},
AlignButtonSpec {
value: TextVerticalAlignValue::Middle,
icon: Icon::AlignCenterV,
},
AlignButtonSpec {
value: TextVerticalAlignValue::Bottom,
icon: Icon::AlignBottom,
},
];
fn format_panel_number(value: f32) -> String {
if value.fract().abs() < f32::EPSILON {
format!("{}", value.round() as i32)
} else {
format!("{value:.2}")
}
}
fn display_font_family(value: &str) -> &str {
value
.split(',')
.next()
.unwrap_or(value)
.trim()
.trim_matches(['"', '\''])
}

View file

@ -0,0 +1,225 @@
//! Section capability and visibility masks for the property panel.
use op_editor_core::FillType;
use crate::widgets::property_panel_action::{LayoutAlignValue, LayoutJustifyValue};
/// Per-NodeKind toggles for which property-panel sections render.
#[derive(Debug, Clone, Copy)]
pub(crate) struct SectionCapabilities {
pub(crate) create_component: bool,
pub(crate) flex_layout: bool,
pub(crate) size_options: bool,
pub(crate) text: bool,
pub(crate) image: bool,
pub(crate) opacity: bool,
pub(crate) fill: bool,
pub(crate) stroke: bool,
pub(crate) effects: bool,
pub(crate) export: bool,
}
impl SectionCapabilities {
pub(crate) fn for_multi() -> Self {
Self {
create_component: false,
flex_layout: false,
size_options: true,
text: false,
image: false,
opacity: true,
fill: false,
stroke: false,
effects: true,
export: true,
}
}
pub(crate) fn for_kind(kind: &crate::layout_scene::NodeKind) -> Self {
use crate::layout_scene::NodeKind as K;
match kind {
K::Frame => Self {
create_component: true,
flex_layout: true,
size_options: true,
text: false,
image: false,
opacity: true,
fill: true,
stroke: true,
effects: true,
export: true,
},
K::Group => Self {
create_component: true,
flex_layout: true,
size_options: true,
text: false,
image: false,
opacity: true,
fill: false,
stroke: false,
effects: true,
export: true,
},
K::Other(tag) if tag == "image" => Self {
create_component: false,
flex_layout: false,
size_options: true,
text: false,
image: true,
opacity: true,
fill: false,
stroke: false,
effects: true,
export: true,
},
K::Other(tag) if tag == "icon_font" => Self {
create_component: false,
flex_layout: false,
size_options: true,
text: false,
image: false,
opacity: true,
fill: true,
stroke: true,
effects: false,
export: true,
},
K::Other(tag) if tag == "ref" => Self {
create_component: true,
flex_layout: false,
size_options: false,
text: false,
image: false,
opacity: true,
fill: false,
stroke: false,
effects: true,
export: true,
},
K::Other(_) => Self {
create_component: false,
flex_layout: false,
size_options: false,
text: false,
image: false,
opacity: true,
fill: false,
stroke: false,
effects: true,
export: true,
},
K::Rect => Self {
create_component: true,
flex_layout: true,
size_options: true,
text: false,
image: false,
opacity: true,
fill: true,
stroke: true,
effects: true,
export: true,
},
K::Ellipse | K::Polygon => Self {
create_component: false,
flex_layout: false,
size_options: true,
text: false,
image: false,
opacity: true,
fill: true,
stroke: true,
effects: true,
export: true,
},
K::Line => Self {
create_component: false,
flex_layout: false,
size_options: true,
text: false,
image: false,
opacity: true,
fill: false,
stroke: true,
effects: true,
export: true,
},
K::Path => Self {
create_component: false,
flex_layout: false,
size_options: true,
text: false,
image: false,
opacity: true,
fill: true,
stroke: true,
effects: true,
export: true,
},
K::Text => Self {
create_component: false,
flex_layout: false,
size_options: true,
text: true,
image: false,
opacity: true,
fill: true,
stroke: false,
effects: true,
export: true,
},
}
}
}
/// Whether each section currently paints.
#[derive(Debug, Clone, Copy)]
pub struct VisibleSections {
pub create_component: bool,
pub flex_layout: bool,
pub flex_layout_mode: op_editor_core::FlexLayout,
pub layout_justify: LayoutJustifyValue,
pub layout_align: LayoutAlignValue,
pub size_options: bool,
pub clip_content: bool,
pub text: bool,
pub icon: bool,
pub image: bool,
pub opacity: bool,
pub corner_radius: bool,
pub polygon_sides: bool,
pub ellipse_arc: bool,
pub fill: bool,
pub stroke: bool,
pub effects: bool,
pub export: bool,
pub fill_type: FillType,
pub gradient_stop_count: usize,
}
impl VisibleSections {
pub const ALL: Self = Self {
create_component: true,
flex_layout: true,
flex_layout_mode: op_editor_core::FlexLayout::Free,
layout_justify: LayoutJustifyValue::Start,
layout_align: LayoutAlignValue::Start,
size_options: true,
clip_content: true,
text: false,
icon: false,
image: false,
opacity: true,
corner_radius: true,
polygon_sides: false,
ellipse_arc: false,
fill: true,
stroke: true,
effects: true,
export: true,
fill_type: FillType::Solid,
gradient_stop_count: 0,
};
}

View file

@ -358,6 +358,9 @@ impl ApplicationHandler for DesktopApp {
if self.model_probe.poll_into(&mut self.host) { if self.model_probe.poll_into(&mut self.host) {
self.redraw_dirty = true; self.redraw_dirty = true;
} }
if self.drain_iconify_picker() {
self.redraw_dirty = true;
}
// Drain the background auto-update probe. // Drain the background auto-update probe.
if self.poll_update_probe() { if self.poll_update_probe() {
self.redraw_dirty = true; self.redraw_dirty = true;
@ -426,6 +429,10 @@ impl ApplicationHandler for DesktopApp {
let deadline = self.clock_start + Duration::from_millis(deadline_ms); let deadline = self.clock_start + Duration::from_millis(deadline_ms);
event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); event_loop.set_control_flow(ControlFlow::WaitUntil(deadline));
} else if self.update_probe.is_pending() } else if self.update_probe.is_pending()
|| self
.iconify_job
.as_ref()
.is_some_and(crate::iconify_host::IconifyJob::is_pending)
|| self || self
.git_pull_job .git_pull_job
.as_ref() .as_ref()

View file

@ -0,0 +1,254 @@
//! Desktop-side Iconify loading for the native icon picker.
use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::time::Duration;
use op_editor_core::{IconPickerRemoteIcon, IconifyLoadMoreRequest};
use serde::Deserialize;
use crate::DesktopApp;
const ICONIFY_API: &str = "https://api.iconify.design";
pub struct IconifyJob {
rx: Option<Receiver<IconifyResult>>,
}
struct IconifyResult {
request: IconifyLoadMoreRequest,
result: Result<IconifyPage, String>,
}
struct IconifyPage {
icons: Vec<IconPickerRemoteIcon>,
total: usize,
start: usize,
}
#[derive(Deserialize)]
struct SearchResponse {
icons: Vec<String>,
total: Option<usize>,
start: Option<usize>,
}
#[derive(Deserialize)]
struct IconDataResponse {
icons: HashMap<String, IconData>,
width: Option<f32>,
height: Option<f32>,
}
#[derive(Deserialize)]
struct IconData {
body: String,
width: Option<f32>,
height: Option<f32>,
}
impl IconifyJob {
pub fn spawn(request: IconifyLoadMoreRequest) -> Self {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = fetch_iconify_page(&request);
let _ = tx.send(IconifyResult { request, result });
});
Self { rx: Some(rx) }
}
pub fn is_pending(&self) -> bool {
self.rx.is_some()
}
fn poll(&mut self) -> Option<IconifyResult> {
let rx = self.rx.as_ref()?;
match rx.try_recv() {
Ok(result) => {
self.rx = None;
Some(result)
}
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
self.rx = None;
None
}
}
}
}
impl DesktopApp {
pub(crate) fn drain_iconify_picker(&mut self) -> bool {
let mut changed = self.poll_iconify_job();
if self
.iconify_job
.as_ref()
.is_some_and(IconifyJob::is_pending)
{
return changed;
}
let request = self
.host
.editor_state_mut()
.editor_ui
.icon_picker_load_more_request
.take();
if let Some(request) = request {
self.iconify_job = Some(IconifyJob::spawn(request));
changed = true;
}
changed
}
fn poll_iconify_job(&mut self) -> bool {
let Some(job) = self.iconify_job.as_mut() else {
return false;
};
let Some(result) = job.poll() else {
return false;
};
self.iconify_job = None;
let ui = &mut self.host.editor_state_mut().editor_ui;
if ui.icon_picker_remote.query != result.request.query {
return false;
}
ui.icon_picker_remote.loading = false;
match result.result {
Ok(page) => {
if page.start == 0 {
ui.icon_picker_remote.icons.clear();
}
for icon in page.icons {
if !ui
.icon_picker_remote
.icons
.iter()
.any(|i| i.collection == icon.collection && i.name == icon.name)
{
ui.icon_picker_remote.icons.push(icon);
}
}
ui.icon_picker_remote.total = page.total;
ui.icon_picker_remote.next_start = page.start + result.request.limit;
ui.icon_picker_remote.error = None;
}
Err(err) => {
ui.icon_picker_remote.error = Some(err);
}
}
self.host.mark_editor_state_dirty();
true
}
}
fn fetch_iconify_page(request: &IconifyLoadMoreRequest) -> Result<IconifyPage, String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
runtime.block_on(async {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.user_agent(concat!("openpencil-desktop/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| e.to_string())?;
let search_url = format!(
"{ICONIFY_API}/search?query={}&limit={}&start={}",
encode_component(&request.query),
request.limit,
request.start
);
let search: SearchResponse = client
.get(search_url)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let grouped = group_icon_ids(&search.icons);
let mut loaded = HashMap::new();
for (collection, names) in grouped {
let url = format!(
"{ICONIFY_API}/{}.json?icons={}",
encode_component(&collection),
names
.iter()
.map(|name| encode_component(name))
.collect::<Vec<_>>()
.join(",")
);
let data: IconDataResponse = client
.get(url)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
for (name, icon) in data.icons {
let w = icon.width.or(data.width).unwrap_or(24.0);
let h = icon.height.or(data.height).unwrap_or(24.0);
if let Some(parsed) =
op_editor_ui::widgets::icon_catalog::parse_iconify_body(&icon.body, w, h)
{
let style = match parsed.style {
op_editor_ui::widgets::icon_catalog::IconRenderStyle::Stroke => "stroke",
op_editor_ui::widgets::icon_catalog::IconRenderStyle::Fill => "fill",
};
loaded.insert(
format!("{collection}:{name}"),
IconPickerRemoteIcon {
collection: collection.clone(),
name,
width: parsed.width,
height: parsed.height,
style: style.to_string(),
d: parsed.d,
},
);
}
}
}
let icons = search
.icons
.into_iter()
.filter_map(|id| loaded.remove(&id))
.collect();
Ok(IconifyPage {
icons,
total: search.total.unwrap_or(0),
start: search.start.unwrap_or(request.start),
})
})
}
fn group_icon_ids(ids: &[String]) -> HashMap<String, Vec<String>> {
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
for id in ids {
if let Some((collection, name)) = id.split_once(':') {
grouped
.entry(collection.to_string())
.or_default()
.push(name.to_string());
}
}
grouped
}
fn encode_component(input: &str) -> String {
let mut out = String::new();
for byte in input.as_bytes() {
match *byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*byte as char)
}
b => out.push_str(&format!("%{b:02X}")),
}
}
out
}

View file

@ -23,6 +23,7 @@ mod frame;
mod git_host; mod git_host;
mod git_jobs; mod git_jobs;
mod git_session; mod git_session;
mod iconify_host;
mod keyboard_input; mod keyboard_input;
mod macos_app; mod macos_app;
mod mcp_serve; mod mcp_serve;
@ -95,6 +96,7 @@ struct DesktopApp {
/// on a worker thread; its result is drained into /// on a worker thread; its result is drained into
/// `chat.available_models` on a later frame. /// `chat.available_models` on a later frame.
model_probe: model_discovery::ModelProbe, model_probe: model_discovery::ModelProbe,
iconify_job: Option<iconify_host::IconifyJob>,
/// Document to open once the window is ready — set from argv by /// Document to open once the window is ready — set from argv by
/// the file-association launch path (`openpencil-desktop X.op`). /// the file-association launch path (`openpencil-desktop X.op`).
initial_file: Option<PathBuf>, initial_file: Option<PathBuf>,
@ -106,8 +108,7 @@ struct DesktopApp {
/// on a worker thread; its result is drained into /// on a worker thread; its result is drained into
/// `editor_ui.update_status` on a later frame. /// `editor_ui.update_status` on a later frame.
update_probe: update_check::UpdateProbe, update_probe: update_check::UpdateProbe,
/// Whether the "update available" prompt has already been shown /// Gates the update-available dialog to once per check.
/// for the current probe — gates the dialog to once per check.
update_prompt_shown: bool, update_prompt_shown: bool,
/// Last *windowed* (non-maximized) outer position, physical px. /// Last *windowed* (non-maximized) outer position, physical px.
/// Persisted on exit so a restart restores window placement. /// Persisted on exit so a restart restores window placement.
@ -134,11 +135,9 @@ struct DesktopApp {
/// *during* the async pull — which the spawn-time confirm did /// *during* the async pull — which the spawn-time confirm did
/// not cover — and re-confirm before discarding them. /// not cover — and re-confirm before discarding them.
git_pull_doc_baseline: Option<u64>, git_pull_doc_baseline: Option<u64>,
/// In-flight background Git status query, if any — keeps the /// In-flight background Git status query, if any.
/// working-tree scan (`git status` / `log`) off the UI thread.
git_status_job: Option<git_jobs::GitStatusJob>, git_status_job: Option<git_jobs::GitStatusJob>,
/// In-flight background Git diff (`git diff` / `git show`), if /// In-flight background Git diff (`git diff` / `git show`), if any.
/// any — keeps a potentially large diff off the UI thread.
git_diff_job: Option<git_jobs::GitDiffJob>, git_diff_job: Option<git_jobs::GitDiffJob>,
/// When the Git panel was last re-snapshotted — drives the /// When the Git panel was last re-snapshotted — drives the
/// periodic refresh that keeps an open panel current against /// periodic refresh that keeps an open panel current against
@ -178,6 +177,7 @@ impl DesktopApp {
current_chat: None, current_chat: None,
current_design: None, current_design: None,
model_probe: model_discovery::ModelProbe::spawn(), model_probe: model_discovery::ModelProbe::spawn(),
iconify_job: None,
initial_file, initial_file,
app_menu: None, app_menu: None,
update_probe: update_check::UpdateProbe::spawn(), update_probe: update_check::UpdateProbe::spawn(),

View file

@ -79,6 +79,20 @@ fn font_style_for_weight(weight: u16) -> skia_safe::FontStyle {
) )
} }
fn primary_font_family(stack: &str) -> Option<&str> {
let first = stack.split(',').next()?.trim().trim_matches(['"', '\'']);
if first.is_empty()
|| matches!(
first,
"system-ui" | "sans-serif" | "serif" | "monospace" | "-apple-system"
)
{
None
} else {
Some(first)
}
}
/// OP `Color` → `skia_safe::Color4f` — used by the direct-canvas /// OP `Color` → `skia_safe::Color4f` — used by the direct-canvas
/// helpers (stroke_line / fill_round_rect / stroke_round_rect) /// helpers (stroke_line / fill_round_rect / stroke_round_rect)
/// that skip the jian DrawOp pipeline. /// that skip the jian DrawOp pipeline.
@ -127,19 +141,13 @@ pub struct NativeBackend {
typeface_tried: bool, typeface_tried: bool,
cjk_typeface: Option<skia_safe::Typeface>, cjk_typeface: Option<skia_safe::Typeface>,
cjk_typeface_tried: bool, cjk_typeface_tried: bool,
/// Per-codepoint typeface cache. Populated on first sight of a /// Default-family per-codepoint typeface cache, keyed by
/// non-ASCII character so multi-script chrome (Korean 한국어, /// `(codepoint, weight)`.
/// Devanagari हिन्दी, Thai ไทย, Vietnamese precomposed
/// `Tiếng Việt` …) renders against the right system font
/// instead of falling through to the single `cjk_typeface`
/// (which only covers Han / Hiragana / Katakana on most OSes).
/// Cache key is `(codepoint, weight)` so weight 400 chrome glyphs
/// and weight 700 canvas headlines don't share an entry. Bold
/// text from `.op` files (`fontWeight: 700`) re-resolves the
/// typeface against `FontStyle::new(Weight, Width::NORMAL, …)`
/// so the system FontMgr returns the bold-variant TTF when one
/// is installed (TS app parity).
char_typeface_cache: std::collections::HashMap<(i32, u16), Option<skia_safe::Typeface>>, char_typeface_cache: std::collections::HashMap<(i32, u16), Option<skia_safe::Typeface>>,
/// Explicit-family typeface cache for selected text / font picker
/// previews, keyed by `(primary family, codepoint, weight)`.
family_typeface_cache:
std::collections::HashMap<(String, i32, u16), Option<skia_safe::Typeface>>,
/// Decoded-image cache keyed by [`op_editor_core::ChatImage::id`]. /// Decoded-image cache keyed by [`op_editor_core::ChatImage::id`].
/// `draw_image` decodes the raw bytes once on first sight; later /// `draw_image` decodes the raw bytes once on first sight; later
/// frames reuse the cached `Image`. A decode failure is cached as /// frames reuse the cached `Image`. A decode failure is cached as
@ -206,6 +214,7 @@ impl NativeBackend {
cjk_typeface: None, cjk_typeface: None,
cjk_typeface_tried: false, cjk_typeface_tried: false,
char_typeface_cache: std::collections::HashMap::new(), char_typeface_cache: std::collections::HashMap::new(),
family_typeface_cache: std::collections::HashMap::new(),
image_cache: std::collections::HashMap::new(), image_cache: std::collections::HashMap::new(),
image_cache_order: std::collections::VecDeque::new(), image_cache_order: std::collections::VecDeque::new(),
}; };
@ -253,14 +262,41 @@ impl NativeBackend {
resolved resolved
} }
fn typeface_for_family_char(
&mut self,
c: char,
family: &str,
weight: u16,
) -> Option<skia_safe::Typeface> {
let Some(primary) = primary_font_family(family) else {
return self.typeface_for_char(c, weight);
};
let key = (primary.to_string(), c as i32, weight);
if let Some(cached) = self.family_typeface_cache.get(&key) {
return cached.clone();
}
let style = font_style_for_weight(weight);
let mgr = skia_safe::FontMgr::new();
let resolved = mgr
.match_family_style_character(primary, style, &[], c as i32)
.or_else(|| self.typeface_for_char(c, weight));
self.family_typeface_cache.insert(key, resolved.clone());
resolved
}
/// Split `text` into contiguous segments that share a typeface, /// Split `text` into contiguous segments that share a typeface,
/// preserving char order. Glyphs without any covering typeface /// preserving char order. Glyphs without any covering typeface
/// are bucketed with the previous segment so they at least /// are bucketed with the previous segment so they at least
/// occupy space (rather than disappearing). /// occupy space (rather than disappearing).
fn segment_text(&mut self, text: &str, weight: u16) -> Vec<(skia_safe::Typeface, String)> { fn segment_text(
&mut self,
text: &str,
family: &str,
weight: u16,
) -> Vec<(skia_safe::Typeface, String)> {
let mut segments: Vec<(skia_safe::Typeface, String)> = Vec::new(); let mut segments: Vec<(skia_safe::Typeface, String)> = Vec::new();
for c in text.chars() { for c in text.chars() {
let tf = self.typeface_for_char(c, weight); let tf = self.typeface_for_family_char(c, family, weight);
let Some(tf) = tf else { let Some(tf) = tf else {
if let Some(last) = segments.last_mut() { if let Some(last) = segments.last_mut() {
last.1.push(c); last.1.push(c);
@ -402,7 +438,7 @@ impl NativeBackend {
/// this the wrap pass measured at 400 and paint at 700 — the /// this the wrap pass measured at 400 and paint at 700 — the
/// rendered string could then overflow the wrap budget. /// rendered string could then overflow the wrap budget.
pub fn measure_text_weighted(&mut self, text: &str, font_size: f32, weight: u16) -> f32 { pub fn measure_text_weighted(&mut self, text: &str, font_size: f32, weight: u16) -> f32 {
let segments = self.segment_text(text, weight); let segments = self.segment_text(text, "", weight);
if segments.is_empty() { if segments.is_empty() {
return 0.0; return 0.0;
} }
@ -431,7 +467,8 @@ impl NativeBackend {
pub fn draw_text(&mut self, canvas: &skia_safe::Canvas, layout: &TextLayout, origin: Point2D) { pub fn draw_text(&mut self, canvas: &skia_safe::Canvas, layout: &TextLayout, origin: Point2D) {
let runs: Vec<_> = layout.runs().to_vec(); let runs: Vec<_> = layout.runs().to_vec();
for run in runs { for run in runs {
let segments = self.segment_text(run.content.as_str(), run.font_weight); let segments =
self.segment_text(run.content.as_str(), &run.font_family, run.font_weight);
if segments.is_empty() { if segments.is_empty() {
continue; continue;
} }
@ -741,6 +778,11 @@ impl NativeBackend {
pub(crate) fn image_cache_len(&self) -> usize { pub(crate) fn image_cache_len(&self) -> usize {
self.image_cache.len() self.image_cache.len()
} }
#[cfg(test)]
pub(crate) fn family_typeface_cache_len(&self) -> usize {
self.family_typeface_cache.len()
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -132,6 +132,23 @@ fn image_cache_decodes_a_valid_png() {
assert_eq!(be.image_cache_len(), 1); assert_eq!(be.image_cache_len(), 1);
} }
#[test]
fn explicit_family_typeface_lookup_is_cached() {
let mut be = NativeBackend::with_dpi(1.0);
assert_eq!(be.family_typeface_cache_len(), 0);
let first = be
.typeface_for_family_char('A', "Georgia", 400)
.map(|tf| tf.unique_id());
assert_eq!(be.family_typeface_cache_len(), 1);
let second = be
.typeface_for_family_char('A', "Georgia", 400)
.map(|tf| tf.unique_id());
assert_eq!(second, first);
assert_eq!(be.family_typeface_cache_len(), 1);
}
/// Encode a solid raster surface to PNG bytes — a real image for /// Encode a solid raster surface to PNG bytes — a real image for
/// the decode-cache test (no hardcoded blob). /// the decode-cache test (no hardcoded blob).
fn encode_test_png(w: i32, h: i32) -> Vec<u8> { fn encode_test_png(w: i32, h: i32) -> Vec<u8> {

View file

@ -51,6 +51,7 @@ mod paint;
mod press; mod press;
mod press_helpers; mod press_helpers;
mod property_dispatch; mod property_dispatch;
mod property_layout_dispatch;
mod scroll; mod scroll;
mod shape_picker_press; mod shape_picker_press;
mod shortcuts; mod shortcuts;
@ -126,6 +127,8 @@ pub struct WidgetHostNative {
pub(in crate::widget_host) design_md_drag: Option<DesignMdDragState>, pub(in crate::widget_host) design_md_drag: Option<DesignMdDragState>,
/// Active Component-Browser panel drag. /// Active Component-Browser panel drag.
pub(in crate::widget_host) component_browser_drag: Option<ComponentBrowserDragState>, pub(in crate::widget_host) component_browser_drag: Option<ComponentBrowserDragState>,
/// Active Icon-picker panel drag.
pub(in crate::widget_host) icon_picker_drag: Option<IconPickerDragState>,
/// Active image-fill adjustment slider drag in the floating /// Active image-fill adjustment slider drag in the floating
/// property popover. /// property popover.
pub(in crate::widget_host) image_adjustment_drag: Option<op_editor_core::ImageAdjustmentField>, pub(in crate::widget_host) image_adjustment_drag: Option<op_editor_core::ImageAdjustmentField>,
@ -377,6 +380,12 @@ pub(in crate::widget_host) struct ComponentBrowserDragState {
pub(in crate::widget_host) grab_dy: f32, pub(in crate::widget_host) grab_dy: f32,
} }
#[derive(Debug, Clone, Copy)]
pub(in crate::widget_host) struct IconPickerDragState {
pub(in crate::widget_host) grab_dx: f32,
pub(in crate::widget_host) grab_dy: f32,
}
impl WidgetHostNative { impl WidgetHostNative {
pub fn new() -> Self { pub fn new() -> Self {
// A fresh launch opens with a single empty starter Frame — // A fresh launch opens with a single empty starter Frame —
@ -394,6 +403,7 @@ impl WidgetHostNative {
chat_drag: None, chat_drag: None,
design_md_drag: None, design_md_drag: None,
component_browser_drag: None, component_browser_drag: None,
icon_picker_drag: None,
image_adjustment_drag: None, image_adjustment_drag: None,
panel_resize: None, panel_resize: None,
node_drag: None, node_drag: None,

View file

@ -473,8 +473,15 @@ impl WidgetHostNative {
if !self.editor_state.editor_ui.icon_picker_open { if !self.editor_state.editor_ui.icon_picker_open {
return None; return None;
} }
let x = ((viewport_w - ICON_PICKER_PANEL_W) / 2.0).max(0.0); let ui = &self.editor_state.editor_ui;
let y = ((viewport_h - ICON_PICKER_PANEL_H) / 2.0).max(0.0); let (px, py) = ui.icon_picker_panel_pos.unwrap_or_else(|| {
(
((viewport_w - ICON_PICKER_PANEL_W) / 2.0).max(0.0),
((viewport_h - ICON_PICKER_PANEL_H) / 2.0).max(0.0),
)
});
let x = px.clamp(0.0, (viewport_w - 80.0).max(0.0));
let y = py.clamp(0.0, (viewport_h - 40.0).max(0.0));
Some(Rect { Some(Rect {
origin: Point2D::new(x, y), origin: Point2D::new(x, y),
size: Point2D::new(ICON_PICKER_PANEL_W, ICON_PICKER_PANEL_H), size: Point2D::new(ICON_PICKER_PANEL_W, ICON_PICKER_PANEL_H),

View file

@ -1,9 +1,10 @@
//! Icon-picker panel press dispatch. //! Icon-picker panel press dispatch.
use op_editor_core::IconifyLoadMoreRequest;
use op_editor_ui::widgets::{IconPickerHit, IconPickerPanel}; use op_editor_ui::widgets::{IconPickerHit, IconPickerPanel};
use op_editor_ui::Point2D; use op_editor_ui::Point2D;
use super::WidgetHostNative; use super::{IconPickerDragState, WidgetHostNative};
impl WidgetHostNative { impl WidgetHostNative {
pub(in crate::widget_host) fn dispatch_icon_picker_press( pub(in crate::widget_host) fn dispatch_icon_picker_press(
@ -24,24 +25,75 @@ impl WidgetHostNative {
match hit { match hit {
IconPickerHit::Close => { IconPickerHit::Close => {
self.editor_state.editor_ui.icon_picker_open = false; self.editor_state.editor_ui.icon_picker_open = false;
self.editor_state.editor_ui.icon_picker_replace_selection = false;
self.editor_state.editor_ui.icon_picker_search.clear(); self.editor_state.editor_ui.icon_picker_search.clear();
} }
IconPickerHit::SelectIcon(name) => { IconPickerHit::DragHeader => {
let (_cx0, _cy0, cw, ch) = self.canvas_region(viewport_width, viewport_height); self.icon_picker_drag = Some(IconPickerDragState {
let doc = self grab_dx: x - panel_rect.origin.x,
.editor_state grab_dy: y - panel_rect.origin.y,
.viewport });
.to_document(Point2D::new(cw / 2.0, ch / 2.0)); }
let inserted = self.editor_state.insert_icon_font_node_at( IconPickerHit::SelectIcon { collection, name } => {
&name, let replace_selection = self.editor_state.editor_ui.icon_picker_replace_selection;
"lucide", if replace_selection {
doc.x as f64, let svg_path = self
doc.y as f64, .editor_state
); .editor_ui
self.editor_state.editor_ui.icon_picker_open = false; .icon_picker_remote
self.editor_state.editor_ui.icon_picker_search.clear(); .icons
if inserted.is_some() { .iter()
self.mark_dirty(); .find(|i| i.collection == collection && i.name == name)
.map(|i| i.d.clone())
.or_else(|| {
op_editor_ui::widgets::icon_catalog::lookup_icon(&collection, &name)
.map(|icon| icon.d.clone())
});
if self.editor_state.replace_selected_icon(
&name,
&collection,
svg_path.as_deref(),
) {
self.mark_dirty();
}
} else {
let (_cx0, _cy0, cw, ch) = self.canvas_region(viewport_width, viewport_height);
let doc = self
.editor_state
.viewport
.to_document(Point2D::new(cw / 2.0, ch / 2.0));
let inserted = self.editor_state.insert_icon_font_node_at(
&name,
&collection,
doc.x as f64,
doc.y as f64,
);
self.editor_state.editor_ui.icon_picker_open = false;
self.editor_state.editor_ui.icon_picker_search.clear();
self.editor_state.editor_ui.icon_picker_replace_selection = false;
if inserted.is_some() {
self.mark_dirty();
}
}
}
IconPickerHit::LoadMore => {
let ui = &mut self.editor_state.editor_ui;
let query = ui.icon_picker_search.trim().to_lowercase();
if !query.is_empty() && !ui.icon_picker_remote.loading {
let start = if ui.icon_picker_remote.query == query {
ui.icon_picker_remote.next_start
} else {
ui.icon_picker_remote = Default::default();
0
};
ui.icon_picker_remote.query = query.clone();
ui.icon_picker_remote.loading = true;
ui.icon_picker_remote.error = None;
ui.icon_picker_load_more_request = Some(IconifyLoadMoreRequest {
query,
start,
limit: op_editor_ui::widgets::ICONIFY_LOAD_MORE_LIMIT,
});
} }
} }
IconPickerHit::Inside => {} IconPickerHit::Inside => {}

View file

@ -1,10 +1,4 @@
//! Non-press input handlers on `WidgetHostNative`. press → press.rs. //! Non-press input handlers on `WidgetHostNative`. press -> press.rs.
//!
//! `EditorState` is the host's source of truth. Scalar / chrome
//! reads go straight to `editor_state`; node-tree hit-tests run
//! against the layout-resolved `LayoutScene`, refreshed at the top
//! of each handler. Every mutation flags `editor_state` so the next
//! refresh re-derives.
use super::helpers::{resize_bounds, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH}; use super::helpers::{resize_bounds, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH};
use super::{PanelResizeKind, WidgetHostNative}; use super::{PanelResizeKind, WidgetHostNative};
@ -32,43 +26,28 @@ impl WidgetHostNative {
self.editor_state.editor_ui.agent_settings.focus.is_some() self.editor_state.editor_ui.agent_settings.focus.is_some()
} }
/// Whether the Git panel's commit-message input owns the /// Whether the visible Git commit-message input owns the keyboard.
/// keyboard. Like [`Self::settings_focus_active`], the desktop
/// runner gates editor shortcuts on this so typing a commit
/// message never mutates the document.
///
/// Never active while the panel is `loading`: the commit input
/// is hidden then, so a stale `commit_focused` must not let
/// keystrokes — or Enter — reach (and commit through) a control
/// the user cannot see.
pub fn git_commit_focus_active(&self) -> bool { pub fn git_commit_focus_active(&self) -> bool {
let panel = &self.editor_state.editor_ui.git_panel; let panel = &self.editor_state.editor_ui.git_panel;
panel.open && panel.commit_focused && !panel.loading panel.open && panel.commit_focused && !panel.loading
} }
/// Whether the Git panel's remote-URL input owns the keyboard. /// Whether the visible Git remote-URL input owns the keyboard.
/// Mirrors [`Self::git_commit_focus_active`] for the Remotes
/// section's URL field.
pub fn git_remote_focus_active(&self) -> bool { pub fn git_remote_focus_active(&self) -> bool {
let panel = &self.editor_state.editor_ui.git_panel; let panel = &self.editor_state.editor_ui.git_panel;
panel.open && panel.remote_focused && !panel.loading panel.open && panel.remote_focused && !panel.loading
} }
/// Whether the Git panel's HTTPS-credential input owns the /// Whether the visible Git HTTPS-credential input owns the keyboard.
/// keyboard — the Remotes section's `username:token` field.
pub fn git_https_focus_active(&self) -> bool { pub fn git_https_focus_active(&self) -> bool {
let panel = &self.editor_state.editor_ui.git_panel; let panel = &self.editor_state.editor_ui.git_panel;
panel.open && panel.https_focused && !panel.loading panel.open && panel.https_focused && !panel.loading
} }
/// After a node-drag translate, compute smart-guide alignment /// Snap node drags to nearby top-level edge/centre guides.
/// against the other top-level nodes, snap the selection onto the
/// nearest edge/centre alignment, and store the guide lines for
/// the canvas painter. Cleared on drag release.
fn apply_smart_guides(&mut self) -> (f64, f64) { fn apply_smart_guides(&mut self) -> (f64, f64) {
use op_editor_core::align_guides::compute_alignment_guides; use op_editor_core::align_guides::compute_alignment_guides;
/// Snap range in doc-px — an edge/centre this close to another /// Snap range in doc-px.
/// node's edge/centre locks on.
const GUIDE_THRESHOLD: f64 = 6.0; const GUIDE_THRESHOLD: f64 = 6.0;
self.refresh_layout_scene(); self.refresh_layout_scene();
@ -115,10 +94,7 @@ impl WidgetHostNative {
} }
pub fn apply_cursor_move(&mut self, x: f32, y: f32) -> bool { pub fn apply_cursor_move(&mut self, x: f32, y: f32) -> bool {
// Every hit-test below (color picker, layer context menu, align // Keep hit-tests on the current layout-resolved scene.
// toolbar, panel resize, …) reasons about the layout-resolved
// render scene. Refresh it once up front so a mutation since the
// last paint can't leave any of them hit-testing stale geometry.
self.refresh_layout_scene(); self.refresh_layout_scene();
if self.editor_state.editor_ui.agent_settings_open && self.update_agent_settings_hover(x, y) if self.editor_state.editor_ui.agent_settings_open && self.update_agent_settings_hover(x, y)
{ {
@ -147,10 +123,7 @@ impl WidgetHostNative {
return true; return true;
} }
} }
// Top-most floating panel drags supersede every lower // Top-most floating panel drags own cursor movement.
// cursor-move branch — once a drag is active the cursor
// belongs to the panel, regardless of which tool / overlay is
// also in play (a pen rubber-band, a node drag, etc.).
if let Some(d) = self.design_md_drag { if let Some(d) = self.design_md_drag {
self.editor_state.editor_ui.design_md_panel_pos = Some((x - d.grab_dx, y - d.grab_dy)); self.editor_state.editor_ui.design_md_panel_pos = Some((x - d.grab_dx, y - d.grab_dy));
self.mark_dirty(); self.mark_dirty();
@ -162,6 +135,12 @@ impl WidgetHostNative {
self.mark_dirty(); self.mark_dirty();
return true; return true;
} }
if let Some(d) = self.icon_picker_drag {
self.editor_state.editor_ui.icon_picker_panel_pos =
Some((x - d.grab_dx, y - d.grab_dy));
self.mark_dirty();
return true;
}
if let Some(field) = self.image_adjustment_drag { if let Some(field) = self.image_adjustment_drag {
if let Some(panel) = if let Some(panel) =
op_editor_ui::widgets::PropertyPanel::for_selection(&self.editor_state) op_editor_ui::widgets::PropertyPanel::for_selection(&self.editor_state)
@ -191,16 +170,10 @@ impl WidgetHostNative {
self.mark_dirty(); self.mark_dirty();
return true; return true;
} }
// Any top-most floating panel (Design-MD / Component-Browser) // Suppress lower-overlay hover while a floating panel is on top.
// suppresses every lower-overlay hover update underneath it —
// a click already routes to the panel first, so a highlight
// bleeding through would be misleading. Moving onto the panel
// also clears any highlight set just before, so none lingers.
let over_topmost = let over_topmost =
self.over_topmost_panel(x, y, self.last_viewport_w, self.last_viewport_h); self.over_topmost_panel(x, y, self.last_viewport_w, self.last_viewport_h);
// `cleared` is folded into the final return so the repaint // Fold stale-hover clearing into the final repaint signal.
// scheduler (which gates on `apply_cursor_move`'s bool) does
// not skip the frame that drops the stale highlight.
let cleared = over_topmost && self.clear_lower_overlay_hover(); let cleared = over_topmost && self.clear_lower_overlay_hover();
if let Some(state) = self if let Some(state) = self
.editor_state .editor_state
@ -301,9 +274,7 @@ impl WidgetHostNative {
let cur = self.editor_state.viewport.to_document(canvas_local); let cur = self.editor_state.viewport.to_document(canvas_local);
let min_x = drag.start_doc_x.min(cur.x); let min_x = drag.start_doc_x.min(cur.x);
let min_y = drag.start_doc_y.min(cur.y); let min_y = drag.start_doc_y.min(cur.y);
// Text needs room for the placeholder glyphs; shape // Text needs room for placeholder glyphs.
// tools start at 1 px so the drag immediately sizes
// the node to the cursor.
let (min_w, min_h) = match self.editor_state.tool { let (min_w, min_h) = match self.editor_state.tool {
op_editor_core::Tool::Text => (96.0_f32, 24.0_f32), op_editor_core::Tool::Text => (96.0_f32, 24.0_f32),
_ => (1.0_f32, 1.0_f32), _ => (1.0_f32, 1.0_f32),
@ -330,11 +301,7 @@ impl WidgetHostNative {
self.editor_state.translate_selected(dx as f64, dy as f64); self.editor_state.translate_selected(dx as f64, dy as f64);
let (snap_dx, snap_dy) = self.apply_smart_guides(); let (snap_dx, snap_dy) = self.apply_smart_guides();
if let Some(drag) = self.node_drag.as_mut() { if let Some(drag) = self.node_drag.as_mut() {
// When smart guides pull the node back onto a guide, // Keep snapped axes accumulating instead of eating small moves.
// keep the cursor baseline on that axis at its previous
// value. Small subsequent cursor moves then accumulate
// until they exceed the snap threshold instead of being
// eaten one frame at a time.
if snap_dx != 0.0 { if snap_dx != 0.0 {
drag.last_screen_x = prev_screen_x; drag.last_screen_x = prev_screen_x;
} }
@ -347,9 +314,7 @@ impl WidgetHostNative {
} }
return false; return false;
} }
// Path-anchor / handle drag — write the current cursor // Path-anchor / handle drag: always write current cursor position.
// position (codex BLOCK: drag-back-to-start was being
// silently dropped, so always write).
if self.path_anchor_drag.is_some() { if self.path_anchor_drag.is_some() {
use super::AnchorDragTarget; use super::AnchorDragTarget;
let (cx0, cy0) = self.canvas_origin(); let (cx0, cy0) = self.canvas_origin();
@ -367,16 +332,10 @@ impl WidgetHostNative {
d.moved, d.moved,
) )
}; };
// `is_move` uses the raw (rotation-independent) cursor — // Motion detection is frame-agnostic; writes start after first move.
// motion detection is frame-agnostic. The drag mutates
// nothing until the cursor first travels, so a
// press-release leaves the document (and undo stack)
// untouched; once it HAS moved, every event (incl. a
// drag back to the start point) keeps writing.
let is_move = (doc.x - start.x).abs() > 0.001 || (doc.y - start.y).abs() > 0.001; let is_move = (doc.x - start.x).abs() > 0.001 || (doc.y - start.y).abs() > 0.001;
if is_move || already_moved { if is_move || already_moved {
// Un-rotate the cursor into the path's local frame so // Write anchor / handle coords in the path's local frame.
// anchor / handle coords are written rotation-free.
let local = match self let local = match self
.layout_scene .layout_scene
.active_page() .active_page()
@ -400,9 +359,7 @@ impl WidgetHostNative {
); );
} }
AnchorDragTarget::Handle(side) => { AnchorDragTarget::Handle(side) => {
// First real move sets the anchor's point type // First real move sets the anchor's point type.
// — Shift = independent (broken) handles, else
// mirrored (smooth).
if !self if !self
.path_anchor_drag .path_anchor_drag
.as_ref() .as_ref()
@ -432,8 +389,7 @@ impl WidgetHostNative {
} }
return true; return true;
} }
// Ellipse arc-handle drag — recompute the arc geometry from // Ellipse arc-handle drag: recompute arc geometry from the cursor.
// the cursor and re-apply `SetEllipseArc` each move.
if self.arc_handle_drag.is_some() { if self.arc_handle_drag.is_some() {
let (cx0, cy0) = self.canvas_origin(); let (cx0, cy0) = self.canvas_origin();
let canvas_local = Point2D::new(x - cx0, y - cy0); let canvas_local = Point2D::new(x - cx0, y - cy0);
@ -442,9 +398,7 @@ impl WidgetHostNative {
let d = self.arc_handle_drag.as_ref().unwrap(); let d = self.arc_handle_drag.as_ref().unwrap();
(d.node_id.clone(), d.handle, d.start_doc, d.moved) (d.node_id.clone(), d.handle, d.start_doc, d.moved)
}; };
// Mutate nothing until the cursor first travels — a // Do not mutate until the cursor first travels.
// press-release must not write the arc or push an undo
// entry. Once moved, keep writing every event.
let is_move = (doc.x - start.x).abs() > 0.001 || (doc.y - start.y).abs() > 0.001; let is_move = (doc.x - start.x).abs() > 0.001 || (doc.y - start.y).abs() > 0.001;
if is_move || already_moved { if is_move || already_moved {
if let Some(cmd) = self.arc_drag_command(&id, handle, doc) { if let Some(cmd) = self.arc_drag_command(&id, handle, doc) {
@ -511,23 +465,14 @@ impl WidgetHostNative {
drag.last_x = x; drag.last_x = x;
drag.last_y = y; drag.last_y = y;
self.editor_state.viewport.pan(dx, dy); self.editor_state.viewport.pan(dx, dy);
// No `mark_dirty()`: a canvas pan-drag only translates the // Canvas pan only translates the viewport; keep layout cache intact.
// viewport, not the document tree, so the cached
// `layout_scene` stays valid (re-solving taffy layout on
// every drag frame was the pan jank). `return true` still
// drives the repaint that re-applies the viewport.
return true; return true;
} }
// Toolbar per-button hover wash — AFTER drag detection so a // Toolbar hover after drag detection.
// path-anchor / node / pan drag whose cursor crosses the
// toolbar isn't intercepted by the hover update.
if self.update_toolbar_hover(x, y, over_topmost) { if self.update_toolbar_hover(x, y, over_topmost) {
return true; return true;
} }
// Align toolbar hover sync — AFTER drag detection. Suppressed // Align toolbar hover after drag detection.
// when the cursor is over a top-most floating panel
// (`over_topmost`, computed above) so a toolbar button below
// it does not light up.
let new_hover = if self.editor_state.selection_count() >= 2 && !over_topmost { let new_hover = if self.editor_state.selection_count() >= 2 && !over_topmost {
self.align_toolbar_hit(x, y, self.last_viewport_w, self.last_viewport_h) self.align_toolbar_hit(x, y, self.last_viewport_w, self.last_viewport_h)
} else { } else {
@ -539,8 +484,7 @@ impl WidgetHostNative {
self.mark_dirty(); self.mark_dirty();
return true; return true;
} }
// `cleared` carries a stale-hover drop that no earlier branch // Fold stale-hover clearing into the repaint signal.
// reported — fold it in so the repaint is scheduled.
cleared cleared
} }
@ -565,8 +509,7 @@ impl WidgetHostNative {
return true; return true;
} }
if self.create_drag.take().is_some() { if self.create_drag.take().is_some() {
// Switch back to Select so the user can immediately // Switch back to Select for immediate shape refinement.
// refine the freshly-created shape.
self.editor_state.tool = op_editor_core::Tool::Select; self.editor_state.tool = op_editor_core::Tool::Select;
self.mark_dirty(); self.mark_dirty();
return true; return true;
@ -577,9 +520,7 @@ impl WidgetHostNative {
return true; return true;
} }
if let Some(drag) = self.path_anchor_drag.take() { if let Some(drag) = self.path_anchor_drag.take() {
// Push history snapshot only when the anchor actually // Push history only when the anchor actually moved.
// moved (codex CONCERN — a press-release without motion
// was polluting the undo stack with no-op entries).
if drag.moved { if drag.moved {
self.editor_state.history_push_past(drag.pre_drag_snapshot); self.editor_state.history_push_past(drag.pre_drag_snapshot);
return true; return true;
@ -595,13 +536,15 @@ impl WidgetHostNative {
return false; return false;
} }
if self.design_md_drag.take().is_some() { if self.design_md_drag.take().is_some() {
// The panel position was updated live during the drag; // Position was updated live; release only ends the drag.
// release just ends it.
return true; return true;
} }
if self.component_browser_drag.take().is_some() { if self.component_browser_drag.take().is_some() {
return true; return true;
} }
if self.icon_picker_drag.take().is_some() {
return true;
}
if self.image_adjustment_drag.take().is_some() { if self.image_adjustment_drag.take().is_some() {
return true; return true;
} }
@ -613,9 +556,7 @@ impl WidgetHostNative {
return self.commit_layer_drag(d, viewport_h); return self.commit_layer_drag(d, viewport_h);
} }
if let Some(d) = self.chat_drag.take() { if let Some(d) = self.chat_drag.take() {
// Use the live panel size (expanded vs collapsed) so a // Snap using the live expanded/collapsed panel size.
// dragged collapsed pill snaps to the corner closest to
// its actual center.
let (panel_w, panel_h) = self.ai_chat_size(); let (panel_w, panel_h) = self.ai_chat_size();
let center = Point2D::new(d.pos_x + panel_w / 2.0, d.pos_y + panel_h / 2.0); let center = Point2D::new(d.pos_x + panel_w / 2.0, d.pos_y + panel_h / 2.0);
let (cx0, cy0, cw, ch) = self.canvas_region(viewport_w, viewport_h); let (cx0, cy0, cw, ch) = self.canvas_region(viewport_w, viewport_h);
@ -651,17 +592,14 @@ impl WidgetHostNative {
return true; return true;
} }
if self.marquee_drag.take().is_some() { if self.marquee_drag.take().is_some() {
// Can't compute the doc-space marquee rect without a // No viewport: drop without committing.
// viewport; drop without committing.
return true; return true;
} }
if self.layer_drag.take().is_some() { if self.layer_drag.take().is_some() {
// Same story as marquee — no viewport, drop the candidate. // No viewport: drop the candidate.
return true; return true;
} }
// Path-anchor / arc-handle drags — commit the history snapshot // Commit path / arc history when the drag actually moved.
// when they actually moved (parity with the with-viewport
// release; without this a stale drag would leak).
if let Some(drag) = self.path_anchor_drag.take() { if let Some(drag) = self.path_anchor_drag.take() {
if drag.moved { if drag.moved {
self.editor_state.history_push_past(drag.pre_drag_snapshot); self.editor_state.history_push_past(drag.pre_drag_snapshot);
@ -684,6 +622,9 @@ impl WidgetHostNative {
if self.component_browser_drag.take().is_some() { if self.component_browser_drag.take().is_some() {
return true; return true;
} }
if self.icon_picker_drag.take().is_some() {
return true;
}
if self.image_adjustment_drag.take().is_some() { if self.image_adjustment_drag.take().is_some() {
return true; return true;
} }

View file

@ -731,6 +731,30 @@ fn icon_picker_click_inserts_icon_font_node() {
assert_eq!(host.editor_state().selection.anchor.as_str(), icon.base.id); assert_eq!(host.editor_state().selection.anchor.as_str(), icon.base.id);
} }
#[test]
fn icon_picker_header_drag_moves_the_panel() {
let mut host = WidgetHostNative::new();
let viewport_w = 1440.0;
let viewport_h = 900.0;
host.editor_state_mut().editor_ui.icon_picker_open = true;
let start = host
.icon_picker_panel_rect(viewport_w, viewport_h)
.expect("icon picker rect");
let press_x = start.origin.x + 72.0;
let press_y = start.origin.y + 20.0;
assert!(host.apply_press(press_x, press_y, viewport_w, viewport_h));
assert!(host.apply_cursor_move(press_x + 96.0, press_y + 44.0));
let moved = host
.icon_picker_panel_rect(viewport_w, viewport_h)
.expect("icon picker rect after drag");
assert_eq!(moved.origin.x, start.origin.x + 96.0);
assert_eq!(moved.origin.y, start.origin.y + 44.0);
assert!(host.apply_release_with_viewport(viewport_w, viewport_h));
}
#[test] #[test]
fn anchor_press_release_without_motion_does_not_push_history() { fn anchor_press_release_without_motion_does_not_push_history() {
// Codex CONCERN: a press-release on an anchor without any // Codex CONCERN: a press-release on an anchor without any

View file

@ -667,6 +667,7 @@ impl WidgetHostNative {
} }
if self.editor_state.editor_ui.icon_picker_open { if self.editor_state.editor_ui.icon_picker_open {
self.editor_state.editor_ui.icon_picker_open = false; self.editor_state.editor_ui.icon_picker_open = false;
self.editor_state.editor_ui.icon_picker_replace_selection = false;
self.editor_state.editor_ui.icon_picker_search.clear(); self.editor_state.editor_ui.icon_picker_search.clear();
self.mark_dirty(); self.mark_dirty();
return true; return true;

View file

@ -368,6 +368,13 @@ impl WidgetHostNative {
return true; return true;
} }
// 0c0a1. Text font-family picker — outside-click dismiss.
if !in_git_panel
&& self.dismiss_font_family_picker_on_press(x, y, viewport_width, viewport_height)
{
return true;
}
// 0c0b. Export scale / format inline select popup — // 0c0b. Export scale / format inline select popup —
// outside-click dismiss (`property_dispatch.rs`). // outside-click dismiss (`property_dispatch.rs`).
if !in_git_panel if !in_git_panel

View file

@ -139,6 +139,13 @@ pub(in crate::widget_host) fn property_focus_initial(
F::PositionY => panel.snapshot.y.to_string(), F::PositionY => panel.snapshot.y.to_string(),
F::SizeW => panel.snapshot.width.to_string(), F::SizeW => panel.snapshot.width.to_string(),
F::SizeH => panel.snapshot.height.to_string(), F::SizeH => panel.snapshot.height.to_string(),
F::LayoutGap => format_panel_number(panel.snapshot.layout_gap),
F::PaddingTop | F::PaddingRight | F::PaddingBottom | F::PaddingLeft => panel
.snapshot
.layout_padding
.value_for(focus)
.map(format_panel_number)
.unwrap_or_else(|| "0".to_string()),
F::Rotation => (panel.snapshot.rotation_deg.round() as i32).to_string(), F::Rotation => (panel.snapshot.rotation_deg.round() as i32).to_string(),
F::PositionR => (panel.snapshot.corner_radius.round() as i32).to_string(), F::PositionR => (panel.snapshot.corner_radius.round() as i32).to_string(),
F::Opacity => "100".to_string(), F::Opacity => "100".to_string(),
@ -164,6 +171,30 @@ pub(in crate::widget_host) fn property_focus_initial(
.map(|a| a.inner_percent) .map(|a| a.inner_percent)
.unwrap_or(0.0), .unwrap_or(0.0),
), ),
F::FontSize => panel
.snapshot
.text
.as_ref()
.map(|t| format_panel_number(t.font_size))
.unwrap_or_else(|| "16".to_string()),
F::FontWeight => panel
.snapshot
.text
.as_ref()
.map(|t| t.font_weight.to_string())
.unwrap_or_else(|| "400".to_string()),
F::LineHeight => panel
.snapshot
.text
.as_ref()
.map(|t| format_panel_number(t.line_height_percent))
.unwrap_or_else(|| "120".to_string()),
F::LetterSpacing => panel
.snapshot
.text
.as_ref()
.map(|t| format_panel_number(t.letter_spacing))
.unwrap_or_else(|| "0".to_string()),
F::FillOpacity => ((panel.snapshot.fill_opacity * 100.0).round() as i32).to_string(), F::FillOpacity => ((panel.snapshot.fill_opacity * 100.0).round() as i32).to_string(),
F::FillHex => panel F::FillHex => panel
.snapshot .snapshot

View file

@ -7,7 +7,8 @@
use super::helpers::parse_hex_color; use super::helpers::parse_hex_color;
use super::WidgetHostNative; use super::WidgetHostNative;
use op_editor_core::ui_draft::PropertyFocus; use jian_ops_schema::sizing::SizingKeyword;
use op_editor_core::PropertyFocus;
impl WidgetHostNative { impl WidgetHostNative {
pub(in crate::widget_host) fn apply_property_action( pub(in crate::widget_host) fn apply_property_action(
@ -17,27 +18,32 @@ impl WidgetHostNative {
use op_editor_ui::widgets::PropertyPanelAction as A; use op_editor_ui::widgets::PropertyPanelAction as A;
match action { match action {
A::SetFlexLayout(mode) => { A::SetFlexLayout(mode) => {
self.editor_state.editor_ui.flex_layout = mode; self.set_selected_layout_mode(mode);
} }
A::ToggleSizeFillWidth => { A::ToggleSizeFillWidth => {
let v = &mut self.editor_state.editor_ui.size_fill_width; self.toggle_selected_sizing(true, SizingKeyword::FillContainer);
*v = !*v;
} }
A::ToggleSizeFillHeight => { A::ToggleSizeFillHeight => {
let v = &mut self.editor_state.editor_ui.size_fill_height; self.toggle_selected_sizing(false, SizingKeyword::FillContainer);
*v = !*v;
} }
A::ToggleSizeHugWidth => { A::ToggleSizeHugWidth => {
let v = &mut self.editor_state.editor_ui.size_hug_width; self.toggle_selected_sizing(true, SizingKeyword::FitContent);
*v = !*v;
} }
A::ToggleSizeHugHeight => { A::ToggleSizeHugHeight => {
let v = &mut self.editor_state.editor_ui.size_hug_height; self.toggle_selected_sizing(false, SizingKeyword::FitContent);
*v = !*v;
} }
A::ToggleSizeClipContent => { A::ToggleSizeClipContent => {
let v = &mut self.editor_state.editor_ui.size_clip_content; self.toggle_selected_clip_content();
*v = !*v; }
A::SetLayoutAlign(value) => {
self.set_selected_layout_align(value);
}
A::SetLayoutJustify(value) => {
self.set_selected_layout_justify(value);
}
A::SetLayoutAlignment { justify, align } => {
self.set_selected_layout_justify(justify);
self.set_selected_layout_align(align);
} }
A::CreateComponent => { A::CreateComponent => {
let id = self.editor_state.selection.anchor.clone(); let id = self.editor_state.selection.anchor.clone();
@ -49,16 +55,34 @@ impl WidgetHostNative {
let ui = &mut self.editor_state.editor_ui; let ui = &mut self.editor_state.editor_ui;
ui.fill_type_picker_open = !ui.fill_type_picker_open; ui.fill_type_picker_open = !ui.fill_type_picker_open;
ui.image_fill_popover_open = false; ui.image_fill_popover_open = false;
ui.font_family_picker_open = false;
} }
A::SetFillType(t) => { A::SetFillType(t) => {
self.editor_state.set_selected_fill_type(t); self.editor_state.set_selected_fill_type(t);
self.editor_state.editor_ui.fill_type_picker_open = false; self.editor_state.editor_ui.fill_type_picker_open = false;
self.editor_state.editor_ui.image_fill_popover_open = false; self.editor_state.editor_ui.image_fill_popover_open = false;
} }
A::AddFill => {
let _ = self
.editor_state
.set_selected_fill_type(op_editor_core::FillType::Solid);
}
A::RemoveFill => {
let _ = self.editor_state.clear_selected_fills();
self.editor_state.editor_ui.fill_type_picker_open = false;
self.editor_state.editor_ui.image_fill_popover_open = false;
}
A::AddGradientStop => {
let _ = self.editor_state.add_selected_gradient_stop();
}
A::RemoveGradientStop(index) => {
let _ = self.editor_state.remove_selected_gradient_stop(index);
}
A::ToggleImageFillPopover => { A::ToggleImageFillPopover => {
let ui = &mut self.editor_state.editor_ui; let ui = &mut self.editor_state.editor_ui;
ui.image_fill_popover_open = !ui.image_fill_popover_open; ui.image_fill_popover_open = !ui.image_fill_popover_open;
ui.fill_type_picker_open = false; ui.fill_type_picker_open = false;
ui.font_family_picker_open = false;
ui.export_scale_picker_open = false; ui.export_scale_picker_open = false;
ui.export_format_picker_open = false; ui.export_format_picker_open = false;
} }
@ -78,6 +102,38 @@ impl WidgetHostNative {
self.image_adjustment_drag = None; self.image_adjustment_drag = None;
let _ = self.editor_state.reset_selected_image_adjustments(); let _ = self.editor_state.reset_selected_image_adjustments();
} }
A::OpenSelectedIconPicker => {
let ui = &mut self.editor_state.editor_ui;
ui.icon_picker_open = true;
ui.icon_picker_replace_selection = true;
ui.icon_picker_search.clear();
ui.fill_type_picker_open = false;
ui.image_fill_popover_open = false;
ui.font_family_picker_open = false;
ui.export_scale_picker_open = false;
ui.export_format_picker_open = false;
}
A::SetTextAlign(value) => {
self.set_selected_text_align(value);
}
A::SetTextVerticalAlign(value) => {
self.set_selected_text_vertical_align(value);
}
A::SetTextGrowth(value) => {
self.set_selected_text_growth(value);
}
A::ToggleFontFamilyPicker => {
let ui = &mut self.editor_state.editor_ui;
ui.font_family_picker_open = !ui.font_family_picker_open;
ui.fill_type_picker_open = false;
ui.image_fill_popover_open = false;
ui.export_scale_picker_open = false;
ui.export_format_picker_open = false;
}
A::SetFontFamily(choice) => {
self.set_selected_text_font_family(choice.family());
self.editor_state.editor_ui.font_family_picker_open = false;
}
A::OpenColorPicker(target) => { A::OpenColorPicker(target) => {
// Fallback anchor when called outside the press path. // Fallback anchor when called outside the press path.
let _ = self let _ = self
@ -88,12 +144,14 @@ impl WidgetHostNative {
let ui = &mut self.editor_state.editor_ui; let ui = &mut self.editor_state.editor_ui;
ui.export_scale_picker_open = !ui.export_scale_picker_open; ui.export_scale_picker_open = !ui.export_scale_picker_open;
ui.export_format_picker_open = false; ui.export_format_picker_open = false;
ui.font_family_picker_open = false;
ui.export_picker_hover = None; ui.export_picker_hover = None;
} }
A::ToggleExportFormatPicker => { A::ToggleExportFormatPicker => {
let ui = &mut self.editor_state.editor_ui; let ui = &mut self.editor_state.editor_ui;
ui.export_format_picker_open = !ui.export_format_picker_open; ui.export_format_picker_open = !ui.export_format_picker_open;
ui.export_scale_picker_open = false; ui.export_scale_picker_open = false;
ui.font_family_picker_open = false;
ui.export_picker_hover = None; ui.export_picker_hover = None;
} }
A::SetExportScale(scale) => { A::SetExportScale(scale) => {
@ -274,6 +332,42 @@ impl WidgetHostNative {
true true
} }
pub(in crate::widget_host) fn dismiss_font_family_picker_on_press(
&mut self,
x: f32,
y: f32,
viewport_width: f32,
viewport_height: f32,
) -> bool {
use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction as A, TOP_BAR_HEIGHT};
use op_editor_ui::{Point2D, Rect};
if !self.editor_state.editor_ui.font_family_picker_open {
return false;
}
self.refresh_layout_scene();
if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) {
let property_rect = Rect {
origin: Point2D::new(
viewport_width - self.editor_state.editor_ui.property_panel_width,
TOP_BAR_HEIGHT,
),
size: Point2D::new(
self.editor_state.editor_ui.property_panel_width,
(viewport_height - TOP_BAR_HEIGHT).max(0.0),
),
};
if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) {
if matches!(action, A::SetFontFamily(_) | A::ToggleFontFamilyPicker) {
self.apply_property_action(action);
return true;
}
}
}
self.editor_state.editor_ui.font_family_picker_open = false;
self.mark_dirty();
true
}
/// Export-dialog press dispatcher. /// Export-dialog press dispatcher.
pub(in crate::widget_host) fn dispatch_export_dialog_press( pub(in crate::widget_host) fn dispatch_export_dialog_press(
&mut self, &mut self,

View file

@ -0,0 +1,221 @@
//! Layout and text property dispatch helpers for the native property panel.
use super::WidgetHostNative;
use jian_ops_schema::node::PenNode;
use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
use op_editor_core::ui_draft::PropertyFocus;
impl WidgetHostNative {
pub(in crate::widget_host) fn set_selected_layout_mode(
&mut self,
mode: op_editor_core::FlexLayout,
) {
let id = self.editor_state.selection.anchor.clone();
if !id.is_real() {
return;
}
let value = match mode {
op_editor_core::FlexLayout::Free => "none",
op_editor_core::FlexLayout::Vertical => "vertical",
op_editor_core::FlexLayout::Horizontal => "horizontal",
};
self.editor_state.commit_history();
let _ = self
.editor_state
.apply(op_editor_core::EditorCommand::SetNodeLayoutProp {
node_id: id,
property: "layout".to_string(),
value: op_editor_core::LayoutPropValue::Keyword(value.to_string()),
});
}
pub(in crate::widget_host) fn toggle_selected_sizing(
&mut self,
width: bool,
keyword: SizingKeyword,
) {
let id = self.editor_state.selection.anchor.clone();
if !id.is_real() {
return;
}
let (is_current, fallback) = {
let Some(node) = self.editor_state.selected_node() else {
return;
};
let sizing = selected_sizing(node, width);
let is_current = matches!(sizing, Some(SizingBehavior::Keyword(k)) if *k == keyword);
let bounds = op_editor_core::aggregate_bounds(node);
(is_current, if width { bounds.w } else { bounds.h })
};
self.editor_state.commit_history();
if is_current {
let focus = if width {
PropertyFocus::SizeW
} else {
PropertyFocus::SizeH
};
let _ = self
.editor_state
.commit_property_edit(focus, fallback.max(0.0) as f32);
} else {
let prop = if width { "width" } else { "height" };
let value = match keyword {
SizingKeyword::FitContent => "fit_content",
SizingKeyword::FillContainer => "fill_container",
};
let _ = self
.editor_state
.apply(op_editor_core::EditorCommand::SetNodeLayoutProp {
node_id: id,
property: prop.to_string(),
value: op_editor_core::LayoutPropValue::Keyword(value.to_string()),
});
}
}
pub(in crate::widget_host) fn toggle_selected_clip_content(&mut self) {
let id = self.editor_state.selection.anchor.clone();
if !id.is_real() {
return;
}
let current = self
.editor_state
.selected_node()
.map(selected_clip_content)
.unwrap_or(false);
self.editor_state.commit_history();
let _ = self
.editor_state
.apply(op_editor_core::EditorCommand::SetNodeLayoutProp {
node_id: id,
property: "clipContent".to_string(),
value: op_editor_core::LayoutPropValue::Bool(!current),
});
}
pub(in crate::widget_host) fn set_selected_text_align(
&mut self,
value: op_editor_ui::widgets::property_panel::TextAlignValue,
) {
let keyword = match value {
op_editor_ui::widgets::property_panel::TextAlignValue::Left => "left",
op_editor_ui::widgets::property_panel::TextAlignValue::Center => "center",
op_editor_ui::widgets::property_panel::TextAlignValue::Right => "right",
op_editor_ui::widgets::property_panel::TextAlignValue::Justify => "justify",
};
self.set_selected_layout_keyword("textAlign", keyword);
}
pub(in crate::widget_host) fn set_selected_layout_align(
&mut self,
value: op_editor_ui::widgets::property_panel::LayoutAlignValue,
) {
let keyword = match value {
op_editor_ui::widgets::property_panel::LayoutAlignValue::Start => "start",
op_editor_ui::widgets::property_panel::LayoutAlignValue::Center => "center",
op_editor_ui::widgets::property_panel::LayoutAlignValue::End => "end",
};
self.set_selected_layout_keyword("alignItems", keyword);
}
pub(in crate::widget_host) fn set_selected_layout_justify(
&mut self,
value: op_editor_ui::widgets::property_panel::LayoutJustifyValue,
) {
let keyword = match value {
op_editor_ui::widgets::property_panel::LayoutJustifyValue::Start => "start",
op_editor_ui::widgets::property_panel::LayoutJustifyValue::Center => "center",
op_editor_ui::widgets::property_panel::LayoutJustifyValue::End => "end",
op_editor_ui::widgets::property_panel::LayoutJustifyValue::SpaceBetween => {
"space_between"
}
op_editor_ui::widgets::property_panel::LayoutJustifyValue::SpaceAround => {
"space_around"
}
};
self.set_selected_layout_keyword("justifyContent", keyword);
}
pub(in crate::widget_host) fn set_selected_text_vertical_align(
&mut self,
value: op_editor_ui::widgets::property_panel::TextVerticalAlignValue,
) {
let keyword = match value {
op_editor_ui::widgets::property_panel::TextVerticalAlignValue::Top => "top",
op_editor_ui::widgets::property_panel::TextVerticalAlignValue::Middle => "middle",
op_editor_ui::widgets::property_panel::TextVerticalAlignValue::Bottom => "bottom",
};
self.set_selected_layout_keyword("textAlignVertical", keyword);
}
pub(in crate::widget_host) fn set_selected_text_growth(
&mut self,
value: op_editor_ui::widgets::property_panel::TextGrowthValue,
) {
let keyword = match value {
op_editor_ui::widgets::property_panel::TextGrowthValue::Auto => "auto",
op_editor_ui::widgets::property_panel::TextGrowthValue::FixedWidth => "fixed-width",
op_editor_ui::widgets::property_panel::TextGrowthValue::FixedWidthHeight => {
"fixed-width-height"
}
};
self.set_selected_layout_keyword("textGrowth", keyword);
}
pub(in crate::widget_host) fn set_selected_text_font_family(&mut self, family: &str) {
if family.trim().is_empty() {
return;
}
self.set_selected_layout_keyword("fontFamily", family);
}
fn set_selected_layout_keyword(&mut self, property: &str, keyword: &str) {
let id = self.editor_state.selection.anchor.clone();
if !id.is_real() {
return;
}
self.editor_state.commit_history();
let _ = self
.editor_state
.apply(op_editor_core::EditorCommand::SetNodeLayoutProp {
node_id: id,
property: property.to_string(),
value: op_editor_core::LayoutPropValue::Keyword(keyword.to_string()),
});
}
}
fn selected_sizing(node: &PenNode, width: bool) -> Option<&SizingBehavior> {
match (node, width) {
(PenNode::Frame(n), true) => n.container.width.as_ref(),
(PenNode::Frame(n), false) => n.container.height.as_ref(),
(PenNode::Group(n), true) => n.container.width.as_ref(),
(PenNode::Group(n), false) => n.container.height.as_ref(),
(PenNode::Rectangle(n), true) => n.container.width.as_ref(),
(PenNode::Rectangle(n), false) => n.container.height.as_ref(),
(PenNode::Ellipse(n), true) => n.width.as_ref(),
(PenNode::Ellipse(n), false) => n.height.as_ref(),
(PenNode::Polygon(n), true) => n.width.as_ref(),
(PenNode::Polygon(n), false) => n.height.as_ref(),
(PenNode::Path(n), true) => n.width.as_ref(),
(PenNode::Path(n), false) => n.height.as_ref(),
(PenNode::Text(n), true) => n.width.as_ref(),
(PenNode::Text(n), false) => n.height.as_ref(),
(PenNode::TextInput(n), true) => n.width.as_ref(),
(PenNode::TextInput(n), false) => n.height.as_ref(),
(PenNode::Image(n), true) => n.width.as_ref(),
(PenNode::Image(n), false) => n.height.as_ref(),
(PenNode::IconFont(n), true) => n.width.as_ref(),
(PenNode::IconFont(n), false) => n.height.as_ref(),
(PenNode::Line(_) | PenNode::Ref(_), _) => None,
}
}
fn selected_clip_content(node: &PenNode) -> bool {
match node {
PenNode::Frame(n) => n.container.clip_content.unwrap_or(false),
PenNode::Group(n) => n.container.clip_content.unwrap_or(false),
PenNode::Rectangle(n) => n.container.clip_content.unwrap_or(false),
_ => false,
}
}

View file

@ -28,6 +28,7 @@ impl WidgetHostNative {
} }
ShapeChoice::OpenIconPicker => { ShapeChoice::OpenIconPicker => {
self.editor_state.editor_ui.icon_picker_open = true; self.editor_state.editor_ui.icon_picker_open = true;
self.editor_state.editor_ui.icon_picker_replace_selection = false;
self.editor_state.editor_ui.icon_picker_search.clear(); self.editor_state.editor_ui.icon_picker_search.clear();
} }
ShapeChoice::ImportImageOrSvg => { ShapeChoice::ImportImageOrSvg => {

View file

@ -159,6 +159,7 @@ impl WidgetHost {
// now. A future implementation would surface a // now. A future implementation would surface a
// `<input type="file">` via the JS bridge. // `<input type="file">` via the JS bridge.
} }
_ => {}
} }
self.mark_dirty(); self.mark_dirty();
} }

View file

@ -564,8 +564,23 @@ fn text_to_payload(n: &TextNode) -> NodePayload {
.join(""), .join(""),
}); });
assign_first_fill(&mut p, n.fill.as_deref()); assign_first_fill(&mut p, n.fill.as_deref());
p.font_family = n.font_family.clone().unwrap_or_default();
p.font_size = n.font_size.unwrap_or(0.0) as f32; p.font_size = n.font_size.unwrap_or(0.0) as f32;
p.font_weight = resolve_font_weight(n.font_weight.as_ref()); p.font_weight = resolve_font_weight(n.font_weight.as_ref());
p.line_height = n.line_height.unwrap_or(0.0) as f32;
p.letter_spacing = n.letter_spacing.unwrap_or(0.0) as f32;
p.text_align = n
.text_align
.as_ref()
.map(text_align_keyword)
.unwrap_or("")
.to_string();
p.text_vertical_align = n
.text_align_vertical
.as_ref()
.map(text_vertical_align_keyword)
.unwrap_or("")
.to_string();
// Only wrap text when the schema explicitly authored // Only wrap text when the schema explicitly authored
// `textGrowth: fixed-width` (or fixed-width-and-height) — // `textGrowth: fixed-width` (or fixed-width-and-height) —
// matches canonical paint behaviour. Default-growth text was // matches canonical paint behaviour. Default-growth text was
@ -613,6 +628,23 @@ fn resolve_font_weight(w: Option<&FontWeight>) -> u16 {
} }
} }
fn text_align_keyword(value: &jian_ops_schema::node::TextAlign) -> &'static str {
match value {
jian_ops_schema::node::TextAlign::Left => "left",
jian_ops_schema::node::TextAlign::Center => "center",
jian_ops_schema::node::TextAlign::Right => "right",
jian_ops_schema::node::TextAlign::Justify => "justify",
}
}
fn text_vertical_align_keyword(value: &jian_ops_schema::node::TextAlignVertical) -> &'static str {
match value {
jian_ops_schema::node::TextAlignVertical::Top => "top",
jian_ops_schema::node::TextAlignVertical::Middle => "middle",
jian_ops_schema::node::TextAlignVertical::Bottom => "bottom",
}
}
fn text_input_to_payload(n: &TextInputNode) -> NodePayload { fn text_input_to_payload(n: &TextInputNode) -> NodePayload {
let mut p = base_payload(&n.base, "text"); let mut p = base_payload(&n.base, "text");
p.text = n.value.clone().or_else(|| n.placeholder.clone()); p.text = n.value.clone().or_else(|| n.placeholder.clone());
@ -652,6 +684,10 @@ fn icon_font_to_payload(n: &IconFontNode) -> NodePayload {
// scale-to-fit + stroke style. // scale-to-fit + stroke style.
let mut p = base_payload(&n.base, "icon_font"); let mut p = base_payload(&n.base, "icon_font");
p.text = Some(n.icon_font_name.clone()); p.text = Some(n.icon_font_name.clone());
p.font_family = n
.icon_font_family
.clone()
.unwrap_or_else(|| "lucide".to_string());
assign_first_fill(&mut p, n.fill.as_deref()); assign_first_fill(&mut p, n.fill.as_deref());
p p
} }
@ -675,6 +711,7 @@ fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload {
fill: None, fill: None,
stroke: None, stroke: None,
text: None, text: None,
font_family: String::new(),
rotation: (base.rotation.unwrap_or(0.0) as f32).to_radians(), rotation: (base.rotation.unwrap_or(0.0) as f32).to_radians(),
corner_radius: 0.0, corner_radius: 0.0,
arc_start_angle: None, arc_start_angle: None,
@ -692,6 +729,10 @@ fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload {
svg_path: None, svg_path: None,
font_size: 0.0, font_size: 0.0,
font_weight: 0, font_weight: 0,
line_height: 0.0,
letter_spacing: 0.0,
text_align: String::new(),
text_vertical_align: String::new(),
text_wrap: false, text_wrap: false,
effects: Vec::new(), effects: Vec::new(),
image_src: None, image_src: None,

View file

@ -24,7 +24,7 @@
use op_editor_ui::layout_scene::NodeKind; use op_editor_ui::layout_scene::NodeKind;
use op_editor_ui::layout_scene::{ use op_editor_ui::layout_scene::{
LayoutScene, SceneFillType, SceneGradient, SceneGradientStop, SceneImageFit, SceneNode, LayoutScene, SceneFillType, SceneGradient, SceneGradientStop, SceneImageFit, SceneNode,
ScenePage, SceneStroke, ScenePage, SceneStroke, SceneTextAlign, SceneTextVerticalAlign,
}; };
use op_editor_ui::scene_vars::VariableTable; use op_editor_ui::scene_vars::VariableTable;
use op_editor_ui::Color; use op_editor_ui::Color;
@ -112,8 +112,13 @@ fn node_payload_to_scene(node: &NodePayload, var_table: &VariableTable) -> Scene
.as_ref() .as_ref()
.map(|s| scene_stroke(s, &node_id, var_table)), .map(|s| scene_stroke(s, &node_id, var_table)),
text: node.text.clone(), text: node.text.clone(),
font_family: node.font_family.clone(),
font_size: node.font_size, font_size: node.font_size,
font_weight: node.font_weight, font_weight: node.font_weight,
line_height: node.line_height,
letter_spacing: node.letter_spacing,
text_align: text_align_to_scene(&node.text_align),
text_vertical_align: text_vertical_align_to_scene(&node.text_vertical_align),
text_wrap: node.text_wrap, text_wrap: node.text_wrap,
points: node points: node
.points .points
@ -141,6 +146,23 @@ fn node_payload_to_scene(node: &NodePayload, var_table: &VariableTable) -> Scene
} }
} }
fn text_align_to_scene(value: &str) -> SceneTextAlign {
match value {
"center" => SceneTextAlign::Center,
"right" => SceneTextAlign::Right,
"justify" => SceneTextAlign::Justify,
_ => SceneTextAlign::Left,
}
}
fn text_vertical_align_to_scene(value: &str) -> SceneTextVerticalAlign {
match value {
"middle" => SceneTextVerticalAlign::Middle,
"bottom" => SceneTextVerticalAlign::Bottom,
_ => SceneTextVerticalAlign::Top,
}
}
fn image_fit_to_scene(value: Option<&str>) -> SceneImageFit { fn image_fit_to_scene(value: Option<&str>) -> SceneImageFit {
match value { match value {
Some("fit") => SceneImageFit::Fit, Some("fit") => SceneImageFit::Fit,

View file

@ -132,6 +132,33 @@ fn variable_ref_fill_resolves_to_concrete_color() {
assert!(fill.b.abs() < 0.01, "blue channel: {}", fill.b); assert!(fill.b.abs() < 0.01, "blue channel: {}", fill.b);
} }
#[test]
fn text_typography_fields_flow_through_to_scene() {
let src = r##"{
"version":"1.0.0","pages":[{"id":"p","name":"P","children":[
{"type":"text","id":"t","x":10,"y":20,"width":200,"height":80,
"content":"hello",
"fontFamily":"Georgia","fontSize":28,"fontWeight":700,
"lineHeight":1.6,"letterSpacing":3,
"textAlign":"center","textAlignVertical":"middle",
"textGrowth":"fixed-width-height"}
]}],"children":[]
}"##;
let scene = editor_state_to_layout_scene(&state_from(src));
let n = scene.pages[0].find("t").expect("text node in scene");
assert_eq!(n.font_family, "Georgia");
assert_eq!(n.font_size, 28.0);
assert_eq!(n.font_weight, 700);
assert_eq!(n.line_height, 1.6);
assert_eq!(n.letter_spacing, 3.0);
assert_eq!(n.text_align, op_editor_ui::SceneTextAlign::Center);
assert_eq!(
n.text_vertical_align,
op_editor_ui::SceneTextVerticalAlign::Middle
);
assert!(n.text_wrap);
}
#[test] #[test]
fn no_variable_ref_keeps_authored_fill() { fn no_variable_ref_keeps_authored_fill() {
// Without a registered `$ref`, the node keeps its authored fill. // Without a registered `$ref`, the node keeps its authored fill.

View file

@ -51,6 +51,9 @@ pub struct NodePayload {
pub stroke: Option<StrokePayload>, pub stroke: Option<StrokePayload>,
#[serde(default)] #[serde(default)]
pub text: Option<String>, pub text: Option<String>,
/// CSS font-family stack. Text-only.
#[serde(default)]
pub font_family: String,
#[serde(default)] #[serde(default)]
pub rotation: f32, pub rotation: f32,
#[serde(default)] #[serde(default)]
@ -105,6 +108,18 @@ pub struct NodePayload {
/// CSS-style font weight (100-900). 0 = default 400. Text-only. /// CSS-style font weight (100-900). 0 = default 400. Text-only.
#[serde(default)] #[serde(default)]
pub font_weight: u16, pub font_weight: u16,
/// Line-height multiplier. 0 = renderer default. Text-only.
#[serde(default)]
pub line_height: f32,
/// Extra letter spacing in doc-px. Text-only.
#[serde(default)]
pub letter_spacing: f32,
/// Horizontal alignment keyword. Text-only.
#[serde(default)]
pub text_align: String,
/// Vertical alignment keyword. Text-only.
#[serde(default)]
pub text_vertical_align: String,
#[serde(default)] #[serde(default)]
pub text_wrap: bool, pub text_wrap: bool,
/// Drop-shadow effects. /// Drop-shadow effects.

View file

@ -0,0 +1,152 @@
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const SETS = ['lucide', 'feather', 'simple-icons'];
const OUT = path.resolve('crates/op-editor-ui/assets/iconify-catalog.json');
function attr(tag, name) {
const match = tag.match(new RegExp(`\\b${name}="([^"]*)"`));
return match ? match[1] : undefined;
}
function num(value, fallback = 0) {
if (value === undefined || value === '') return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function circlePath(cx, cy, r) {
return `M${cx - r} ${cy}A${r} ${r} 0 1 0 ${cx + r} ${cy}A${r} ${r} 0 1 0 ${cx - r} ${cy}Z`;
}
function ellipsePath(cx, cy, rx, ry) {
return `M${cx - rx} ${cy}A${rx} ${ry} 0 1 0 ${cx + rx} ${cy}A${rx} ${ry} 0 1 0 ${cx - rx} ${cy}Z`;
}
function rectPath(x, y, w, h, r) {
if (r <= 0) return `M${x} ${y}H${x + w}V${y + h}H${x}Z`;
const rr = Math.min(r, w / 2, h / 2);
return [
`M${x + rr} ${y}`,
`H${x + w - rr}`,
`A${rr} ${rr} 0 0 1 ${x + w} ${y + rr}`,
`V${y + h - rr}`,
`A${rr} ${rr} 0 0 1 ${x + w - rr} ${y + h}`,
`H${x + rr}`,
`A${rr} ${rr} 0 0 1 ${x} ${y + h - rr}`,
`V${y + rr}`,
`A${rr} ${rr} 0 0 1 ${x + rr} ${y}`,
'Z',
].join('');
}
function pointsPath(points, close) {
const nums = points
.trim()
.split(/[\s,]+/)
.map(Number)
.filter(Number.isFinite);
if (nums.length < 4) return null;
const pairs = [];
for (let i = 0; i + 1 < nums.length; i += 2) pairs.push([nums[i], nums[i + 1]]);
return `M${pairs.map((p) => `${p[0]} ${p[1]}`).join('L')}${close ? 'Z' : ''}`;
}
function pathsFromBody(body) {
const paths = [];
for (const match of body.matchAll(/<path\b[^>]*?\bd="([^"]+)"[^>]*>/gi)) {
paths.push(match[1]);
}
for (const match of body.matchAll(/<line\b[^>]*>/gi)) {
const tag = match[0];
paths.push(
`M${num(attr(tag, 'x1'))} ${num(attr(tag, 'y1'))}L${num(attr(tag, 'x2'))} ${num(attr(tag, 'y2'))}`,
);
}
for (const match of body.matchAll(/<circle\b[^>]*>/gi)) {
const tag = match[0];
paths.push(circlePath(num(attr(tag, 'cx')), num(attr(tag, 'cy')), num(attr(tag, 'r'))));
}
for (const match of body.matchAll(/<ellipse\b[^>]*>/gi)) {
const tag = match[0];
paths.push(
ellipsePath(
num(attr(tag, 'cx')),
num(attr(tag, 'cy')),
num(attr(tag, 'rx')),
num(attr(tag, 'ry')),
),
);
}
for (const match of body.matchAll(/<rect\b[^>]*>/gi)) {
const tag = match[0];
paths.push(
rectPath(
num(attr(tag, 'x')),
num(attr(tag, 'y')),
num(attr(tag, 'width')),
num(attr(tag, 'height')),
num(attr(tag, 'rx'), num(attr(tag, 'ry'))),
),
);
}
for (const match of body.matchAll(/<polyline\b[^>]*?\bpoints="([^"]+)"[^>]*>/gi)) {
const d = pointsPath(match[1], false);
if (d) paths.push(d);
}
for (const match of body.matchAll(/<polygon\b[^>]*?\bpoints="([^"]+)"[^>]*>/gi)) {
const d = pointsPath(match[1], true);
if (d) paths.push(d);
}
for (let i = 1; i < paths.length; i += 1) {
if (paths[i].startsWith('m')) paths[i] = `M${paths[i].slice(1)}`;
}
return paths;
}
function styleFromBody(body) {
const hasStroke = /\bstroke=|\bstroke-width=|\bstroke-linecap=|\bfill="none"/i.test(body);
const hasFill = /\bfill="(?!none)[^"]*"/i.test(body);
return hasStroke && !hasFill ? 'stroke' : 'fill';
}
const icons = [];
for (const collection of SETS) {
const data = require(`../node_modules/@iconify-json/${collection}/icons.json`);
const names = Object.keys(data.icons).sort((a, b) => a.localeCompare(b));
for (const name of names) {
const icon = data.icons[name];
const paths = pathsFromBody(icon.body);
if (paths.length === 0) continue;
icons.push({
collection,
name,
width: icon.width ?? data.width ?? 24,
height: icon.height ?? data.height ?? 24,
style: styleFromBody(icon.body),
d: paths.join(' '),
});
}
}
for (const [alias, target] of Object.entries({ home: 'house', unlock: 'lock-open' })) {
if (
!icons.some((icon) => icon.collection === 'lucide' && icon.name === alias) &&
icons.some((icon) => icon.collection === 'lucide' && icon.name === target)
) {
const source = icons.find((icon) => icon.collection === 'lucide' && icon.name === target);
icons.push({ ...source, name: alias });
}
}
icons.sort((a, b) => {
const setDelta = SETS.indexOf(a.collection) - SETS.indexOf(b.collection);
return setDelta || a.name.localeCompare(b.name);
});
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, `${JSON.stringify({ icons })}\n`);
console.log(`wrote ${icons.length} icons to ${OUT}`);