mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
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
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:
parent
84e2b5f2bf
commit
b0b52a7842
66 changed files with 5634 additions and 1000 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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]]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
33
crates/op-editor-core/src/icon_picker_state.rs
Normal file
33
crates/op-editor-core/src/icon_picker_state.rs
Normal 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>,
|
||||||
|
}
|
||||||
69
crates/op-editor-core/src/image_node_props.rs
Normal file
69
crates/op-editor-core/src/image_node_props.rs
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
286
crates/op-editor-core/src/property_edit_mutators.rs
Normal file
286
crates/op-editor-core/src/property_edit_mutators.rs
Normal 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],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]`.
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
1
crates/op-editor-ui/assets/iconify-catalog.json
Normal file
1
crates/op-editor-ui/assets/iconify-catalog.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
281
crates/op-editor-ui/src/widgets/icon_catalog.rs
Normal file
281
crates/op-editor-ui/src/widgets/icon_catalog.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
189
crates/op-editor-ui/src/widgets/icons_tests.rs
Normal file
189
crates/op-editor-ui/src/widgets/icons_tests.rs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ pub fn paint_export_picker(
|
||||||
visible,
|
visible,
|
||||||
effects,
|
effects,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
scale_open,
|
scale_open,
|
||||||
format_open,
|
format_open,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
541
crates/op-editor-ui/src/widgets/property_panel_flex.rs
Normal file
541
crates/op-editor-ui/src/widgets/property_panel_flex.rs
Normal 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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
147
crates/op-editor-ui/src/widgets/property_panel_icon.rs
Normal file
147
crates/op-editor-ui/src/widgets/property_panel_icon.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
108
crates/op-editor-ui/src/widgets/property_panel_icon_tests.rs
Normal file
108
crates/op-editor-ui/src/widgets/property_panel_icon_tests.rs
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
82
crates/op-editor-ui/src/widgets/property_panel_image_node.rs
Normal file
82
crates/op-editor-ui/src/widgets/property_panel_image_node.rs
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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((
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
621
crates/op-editor-ui/src/widgets/property_panel_text.rs
Normal file
621
crates/op-editor-ui/src/widgets/property_panel_text.rs
Normal 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(['"', '\''])
|
||||||
|
}
|
||||||
225
crates/op-editor-ui/src/widgets/property_panel_visibility.rs
Normal file
225
crates/op-editor-ui/src/widgets/property_panel_visibility.rs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
254
crates/op-host-desktop/src/iconify_host.rs
Normal file
254
crates/op-host-desktop/src/iconify_host.rs
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 => {}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
152
scripts/generate-iconify-catalog.mjs
Normal file
152
scripts/generate-iconify-catalog.mjs
Normal 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}`);
|
||||||
Loading…
Reference in a new issue