Docking trial: craft-ui 6261d9d, paint_icon, float space, flyout event

This commit is contained in:
aiongg
2026-10-19 13:18:20 +00:00
parent b66c3223f1
commit e6f125437a
7 changed files with 74 additions and 191 deletions
Generated
+1 -1
View File
@@ -849,7 +849,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/aiongg/craft-libs?rev=f4f9e5b206a721d28ea1214ef8bf85b8e30bb779#f4f9e5b206a721d28ea1214ef8bf85b8e30bb779"
source = "git+https://github.com/aiongg/craft-libs?rev=6261d9d39a0a109a832685f37af6ce75321caea1#6261d9d39a0a109a832685f37af6ce75321caea1"
dependencies = [
"egui",
"serde",
+1 -1
View File
@@ -126,4 +126,4 @@ hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0
# Docking trial: craft-ui from the craft-libs `docking` branch.
[patch."https://github.com/storytold/craft-libs"]
craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" }
craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "6261d9d39a0a109a832685f37af6ce75321caea1" }
-1
View File
@@ -22,7 +22,6 @@ mod modal_drag;
mod new_document;
mod object_control;
mod panel_docking;
mod panel_icons;
pub mod panels;
mod prefs;
pub mod render_worker;
+43 -17
View File
@@ -5,14 +5,22 @@
//! languages, the left in right-to-left ones).
use craft_ui::docking::{
Action, Column, DockCommand, DockOperation, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, UiDock, Workspace,
Action, Column, DockCommand, DockOperation, FloatSpace, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, UiDock, Workspace,
};
use serde_json::{Value, json};
use crate::DesignApp;
/// The id of the dock area that draws the panels.
fn area_id() -> egui::Id {
egui::Id::new("designcraft-panel-docking")
}
/// The dock area a headless app floats into, before it ever drew a frame.
const HEADLESS_AREA: egui::Rect = egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1400.0, 900.0));
pub(crate) fn cancel_gesture(ctx: &egui::Context) {
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("designcraft-panel-docking"));
craft_ui::docking::cancel_drag::<String>(ctx, area_id());
}
fn normalize(raw: &str) -> Option<&'static str> {
@@ -412,17 +420,20 @@ fn floats_alone(layout: &Layout<String>, panel: &str) -> bool {
/// A shared docking command (`window.panel.*`, `window.workspace.*`, `ui.dock`) on the layout in
/// use, all or nothing. craft-ui's `DockCommand` parses it and makes the layout actions;
/// DesignCraft resolves panel names, shows a hidden panel before moving, floating or docking
/// it, floats a panel at its own size, and returns a floated panel to its dock place on Dock
/// Panel.
/// it, floats a panel at its own size (also when it was hidden, which craft-ui never drew),
/// and returns a floated panel to its dock place on Dock Panel.
fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result<Value, String> {
let mut params = resolve_panels(params)?;
let whole = |key: &str| params.get(key).and_then(Value::as_bool).unwrap_or(false);
if id == "window.panel.float"
&& !whole("group")
&& !whole("column")
&& let Some(panel) = named_panel(&params)
&& !floats_alone(&layout_of(app), &panel)
&& let Some(fields) = params.as_object_mut()
{
let size = floating_size(&panel);
for (key, default) in [("x", 400.0), ("y", 140.0), ("width", size.x), ("height", size.y)] {
for (key, default) in [("width", size.x), ("height", size.y)] {
fields.entry(key).or_insert(json!(default));
}
}
@@ -440,6 +451,7 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result<Value
_ => {}
}
let rtl = crate::i18n::is_rtl(&app.ui.language);
let space = float_space(app);
let mut txn = Transaction::begin(app)?;
let shows = match &command {
DockCommand::Activate { panel }
@@ -462,7 +474,7 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result<Value
let actions = match &command {
// Showing it was the whole command (craft-ui's `DockCommand::apply` does the same).
DockCommand::Dock { anchor: None, side: None, .. } if shown || returned => Vec::new(),
_ => command.to_actions(&txn.workspace, rtl).map_err(|e| e.to_string())?,
_ => command.to_actions(&txn.workspace, rtl, &space).map_err(|e| e.to_string())?,
};
let reveal = match &command {
DockCommand::Layout { action } | DockCommand::UiDock(UiDock::Action { action }) => match action {
@@ -481,6 +493,14 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result<Value
Ok(txn.commit(app, closed, reveal))
}
/// Where a command floats a panel, group or column it gives no rect: inside the dock area as
/// last drawn, else inside the window, else (headless, before any frame) inside
/// [`HEADLESS_AREA`].
fn float_space(app: &DesignApp) -> FloatSpace<String> {
let Some(ctx) = app.egui_ctx.as_ref() else { return FloatSpace::new(HEADLESS_AREA) };
craft_ui::docking::float_space::<String>(ctx, area_id()).unwrap_or_else(|| FloatSpace::new(ctx.content_rect()))
}
/// A change to the layout in use, committed only when every step succeeded.
struct Transaction {
/// The layout, and the closed panels with where each one goes back.
@@ -721,7 +741,7 @@ pub(crate) fn set_iconic(app: &mut DesignApp, iconic: bool) -> Result<Value, Str
}
pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let area = egui::Id::new("designcraft-panel-docking");
let area = area_id();
let changed_workspace = ui.ctx().data_mut(|data| {
let previous = data.get_temp::<u64>(area.with("generation"));
data.insert_temp(area.with("generation"), app.ui.docking_generation);
@@ -749,12 +769,12 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
if let Some(panel) = raise {
dock = dock.raise_panel(panel);
}
let icons = crate::panel_icons::panel_icons(ui.ctx(), style.icon_size);
let output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app, icons });
app.ui.docking_flyout = craft_ui::docking::flyout::<String>(ui.ctx(), area);
for event in &output.events {
if *event == craft_ui::docking::DockEvent::ToggleAutoCollapse {
app.ui.dock_auto_collapse = !app.ui.dock_auto_collapse;
let mut output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app });
for event in std::mem::take(&mut output.events) {
match event {
craft_ui::docking::DockEvent::ToggleAutoCollapse => app.ui.dock_auto_collapse = !app.ui.dock_auto_collapse,
craft_ui::docking::DockEvent::FlyoutChanged { panel } => app.ui.docking_flyout = panel,
_ => {}
}
}
if let Some(panel) = output.focused {
@@ -778,7 +798,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
/// panels closed with a remembered place, the panel whose flyout is open, and where each
/// docking gesture starts in the last frame (`hitRects`).
pub(crate) fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value {
let hits = craft_ui::docking::DockArea::<String>::hit_rects(ctx, egui::Id::new("designcraft-panel-docking"));
let hits = craft_ui::docking::DockArea::<String>::hit_rects(ctx, area_id());
let hit_rects: Vec<Value> = hits.iter().map(|(hit, r)| json!({"hit": hit, "rect": [r.left(), r.top(), r.width(), r.height()]})).collect();
json!({
"layout": layout_of(app),
@@ -901,10 +921,14 @@ fn floating_size(panel: &str) -> egui::Vec2 {
}
}
/// The name of a dock panel's icon in [`crate::icons`].
fn panel_icon(panel: &str) -> Option<&'static str> {
crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|(id, _, _)| *id == panel).map(|(_, _, icon)| *icon)
}
/// The panels' content and icons.
struct PanelContent<'a> {
app: &'a mut DesignApp,
icons: std::collections::HashMap<String, egui::ImageSource<'static>>,
}
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
fn body(&mut self, ui: &mut egui::Ui, id: &String) {
@@ -939,8 +963,10 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
fn floating_size(&self, id: &String) -> Option<egui::Vec2> {
Some(floating_size(id))
}
fn icon(&self, id: &String) -> Option<egui::ImageSource<'static>> {
self.icons.get(id).cloned()
fn paint_icon(&mut self, painter: &egui::Painter, rect: egui::Rect, id: &String, color: egui::Color32) -> bool {
let Some(icon) = panel_icon(id) else { return false };
crate::icons::paint(painter, rect, icon, color);
true
}
fn bare(&self, id: &String) -> bool {
id == CANVAS
+22
View File
@@ -972,3 +972,25 @@ fn clicking_an_iconic_column_icon_opens_its_flyout() {
click(&mut h, icon.center(), PointerButton::Primary);
assert!(h.state().ui.docking_flyout.is_none());
}
/// Iconic columns paint DesignCraft's own panel icons, in the colour craft-ui passes; a panel
/// without an icon paints nothing.
#[test]
fn panel_icons_are_painted_in_code() {
let mut app = app();
let mut content = PanelContent { app: &mut app };
let ctx = egui::Context::default();
let mut paint = |panel: &str| {
let mut painted = false;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::background());
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(20.0, 20.0));
painted = craft_ui::docking::DockContent::paint_icon(&mut content, &painter, rect, &panel.to_string(), egui::Color32::RED);
});
output.textures_delta.clear();
(painted, output.shapes.len())
};
let (painted, shapes) = paint("pages");
assert!(painted && shapes > 0, "{painted} {shapes}");
assert_eq!(paint(CANVAS), (false, 0));
}
-168
View File
@@ -1,168 +0,0 @@
//! Panel icons as textures, for craft-ui's iconic columns, which draw an `ImageSource`.
//!
//! DesignCraft draws its icons in code ([`crate::icons::paint`]). Each panel's icon is painted
//! once, white, into a scratch egui context; the shapes are tessellated there and the triangles
//! filled on the CPU. The column tints the texture with its text colour.
use std::collections::HashMap;
use egui::{
Color32, ColorImage, Context, Id, ImageSource, LayerId, Pos2, RawInput, Rect, TextureHandle, TextureId, TextureOptions,
epaint::{ImageData, Mesh, Primitive},
load::SizedTexture,
pos2, vec2,
};
/// Icons per row of the scratch canvas.
const COLUMNS: usize = 8;
#[derive(Clone, Default)]
struct Cache {
/// Side of each texture in pixels.
px: usize,
textures: Vec<(String, TextureHandle)>,
}
fn cache_id() -> Id {
Id::new("designcraft-panel-icon-textures")
}
/// Every dock panel's icon at `size` points, keyed by panel id. Textures are made once per
/// pixel size and kept in the context.
pub(crate) fn panel_icons(ctx: &Context, size: f32) -> HashMap<String, ImageSource<'static>> {
let scale = size * ctx.pixels_per_point();
let px = if scale.is_finite() { scale.round().clamp(8.0, 128.0) as usize } else { 20 };
let cached = ctx.data(|d| d.get_temp::<Cache>(cache_id())).filter(|cache| cache.px == px);
let cache = cached.unwrap_or_else(|| {
let panels: Vec<(&str, &str)> = crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).map(|(id, _, icon)| (*id, *icon)).collect();
let images = render(px, &panels);
let textures = panels
.iter()
.zip(images)
.map(|((id, _), image)| ((*id).to_string(), ctx.load_texture(format!("designcraft-panel-icon-{id}"), image, TextureOptions::LINEAR)))
.collect();
let cache = Cache { px, textures };
ctx.data_mut(|d| d.insert_temp(cache_id(), cache.clone()));
cache
});
cache
.textures
.iter()
.map(|(id, texture)| (id.clone(), ImageSource::Texture(SizedTexture::new(texture.id(), vec2(px as f32, px as f32)))))
.collect()
}
/// The icons of `panels` (id, icon name), white on transparent, `px` pixels square.
fn render(px: usize, panels: &[(&str, &str)]) -> Vec<ColorImage> {
let cell = px as f32;
let rows = panels.len().div_ceil(COLUMNS).max(1);
let (width, height) = (COLUMNS * px, rows * px);
let cell_rect = |i: usize| Rect::from_min_size(pos2((i % COLUMNS) as f32 * cell, (i / COLUMNS) as f32 * cell), vec2(cell, cell));
let scratch = Context::default();
scratch.set_fonts(crate::theme::font_definitions(&[], "en"));
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height as f32))), ..Default::default() };
let mut output = scratch.run_ui(input, |ui| {
let painter = ui.ctx().layer_painter(LayerId::background());
for (i, (_, icon)) in panels.iter().enumerate() {
let rect = cell_rect(i);
crate::icons::paint(&painter.with_clip_rect(rect), rect, icon, Color32::WHITE);
}
});
let atlas =
output.textures_delta.set.get(&TextureId::default()).and_then(|deltas| deltas.iter().find(|delta| delta.pos.is_none())).map(|delta| {
match &delta.image {
ImageData::Color(image) => image.clone(),
}
});
let shapes = std::mem::take(&mut output.shapes);
let primitives = scratch.tessellate(shapes, output.pixels_per_point);
output.textures_delta.clear();
let mut canvas = vec![[0.0f32; 4]; width * height];
for primitive in &primitives {
if let Primitive::Mesh(mesh) = &primitive.primitive {
fill(&mut canvas, width, height, mesh, primitive.clip_rect, atlas.as_deref());
}
}
(0..panels.len())
.map(|i| {
let origin = cell_rect(i).min;
let (x0, y0) = (origin.x as usize, origin.y as usize);
let mut pixels = Vec::with_capacity(px * px * 4);
for y in y0..y0 + px {
for x in x0..x0 + px {
let [r, g, b, a] = canvas.get(y * width + x).copied().unwrap_or_default();
pixels.extend([r, g, b, a].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
}
}
ColorImage::from_rgba_premultiplied([px, px], &pixels)
})
.collect()
}
/// Fill `mesh`'s triangles into a premultiplied RGBA canvas, sampling the font atlas for text.
/// Pixel centres inside a triangle take its interpolated colour; egui's feathered edges give
/// the anti-aliasing.
fn fill(canvas: &mut [[f32; 4]], width: usize, height: usize, mesh: &Mesh, clip: Rect, atlas: Option<&ColorImage>) {
let bounds = clip.intersect(Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height as f32)));
if !bounds.is_positive() {
return;
}
let texel = |u: f32, v: f32| -> [f32; 4] {
if mesh.texture_id != TextureId::default() {
return [1.0; 4];
}
let Some(atlas) = atlas else { return [1.0; 4] };
let [w, h] = atlas.size;
let x = ((u * w as f32) as usize).min(w.saturating_sub(1));
let y = ((v * h as f32) as usize).min(h.saturating_sub(1));
atlas.pixels.get(y * w + x).map_or([1.0; 4], |c| c.to_array().map(|v| f32::from(v) / 255.0))
};
let edge = |a: Pos2, b: Pos2, p: Pos2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
for triangle in mesh.indices.as_chunks::<3>().0 {
let vertex = |i: usize| triangle.get(i).and_then(|&index| mesh.vertices.get(index as usize));
let (Some(a), Some(b), Some(c)) = (vertex(0), vertex(1), vertex(2)) else { continue };
let area = edge(a.pos, b.pos, c.pos);
if !area.is_finite() || area.abs() < 1e-6 {
continue;
}
let min = a.pos.min(b.pos).min(c.pos).max(bounds.min);
let max = a.pos.max(b.pos).max(c.pos).min(bounds.max);
if !(min.x < max.x && min.y < max.y) {
continue;
}
let colors = [a.color, b.color, c.color].map(|c| c.to_array().map(|v| f32::from(v) / 255.0));
for y in min.y.floor() as usize..(max.y.ceil() as usize).min(height) {
for x in min.x.floor() as usize..(max.x.ceil() as usize).min(width) {
let p = pos2(x as f32 + 0.5, y as f32 + 0.5);
let wa = edge(b.pos, c.pos, p) / area;
let wb = edge(c.pos, a.pos, p) / area;
let wc = 1.0 - wa - wb;
if wa < 0.0 || wb < 0.0 || wc < 0.0 {
continue;
}
let u = wa * a.uv.x + wb * b.uv.x + wc * c.uv.x;
let v = wa * a.uv.y + wb * b.uv.y + wc * c.uv.y;
let t = texel(u, v);
let src: [f32; 4] = std::array::from_fn(|k| (wa * colors[0][k] + wb * colors[1][k] + wc * colors[2][k]) * t[k]);
if let Some(dst) = canvas.get_mut(y * width + x) {
let keep = 1.0 - src[3];
*dst = std::array::from_fn(|k| src[k] + dst[k] * keep);
}
}
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn panel_icons_are_painted_into_their_textures() {
let images = super::render(20, &[("pages", "panel-pages"), ("character", "panel-character")]);
assert_eq!(images.len(), 2);
for image in &images {
assert_eq!(image.size, [20, 20]);
let inked = image.pixels.iter().filter(|p| p.a() > 128).count();
assert!(inked > 10 && inked < 300, "{inked} inked pixels");
}
}
}
+7 -3
View File
@@ -109,8 +109,11 @@ read as `anchor`.
`zone` and `before`.
- `window.panel.float {panel, x?, y?, width?, height?, group?, column?}` (and
`window.floatPanel`) detaches a panel, its tab group (`group`) or its dock column
(`column`). A panel floats at 400, 140 at its own size unless the params say otherwise; a
panel already alone in a floating window moves or resizes.
(`column`). Without a position it floats 24 points inside the dock area's top-left corner,
clear of the menu bar; a panel floats at its own size and a tab group or column at the size
a drag would float it at. Given values replace these. A panel already alone in a floating
window moves or resizes, and a tab group that is already a whole floating window keeps its
rect unless the params change it.
- `window.panel.dock {panel, anchor?, zone?, before?, group?, column?, side?}` (and
`window.dockPanel`) docks beside `anchor`, or in a new column of the dock at `side`
(`Start` or `End`; a new dock when there is none). Without either, a panel that floated from
@@ -179,7 +182,8 @@ default; a column bar's menu toggles it), Escape or a click outside the flyout c
`layout` is craft-ui's serialized `Layout`; `customized` is false while the workspace's
default layout is in use; `focused` is the panel (or `canvas`) last clicked or selected, or
null; `flyout` is the panel whose iconic-column flyout is open; `autoCollapse` is the
null; `flyout` is the panel whose iconic-column flyout is open (as drawn in the last frame,
or as a command that opens one sets it); `autoCollapse` is the
Auto-Collapse Iconic Panels setting; `hidden` lists closed panels that remember their place.
`hitRects` lists where each docking gesture starts in the last frame, from craft-ui's
`DockArea::hit_rects`: `hit` is a serialized craft-ui `Hit` (`Tab`, `DragBar`, `Border`,