nodes: text becomes structured footage with a real text engine
Text is no longer a hand-written HTML effect (docs in docs/zh/plans/text-footage-redesign.md): - textv3 gains structured inputs - plain text, font family/size, font color, outline (enable/color/width), glow (enable/color/radius); the legacy text_in HTML is hidden and auto-migrated to plain text on load. - Outline and glow render as GPU post-process chains (dilate/blur + colorize under the text); the font color tints the raster premultiplied. Plain text now rasterizes even with both passes off (previously a null deferred job), fixing a use-after-free where the handle was lifted out of an owning Option<NodeValue> before addref. - A cosmic-text backend (the lockfile's 0.19) installs at engine startup through the textbackend hooks and feeds the font-family combo. - The project panel gains 添加文本素材 next to 新建序列: a text entry in the bin that drops onto the timeline as a clip (one undo row), its parameters shown as structured fields in the inspector (multiline text area, no HTML anywhere). text3 is hidden from the effect add menus; legacy text3 chains keep evaluating.
This commit is contained in:
Generated
+1
@@ -4723,6 +4723,7 @@ dependencies = [
|
||||
name = "oak-app"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cosmic-text",
|
||||
"embed-resource",
|
||||
"gpui",
|
||||
"gpui_elements",
|
||||
|
||||
@@ -65,6 +65,11 @@ serde_yaml = "0.9"
|
||||
# stderr backend (see oakapp::logging) so validation errors and warnings
|
||||
# are actually visible (RUST_LOG selects the verbosity).
|
||||
log = "0.4"
|
||||
# Text shaping/rasterization for the text generator nodes: the app is the
|
||||
# facade layer that installs oak-node's text backend hooks (see
|
||||
# oakui/textengine.rs). Pinned to the version the gpui_wgpu backend already
|
||||
# links so the lockfile keeps a single cosmic-text.
|
||||
cosmic-text = "=0.19.0"
|
||||
|
||||
# M14 R3: the app is a PURE module-crate consumer — every engine call is a
|
||||
# direct Rust call into the oak* rlibs (oak-node for the project graph,
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
//! tested).
|
||||
//! * [`timecode`] — timecode / duration / fps / resolution formatting (pure,
|
||||
//! unit tested).
|
||||
//! * [`textengine`] — the cosmic-text backend behind the text generator
|
||||
//! nodes: the measure/render hooks they call through (`install`), and the
|
||||
//! system font list feeding their font-family combo.
|
||||
|
||||
pub mod audio_thread;
|
||||
pub mod component;
|
||||
@@ -57,6 +60,7 @@ pub mod projectbrowser;
|
||||
pub mod real;
|
||||
pub mod renderops;
|
||||
pub mod scopes;
|
||||
pub mod textengine;
|
||||
pub mod timecode;
|
||||
pub mod transport;
|
||||
pub mod waveform;
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! cosmic-text backend for the text generator nodes: the facade-layer half
|
||||
//! of [`oak_node::nodes::textbackend`].
|
||||
//!
|
||||
//! `oak-node` deliberately links no font or shaping crate: the text
|
||||
//! generator nodes describe their job with the
|
||||
//! [`TextLayoutRequest`](oak_node::nodes::textbackend::TextLayoutRequest) /
|
||||
//! [`TextRenderTarget`](oak_node::nodes::textbackend::TextRenderTarget) PODs
|
||||
//! and call the two function-pointer hooks that [`install`] fills in. This
|
||||
//! module is that backend: layout and rasterization run on `cosmic-text` +
|
||||
//! `swash`, the same stack the `gpui_wgpu` text system already links, so the
|
||||
//! app keeps a single font stack and a single `cosmic-text` in the lockfile.
|
||||
//!
|
||||
//! [`RealEngine::new`](super::real::RealEngine::new) calls [`install`] once;
|
||||
//! [`font_families`] is the other entry point, feeding the
|
||||
//! `font_family_in` combo of the text nodes (see
|
||||
//! [`super::effectchain::effect_params`]).
|
||||
//!
|
||||
//! # Units
|
||||
//!
|
||||
//! [`TextLayoutRequest::dots_per_meter`] is the paint device resolution
|
||||
//! (Qt's `QTextDocument`/`QPainter` device metric); font sizes and geometry
|
||||
//! in the request are points, and the laid-out document the hooks return and
|
||||
//! draw in is sized in *device pixels*, exactly like the `QTextDocument`
|
||||
//! this replaced. The conversion is `px = pt * dots_per_meter * 0.0254 / 72`
|
||||
//! (3780 dots/meter, the Qt default for 96 DPI, gives the familiar 4/3
|
||||
//! factor); a zero or negative `dots_per_meter` falls back to 3780 and a
|
||||
//! non-positive or non-finite `font_size_pt` to 72 pt. Both fallbacks match
|
||||
//! the text nodes' own defaults (`oak_node::nodes::textv3`'s
|
||||
//! `font_size_in` default is 72 pt).
|
||||
//!
|
||||
//! # Approximations compared to the former Qt implementation
|
||||
//!
|
||||
//! * Line spacing is `1.2 * font_size` (Qt's single spacing depends on the
|
||||
//! font's own metrics).
|
||||
//! * `Html` / `OliveHtml` requests are flattened by [`html_to_plain`]
|
||||
//! instead of being laid out as rich text: tags carry no formatting here,
|
||||
//! and the text is painted white throughout — per-span colors, bold /
|
||||
//! italic runs and the rich-text alignment / list layout of the old
|
||||
//! `QTextDocument` are not reproduced.
|
||||
//! * Text decorations (underline / strikethrough) are not painted.
|
||||
//! * `center_horizontally` maps to [`Align::Center`] over the wrap width;
|
||||
//! like the C++ default `QTextOption(Qt::AlignCenter)` it is a no-op when
|
||||
//! the request carries no wrap width.
|
||||
//! * A request whose flattened text is empty measures 0×0 and paints
|
||||
//! nothing (an empty `QTextDocument` reports one empty line's height).
|
||||
//!
|
||||
//! The layout and rasterization themselves follow the C++ contract to the
|
||||
//! pixel: a document point `p` lands at
|
||||
//! `((p.x + draw_offset_x) * scale, (p.y + draw_offset_y) * scale)`, clipped
|
||||
//! to the scaled clip rect, and the two target formats are the Qt
|
||||
//! `Format_Grayscale8` coverage buffer (channel count 1) and the
|
||||
//! `Format_RGBA8888_Premultiplied` buffer (channel count 4).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::{Mutex, MutexGuard, Once, OnceLock};
|
||||
|
||||
use cosmic_text::{
|
||||
Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap,
|
||||
};
|
||||
use oak_node::nodes::textbackend::{
|
||||
set_text_backends, TextLayoutMode, TextLayoutRequest, TextLayoutSize, TextRenderTarget,
|
||||
TextRenderTransform,
|
||||
};
|
||||
|
||||
/// Fallback font size in points (the text nodes' `font_size_in` default).
|
||||
const DEFAULT_FONT_SIZE_PT: f64 = 72.0;
|
||||
|
||||
/// Fallback paint device resolution: 3780 dots/meter ≈ 96 DPI, Qt's default.
|
||||
const DEFAULT_DOTS_PER_METER: f64 = 3780.0;
|
||||
|
||||
/// Fallback font size in device pixels, used only when the point size and
|
||||
/// the resolution cannot produce a usable pixel size.
|
||||
const DEFAULT_FONT_PX: f32 = 96.0;
|
||||
|
||||
/// Points per inch (the numerator of the point → pixel conversion).
|
||||
const POINTS_PER_INCH: f64 = 72.0;
|
||||
|
||||
/// Meters per inch (the denominator of the point → pixel conversion).
|
||||
const METERS_PER_INCH: f64 = 0.0254;
|
||||
|
||||
/// Line height as a multiple of the font size.
|
||||
const LINE_HEIGHT_SCALE: f32 = 1.2;
|
||||
|
||||
/// Upper bound on the rasterized font size. A project file can carry an
|
||||
/// arbitrary `font_size_pt` / `dots_per_meter`; without a ceiling the swash
|
||||
/// bitmap cache would happily try to allocate gigabytes for a glyph.
|
||||
const MAX_FONT_PX: f32 = 8192.0;
|
||||
|
||||
/// Upper bound on the wrap width, keeping the layout solver's inputs sane
|
||||
/// for a corrupt `wrap_width`.
|
||||
const MAX_WRAP_PX: f32 = 1.0e6;
|
||||
|
||||
/// Glyph rasterization state shared by every request.
|
||||
///
|
||||
/// [`FontSystem`] scans the system font directories on construction, so it
|
||||
/// is built once (lazily, on the first non-empty request) and reused; a
|
||||
/// [`Buffer`] is cheap and stays per-request.
|
||||
struct TextSystem {
|
||||
font_system: FontSystem,
|
||||
swash_cache: SwashCache,
|
||||
}
|
||||
|
||||
/// The process-wide text system.
|
||||
fn system() -> &'static Mutex<TextSystem> {
|
||||
static SYSTEM: OnceLock<Mutex<TextSystem>> = OnceLock::new();
|
||||
SYSTEM.get_or_init(|| {
|
||||
Mutex::new(TextSystem {
|
||||
font_system: FontSystem::new(),
|
||||
swash_cache: SwashCache::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Locks the text system, recovering from a poisoned lock: a panic while
|
||||
/// shaping one text node must not take the whole app's text rendering down.
|
||||
fn lock_system() -> MutexGuard<'static, TextSystem> {
|
||||
system().lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Installs this module's hooks as the process-wide text backends (C++
|
||||
/// `set_text_backends()` at app startup).
|
||||
///
|
||||
/// Idempotent: only the first call installs, so a second engine instance
|
||||
/// cannot swap the hooks out from under a layout in flight.
|
||||
pub fn install() {
|
||||
static INSTALL: Once = Once::new();
|
||||
INSTALL.call_once(|| {
|
||||
set_text_backends(Some(measure), Some(render));
|
||||
});
|
||||
}
|
||||
|
||||
/// Measure hook: lays the request out and returns the document size in
|
||||
/// device pixels (C++ `QTextDocument::size()`).
|
||||
pub fn measure(req: &TextLayoutRequest) -> TextLayoutSize {
|
||||
let text = request_text(req);
|
||||
if text.is_empty() {
|
||||
return TextLayoutSize::default();
|
||||
}
|
||||
let mut sys = lock_system();
|
||||
let buffer = layout(&mut sys.font_system, req, &text);
|
||||
let mut width = 0.0f32;
|
||||
let mut height = 0.0f32;
|
||||
for run in buffer.layout_runs() {
|
||||
width = width.max(run.line_w);
|
||||
height = height.max(run.line_top + run.line_height);
|
||||
}
|
||||
TextLayoutSize {
|
||||
width: width as f64,
|
||||
height: height as f64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render hook: paints the request into `target` (C++
|
||||
/// `QAbstractTextDocumentLayout::draw()`).
|
||||
pub fn render(req: &TextLayoutRequest, transform: &TextRenderTransform, target: TextRenderTarget) {
|
||||
let TextRenderTarget {
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
linesize_bytes,
|
||||
channel_count,
|
||||
} = target;
|
||||
if width <= 0 || height <= 0 || linesize_bytes <= 0 || !matches!(channel_count, 1 | 4) {
|
||||
return;
|
||||
}
|
||||
// `QPainter::scale(0, 0)` (degenerate transform in the stored
|
||||
// parameters) paints nothing rather than collapsing to a matrix.
|
||||
let scale = transform.scale;
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let text = request_text(req);
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (clip_left, clip_top, clip_right, clip_bottom) =
|
||||
clip_rect(transform, width, height, scale);
|
||||
if !(clip_left < clip_right && clip_top < clip_bottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sys = lock_system();
|
||||
let mut buffer = layout(&mut sys.font_system, req, &text);
|
||||
let TextSystem {
|
||||
font_system,
|
||||
swash_cache,
|
||||
} = &mut *sys;
|
||||
let offset_x = transform.draw_offset_x * scale;
|
||||
for run in buffer.layout_runs() {
|
||||
let offset_y = (run.line_y as f64 + transform.draw_offset_y) * scale;
|
||||
for glyph in run.glyphs {
|
||||
let physical = glyph.physical((offset_x as f32, offset_y as f32), scale as f32);
|
||||
// The raster extends about one em around the glyph origin in
|
||||
// both axes; skip the glyphs that cannot touch the clip rect
|
||||
// instead of letting swash rasterize (and cache) them.
|
||||
let em = glyph.font_size as f64 * scale;
|
||||
let gx = physical.x as f64;
|
||||
let gy = physical.y as f64;
|
||||
if gx + em < clip_left
|
||||
|| gx - em > clip_right
|
||||
|| gy + em < clip_top
|
||||
|| gy - em > clip_bottom
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let base = glyph.color_opt.unwrap_or(WHITE);
|
||||
swash_cache.with_pixels(font_system, physical.cache_key, base, |px, py, color| {
|
||||
let x = physical.x + px;
|
||||
let y = physical.y + py;
|
||||
if (x as f64) < clip_left
|
||||
|| (x as f64) >= clip_right
|
||||
|| (y as f64) < clip_top
|
||||
|| (y as f64) >= clip_bottom
|
||||
{
|
||||
return;
|
||||
}
|
||||
blend(data, linesize_bytes, channel_count, x, y, color);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The default text color: the backends always paint white unless the
|
||||
/// markup overrides it (C++ `QPalette::Text` = `Qt::white`).
|
||||
const WHITE: Color = Color::rgb(0xFF, 0xFF, 0xFF);
|
||||
|
||||
/// The sorted, de-duplicated font families of the system font database.
|
||||
///
|
||||
/// Feeds the text nodes' `font_family_in` combo (the `combo_option`
|
||||
/// injection in [`super::effectchain::effect_params`]). Names are the
|
||||
/// English family names, so they match what a project stores; an empty
|
||||
/// database yields an empty list and the combo keeps free-form entry.
|
||||
///
|
||||
/// The list is snapshotted on first use: `effect_params` rebuilds the
|
||||
/// inspector's parameters on every engine change, and the font database
|
||||
/// only changes when fonts are installed (an app restart).
|
||||
pub fn font_families() -> Vec<String> {
|
||||
static FAMILIES: OnceLock<Vec<String>> = OnceLock::new();
|
||||
FAMILIES
|
||||
.get_or_init(|| {
|
||||
let mut names: Vec<String> = {
|
||||
let sys = lock_system();
|
||||
sys.font_system
|
||||
.db()
|
||||
.faces()
|
||||
.filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
|
||||
.collect()
|
||||
};
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The pixel-space clip rectangle of a render, already intersected with the
|
||||
/// target buffer. An empty (or inverted) rectangle means nothing is drawn;
|
||||
/// a non-finite rect from a corrupt transform collapses to empty too.
|
||||
fn clip_rect(
|
||||
transform: &TextRenderTransform,
|
||||
width: i32,
|
||||
height: i32,
|
||||
scale: f64,
|
||||
) -> (f64, f64, f64, f64) {
|
||||
let mut left = 0.0f64;
|
||||
let mut top = 0.0f64;
|
||||
let mut right = width as f64;
|
||||
let mut bottom = height as f64;
|
||||
if transform.clip_enabled {
|
||||
let x = transform.clip_offset_x * scale;
|
||||
let y = transform.clip_offset_y * scale;
|
||||
let w = transform.clip_width * scale;
|
||||
let h = transform.clip_height * scale;
|
||||
if !(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite()) {
|
||||
return (0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
left = left.max(x);
|
||||
top = top.max(y);
|
||||
right = right.min(x + w.max(0.0));
|
||||
bottom = bottom.min(y + h.max(0.0));
|
||||
}
|
||||
(left, top, right, bottom)
|
||||
}
|
||||
|
||||
/// Composites one source pixel over the target.
|
||||
///
|
||||
/// `channel_count == 1` is the grayscale coverage buffer the v1/v2 nodes
|
||||
/// tint afterwards: the glyph contributes its alpha as coverage. Channel
|
||||
/// count 4 is the premultiplied RGBA buffer of v3, so the source is
|
||||
/// premultiplied before the over-blend (the hook's colors are straight —
|
||||
/// swash's mask pixels carry the (white) base color plus coverage-as-alpha,
|
||||
/// and its color bitmaps are straight RGBA).
|
||||
fn blend(
|
||||
data: &mut [u8],
|
||||
linesize_bytes: i32,
|
||||
channel_count: i32,
|
||||
x: i32,
|
||||
y: i32,
|
||||
color: Color,
|
||||
) {
|
||||
if x < 0 || y < 0 {
|
||||
return;
|
||||
}
|
||||
let alpha = color.a();
|
||||
if alpha == 0 {
|
||||
return;
|
||||
}
|
||||
let offset = y as usize * linesize_bytes as usize + x as usize * channel_count as usize;
|
||||
if channel_count == 1 {
|
||||
let Some(dst) = data.get_mut(offset) else {
|
||||
return;
|
||||
};
|
||||
let src = alpha as u32;
|
||||
let out = src + (*dst as u32 * (255 - src)) / 255;
|
||||
*dst = out.min(255) as u8;
|
||||
return;
|
||||
}
|
||||
let Some(pixel) = data.get_mut(offset..offset + 4) else {
|
||||
return;
|
||||
};
|
||||
let src_alpha = alpha as u32;
|
||||
let inverse = 255 - src_alpha;
|
||||
let src = [
|
||||
(color.r() as u32 * src_alpha + 127) / 255,
|
||||
(color.g() as u32 * src_alpha + 127) / 255,
|
||||
(color.b() as u32 * src_alpha + 127) / 255,
|
||||
src_alpha,
|
||||
];
|
||||
for (dst, value) in pixel.iter_mut().zip(src) {
|
||||
*dst = (value + *dst as u32 * inverse / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
/// The text a request lays out: the request's own text, or the flattened
|
||||
/// markup for the two HTML modes.
|
||||
fn request_text(req: &TextLayoutRequest) -> Cow<'_, str> {
|
||||
match req.mode {
|
||||
TextLayoutMode::PlainText => Cow::Borrowed(req.text.as_str()),
|
||||
TextLayoutMode::Html | TextLayoutMode::OliveHtml => Cow::Owned(html_to_plain(&req.text)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays a request out into a shaped [`Buffer`].
|
||||
fn layout(font_system: &mut FontSystem, req: &TextLayoutRequest, text: &str) -> Buffer {
|
||||
let metrics = Metrics::relative(font_px(req), LINE_HEIGHT_SCALE);
|
||||
let mut buffer = Buffer::new(font_system, metrics);
|
||||
let mut attrs = Attrs::new();
|
||||
if !req.font_family.is_empty() {
|
||||
attrs = attrs.family(Family::Name(req.font_family.as_str()));
|
||||
}
|
||||
let alignment = if req.center_horizontally {
|
||||
Some(Align::Center)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
buffer.set_text(text, &attrs, Shaping::Advanced, alignment);
|
||||
if req.wrap_width.is_finite() && req.wrap_width > 0.0 {
|
||||
buffer.set_size(Some(req.wrap_width.min(MAX_WRAP_PX as f64) as f32), None);
|
||||
}
|
||||
// CJK text has no spaces to break at, so word wrapping alone would
|
||||
// overflow the wrap width; `WordOrGlyph` keeps the C++ behavior of
|
||||
// wrapping inside a run of CJK.
|
||||
buffer.set_wrap(Wrap::WordOrGlyph);
|
||||
buffer.shape_until_scroll(font_system, false);
|
||||
buffer
|
||||
}
|
||||
|
||||
/// The font size of a request in device pixels, with the documented
|
||||
/// fallbacks and a sanity ceiling.
|
||||
fn font_px(req: &TextLayoutRequest) -> f32 {
|
||||
let pt = if req.font_size_pt.is_finite() && req.font_size_pt > 0.0 {
|
||||
req.font_size_pt
|
||||
} else {
|
||||
DEFAULT_FONT_SIZE_PT
|
||||
};
|
||||
let dots_per_meter = if req.dots_per_meter > 0 {
|
||||
req.dots_per_meter as f64
|
||||
} else {
|
||||
DEFAULT_DOTS_PER_METER
|
||||
};
|
||||
let px = pt * dots_per_meter * (METERS_PER_INCH / POINTS_PER_INCH);
|
||||
if !px.is_finite() || px <= 0.0 {
|
||||
return DEFAULT_FONT_PX;
|
||||
}
|
||||
(px as f32).min(MAX_FONT_PX)
|
||||
}
|
||||
|
||||
/// Flattens the HTML the text nodes may carry into plain text.
|
||||
///
|
||||
/// This mirrors `oak_node::nodes::textv3`'s legacy-HTML stripper (which
|
||||
/// is crate-private): tags are dropped without being rescanned, the
|
||||
/// entities it knows are decoded, unknown entities and bare `&` are kept
|
||||
/// as-is, and whitespace is neither collapsed nor trimmed. On top of that,
|
||||
/// `<br>` and the block-level end tags (`</p>`, `</div>`, `</li>`, the
|
||||
/// headings, table rows, …) become line breaks so the paragraph structure
|
||||
/// of `Html` / `OliveHtml` text survives flattening; the breaks a trailing
|
||||
/// block end tag would add are dropped.
|
||||
fn html_to_plain(html: &str) -> String {
|
||||
/// Whether `chars` starts with the (ASCII) `entity` text.
|
||||
fn starts_with(chars: &[char], entity: &str) -> bool {
|
||||
let mut it = chars.iter();
|
||||
entity.chars().all(|c| it.next() == Some(&c))
|
||||
}
|
||||
|
||||
const ENTITIES: [(&str, &str); 6] = [
|
||||
("&", "&"),
|
||||
("<", "<"),
|
||||
(">", ">"),
|
||||
(""", "\""),
|
||||
("'", "'"),
|
||||
(" ", " "),
|
||||
];
|
||||
|
||||
/// Block-level end tags that start a new line, plus `<br>` itself.
|
||||
const LINE_BREAK_TAGS: [&str; 21] = [
|
||||
"br",
|
||||
"/p",
|
||||
"/div",
|
||||
"/li",
|
||||
"/tr",
|
||||
"/h1",
|
||||
"/h2",
|
||||
"/h3",
|
||||
"/h4",
|
||||
"/h5",
|
||||
"/h6",
|
||||
"/blockquote",
|
||||
"/pre",
|
||||
"/table",
|
||||
"/ul",
|
||||
"/ol",
|
||||
"/dl",
|
||||
"/dt",
|
||||
"/dd",
|
||||
"/section",
|
||||
"/figure",
|
||||
];
|
||||
|
||||
let chars: Vec<char> = html.chars().collect();
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
match chars[i] {
|
||||
'<' => {
|
||||
// Drop up to and including the tag's closing '>'; an
|
||||
// unterminated tag drops the remainder. The tag text is
|
||||
// discarded, never rescanned, so a decoded `<p>`
|
||||
// cannot turn into a tag afterwards.
|
||||
let start = i + 1;
|
||||
let mut end = start;
|
||||
while end < chars.len() && chars[end] != '>' {
|
||||
end += 1;
|
||||
}
|
||||
let tag: String = chars[start..end].iter().collect::<String>().to_lowercase();
|
||||
let trimmed = tag.trim_start();
|
||||
let (closing, rest) = match trimmed.strip_prefix('/') {
|
||||
Some(rest) => (true, rest.trim_start()),
|
||||
None => (false, trimmed),
|
||||
};
|
||||
let name: String = rest
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric())
|
||||
.collect();
|
||||
let name = if closing { format!("/{name}") } else { name };
|
||||
if !out.is_empty() && LINE_BREAK_TAGS.contains(&name.as_str()) {
|
||||
out.push('\n');
|
||||
}
|
||||
i = end + 1;
|
||||
}
|
||||
'&' => {
|
||||
let decoded = ENTITIES
|
||||
.iter()
|
||||
.find(|(entity, _)| starts_with(&chars[i..], entity));
|
||||
match decoded {
|
||||
Some((entity, replacement)) => {
|
||||
out.push_str(replacement);
|
||||
i += entity.chars().count();
|
||||
}
|
||||
None => {
|
||||
// Unknown entity (or a bare '&'): keep it as-is.
|
||||
out.push('&');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
c => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
while out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
/// The hook statics are process-global; the one test that installs them
|
||||
/// holds this so it cannot race a future one.
|
||||
static HOOK_LOCK: StdMutex<()> = StdMutex::new(());
|
||||
|
||||
/// A plain-text request with the test defaults (72 pt at 3780
|
||||
/// dots/meter, no wrap, no centering, backend default family).
|
||||
fn plain(text: &str, font_size_pt: f64, wrap_width: f64) -> TextLayoutRequest {
|
||||
TextLayoutRequest {
|
||||
text: text.to_string(),
|
||||
mode: TextLayoutMode::PlainText,
|
||||
font_family: String::new(),
|
||||
font_size_pt,
|
||||
dots_per_meter: 3780,
|
||||
wrap_width,
|
||||
center_horizontally: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A transform with only the scale set (the rest neutral).
|
||||
fn scaled(scale: f64) -> TextRenderTransform {
|
||||
TextRenderTransform {
|
||||
scale,
|
||||
..TextRenderTransform::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a request into a zeroed buffer and returns it.
|
||||
fn render_into(
|
||||
req: &TextLayoutRequest,
|
||||
transform: &TextRenderTransform,
|
||||
width: i32,
|
||||
height: i32,
|
||||
channel_count: i32,
|
||||
) -> Vec<u8> {
|
||||
let mut data = vec![0u8; (width * height * channel_count) as usize];
|
||||
render(
|
||||
req,
|
||||
transform,
|
||||
TextRenderTarget {
|
||||
data: &mut data,
|
||||
width,
|
||||
height,
|
||||
linesize_bytes: width * channel_count,
|
||||
channel_count,
|
||||
},
|
||||
);
|
||||
data
|
||||
}
|
||||
|
||||
/// Bounding box (`min_x`, `min_y`, `max_x`, `max_y`) of the nonzero
|
||||
/// pixels of a grayscale buffer; `None` when nothing was painted.
|
||||
fn ink_bounds(data: &[u8], width: i32, height: i32) -> Option<(i32, i32, i32, i32)> {
|
||||
let mut bounds: Option<(i32, i32, i32, i32)> = None;
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
if data[(y * width + x) as usize] != 0 {
|
||||
bounds = Some(match bounds {
|
||||
None => (x, y, x, y),
|
||||
Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
fn has_ink(data: &[u8]) -> bool {
|
||||
data.iter().any(|b| *b != 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_measures_nonzero() {
|
||||
let size = measure(&plain("Hello", 72.0, 0.0));
|
||||
assert!(size.width > 0.0, "width = {}", size.width);
|
||||
assert!(size.height > 0.0, "height = {}", size.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_scales_with_font_size() {
|
||||
let small = measure(&plain("Hello", 36.0, 0.0));
|
||||
let large = measure(&plain("Hello", 72.0, 0.0));
|
||||
assert!(large.width > small.width * 1.5);
|
||||
assert!(large.height > small.height * 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_text_measures_nonzero() {
|
||||
// No explicit family: the default fallback chain has to resolve the
|
||||
// glyphs through fontconfig.
|
||||
let size = measure(&plain("中文字体测试", 72.0, 0.0));
|
||||
assert!(size.width > 0.0, "width = {}", size.width);
|
||||
assert!(size.height > 0.0, "height = {}", size.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_text_measures_zero() {
|
||||
let size = measure(&plain("", 72.0, 0.0));
|
||||
assert_eq!(size.width, 0.0);
|
||||
assert_eq!(size.height, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_line_is_taller_than_single_line() {
|
||||
let one = measure(&plain("Hello", 72.0, 0.0));
|
||||
let two = measure(&plain("Hello\nWorld", 72.0, 0.0));
|
||||
assert!(two.height > one.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_width_wraps() {
|
||||
let unwrapped = measure(&plain("中文字体测试", 72.0, 0.0));
|
||||
let wrapped = measure(&plain("中文字体测试", 72.0, 200.0));
|
||||
assert!(
|
||||
wrapped.width < unwrapped.width,
|
||||
"wrapped {} >= unwrapped {}",
|
||||
wrapped.width,
|
||||
unwrapped.width
|
||||
);
|
||||
assert!(wrapped.width <= 200.0, "wrapped width {}", wrapped.width);
|
||||
assert!(wrapped.height > unwrapped.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_mode_flattens_tags() {
|
||||
let plain_req = plain("Hello", 72.0, 0.0);
|
||||
let html_req = TextLayoutRequest {
|
||||
text: "<b>Hello</b>".to_string(),
|
||||
mode: TextLayoutMode::Html,
|
||||
..plain("Hello", 72.0, 0.0)
|
||||
};
|
||||
assert_eq!(measure(&html_req).width, measure(&plain_req).width);
|
||||
assert_eq!(measure(&html_req).height, measure(&plain_req).height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_to_plain_decodes_and_breaks() {
|
||||
assert_eq!(html_to_plain("<b>a</b>"), "a");
|
||||
assert_eq!(html_to_plain("a<br>b"), "a\nb");
|
||||
assert_eq!(html_to_plain("a<br/>b"), "a\nb");
|
||||
assert_eq!(html_to_plain("<p>a</p><p>b</p>"), "a\nb");
|
||||
assert_eq!(html_to_plain("a</DIV>b"), "a\nb");
|
||||
assert_eq!(html_to_plain("&<>"' "), "&<>\"' ");
|
||||
// Unknown entities and bare `&` survive untouched.
|
||||
assert_eq!(html_to_plain("a &b"), "a &b");
|
||||
// A decoded `<p>` is text, never rescanned into a tag.
|
||||
assert_eq!(html_to_plain("<p>"), "<p>");
|
||||
// Unterminated tags drop the remainder; trailing breaks are dropped.
|
||||
assert_eq!(html_to_plain("<p>a"), "a");
|
||||
assert_eq!(html_to_plain("a</p>"), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_families_are_sorted_and_unique() {
|
||||
let families = font_families();
|
||||
assert!(
|
||||
!families.is_empty(),
|
||||
"the system font database has no family"
|
||||
);
|
||||
let mut sorted = families.clone();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(families, sorted);
|
||||
// Cached: the second call hands out the same list.
|
||||
assert_eq!(font_families(), families);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_pixel_size_fallbacks() {
|
||||
let base = plain("x", 72.0, 0.0);
|
||||
// 72 pt at 3780 dots/meter == 96 DPI == 96.012 px.
|
||||
assert!((font_px(&base) as f64 - 96.012).abs() < 0.01);
|
||||
// 0 / non-finite point sizes fall back to 72 pt.
|
||||
assert_eq!(font_px(&plain("x", 0.0, 0.0)), font_px(&base));
|
||||
assert_eq!(font_px(&plain("x", f64::NAN, 0.0)), font_px(&base));
|
||||
// Non-positive dots-per-meter falls back to 3780.
|
||||
let mut req = plain("x", 72.0, 0.0);
|
||||
req.dots_per_meter = 0;
|
||||
assert_eq!(font_px(&req), font_px(&base));
|
||||
req.dots_per_meter = -3780;
|
||||
assert_eq!(font_px(&req), font_px(&base));
|
||||
// Absurd sizes clamp instead of asking swash for a huge bitmap.
|
||||
assert_eq!(font_px(&plain("x", 1.0e9, 0.0)), MAX_FONT_PX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_writes_premultiplied_white_rgba() {
|
||||
let data = render_into(&plain("H", 72.0, 0.0), &scaled(1.0), 160, 160, 4);
|
||||
let mut ink = 0usize;
|
||||
for px in data.chunks_exact(4) {
|
||||
let (r, g, b, a) = (px[0], px[1], px[2], px[3]);
|
||||
if a == 0 && r == 0 && g == 0 && b == 0 {
|
||||
continue;
|
||||
}
|
||||
ink += 1;
|
||||
assert_eq!((r, g, b), (r, r, r), "non-white pixel {px:?}");
|
||||
assert!(r <= a, "not premultiplied: {px:?}");
|
||||
}
|
||||
assert!(ink > 0, "the render painted nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_writes_grayscale_coverage() {
|
||||
let req = plain("H", 72.0, 0.0);
|
||||
let gray = render_into(&req, &scaled(1.0), 160, 160, 1);
|
||||
assert!(has_ink(&gray), "the render painted nothing");
|
||||
// The RGBA alpha is that same coverage (both blend over a zeroed
|
||||
// buffer), so the two formats have to agree pixel for pixel.
|
||||
let rgba = render_into(&req, &scaled(1.0), 160, 160, 4);
|
||||
for (i, alpha) in rgba.chunks_exact(4).map(|px| px[3]).enumerate() {
|
||||
assert_eq!(gray[i], alpha, "coverage mismatch at pixel {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_scales_the_glyph() {
|
||||
let req = plain("H", 72.0, 0.0);
|
||||
let one = render_into(&req, &scaled(1.0), 256, 256, 1);
|
||||
let two = render_into(&req, &scaled(2.0), 256, 256, 1);
|
||||
let (x0, y0, x1, y1) = ink_bounds(&one, 256, 256).expect("scale 1 ink");
|
||||
let (u0, v0, u1, v1) = ink_bounds(&two, 256, 256).expect("scale 2 ink");
|
||||
assert!((u1 - u0) > (x1 - x0) * 3 / 2, "width did not scale");
|
||||
assert!((v1 - v0) > (y1 - y0) * 3 / 2, "height did not scale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_skips_degenerate_scale() {
|
||||
for scale in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
let data = render_into(&plain("H", 72.0, 0.0), &scaled(scale), 64, 64, 1);
|
||||
assert!(!has_ink(&data), "scale {scale} painted something");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_clips_to_the_clip_rect() {
|
||||
let req = plain("H", 72.0, 0.0);
|
||||
let full = render_into(&req, &scaled(1.0), 160, 160, 1);
|
||||
let (min_x, _, _, _) = ink_bounds(&full, 160, 160).expect("unclipped ink");
|
||||
assert!(min_x < 40, "the unclipped ink starts at {min_x}");
|
||||
|
||||
// A clip that keeps only the right half of the glyph.
|
||||
let clipped = render_into(
|
||||
&req,
|
||||
&TextRenderTransform {
|
||||
clip_enabled: true,
|
||||
clip_offset_x: 40.0,
|
||||
clip_width: 200.0,
|
||||
clip_height: 200.0,
|
||||
..scaled(1.0)
|
||||
},
|
||||
160,
|
||||
160,
|
||||
1,
|
||||
);
|
||||
let (cmin_x, _, cmax_x, cmax_y) =
|
||||
ink_bounds(&clipped, 160, 160).expect("ink inside the clip");
|
||||
assert!(cmin_x >= 40, "ink left of the clip at {cmin_x}");
|
||||
assert!(cmin_x > min_x, "the clip removed nothing");
|
||||
assert!(cmax_x < 160 && cmax_y < 160);
|
||||
|
||||
// An empty clip rect and a non-finite one paint nothing.
|
||||
let empty = render_into(
|
||||
&req,
|
||||
&TextRenderTransform {
|
||||
clip_enabled: true,
|
||||
clip_width: 0.0,
|
||||
clip_height: 0.0,
|
||||
..scaled(1.0)
|
||||
},
|
||||
160,
|
||||
160,
|
||||
1,
|
||||
);
|
||||
assert!(!has_ink(&empty));
|
||||
let nan = render_into(
|
||||
&req,
|
||||
&TextRenderTransform {
|
||||
clip_enabled: true,
|
||||
clip_offset_x: f64::NAN,
|
||||
clip_width: 64.0,
|
||||
clip_height: 64.0,
|
||||
..scaled(1.0)
|
||||
},
|
||||
160,
|
||||
160,
|
||||
1,
|
||||
);
|
||||
assert!(!has_ink(&nan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_honors_draw_offset() {
|
||||
let req = plain("H", 72.0, 0.0);
|
||||
let base = render_into(&req, &scaled(1.0), 256, 256, 1);
|
||||
let shifted = render_into(
|
||||
&req,
|
||||
&TextRenderTransform {
|
||||
draw_offset_x: 100.0,
|
||||
draw_offset_y: 100.0,
|
||||
..scaled(1.0)
|
||||
},
|
||||
256,
|
||||
256,
|
||||
1,
|
||||
);
|
||||
let (bx, by, _, _) = ink_bounds(&base, 256, 256).expect("unshifted ink");
|
||||
let (sx, sy, _, _) = ink_bounds(&shifted, 256, 256).expect("shifted ink");
|
||||
// The offset is applied in device pixels after the scale, so the
|
||||
// corners move by exactly the offset.
|
||||
assert_eq!((sx, sy), (bx + 100, by + 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_install_and_drive_the_nodes() {
|
||||
let _guard = HOOK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
use oak_node::nodes::textbackend::{text_measure_backend, text_render_backend};
|
||||
|
||||
install();
|
||||
let measure_fn = text_measure_backend().expect("install() sets the measure hook");
|
||||
let render_fn = text_render_backend().expect("install() sets the render hook");
|
||||
|
||||
let req = plain("Hello", 72.0, 0.0);
|
||||
let size = measure_fn(&req);
|
||||
assert!(size.width > 0.0 && size.height > 0.0);
|
||||
|
||||
let mut data = vec![0u8; 192 * 192];
|
||||
render_fn(
|
||||
&req,
|
||||
&scaled(1.0),
|
||||
TextRenderTarget {
|
||||
data: &mut data,
|
||||
width: 192,
|
||||
height: 192,
|
||||
linesize_bytes: 192,
|
||||
channel_count: 1,
|
||||
},
|
||||
);
|
||||
assert!(has_ink(&data), "the render hook painted nothing");
|
||||
|
||||
// Leave the process hooks uninstalled like `textbackend`'s own tests
|
||||
// do, so no later test depends on this module's state.
|
||||
set_text_backends(None, None);
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,19 @@
|
||||
//! - boolean → [`CheckBox`]
|
||||
//! - combo → [`ComboBox`] fed from the repeated `("combo_option", _)`
|
||||
//! properties; string-combo values come from `("combo_value", _)`
|
||||
//! - text → [`EditableTextState`]
|
||||
//! - text → [`EditableTextState`] (`text_input`; the `("multiline", true)`
|
||||
//! property that [`super::effectchain::effect_params`] adds to the v3
|
||||
//! text node's text inputs builds a `text_area` instead)
|
||||
//! - vec2 / vec3 → one [`SpinBox`] per component
|
||||
//! - color → a swatch + deferred popup picker ([`OfxColorPicker`]:
|
||||
//! R/G/B/A sliders, live preview, hex input, Cancel/OK)
|
||||
//! - push button → a clickable button (`AppEngine::effect_push_button`)
|
||||
//!
|
||||
//! Secret (HIDDEN) inputs never reach the snapshot, so they render
|
||||
//! nothing; `ui_group` / `ui_page` become section titles. Every edit is
|
||||
//! routed through [`AppEngine::set_effect_param`] (undoable).
|
||||
//! Secret (HIDDEN) inputs never reach the snapshot the facade builds, and
|
||||
//! the view skips them again on its own (a plugin or mock engine can hand
|
||||
//! one over; the inspector must not show a secret input even then).
|
||||
//! `ui_group` / `ui_page` become section titles. Every edit is routed
|
||||
//! through [`AppEngine::set_effect_param`] (undoable).
|
||||
//!
|
||||
//! The control set is built once per expanded card — the stack view caches
|
||||
//! the params view per effect (recreating it per render would kill
|
||||
@@ -93,8 +97,13 @@ enum ControlKind {
|
||||
Spin(Vec<(Entity<SpinBox>, usize)>),
|
||||
/// A colour swatch + popup picker (color).
|
||||
Color(Entity<OfxColorPicker>),
|
||||
/// A text field (string).
|
||||
Text(Entity<EditableTextState>),
|
||||
/// A text field: single-line by default, multi-line when the param
|
||||
/// carries the `("multiline", true)` property (the v3 text node's text
|
||||
/// inputs; the inspector then builds a `text_area`).
|
||||
Text {
|
||||
editor: Entity<EditableTextState>,
|
||||
multiline: bool,
|
||||
},
|
||||
/// One curve editor per dimension (parametric parameter).
|
||||
Curve(Vec<Entity<gpui_widgets::curve_editor::CurveEditor>>),
|
||||
/// A push button (rendered inline, no entity).
|
||||
@@ -122,6 +131,11 @@ impl<E: AppEngine> OfxParamsView<E> {
|
||||
let mut control_id = 0usize;
|
||||
let controls = params
|
||||
.iter()
|
||||
// The built-in facade already drops hidden inputs, but a plugin
|
||||
// or mock engine's snapshot can carry them; a secret input must
|
||||
// never reach the inspector, so filter here as well (the params
|
||||
// view is the last stop before a control is built).
|
||||
.filter(|param| param.flags & oak_node::input::flags::HIDDEN == 0)
|
||||
.map(|param| build_control(param, &mut control_id, window, cx))
|
||||
.collect();
|
||||
|
||||
@@ -201,7 +215,7 @@ impl<E: AppEngine> OfxParamsView<E> {
|
||||
picker.update(cx, |picker, cx| picker.apply_viewer_pick(color, cx));
|
||||
}
|
||||
}
|
||||
ControlKind::Text(editor) => {
|
||||
ControlKind::Text { editor, .. } => {
|
||||
let text = match ¶m.value {
|
||||
NodeValue::Text(s) => s.clone(),
|
||||
NodeValue::StrCombo(s) => s.clone(),
|
||||
@@ -425,6 +439,16 @@ fn curve_points_close(
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a parameter asks for a multi-line text field (the
|
||||
/// `("multiline", true)` property the facade adds to the v3 text node's
|
||||
/// text inputs).
|
||||
fn is_multiline(param: &EffectParam) -> bool {
|
||||
param
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "multiline" && matches!(v, NodeValue::Boolean(true)))
|
||||
}
|
||||
|
||||
/// The SliderValue for a param's current value (int → Integer, float →
|
||||
/// Float).
|
||||
fn slider_value(param: &EffectParam) -> SliderValue {
|
||||
@@ -435,17 +459,39 @@ fn slider_value(param: &EffectParam) -> SliderValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected option index of a combo/string-combo parameter. Integer
|
||||
/// combos carry the index directly; string combos are matched against the
|
||||
/// `("combo_value", _)` (or `("combo_option", _)`) list by value.
|
||||
fn combo_index_for(param: &EffectParam) -> usize {
|
||||
if param.value_type == ValueType::StrCombo {
|
||||
/// The option list of a combo/string-combo parameter: the
|
||||
/// `("combo_value", _)` strings when it has any, else the
|
||||
/// `("combo_option", _)` labels. A string combo whose current value is not
|
||||
/// in the list (a font family saved on another machine, say) keeps its
|
||||
/// value: it goes in front, so the combo shows what the node actually
|
||||
/// holds instead of silently falling back to the first option.
|
||||
fn combo_haystack(param: &EffectParam) -> Vec<String> {
|
||||
let mut haystack = if param.value_type == ValueType::StrCombo {
|
||||
let values = crate::oakui::effectchain::combo_values(param);
|
||||
let haystack = if values.is_empty() {
|
||||
if values.is_empty() {
|
||||
crate::oakui::effectchain::combo_options(param)
|
||||
} else {
|
||||
values
|
||||
};
|
||||
}
|
||||
} else {
|
||||
crate::oakui::effectchain::combo_options(param)
|
||||
};
|
||||
if param.value_type == ValueType::StrCombo {
|
||||
if let NodeValue::StrCombo(s) | NodeValue::Text(s) = ¶m.value {
|
||||
if !s.is_empty() && !haystack.iter().any(|v| v == s) {
|
||||
haystack.insert(0, s.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
haystack
|
||||
}
|
||||
|
||||
/// The selected option index of a combo/string-combo parameter. Integer
|
||||
/// combos carry the index directly; string combos are matched against the
|
||||
/// [`combo_haystack`] list by value.
|
||||
fn combo_index_for(param: &EffectParam) -> usize {
|
||||
if param.value_type == ValueType::StrCombo {
|
||||
let haystack = combo_haystack(param);
|
||||
match ¶m.value {
|
||||
NodeValue::StrCombo(s) | NodeValue::Text(s) => {
|
||||
haystack.iter().position(|v| v == s).unwrap_or(0)
|
||||
@@ -479,27 +525,159 @@ fn default_range(value_type: ValueType) -> (f64, f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The min/max from the parameter's `("min", Float)` / `("max", Float)`
|
||||
/// The min/max from the parameter's `("min", _)` / `("max", _)`
|
||||
/// properties, falling back to [`default_range`].
|
||||
fn numeric_range(param: &EffectParam) -> (f64, f64) {
|
||||
let prop = |key: &str| {
|
||||
param
|
||||
.properties
|
||||
.iter()
|
||||
.find(|(k, _)| k == key)
|
||||
.and_then(|(_, v)| match v {
|
||||
NodeValue::Float(f) => Some(*f),
|
||||
NodeValue::Int(i) => Some(*i as f64),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let (dmin, dmax) = default_range(param.value_type);
|
||||
(
|
||||
prop("min").unwrap_or(dmin),
|
||||
prop("max").unwrap_or(dmax),
|
||||
range_property(param, "min").unwrap_or(dmin),
|
||||
range_property(param, "max").unwrap_or(dmax),
|
||||
)
|
||||
}
|
||||
|
||||
/// The `("min", _)` / `("max", _)` property of a parameter as a finite
|
||||
/// number: a NaN or infinite bound would make the slider's arithmetic
|
||||
/// meaningless, so it is ignored.
|
||||
fn range_property(param: &EffectParam, key: &str) -> Option<f64> {
|
||||
param
|
||||
.properties
|
||||
.iter()
|
||||
.find(|(k, _)| k == key)
|
||||
.and_then(|(_, v)| match v {
|
||||
NodeValue::Float(f) => Some(*f),
|
||||
NodeValue::Int(i) => Some(*i as f64),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// The slider range and step of an int/float parameter: `(min, max, step)`.
|
||||
///
|
||||
/// The OFX translation only attaches min/max to colour inputs, but the
|
||||
/// built-in nodes attach `("min", _)` to the parameters whose domain has a
|
||||
/// floor and no ceiling (font size, outline width, glow radius). A flat
|
||||
/// default range then snaps the value onto a coarse grid nowhere near it —
|
||||
/// `font_size_in` at 72 showed as 50.995 over a 1..10000 range. Instead the
|
||||
/// value itself defines the grid: a "nice" step that divides the value's
|
||||
/// distance from its bound, over a range holding 200 of them, so a
|
||||
/// min-only parameter slides up from its floor with the handle exactly on
|
||||
/// the value. The slider's double-click entry types exact values, so the
|
||||
/// range never has to cover everything.
|
||||
fn slider_range_and_step(param: &EffectParam) -> (f64, f64, f64) {
|
||||
let value = param.value.to_double();
|
||||
if param.value_type == ValueType::Int {
|
||||
// Integer parameters step by one; only the bounds need ordering
|
||||
// (an inverted range would panic the slider's clamp).
|
||||
let (min, max) = numeric_range(param);
|
||||
let (mut lo, mut hi) = (min.min(max), min.max(max));
|
||||
if value.is_finite() {
|
||||
lo = lo.min(value);
|
||||
hi = hi.max(value);
|
||||
}
|
||||
if hi <= lo {
|
||||
hi = lo + 200.0;
|
||||
}
|
||||
return (lo, hi, 1.0);
|
||||
}
|
||||
match (range_property(param, "min"), range_property(param, "max")) {
|
||||
// A floor but no ceiling.
|
||||
(Some(min), None) => {
|
||||
let v = if value.is_finite() { value.max(min) } else { min };
|
||||
let span = v - min;
|
||||
let step = if span > 0.0 {
|
||||
nice_grid_step(span)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
(min, min + 200.0 * step, step)
|
||||
}
|
||||
// A ceiling but no floor: the mirror image.
|
||||
(None, Some(max)) => {
|
||||
let v = if value.is_finite() { value.min(max) } else { max };
|
||||
let span = max - v;
|
||||
let step = if span > 0.0 {
|
||||
nice_grid_step(span)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
(max - 200.0 * step, max, step)
|
||||
}
|
||||
// Both bounds, or neither (the wide default range): one step over
|
||||
// the whole range, rounded so the value lands on the grid.
|
||||
(bound_min, bound_max) => {
|
||||
let (dmin, dmax) = default_range(param.value_type);
|
||||
let min = bound_min.unwrap_or(dmin);
|
||||
let max = bound_max.unwrap_or(dmax);
|
||||
let (lo, hi) = (min.min(max), min.max(max));
|
||||
let step0 = ((hi - lo) / 200.0).max(0.001);
|
||||
let span = if value.is_finite() {
|
||||
value.clamp(lo, hi) - lo
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let step = if span > 0.0 {
|
||||
span / (span / step0).round().max(1.0)
|
||||
} else {
|
||||
step0
|
||||
};
|
||||
(lo, hi, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A "nice" step for a slider whose value sits `span` above its bound: one
|
||||
/// of `1/2/5 × 10^k`, close to a twenty-fifth of the span (a slider wants a
|
||||
/// few dozen steps to feel controllable) and dividing the span into a whole
|
||||
/// number of them, so the value itself is on the grid.
|
||||
fn nice_grid_step(span: f64) -> f64 {
|
||||
let target = span / 25.0;
|
||||
if !(target.is_finite() && target > 0.0) {
|
||||
return 1.0;
|
||||
}
|
||||
let exp = target.log10().floor() as i32;
|
||||
let mut best: Option<f64> = None;
|
||||
for k in -2..=2 {
|
||||
for m in [1.0, 2.0, 5.0] {
|
||||
let step = scale_by_pow10(m, exp + k);
|
||||
if !(step.is_finite() && step > 0.0) || step < span / 100.0 || step > span / 8.0 {
|
||||
continue;
|
||||
}
|
||||
let steps = span / step;
|
||||
if steps < 1.0 || (steps - steps.round()).abs() > 1e-9 * steps.abs().max(1.0) {
|
||||
continue;
|
||||
}
|
||||
let better = match best {
|
||||
Some(b) => (step / target).ln().abs() < (b / target).ln().abs(),
|
||||
None => true,
|
||||
};
|
||||
if better {
|
||||
best = Some(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
best.unwrap_or_else(|| {
|
||||
let steps = (span / target).round().max(1.0);
|
||||
span / steps
|
||||
})
|
||||
}
|
||||
|
||||
/// `m × 10^exp`, by repeated multiplication rather than `powf`: the
|
||||
/// result is then bit-identical to the literal grid values (0.1, 0.01, …)
|
||||
/// that a slider step of that size is expected to land on.
|
||||
fn scale_by_pow10(m: f64, exp: i32) -> f64 {
|
||||
let mut v = m;
|
||||
if exp >= 0 {
|
||||
for _ in 0..exp {
|
||||
v *= 10.0;
|
||||
}
|
||||
} else {
|
||||
for _ in 0..-exp {
|
||||
v /= 10.0;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Builds one [`ParamControl`] for `param`, creating the control entities
|
||||
/// (each consuming one control id from `next_id`).
|
||||
fn build_control<E: AppEngine>(
|
||||
@@ -510,17 +688,12 @@ fn build_control<E: AppEngine>(
|
||||
) -> ParamControl {
|
||||
let kind = match param.value_type {
|
||||
ValueType::Int | ValueType::Float => {
|
||||
let (min, max) = numeric_range(param);
|
||||
let (min, max, step) = slider_range_and_step(param);
|
||||
let kind = if param.value_type == ValueType::Int {
|
||||
ValueKind::Integer
|
||||
} else {
|
||||
ValueKind::Float
|
||||
};
|
||||
let step = if param.value_type == ValueType::Int {
|
||||
1.0
|
||||
} else {
|
||||
((max - min) / 200.0).max(0.001)
|
||||
};
|
||||
let default_raw = param.value.to_double().clamp(min, max);
|
||||
let model = SliderModel::new(kind, min, max, step, default_raw);
|
||||
let slider = cx.new(|cx| Slider::new(*next_id, model, window, cx));
|
||||
@@ -537,16 +710,7 @@ fn build_control<E: AppEngine>(
|
||||
ControlKind::CheckBox(check)
|
||||
}
|
||||
ValueType::Combo | ValueType::StrCombo => {
|
||||
let options: Vec<String> = if param.value_type == ValueType::StrCombo {
|
||||
let values = crate::oakui::effectchain::combo_values(param);
|
||||
if values.is_empty() {
|
||||
crate::oakui::effectchain::combo_options(param)
|
||||
} else {
|
||||
values
|
||||
}
|
||||
} else {
|
||||
crate::oakui::effectchain::combo_options(param)
|
||||
};
|
||||
let options = combo_haystack(param);
|
||||
if options.is_empty() {
|
||||
// No option list: show the raw value read-only.
|
||||
let text = if param.value_type == ValueType::Combo {
|
||||
@@ -579,7 +743,10 @@ fn build_control<E: AppEngine>(
|
||||
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
|
||||
editor.update(cx, |editor, cx| editor.emplace(&text, cx));
|
||||
*next_id += 1;
|
||||
ControlKind::Text(editor)
|
||||
ControlKind::Text {
|
||||
editor,
|
||||
multiline: is_multiline(param),
|
||||
}
|
||||
}
|
||||
ValueType::Vec2 | ValueType::Vec3 => {
|
||||
let components = value_components(¶m.value);
|
||||
@@ -707,12 +874,7 @@ fn wire_controls<E: AppEngine>(view: &OfxParamsView<E>, cx: &mut Context<OfxPara
|
||||
});
|
||||
let nv = match ¶m {
|
||||
Some(p) if p.value_type == ValueType::StrCombo => {
|
||||
let values = crate::oakui::effectchain::combo_values(p);
|
||||
let haystack = if values.is_empty() {
|
||||
crate::oakui::effectchain::combo_options(p)
|
||||
} else {
|
||||
values
|
||||
};
|
||||
let haystack = combo_haystack(p);
|
||||
NodeValue::StrCombo(
|
||||
haystack.get(*value).cloned().unwrap_or_default(),
|
||||
)
|
||||
@@ -789,7 +951,7 @@ fn wire_controls<E: AppEngine>(view: &OfxParamsView<E>, cx: &mut Context<OfxPara
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
ControlKind::Text(_editor) => {
|
||||
ControlKind::Text { .. } => {
|
||||
// The text field commits explicitly (the commit button in the
|
||||
// row). No event subscription here: the params view is rebuilt
|
||||
// on every card render, so committing on TextChanged would
|
||||
@@ -927,12 +1089,37 @@ impl<E: AppEngine> Render for OfxParamsView<E> {
|
||||
ControlKind::Color(picker) => {
|
||||
div().flex_1().child(picker.clone()).into_any_element()
|
||||
}
|
||||
ControlKind::Text(editor) => {
|
||||
ControlKind::Text { editor, multiline } => {
|
||||
let weak = editor.downgrade();
|
||||
let engine = self.engine.clone();
|
||||
let effect = self.effect;
|
||||
let input_id = control.input_id.clone();
|
||||
let editor_commit = editor.clone();
|
||||
let field = if *multiline {
|
||||
// A multi-line field. `text_input` above is
|
||||
// single-line only (and keeps its theme colors
|
||||
// to itself), so the element is built here with
|
||||
// the same colors the component would use, in a
|
||||
// fixed-height box the text scrolls inside.
|
||||
gpui_elements::editable_text::text_area(format!(
|
||||
"ofx-param-{}",
|
||||
control.input_id
|
||||
))
|
||||
.state(weak)
|
||||
.accepts_input(true)
|
||||
.h(px(96.0))
|
||||
.text_color(colors.text)
|
||||
.placeholder_color(colors.disabled.into())
|
||||
.selection_color(colors.selected.into())
|
||||
.caret_color(colors.text.into())
|
||||
.marked_color(colors.text.into())
|
||||
.into_any_element()
|
||||
} else {
|
||||
text_input(format!("ofx-param-{}", control.input_id), cx)
|
||||
.state(weak)
|
||||
.accepts_input(true)
|
||||
.into_any_element()
|
||||
};
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
@@ -946,7 +1133,7 @@ impl<E: AppEngine> Render for OfxParamsView<E> {
|
||||
.bg(colors.background)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(text_input(format!("ofx-param-{}", control.input_id), cx).state(weak).accepts_input(true)),
|
||||
.child(field),
|
||||
)
|
||||
.child(
|
||||
// Explicit commit: reads the field and pushes the
|
||||
@@ -2691,19 +2878,22 @@ mod tests {
|
||||
cx.run_until_parked();
|
||||
let host = window.root(cx).expect("host root");
|
||||
let view = cx.read(|cx| host.read(cx).view.clone());
|
||||
// Two inputs are text fields: the single-line `args_in` and the
|
||||
// multi-line `plain_text_in`. This test types into the latter.
|
||||
let editor = cx.read(|cx| {
|
||||
view.read(cx)
|
||||
.controls
|
||||
.iter()
|
||||
.find_map(|c| match &c.kind {
|
||||
ControlKind::Text(editor) => Some(editor.clone()),
|
||||
_ => None,
|
||||
.find(|c| c.input_id == "plain_text_in")
|
||||
.map(|c| match &c.kind {
|
||||
ControlKind::Text { editor, .. } => editor.clone(),
|
||||
_ => panic!("plain_text_in should build a text control"),
|
||||
})
|
||||
.expect("a text control")
|
||||
.expect("the plain_text_in control")
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|cx| editor.read(cx).as_str().to_string()),
|
||||
"<p>engine text</p>"
|
||||
"文本\nsecond line"
|
||||
);
|
||||
|
||||
// Focus the field and type: the in-progress text must survive the
|
||||
@@ -2745,9 +2935,343 @@ mod tests {
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.read(|cx| editor.read(cx).as_str().to_string()),
|
||||
"<p>engine text</p>",
|
||||
"文本\nsecond line",
|
||||
"the field re-syncs to the engine value on blur"
|
||||
);
|
||||
}
|
||||
|
||||
/// The v3 text node's parameter set renders as one structured control per
|
||||
/// visible input — a multi-line text field, a font-family combo, colour
|
||||
/// pickers, sliders, checkboxes, vec2 spinboxes — and the three hidden
|
||||
/// inputs (the legacy text, the vertical align, the use-args toggle)
|
||||
/// render nothing at all.
|
||||
#[gpui::test]
|
||||
async fn text3_params_render_as_structured_controls(cx: &mut TestAppContext) {
|
||||
use crate::oakui::mock::MockEngine;
|
||||
struct Host {
|
||||
view: Entity<OfxParamsView<MockEngine>>,
|
||||
}
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.view.clone())
|
||||
}
|
||||
}
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(400.0), px(600.0)), |window, cx| {
|
||||
let engine = cx.new(|cx| MockEngine::create(cx));
|
||||
let view = cx.new(|cx| OfxParamsView::<MockEngine>::new(EffectId(900), engine, window, cx));
|
||||
Host { view }
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let host = window.root(cx).expect("host root");
|
||||
let view = cx.read(|cx| host.read(cx).view.clone());
|
||||
|
||||
// The mock hands the hidden inputs over (a plugin engine's snapshot
|
||||
// can carry them too); the view must drop them before any control is
|
||||
// built.
|
||||
let raw_ids: Vec<String> = cx.read(|cx| {
|
||||
view.read(cx)
|
||||
.engine
|
||||
.read(cx)
|
||||
.effect_params(EffectId(900))
|
||||
.expect("the mock carries the text3 parameter set")
|
||||
.into_iter()
|
||||
.map(|p| p.input_id)
|
||||
.collect()
|
||||
});
|
||||
for hidden in ["text_in", "valign_in", "use_args_in"] {
|
||||
assert!(
|
||||
raw_ids.iter().any(|id| id == hidden),
|
||||
"the mock snapshot carries {hidden} ({raw_ids:?})"
|
||||
);
|
||||
}
|
||||
|
||||
// Draw once: the render pass is where `sync_values` reapplies the
|
||||
// engine snapshot to the widgets (the sliders snap to their grid).
|
||||
let mut visual = gpui::VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
|
||||
/// One plain-data snapshot of the control set (the kinds carry
|
||||
/// entities, so they cannot be compared directly).
|
||||
struct Snapshot {
|
||||
kinds: Vec<(String, &'static str)>,
|
||||
sliders: Vec<(String, f64)>,
|
||||
texts: Vec<(String, String, bool)>,
|
||||
combos: Vec<(String, Option<usize>)>,
|
||||
spins: Vec<(String, Vec<f64>)>,
|
||||
colors: Vec<String>,
|
||||
}
|
||||
let snap = visual.read(|cx| {
|
||||
let view = view.read(cx);
|
||||
let mut snap = Snapshot {
|
||||
kinds: Vec::new(),
|
||||
sliders: Vec::new(),
|
||||
texts: Vec::new(),
|
||||
combos: Vec::new(),
|
||||
spins: Vec::new(),
|
||||
colors: Vec::new(),
|
||||
};
|
||||
for control in &view.controls {
|
||||
let kind = match &control.kind {
|
||||
ControlKind::Slider(slider) => {
|
||||
snap.sliders
|
||||
.push((control.input_id.clone(), slider.read(cx).value().to_f64()));
|
||||
"slider"
|
||||
}
|
||||
ControlKind::CheckBox(_) => "checkbox",
|
||||
ControlKind::Combo(combo) => {
|
||||
snap.combos.push((control.input_id.clone(), combo.read(cx).selected()));
|
||||
"combo"
|
||||
}
|
||||
ControlKind::Spin(spins) => {
|
||||
snap.spins.push((
|
||||
control.input_id.clone(),
|
||||
spins.iter().map(|(spin, _)| spin.read(cx).value().to_f64()).collect(),
|
||||
));
|
||||
"spin"
|
||||
}
|
||||
ControlKind::Color(_) => {
|
||||
snap.colors.push(control.input_id.clone());
|
||||
"color"
|
||||
}
|
||||
ControlKind::Text { editor, multiline } => {
|
||||
snap.texts.push((
|
||||
control.input_id.clone(),
|
||||
editor.read(cx).as_str().to_string(),
|
||||
*multiline,
|
||||
));
|
||||
if *multiline {
|
||||
"text-area"
|
||||
} else {
|
||||
"text"
|
||||
}
|
||||
}
|
||||
ControlKind::Curve(_) => "curve",
|
||||
ControlKind::PushButton => "button",
|
||||
ControlKind::ReadOnly(_) => "readonly",
|
||||
};
|
||||
snap.kinds.push((control.input_id.clone(), kind));
|
||||
}
|
||||
snap
|
||||
});
|
||||
|
||||
let kinds: Vec<(&str, &str)> = snap
|
||||
.kinds
|
||||
.iter()
|
||||
.map(|(id, kind)| (id.as_str(), *kind))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![
|
||||
("pos_in", "spin"),
|
||||
("size_in", "spin"),
|
||||
("plain_text_in", "text-area"),
|
||||
("font_family_in", "combo"),
|
||||
("font_size_in", "slider"),
|
||||
("outline_enabled_in", "checkbox"),
|
||||
("outline_color_in", "color"),
|
||||
("outline_width_in", "slider"),
|
||||
("glow_enabled_in", "checkbox"),
|
||||
("glow_color_in", "color"),
|
||||
("glow_radius_in", "slider"),
|
||||
("args_in", "text"),
|
||||
],
|
||||
"one control per visible text3 input, hidden inputs and secret textures skipped"
|
||||
);
|
||||
|
||||
// Each numeric widget shows the engine value (not a grid neighbour).
|
||||
let slider_values: Vec<(&str, f64)> = snap
|
||||
.sliders
|
||||
.iter()
|
||||
.map(|(id, value)| (id.as_str(), *value))
|
||||
.collect();
|
||||
for (id, want) in [
|
||||
("font_size_in", 72.0),
|
||||
("outline_width_in", 2.0),
|
||||
("glow_radius_in", 8.0),
|
||||
] {
|
||||
let got = slider_values
|
||||
.iter()
|
||||
.find(|(input_id, _)| *input_id == id)
|
||||
.unwrap_or_else(|| panic!("{id} should be a slider: {slider_values:?}"));
|
||||
assert!(
|
||||
(got.1 - want).abs() <= 1e-9 * want.abs().max(1.0),
|
||||
"{id} shows {} but the engine holds {want}",
|
||||
got.1
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
snap.texts
|
||||
.iter()
|
||||
.map(|(id, text, multiline)| (id.as_str(), text.as_str(), *multiline))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("plain_text_in", "文本\nsecond line", true),
|
||||
("args_in", "", false),
|
||||
],
|
||||
"the editable text is multi-line and holds the node's text verbatim"
|
||||
);
|
||||
assert_eq!(
|
||||
snap.combos,
|
||||
vec![("font_family_in".to_string(), Some(0))],
|
||||
"the font family is a (string) combo"
|
||||
);
|
||||
assert_eq!(
|
||||
snap.colors,
|
||||
vec!["outline_color_in".to_string(), "glow_color_in".to_string()],
|
||||
"both colours get a picker"
|
||||
);
|
||||
assert_eq!(
|
||||
snap.spins,
|
||||
vec![
|
||||
("pos_in".to_string(), vec![0.0, 0.0]),
|
||||
("size_in".to_string(), vec![400.0, 300.0]),
|
||||
],
|
||||
"the vec2 params get one spinbox per component"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every numeric parameter seeds its slider on the step grid, inside the
|
||||
/// range, and a re-sync does not move it: `font_size_in` (72 with a floor
|
||||
/// of 1) used to show 50.995, because the range came from the type
|
||||
/// default and the value snapped onto a coarse grid nowhere near it.
|
||||
#[test]
|
||||
fn numeric_params_stay_on_their_slider_grid() {
|
||||
fn param(
|
||||
value_type: ValueType,
|
||||
value: NodeValue,
|
||||
properties: Vec<(&str, NodeValue)>,
|
||||
) -> EffectParam {
|
||||
EffectParam {
|
||||
input_id: "test_in".to_string(),
|
||||
display_name: "Test".to_string(),
|
||||
value_type,
|
||||
value,
|
||||
flags: 0,
|
||||
properties: properties
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
fn close(got: f64, want: f64) -> bool {
|
||||
(got - want).abs() <= 1e-9 * want.abs().max(1.0)
|
||||
}
|
||||
|
||||
struct Case {
|
||||
label: &'static str,
|
||||
param: EffectParam,
|
||||
want_value: f64,
|
||||
want_range: (f64, f64),
|
||||
}
|
||||
let cases = vec![
|
||||
Case {
|
||||
label: "font size 72 over a floor of 1",
|
||||
param: param(
|
||||
ValueType::Float,
|
||||
NodeValue::Float(72.0),
|
||||
vec![("min", NodeValue::Float(1.0))],
|
||||
),
|
||||
want_value: 72.0,
|
||||
want_range: (1.0, 201.0),
|
||||
},
|
||||
Case {
|
||||
label: "outline width 2 over a floor of 0",
|
||||
param: param(
|
||||
ValueType::Float,
|
||||
NodeValue::Float(2.0),
|
||||
vec![("min", NodeValue::Float(0.0))],
|
||||
),
|
||||
want_value: 2.0,
|
||||
want_range: (0.0, 20.0),
|
||||
},
|
||||
Case {
|
||||
label: "glow radius 8 over a floor of 0",
|
||||
param: param(
|
||||
ValueType::Float,
|
||||
NodeValue::Float(8.0),
|
||||
vec![("min", NodeValue::Float(0.0))],
|
||||
),
|
||||
want_value: 8.0,
|
||||
want_range: (0.0, 100.0),
|
||||
},
|
||||
Case {
|
||||
label: "a value sitting on its floor",
|
||||
param: param(
|
||||
ValueType::Float,
|
||||
NodeValue::Float(1.0),
|
||||
vec![("min", NodeValue::Float(1.0))],
|
||||
),
|
||||
want_value: 1.0,
|
||||
want_range: (1.0, 201.0),
|
||||
},
|
||||
Case {
|
||||
label: "a bounded 0..1 float",
|
||||
param: param(
|
||||
ValueType::Float,
|
||||
NodeValue::Float(0.5),
|
||||
vec![("min", NodeValue::Float(0.0)), ("max", NodeValue::Float(1.0))],
|
||||
),
|
||||
want_value: 0.5,
|
||||
want_range: (0.0, 1.0),
|
||||
},
|
||||
Case {
|
||||
label: "an unbounded float (the wide default range)",
|
||||
param: param(ValueType::Float, NodeValue::Float(0.7234), Vec::new()),
|
||||
want_value: 0.7234,
|
||||
want_range: (-10000.0, 10000.0),
|
||||
},
|
||||
Case {
|
||||
label: "an int over 0..10",
|
||||
param: param(
|
||||
ValueType::Int,
|
||||
NodeValue::Int(5),
|
||||
vec![("min", NodeValue::Int(0)), ("max", NodeValue::Int(10))],
|
||||
),
|
||||
want_value: 5.0,
|
||||
want_range: (0.0, 10.0),
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let label = case.label;
|
||||
let (min, max, step) = slider_range_and_step(&case.param);
|
||||
assert!(
|
||||
min.is_finite() && max.is_finite() && step.is_finite() && step > 0.0,
|
||||
"{label}: bad slider geometry ({min}, {max}) step {step}"
|
||||
);
|
||||
assert_eq!((min, max), case.want_range, "{label}: the slider range");
|
||||
let kind = if case.param.value_type == ValueType::Int {
|
||||
ValueKind::Integer
|
||||
} else {
|
||||
ValueKind::Float
|
||||
};
|
||||
let engine_value = case.param.value.to_double();
|
||||
let mut model = SliderModel::new(kind, min, max, step, engine_value.clamp(min, max));
|
||||
assert!(
|
||||
close(model.raw, case.want_value),
|
||||
"{label}: seeded at {} instead of {}",
|
||||
model.raw,
|
||||
case.want_value
|
||||
);
|
||||
// `sync_values` re-applies the engine value on every render: the
|
||||
// snapshot must land back on the same position.
|
||||
model.set_value(SliderValue::Float(engine_value));
|
||||
assert!(
|
||||
close(model.raw, case.want_value),
|
||||
"{label}: a re-sync moved the value to {} (want {})",
|
||||
model.raw,
|
||||
case.want_value
|
||||
);
|
||||
assert!(
|
||||
min <= model.raw && model.raw <= max,
|
||||
"{label}: {} is outside {min}..{max}",
|
||||
model.raw
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,6 +262,27 @@ impl<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
cx.emit(NewSequenceRequested);
|
||||
}))
|
||||
.child(crate::i18n::tr("project.new_sequence"));
|
||||
// 添加文本素材: creates a text generator (a bin entry that drops on
|
||||
// the timeline as a generator clip).
|
||||
let add_text_button = div()
|
||||
.id("project-add-text-footage")
|
||||
.debug_selector(|| "project-add-text-footage".into())
|
||||
.px_2()
|
||||
.py_0p5()
|
||||
.rounded_sm()
|
||||
.flex()
|
||||
.items_center()
|
||||
.cursor_pointer()
|
||||
.text_color(colors.text)
|
||||
.text_xs()
|
||||
.hover(|style| style.bg(colors.selected))
|
||||
.tooltip(move |window, cx| {
|
||||
tooltip_view(crate::i18n::tr("project.add_text_footage").into(), window, cx)
|
||||
})
|
||||
.on_click(cx.listener(|_this, _event: &ClickEvent, _window, cx| {
|
||||
cx.emit(NewTextFootageRequested);
|
||||
}))
|
||||
.child(crate::i18n::tr("project.add_text_footage"));
|
||||
let header = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
@@ -274,6 +295,7 @@ impl<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.child(div().flex_1().child(crate::i18n::tr("panel.project")))
|
||||
.child(add_text_button)
|
||||
.child(new_sequence_button);
|
||||
div()
|
||||
.size_full()
|
||||
@@ -316,6 +338,13 @@ pub struct NewSequenceRequested;
|
||||
|
||||
impl<E: AppEngine> EventEmitter<NewSequenceRequested> for ProjectExplorerPanel<E> {}
|
||||
|
||||
/// The project explorer asked the shell to create a text generator bin
|
||||
/// entry (the header's 添加文本素材 action).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NewTextFootageRequested;
|
||||
|
||||
impl<E: AppEngine> EventEmitter<NewTextFootageRequested> for ProjectExplorerPanel<E> {}
|
||||
|
||||
/// The project explorer asked the shell to rename entry `id`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RenameRequested(pub u64);
|
||||
|
||||
+1508
-47
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
//! GPU pixel tests for the text3 outline/glow post-process (skip when no
|
||||
//! GPU adapter).
|
||||
use oak_core::texture::Texture;
|
||||
use oak_core::{PixelFormat, Rational};
|
||||
use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
|
||||
use oak_node::nodes::textbackend::{
|
||||
set_text_backends, TextLayoutRequest, TextLayoutSize, TextRenderTarget, TextRenderTransform,
|
||||
};
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn texture_value(t: Texture) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(t)) }
|
||||
fn gpu() -> bool { oak_core::backend::GpuContext::shared().is_some() }
|
||||
#[allow(dead_code)]
|
||||
fn filled_frame(size: (i32, i32), rgba: [f32; 4]) -> Texture {
|
||||
let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
|
||||
for px in f.data.chunks_exact_mut(16) { for (c, v) in px.chunks_exact_mut(4).zip(rgba) { c.copy_from_slice(&v.to_le_bytes()); } }
|
||||
Texture::wrap_frame(f)
|
||||
}
|
||||
fn pixel_at(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
|
||||
let stride = frame.linesize_bytes() as usize;
|
||||
let at = y * stride + x * 16;
|
||||
let mut out = [0f32; 4];
|
||||
for c in 0..4 { out[c] = f32::from_le_bytes(frame.data[at + c*4..at + c*4 + 4].try_into().unwrap()); }
|
||||
out
|
||||
}
|
||||
fn eval_node_row(type_id: &str, inputs: NodeValueRow, frame_size: Option<(i32, i32)>) -> oak_core::texture::Frame {
|
||||
use oak_node::traverser::RenderHooks;
|
||||
let (core, behavior) = oak_node::factory::Factory::global().create_any(type_id).expect("node type registered");
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let mut hooks = oak_render::eval::RenderEvalHooks::new();
|
||||
hooks.frame_size = frame_size;
|
||||
hooks.resolve(oak_node::id::NodeId::INVALID, &inputs, &mut table);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else { panic!("{type_id}: no texture produced") };
|
||||
if handle.ctx.is_null() { panic!("{type_id}: null texture produced"); }
|
||||
let tex = unsafe { oak_node::handle::get_checked::<Texture>(handle) }.expect("resolved texture");
|
||||
assert!(matches!(tex, Texture::Gpu { .. }), "{type_id}: must render on the GPU");
|
||||
tex.to_frame().expect("readback")
|
||||
}
|
||||
|
||||
const TEXT3: &str = "org.olivevideoeditor.Olive.text3";
|
||||
|
||||
/// The text backends are process globals: keep the tests that install
|
||||
/// them off each other.
|
||||
static BACKENDS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Measure stub: a 16x16 document, so the raster is 16x16 whatever the
|
||||
/// font engine would have laid out.
|
||||
fn block_measure(_req: &TextLayoutRequest) -> TextLayoutSize { TextLayoutSize { width: 16.0, height: 16.0 } }
|
||||
|
||||
/// Render stub: paint white premultiplied coverage over the middle half
|
||||
/// of the target (pixels 4..12 on both axes).
|
||||
fn block_render(_req: &TextLayoutRequest, _transform: &TextRenderTransform, target: TextRenderTarget) {
|
||||
if target.channel_count != 4 { return; }
|
||||
let stride = target.linesize_bytes as usize;
|
||||
let (w, h) = (target.width as usize, target.height as usize);
|
||||
for y in h / 4..3 * h / 4 {
|
||||
for x in w / 4..3 * w / 4 {
|
||||
let at = y * stride + x * 4;
|
||||
target.data[at..at + 4].copy_from_slice(&[255; 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert boolean parameters (input id -> value).
|
||||
fn set_bools(row: &mut NodeValueRow, params: &[(&str, bool)]) {
|
||||
for (id, v) in params {
|
||||
row.insert((*id).to_string(), NodeValue::Boolean(*v));
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert float parameters (input id -> value).
|
||||
fn set_floats(row: &mut NodeValueRow, params: &[(&str, f64)]) {
|
||||
for (id, v) in params {
|
||||
row.insert((*id).to_string(), NodeValue::Float(*v));
|
||||
}
|
||||
}
|
||||
|
||||
/// The evaluation row of the three tests: a 16x16 raster painted by
|
||||
/// [`block_render`], with a 2px outline and/or a 4px glow. The outline
|
||||
/// and glow colors are left at the node defaults (opaque black, opaque
|
||||
/// yellow).
|
||||
fn text_row(outline: bool, glow: bool) -> NodeValueRow {
|
||||
let mut row = NodeValueRow::new();
|
||||
row.insert("plain_text_in".to_string(), NodeValue::Text("X".to_string()));
|
||||
set_bools(
|
||||
&mut row,
|
||||
&[
|
||||
("use_args_in", false),
|
||||
("outline_enabled_in", outline),
|
||||
("glow_enabled_in", glow),
|
||||
],
|
||||
);
|
||||
set_floats(&mut row, &[("outline_width_in", 2.0), ("glow_radius_in", 4.0)]);
|
||||
row.insert("size_in".to_string(), NodeValue::Vec2([16.0, 16.0]));
|
||||
row
|
||||
}
|
||||
|
||||
/// Evaluate `TEXT3` with the raster backends installed. Callers hold
|
||||
/// [`BACKENDS`].
|
||||
fn eval_text(row: NodeValueRow) -> oak_core::texture::Frame {
|
||||
set_text_backends(Some(block_measure), Some(block_render));
|
||||
let frame = eval_node_row(TEXT3, row, None);
|
||||
set_text_backends(None, None);
|
||||
frame
|
||||
}
|
||||
|
||||
/// Assert the four channels of a pixel against the expected values.
|
||||
fn assert_pixel(px: [f32; 4], want: [f32; 4], what: &str) {
|
||||
for c in 0..4 {
|
||||
assert!(
|
||||
(px[c] - want[c]).abs() < 1e-3,
|
||||
"{what}: channel {c}: got {px:?}, want {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline only: the coverage is dilated by 2 pixels and tinted opaque
|
||||
/// black under the text, so the glyph pixel stays white, the 2px band
|
||||
/// around it is `[0,0,0,1]` and everything further out is transparent.
|
||||
#[test]
|
||||
fn outline_dilates_and_strokes_under_the_text() {
|
||||
if !gpu() {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
}
|
||||
let _guard = BACKENDS.lock().unwrap();
|
||||
set_text_backends(None, None);
|
||||
|
||||
let frame = eval_text(text_row(true, false));
|
||||
assert_eq!((frame.width, frame.height), (16, 16), "raster size");
|
||||
|
||||
// The glyph is drawn over its own outline.
|
||||
assert_pixel(pixel_at(&frame, 8, 8), [1.0, 1.0, 1.0, 1.0], "text over stroke");
|
||||
|
||||
// Inside the 2px dilate band (coverage 4..12 widened to 2..14).
|
||||
for (x, y) in [(2, 8), (13, 8), (8, 2), (8, 13), (2, 2), (13, 13)] {
|
||||
assert_pixel(pixel_at(&frame, x, y), [0.0, 0.0, 0.0, 1.0], &format!("stroke ({x},{y})"));
|
||||
}
|
||||
|
||||
// One pixel outside the band.
|
||||
for (x, y) in [(1, 8), (14, 8), (8, 1), (8, 14), (0, 0), (15, 15)] {
|
||||
assert_pixel(pixel_at(&frame, x, y), [0.0, 0.0, 0.0, 0.0], &format!("outside ({x},{y})"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Glow only: the coverage is box-blurred with radius 4 (a 9-tap pass
|
||||
/// per axis) and tinted yellow under the text. The blurred alpha is
|
||||
/// `(horizontal taps / 9) * (vertical taps / 9)`, e.g. 32/81 at (3,8)
|
||||
/// and 1/81 at the clamped corners.
|
||||
#[test]
|
||||
fn glow_blurs_the_coverage_under_the_text() {
|
||||
if !gpu() {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
}
|
||||
let _guard = BACKENDS.lock().unwrap();
|
||||
set_text_backends(None, None);
|
||||
|
||||
let frame = eval_text(text_row(false, true));
|
||||
assert_eq!((frame.width, frame.height), (16, 16), "raster size");
|
||||
|
||||
// The glyph is drawn over the glow.
|
||||
assert_pixel(pixel_at(&frame, 8, 8), [1.0, 1.0, 1.0, 1.0], "text over glow");
|
||||
|
||||
// Yellow is premultiplied: [a, a, 0, a].
|
||||
let a = |v: f32| [v, v, 0.0, v];
|
||||
assert_pixel(pixel_at(&frame, 3, 8), a(32.0 / 81.0), "glow 4px left of the block");
|
||||
assert_pixel(pixel_at(&frame, 2, 8), a(8.0 / 27.0), "glow 2px left of the block");
|
||||
assert_pixel(pixel_at(&frame, 1, 8), a(16.0 / 81.0), "glow 3px left of the block");
|
||||
assert_pixel(pixel_at(&frame, 2, 2), a(1.0 / 9.0), "glow on the block corner");
|
||||
// The clamped corners mirror each other.
|
||||
assert_pixel(pixel_at(&frame, 0, 0), a(1.0 / 81.0), "glow top-left corner");
|
||||
assert_pixel(pixel_at(&frame, 15, 15), a(1.0 / 81.0), "glow bottom-right corner");
|
||||
}
|
||||
|
||||
/// Outline and glow: the glow blurs the *stroke* and is drawn beneath
|
||||
/// it, so the opaque stroke hides it inside the band while it still
|
||||
/// bleeds the 4px radius past the stroke edge.
|
||||
#[test]
|
||||
fn outline_and_glow_glow_the_stroke() {
|
||||
if !gpu() {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
}
|
||||
let _guard = BACKENDS.lock().unwrap();
|
||||
set_text_backends(None, None);
|
||||
|
||||
let frame = eval_text(text_row(true, true));
|
||||
assert_eq!((frame.width, frame.height), (16, 16), "raster size");
|
||||
|
||||
assert_pixel(pixel_at(&frame, 8, 8), [1.0, 1.0, 1.0, 1.0], "text over stroke and glow");
|
||||
assert_pixel(pixel_at(&frame, 2, 8), [0.0, 0.0, 0.0, 1.0], "stroke hides the glow");
|
||||
|
||||
// Past the stroke edge the glow shows again, dimmer the wider the
|
||||
// blur source (the stroke) is than the bare coverage.
|
||||
let a = |v: f32| [v, v, 0.0, v];
|
||||
assert_pixel(pixel_at(&frame, 1, 8), a(4.0 / 9.0), "glow just past the stroke");
|
||||
assert_pixel(pixel_at(&frame, 15, 8), a(1.0 / 3.0), "glow at the right edge");
|
||||
assert_pixel(pixel_at(&frame, 0, 0), a(1.0 / 9.0), "glow top-left corner");
|
||||
assert_pixel(pixel_at(&frame, 14, 14), a(16.0 / 81.0), "glow bottom-right of the stroke");
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
# 文本素材化与结构化文本编辑器改造计划
|
||||
|
||||
> 面向实现者的任务书(2026-09-10)。本文只描述方案与工作项,不含已执行的代码修改。
|
||||
> 提出背景(用户原话):"不能让用户手工输入 HTML;输入框输入不了内容(已修复,见 §0);
|
||||
> 应该让用户手工编辑文本、手工设置字体、字号、位置、字体颜色、轮廓、发光;文字应该是一个
|
||||
> 单独的素材而不是一个特效——文字作为 clip 被拖动到时间轴上,而不是作为特效被拖动到检查器;
|
||||
> 文字不应该被放在特效那里,应该在项目的'新建序列'旁边添加一个'添加文本素材'。"
|
||||
|
||||
## 0. 已先行修复(不在本计划范围)
|
||||
|
||||
- 输入框无法输入:参数视图每个引擎 tick 都把引擎值重刷进输入框,击键下一帧即被清掉。
|
||||
已改为聚焦期间跳过重同步(`crates/oak-app/src/panels/ofx_params.rs` sync_values 的
|
||||
Text 分支,与曲线编辑器拖拽保护同款),含回归测试
|
||||
`text_field_keeps_in_progress_edits_while_focused`。**已提交**(`5f8db8e31`)。
|
||||
|
||||
## 1. 现状
|
||||
|
||||
### 1.1 节点层
|
||||
|
||||
- `crates/oak-node/src/nodes/textv3.rs`(type id `org.olivevideoeditor.Olive.text3`):
|
||||
当前"Text"特效。输入仅 `text_in`(**HTML 原文**,默认值是
|
||||
`<p style='font-size: 72pt; color: white;'>Sample Text</p>`)、`valign_in`、
|
||||
`use_args_in`、`args_in`,外加 ShapeNodeBase 继承的 `pos_in`/`size_in`/`color_in`。
|
||||
字体、字号、颜色全部编码在 HTML 里;轮廓、发光**根本不存在**。
|
||||
`create()` 设 `VIDEO_EFFECT` 标志 → 出现在特效库/检查器"添加特效"菜单
|
||||
(`crates/oak-app/src/oakui/effectchain.rs::addable_effects`,内置特效取
|
||||
`VIDEO_EFFECT && !DONT_SHOW_IN_CREATE_MENU`)。
|
||||
- `textv1.rs`(`textgenerator`):旧版,已带 `DONT_SHOW_IN_CREATE_MENU`。
|
||||
- `textbackend.rs`:**只有 hook 层**。`TextLayoutRequest{text, mode, font_family,
|
||||
font_size_pt, dots_per_meter, wrap_width, center_horizontally}` +
|
||||
`set_text_backends(measure, render)` 两个函数指针。**没有任何已安装的文本引擎**,
|
||||
即当前 text3 根本无法真正出字(后端决策被刻意推迟到 facade 层,见模块文档)。
|
||||
- 字体引擎可用性:workspace lockfile 已有 `cosmic-text 0.19.0`(gpui 文本系统在用)与
|
||||
`swash`。无需新增重量级依赖即可实现后端;GPU 侧轮廓/发光可作为覆盖率纹理的后处理
|
||||
pass 实现,不走字体引擎。
|
||||
|
||||
### 1.2 素材/时间轴层
|
||||
|
||||
- bin 条目 = 项目根文件夹 `FolderBehavior.children`(`crates/oak-app/src/oakui/projectbrowser.rs`,
|
||||
`roots()/children()`,条目 id = 节点 identity,名称 = `core.label`)。
|
||||
- 时间轴接受 bin 拖放:footage 走 `engine.drop_footage_at` →
|
||||
`graphops::place_footage_clip`(footage 节点连到 clip `tex_in`)。**没有**非 footage
|
||||
条目的拖放路径。
|
||||
- clip 由生成器喂入的机制已存在:clip 的 `tex_in` 可以接任意节点输出(多机位、
|
||||
效果链都是这么接的,见 `effectchain::insert` 的连线模式)。
|
||||
- 项目面板"新建序列"按钮:`crates/oak-app/src/panels/project_explorer.rs:246`,
|
||||
emit `NewSequenceRequested` → app.rs 订阅处理。
|
||||
|
||||
### 1.3 渲染层
|
||||
|
||||
- text3 的 `value()` 产出 shader job(`ShaderJobPayload`),渲染端
|
||||
`process_shader_job` 编译执行。文本栅格化在节点侧经 textbackend 完成
|
||||
(当前无后端 → 空)。轮廓/发光若做 GPU 后处理,可复用同一个 job 管线
|
||||
(多级 `iterations` 或嵌套 payload 都已支持)。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
1. 用户永远看不到、也输不了 HTML。文本内容、字体、字号、位置、颜色、轮廓、发光
|
||||
全部是结构化字段。
|
||||
2. 文字是**素材**:项目面板"新建序列"旁出现"添加文本素材",创建后出现在 bin 里,
|
||||
可拖到时间轴成为 clip;选中文本 clip 在检查器里编辑上述字段。
|
||||
3. 特效库不再出现 text3(迁移完成后隐藏;迁移期不破坏旧项目)。
|
||||
|
||||
## 3. 方案
|
||||
|
||||
### 3.1 textv3 节点结构化输入(节点层)
|
||||
|
||||
给 `textv3.rs` 增加输入(保留 `text_in` 作内部合成载体与旧项目兼容):
|
||||
|
||||
| 新输入 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `plain_text_in` | Text | `"文本"` | 纯文本内容(多行) |
|
||||
| `font_family_in` | Combo(StrCombo) | 空=后端默认 | 字体族(选项由后端枚举注入;手输亦可) |
|
||||
| `font_size_in` | Float | 72.0 | 字号 pt,min 1 |
|
||||
| `text_color_in` | Color | (1,1,1,1) | 字体颜色(替代 HTML color) |
|
||||
| `outline_enabled_in` | Boolean | false | 轮廓开关 |
|
||||
| `outline_color_in` | Color | (0,0,0,1) | 轮廓颜色 |
|
||||
| `outline_width_in` | Float | 2.0 | 轮廓宽度 px,min 0 |
|
||||
| `glow_enabled_in` | Boolean | false | 发光开关 |
|
||||
| `glow_color_in` | Color | (1,1,0,1) | 发光颜色 |
|
||||
| `glow_radius_in` | Float | 8.0 | 发光半径 px,min 0 |
|
||||
|
||||
- `value()` 变更:不再把用户文本当 HTML。若装了后端,用 `TextLayoutMode::PlainText`
|
||||
+ `font_family_in`/`font_size_in` 布局;颜色经 `color_in`(已有的 shape 基类输入,
|
||||
改名为 UI 上呈现为"字体颜色"还是保留 `color_in` 复用——**实现时选复用 `color_in`**,
|
||||
少一个冗余输入;`text_color_in` 不建)。轮廓/发光:
|
||||
- 首选 **GPU 后处理**:文本覆盖率纹理 → 轮廓 = 覆盖率膨胀(dilate)+底色垫底合成;
|
||||
发光 = 覆盖率高斯模糊+加色合成。两者都是现成模糊/合成 shader 的组合,
|
||||
作为 text3 `value()` 内的嵌套 payload 链(eval 已支持嵌套递归,深度上限 8)。
|
||||
- 轮廓膨胀/模糊模糊 kernel 复用 `blur.rs` 的 box blur 迭代模式即可(视觉可接受,
|
||||
避免新写高斯)。
|
||||
- `text_in` 保留但改为**内部输入**(UI 隐藏,`input::flags::HIDDEN`):
|
||||
旧项目文件里它是 HTML,载入时若 `plain_text_in` 为空而 `text_in` 非空,
|
||||
`Retranslate`/加载钩子里做一次 HTML→纯文本剥离(简单正则去标签即可,
|
||||
写 `strip_html_to_plain()` 单测覆盖)。
|
||||
- `valign_in`/`use_args_in`/`args_in` 保留原样(已是 HIDDEN|STATIC 或正常输入)。
|
||||
|
||||
### 3.2 文本后端(cosmic-text)
|
||||
|
||||
- 新增 `crates/oak-app/src/oakui/textengine.rs`(app 层安装 hook,oak-node 不加依赖):
|
||||
- 用 lockfile 已有的 `cosmic-text`(在 oak-app 的 Cargo.toml 提升为直接依赖,
|
||||
版本与 gpui 一致 0.19,避免双版本)。
|
||||
- 实现 `measure`/`render` 两个 `fn`,在 `RealEngine::create`(或 app 启动)
|
||||
调 `oak_node::nodes::textbackend::set_text_backends(Some(..), Some(..))`。
|
||||
(注意 textbackend 模块在 oak-node 是私有 mod 还是 pub——实现时若私有需改
|
||||
`pub mod textbackend`;hook 函数签名是 plain `fn`,跨 crate 直接传。)
|
||||
- `render` 输出 RGBA premultiplied(channel_count=4,白字默认色——节点侧
|
||||
`color_in` 着色在 shader 里做,与 v1/v3 的 C++ 语义一致)。
|
||||
- 字体枚举:`cosmic_text::fontdb` 系统字体库 → `font_family_in` 的 combo 选项
|
||||
由引擎 `effect_params` 组装时注入(`combo_option` 属性)。
|
||||
- 风险:cosmic-text 的 CJK 字体回退(fontdb 自带 fallback 链,Linux 上
|
||||
Noto Sans CJK 通常可用);多行/换行由 wrap_width + 文本含 `\n` 覆盖。
|
||||
|
||||
### 3.3 素材化(bin + 时间轴)
|
||||
|
||||
- **创建入口**:项目面板标题栏"新建序列"按钮旁加"添加文本素材"按钮
|
||||
(`project_explorer.rs` header,新 emit `NewTextFootageRequested`;app.rs 订阅)。
|
||||
行为:在项目根文件夹创建一个 text3 生成器节点(`core.label = "文本"`),
|
||||
作为 bin 条目出现。引擎方法 `AppEngine::create_text_footage(cx) -> Result<u64, String>`
|
||||
(real 实现:graphops 建节点 + 挂到 root folder children + undoable;
|
||||
mock 实现:记一条假条目)。
|
||||
- **拖放到时间轴**:时间轴 drop 目前只认 footage。扩展 `drop_footage_at`
|
||||
(或新增 `drop_generator_at`):若拖入的 bin 条目是 text3 节点,则
|
||||
`block_clip_create` + 把 text3 节点连到 clip `tex_in` + 放置到轨道
|
||||
(undoable,一条 undo)。clip 时长默认 5 秒(可拖长)。判定"条目是 text3":
|
||||
`graphops` 按 identity 取节点比较 type_id。
|
||||
- **bin 删除**:复用现有 `delete_entry`(从文件夹移除 + 断开图连接,已 undoable)。
|
||||
文本 clip 删除走现有 clip 删除路径。
|
||||
- **检查器编辑**:选中文本 clip 时,检查器显示其 **生成器节点**的参数
|
||||
(现在选中 clip 显示效果链;对 generator-fed clip,链头即 text3——
|
||||
检查器需要一个小改动:当 clip 的 `tex_in` 直连一个 generator 节点时,
|
||||
把该生成器的参数也列出(或直接把选择路由到生成器节点)。
|
||||
**实现时确定**:倾向"generator 节点作为链的第一张卡展示"——
|
||||
`selected_effect_cards` 已经遍历 chain,chain() 目前把喂入节点(footage/
|
||||
生成器)都算作链尾一张卡(见 effectchain.rs chain() 的已知怪癖),
|
||||
text3 参数会自然出现;需要的是 text3 的参数在 build_control 下呈现为
|
||||
结构化字段(文本=多行输入、字体=combo、颜色=颜色选择器、数值=spin)。
|
||||
- **特效库隐藏 text3**:`textv3.rs::create()` 的 flags 增加
|
||||
`DONT_SHOW_IN_CREATE_MENU`。旧项目里已存在的 text3 特效链节点**不受影响**
|
||||
(只是不能再新增)。此项放在最后做,确认素材路径可用后再隐藏。
|
||||
|
||||
### 3.4 UI 文案(i18n)
|
||||
|
||||
8 个语言文件新增:`project.add_text_footage`(添加文本素材)、
|
||||
`text.font_family`(字体)、`text.font_size`(字号)、`text.outline`(轮廓)、
|
||||
`text.outline_width`(轮廓宽度)、`text.glow`(发光)、`text.glow_radius`(发光半径)、
|
||||
`text.content`(文本内容)。zh-CN/en-US 翻译,其余语言给英文。
|
||||
|
||||
## 4. 工作项(可分配给子代理的最小单元)
|
||||
|
||||
1. **W1 节点输入扩展**:textv3 新输入 + `value()` 纯文本路径 + `text_in` 隐藏 +
|
||||
HTML 剥离迁移 + 节点单测(输入存在/默认值/隐藏标志/纯文本 job 参数)。
|
||||
2. **W2 轮廓/发光 GPU 后处理**:text3 `value()` 嵌套 payload(膨胀/模糊/合成),
|
||||
eval.rs GPU 像素测试(白字黑轮廓边缘检测、发光半径扩散检测)。
|
||||
3. **W3 cosmic-text 后端**:textengine.rs + hook 安装 + 字体枚举注入 +
|
||||
集成测试(装后端后 text3 渲染出非空纹理,GPU 测试)。
|
||||
4. **W4 素材创建+拖放**:面板按钮 + `create_text_footage` + drop 扩展 +
|
||||
i18n + app 层测试(mock:按钮 emit;real:创建后 bin 有条目、拖放后轨道有 clip
|
||||
且 tex_in 连到 text3)。
|
||||
5. **W5 检查器结构化呈现**:确认 text3 参数以结构化字段出现在文本 clip 的检查器
|
||||
(含聚焦保护已修的多行文本输入);颜色走 OfxColorPicker。
|
||||
6. **W6 特效库隐藏 + 收尾**:DONT_SHOW_IN_CREATE_MENU、全量测试、文档更新
|
||||
(docs/zh 如有特效清单)。
|
||||
|
||||
依赖顺序:W1→W2/W3(可并行)→W4→W5→W6。W2 与 W3 独立。
|
||||
建议 W1+W2 一个子代理、W3 一个、W4+W5 一个、W6 收尾由主代理审查后执行。
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
1. 项目面板点"添加文本素材"→ bin 出现"文本"条目;拖到时间轴 → 出现文本 clip。
|
||||
2. 选中文本 clip,检查器可编辑:内容(多行)、字体、字号、位置、颜色、轮廓
|
||||
(开关/颜色/宽度)、发光(开关/颜色/半径);全程无 HTML 可见。
|
||||
3. 编辑任一字段,暂停的画面立即更新(依赖已提交的暂停刷新修复)。
|
||||
4. 特效库/添加特效菜单中不再出现 Text/text3。
|
||||
5. 含旧 text3 特效的项目能打开、能渲染(HTML 自动剥成纯文本进 `plain_text_in`)。
|
||||
6. `cargo test --workspace` 全绿,新增 GPU 测试在无 GPU 环境跳过。
|
||||
Reference in New Issue
Block a user