mod font;
mod geometry;
mod input;
mod paint;
mod render_perf;
mod scene;
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::ops::Range;
use std::rc::Rc;
use std::sync::Arc;
pub use font::Font;
use geometry::*;
use input::WhiteboardInputElement;
use paint::*;
use render_perf::WorldViewport;
pub use scene::*;
use gpui::{
AnyElement, AnyView, App, AppContext, Bounds, Context, CursorStyle, Div, ElementId,
ElementInputHandler, Entity, EntityInputHandler, FocusHandle, GlobalElementId, Hsla,
InspectorElementId, InteractiveElement, IntoElement, KeyDownEvent, LayoutId, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ObjectFit, ParentElement, PathBuilder,
PinchEvent, Pixels, Point, Render, Rgba, ScrollDelta, ScrollWheelEvent, SharedString,
StatefulInteractiveElement, Style, Styled, StyledImage, TransformationMatrix, UTF16Selection,
Window, canvas, div, fill, hsla, linear_color_stop, linear_gradient, point, px, relative, rgba,
size,
};
use serde::{Deserialize, Serialize};
const MIN_ZOOM: f32 = 0.1;
const MAX_ZOOM: f32 = 8.0;
const GRID: f32 = 24.0;
const MIN_DOT_SPACING: f32 = 16.0;
const DOT: f32 = 2.0;
const LINE_PX: f32 = 16.0;
const VIEWPORT_CULL_MARGIN_PX: f32 = 96.0;
fn accepts_wheel_input(read_only: bool) -> bool {
!read_only
}
const NIB: f32 = 2.5;
const WIDTH_PRESETS: [f32; 5] = [1.0, 2.5, 4.0, 6.0, 9.0];
const WIDTH_MIN: f32 = 1.0;
const WIDTH_MAX: f32 = 20.0;
const WIDTH_SLIDER_W: f32 = 156.0;
const MIN_POINT_PX: f32 = 2.0;
const SELECT_PAD: f32 = 6.0;
const UNDO_CAP: usize = 50;
const HANDLE_HALF: f32 = 4.0;
const HANDLE_GRAB: f32 = 10.0;
const CONNECTOR_BUTTON_GAP: f32 = 24.0;
const CONNECTOR_BUTTON_SIZE: f32 = 20.0;
const SV_W: f32 = 216.0;
const SV_H: f32 = 140.0;
const HUE_H: f32 = 14.0;
const ROT_EPS: f32 = 0.05;
const ROT_SNAP: f32 = 0.105;
const TEXT_SIZE: f32 = 18.0;
const LABEL_PAD: f32 = 8.0;
const HIGHLIGHT_DEFAULT: u32 = 0xffe06680;
const TEXT_CHAR_W: f32 = 0.55;
const TEXT_LINE_H: f32 = 1.3;
const EMBED_W: f32 = 210.0;
const EMBED_H: f32 = 76.0;
const MINDMAP_ROOT_W: f32 = 196.0;
const MINDMAP_ROOT_H: f32 = 60.0;
const MINDMAP_NODE_W: f32 = 164.0;
const MINDMAP_NODE_H: f32 = 48.0;
const MINDMAP_BRANCH_GAP_X: f32 = 120.0;
const MINDMAP_BRANCH_GAP_Y: f32 = 84.0;
const FLOWCHART_NODE_W: f32 = 180.0;
const FLOWCHART_NODE_H: f32 = 52.0;
const FLOWCHART_GAP_Y: f32 = 92.0;
const FLOWCHART_BRANCH_GAP_X: f32 = 240.0;
const IMAGE_PLACE_PX: f32 = 280.0;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Tool {
Pan,
Select,
Pen,
Rect,
Ellipse,
Diamond,
Triangle,
RoundRect,
Star,
Hexagon,
Line,
Arrow,
DashedArrow,
Text,
MindMap,
Flowchart,
Embed,
Image,
}
impl Tool {
fn glyph(self) -> &'static str {
match self {
Tool::Pan => "☞",
Tool::Select => "↖",
Tool::Pen => "✎",
Tool::Rect => "▭",
Tool::Ellipse => "◯",
Tool::Diamond => "◇",
Tool::Triangle => "△",
Tool::RoundRect => "▢",
Tool::Star => "☆",
Tool::Hexagon => "⬡",
Tool::Line => "╱",
Tool::Arrow => "↗",
Tool::DashedArrow => "⇢",
Tool::Text => "T",
Tool::MindMap => "◎",
Tool::Flowchart => "⇅",
Tool::Embed => "▤",
Tool::Image => "▦",
}
}
fn label(self) -> &'static str {
match self {
Tool::Pan => "Pan — drag to move (H)",
Tool::Select => "Select (V)",
Tool::Pen => "Pen (P)",
Tool::Rect => "Rectangle (R)",
Tool::Ellipse => "Ellipse (O)",
Tool::Diamond => "Diamond (D)",
Tool::Triangle => "Triangle (G)",
Tool::RoundRect => "Rounded rectangle (U)",
Tool::Star => "Star (S)",
Tool::Hexagon => "Hexagon (X)",
Tool::Line => "Line (L)",
Tool::Arrow => "Arrow (A)",
Tool::DashedArrow => "Dashed arrow (K)",
Tool::Text => "Text (T)",
Tool::MindMap => "Mind map (M)",
Tool::Flowchart => "Flowchart (F)",
Tool::Embed => "Page card",
Tool::Image => "Image (I) — click to place",
}
}
fn shortcut(key: &str) -> Option<Tool> {
Some(match key {
"h" => Tool::Pan,
"v" => Tool::Select,
"p" => Tool::Pen,
"r" => Tool::Rect,
"o" => Tool::Ellipse,
"d" => Tool::Diamond,
"g" => Tool::Triangle,
"u" => Tool::RoundRect,
"s" => Tool::Star,
"x" => Tool::Hexagon,
"l" => Tool::Line,
"a" => Tool::Arrow,
"k" => Tool::DashedArrow,
"t" => Tool::Text,
"m" => Tool::MindMap,
"f" => Tool::Flowchart,
"i" => Tool::Image,
_ => return None,
})
}
fn icon(self) -> Option<(&'static str, &'static [u8])> {
const PAN: &[u8] = include_bytes!("../assets/icons/pan.svg");
const SELECT: &[u8] = include_bytes!("../assets/icons/select.svg");
const PEN: &[u8] = include_bytes!("../assets/icons/pen.svg");
const RECT: &[u8] = include_bytes!("../assets/icons/rect.svg");
const ELLIPSE: &[u8] = include_bytes!("../assets/icons/ellipse.svg");
const DIAMOND: &[u8] = include_bytes!("../assets/icons/diamond.svg");
const TRIANGLE: &[u8] = include_bytes!("../assets/icons/triangle.svg");
const ROUND_RECT: &[u8] = include_bytes!("../assets/icons/round-rect.svg");
const STAR: &[u8] = include_bytes!("../assets/icons/star.svg");
const HEXAGON: &[u8] = include_bytes!("../assets/icons/hexagon.svg");
const LINE: &[u8] = include_bytes!("../assets/icons/line.svg");
const ARROW: &[u8] = include_bytes!("../assets/icons/arrow.svg");
const TEXT: &[u8] = include_bytes!("../assets/icons/text.svg");
const MINDMAP: &[u8] = include_bytes!("../assets/icons/mindmap.svg");
const FLOWCHART: &[u8] = include_bytes!("../assets/icons/flowchart.svg");
const EMBED: &[u8] = include_bytes!("../assets/icons/embed.svg");
const IMAGE: &[u8] = include_bytes!("../assets/icons/image.svg");
match self {
Tool::Pan => Some(("wb-icon-pan", PAN)),
Tool::Select => Some(("wb-icon-select", SELECT)),
Tool::Pen => Some(("wb-icon-pen", PEN)),
Tool::Rect => Some(("wb-icon-rect", RECT)),
Tool::Ellipse => Some(("wb-icon-ellipse", ELLIPSE)),
Tool::Diamond => Some(("wb-icon-diamond", DIAMOND)),
Tool::Triangle => Some(("wb-icon-triangle", TRIANGLE)),
Tool::RoundRect => Some(("wb-icon-round-rect", ROUND_RECT)),
Tool::Star => Some(("wb-icon-star", STAR)),
Tool::Hexagon => Some(("wb-icon-hexagon", HEXAGON)),
Tool::Line => Some(("wb-icon-line", LINE)),
Tool::Arrow => Some(("wb-icon-arrow", ARROW)),
Tool::DashedArrow => None,
Tool::Text => Some(("wb-icon-text", TEXT)),
Tool::MindMap => Some(("wb-icon-mindmap", MINDMAP)),
Tool::Flowchart => Some(("wb-icon-flowchart", FLOWCHART)),
Tool::Embed => Some(("wb-icon-embed", EMBED)),
Tool::Image => Some(("wb-icon-image", IMAGE)),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ToolGroup {
Shapes,
Lines,
PagesImages,
}
impl ToolGroup {
const ALL: [ToolGroup; 3] = [ToolGroup::Shapes, ToolGroup::Lines, ToolGroup::PagesImages];
fn tools(self) -> &'static [Tool] {
match self {
ToolGroup::Shapes => &[
Tool::Rect,
Tool::RoundRect,
Tool::Ellipse,
Tool::Diamond,
Tool::Triangle,
Tool::Hexagon,
Tool::Star,
],
ToolGroup::Lines => &[Tool::Pen, Tool::Line, Tool::Arrow, Tool::DashedArrow],
ToolGroup::PagesImages => &[Tool::MindMap, Tool::Flowchart, Tool::Embed, Tool::Image],
}
}
fn contains(self, t: Tool) -> bool {
self.tools().contains(&t)
}
fn representative(self) -> Tool {
match self {
ToolGroup::Shapes => Tool::Rect,
ToolGroup::Lines => Tool::Arrow,
ToolGroup::PagesImages => Tool::Flowchart,
}
}
fn label(self) -> &'static str {
match self {
ToolGroup::Shapes => "Shapes",
ToolGroup::Lines => "Lines",
ToolGroup::PagesImages => "Pages & images",
}
}
}
fn svg_icon(key: &'static str, bytes: &'static [u8], color: Hsla, sz: f32) -> impl IntoElement {
canvas(
|_, _, _| {},
move |bounds, _, window, cx| {
let _ = window.paint_svg(
bounds,
SharedString::from(key),
Some(bytes),
TransformationMatrix::default(),
color,
cx,
);
},
)
.w(px(sz))
.h(px(sz))
}
fn toolbar_divider(color: Hsla, vertical: bool) -> gpui::AnyElement {
let d = div().bg(color);
if vertical {
d.h(px(1.0)).w(px(16.0)).my(px(3.0))
} else {
d.w(px(1.0)).h(px(16.0)).mx(px(3.0))
}
.into_any_element()
}
struct Tip {
text: SharedString,
fg: Hsla,
bg: Hsla,
border: Hsla,
}
impl Render for Tip {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().pt(px(16.0)).child(
div()
.px(px(6.0))
.py(px(2.0))
.rounded(px(4.0))
.border_1()
.border_color(self.border)
.bg(self.bg)
.text_color(self.fg)
.text_size(px(11.0))
.child(self.text.clone()),
)
}
}
#[derive(Clone, Debug)]
pub struct WhiteboardStyle {
pub bg: Hsla,
pub grid: Hsla,
pub text: Hsla,
pub ink: Hsla,
pub panel: Hsla,
pub panel_strong: Hsla,
pub accent: Hsla,
pub selection: Hsla,
pub swatches: Vec<Hsla>,
}
pub type WhiteboardStyleFn = Rc<dyn Fn() -> WhiteboardStyle>;
pub type ChangeFn = Rc<dyn Fn(String, &mut Window, &mut App)>;
pub type PlaceEmbedFn = Rc<dyn Fn(f32, f32, &mut Window, &mut App)>;
pub type OpenPageFn = Rc<dyn Fn(i64, &mut Window, &mut App)>;
pub type SaveTemplateFn = Rc<dyn Fn(String, &mut Window, &mut App)>;
pub type DeleteTemplateFn = Rc<dyn Fn(i64, &mut Window, &mut App)>;
pub type CopyFn = Rc<dyn Fn(String, &mut Window, &mut App)>;
pub type PasteFn = Rc<dyn Fn(&mut Window, &mut App) -> Option<String>>;
pub type SavedColorsFn = Rc<dyn Fn(Vec<u32>, &mut Window, &mut App)>;
pub type ImageFn = Rc<dyn Fn(&str, f32, &mut Window, &mut App) -> Option<gpui::ImageSource>>;
pub type PlaceImageFn = Rc<dyn Fn(f32, f32, &mut Window, &mut App)>;
pub type DropFilesFn = Rc<dyn Fn(Vec<std::path::PathBuf>, f32, f32, &mut Window, &mut App)>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FontPick {
Upload,
Default,
}
pub type PickFontFn = Rc<dyn Fn(FontPick, &mut Window, &mut App)>;
pub type MoveToolbarFn = Rc<dyn Fn(Option<(f32, f32)>, bool, &mut Window, &mut App)>;
pub type ExpandEmbedFn = Rc<dyn Fn(&mut Window, &mut App)>;
#[derive(Clone, Debug)]
pub struct Template {
pub id: i64,
pub name: String,
pub elements: Vec<Element>,
}
impl Template {
pub fn from_json(id: i64, name: impl Into<String>, elements_json: &str) -> Self {
Template {
id,
name: name.into(),
elements: serde_json::from_str(elements_json).unwrap_or_default(),
}
}
}
struct Pending {
anchor: [f32; 2],
kind: ElementKind,
}
#[derive(Clone, Copy, PartialEq)]
struct ConnectPoint {
id: u64,
index: usize,
pos: [f32; 2],
}
#[derive(Clone, Copy)]
struct ConnectDrag {
from: ConnectPoint,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct AlignmentGuides {
vertical: Option<f32>,
horizontal: Option<f32>,
}
struct Resizing {
id: u64,
handle: ResizeHandle,
anchor: [f32; 2],
from: [f32; 2],
grab: [f32; 2],
orig: ElementKind,
}
#[derive(Clone, Copy)]
enum ResizeHandle {
Corner,
EdgeX,
EdgeY,
}
struct GroupResizing {
handle: ResizeHandle,
anchor: [f32; 2],
from: [f32; 2],
grab: [f32; 2],
orig: Vec<(u64, ElementKind)>,
}
#[derive(Clone, Copy)]
struct EndpointDrag {
id: u64,
which: usize,
}
#[derive(Clone, Copy)]
struct Rotating {
center: [f32; 2],
start_pointer: f32,
applied: f32,
base: Option<f32>,
}
enum HandleGrab {
Corner(Resizing),
Endpoint(EndpointDrag),
Rotate,
GroupCorner(GroupResizing),
}
#[derive(Clone, Copy, PartialEq)]
enum PickerTarget {
Stroke,
Fill,
Text,
}
#[derive(Clone, Copy)]
struct Picker {
target: PickerTarget,
h: f32,
s: f32,
v: f32,
a: f32,
}
#[derive(Clone, Copy, PartialEq)]
enum PickerDrag {
Sv,
Hue,
Alpha,
Width,
}
pub struct WhiteboardView {
scene: Scene,
style: WhiteboardStyleFn,
read_only: bool,
on_change: Option<ChangeFn>,
on_place_embed: Option<PlaceEmbedFn>,
on_open: Option<OpenPageFn>,
on_save_template: Option<SaveTemplateFn>,
on_delete_template: Option<DeleteTemplateFn>,
on_image: Option<ImageFn>,
on_place_image: Option<PlaceImageFn>,
on_drop_files: Option<DropFilesFn>,
on_copy: Option<CopyFn>,
on_paste: Option<PasteFn>,
on_save_colors: Option<SavedColorsFn>,
on_pick_font: Option<PickFontFn>,
on_move_toolbar: Option<MoveToolbarFn>,
saved_colors: Vec<u32>,
templates: Vec<Template>,
context_menu: Option<Point<Pixels>>,
ctx_text_sub: bool,
format_flyout: bool,
font: Font,
text_layout_cache: HashMap<u64, CachedTextLayout>,
label_layout_cache: HashMap<u64, CachedLabelLayout>,
tool: Tool,
focus: FocusHandle,
editing: Option<u64>,
caret: usize,
sel_anchor: usize,
text_selecting: bool,
marked_range: Option<Range<usize>>,
bounds: Rc<Cell<Bounds<Pixels>>>,
pending: Option<Pending>,
selected: Vec<u64>,
marquee: Option<([f32; 2], [f32; 2])>,
hovered_connector: Option<ConnectPoint>,
connecting: Option<ConnectDrag>,
drag_from: Option<[f32; 2]>,
move_origin: [f32; 2],
moved: bool,
alignment_guides: AlignmentGuides,
resizing: Option<Resizing>,
group_resizing: Option<GroupResizing>,
endpoint: Option<EndpointDrag>,
rotating: Option<Rotating>,
active_stroke: Option<u32>,
active_fill: Option<u32>,
active_text: Option<u32>,
pending_style: Option<RunStyle>,
active_width: f32,
picker: Option<Picker>,
open_group: Option<ToolGroup>,
width_open: bool,
font_open: bool,
templates_open: bool,
picker_drag: Option<PickerDrag>,
picker_bounds: Rc<Cell<Bounds<Pixels>>>,
sv_bounds: Rc<Cell<Bounds<Pixels>>>,
hue_bounds: Rc<Cell<Bounds<Pixels>>>,
alpha_bounds: Rc<Cell<Bounds<Pixels>>>,
width_panel_bounds: Rc<Cell<Bounds<Pixels>>>,
width_bounds: Rc<Cell<Bounds<Pixels>>>,
toolbar_bounds: Rc<Cell<Bounds<Pixels>>>,
toolbar_grip_bounds: Rc<Cell<Bounds<Pixels>>>,
toolbar_pos: Option<(f32, f32)>,
toolbar_vertical: bool,
toolbar_drag: Option<(f32, f32)>,
history: Vec<Scene>,
redo: Vec<Scene>,
panning: bool,
last: Point<Pixels>,
next_id: u64,
dirty: bool,
}
pub struct BoardEmbedView {
board: Entity<WhiteboardView>,
style: WhiteboardStyleFn,
on_expand: Option<ExpandEmbedFn>,
}
pub struct BoardThumbnailView {
snapshot: LocalThumbnailSnapshot,
style: WhiteboardStyleFn,
font: Font,
}
impl WhiteboardView {
pub fn new(scene: Scene, style: WhiteboardStyleFn, cx: &mut Context<Self>) -> Self {
let next_id = scene
.elements
.iter()
.map(|e| e.id)
.max()
.map_or(0, |m| m + 1);
Self {
scene,
style,
read_only: false,
on_change: None,
on_place_embed: None,
on_open: None,
on_save_template: None,
on_delete_template: None,
on_image: None,
on_place_image: None,
on_drop_files: None,
on_copy: None,
on_paste: None,
on_save_colors: None,
on_pick_font: None,
on_move_toolbar: None,
saved_colors: Vec::new(),
templates: Vec::new(),
context_menu: None,
ctx_text_sub: false,
format_flyout: false,
font: Font::default(),
text_layout_cache: HashMap::new(),
label_layout_cache: HashMap::new(),
tool: Tool::Pan,
focus: cx.focus_handle(),
editing: None,
caret: 0,
sel_anchor: 0,
text_selecting: false,
marked_range: None,
bounds: Rc::new(Cell::new(Bounds::default())),
pending: None,
selected: Vec::new(),
marquee: None,
hovered_connector: None,
connecting: None,
drag_from: None,
move_origin: [0.0, 0.0],
moved: false,
alignment_guides: AlignmentGuides::default(),
resizing: None,
group_resizing: None,
endpoint: None,
rotating: None,
active_stroke: None,
active_fill: None,
active_text: None,
pending_style: None,
active_width: NIB,
picker: None,
open_group: None,
width_open: false,
font_open: false,
templates_open: false,
picker_drag: None,
picker_bounds: Rc::new(Cell::new(Bounds::default())),
sv_bounds: Rc::new(Cell::new(Bounds::default())),
hue_bounds: Rc::new(Cell::new(Bounds::default())),
alpha_bounds: Rc::new(Cell::new(Bounds::default())),
width_panel_bounds: Rc::new(Cell::new(Bounds::default())),
width_bounds: Rc::new(Cell::new(Bounds::default())),
toolbar_bounds: Rc::new(Cell::new(Bounds::default())),
toolbar_grip_bounds: Rc::new(Cell::new(Bounds::default())),
toolbar_pos: None,
toolbar_vertical: false,
toolbar_drag: None,
history: Vec::new(),
redo: Vec::new(),
panning: false,
last: Point::default(),
next_id,
dirty: false,
}
}
pub fn new_read_only(scene: Scene, style: WhiteboardStyleFn, cx: &mut Context<Self>) -> Self {
let mut this = Self::new(scene, style, cx);
this.read_only = true;
this.tool = Tool::Pan;
this
}
pub fn set_on_change(&mut self, f: ChangeFn) {
self.on_change = Some(f);
}
pub fn set_on_place_embed(&mut self, f: PlaceEmbedFn) {
self.on_place_embed = Some(f);
}
pub fn set_on_open(&mut self, f: OpenPageFn) {
self.on_open = Some(f);
}
pub fn set_on_save_template(&mut self, f: SaveTemplateFn) {
self.on_save_template = Some(f);
}
pub fn set_on_delete_template(&mut self, f: DeleteTemplateFn) {
self.on_delete_template = Some(f);
}
pub fn set_on_image(&mut self, f: ImageFn) {
self.on_image = Some(f);
}
pub fn set_on_place_image(&mut self, f: PlaceImageFn) {
self.on_place_image = Some(f);
}
pub fn set_on_drop_files(&mut self, f: DropFilesFn) {
self.on_drop_files = Some(f);
}
pub fn set_on_copy(&mut self, f: CopyFn) {
self.on_copy = Some(f);
}
pub fn set_on_paste(&mut self, f: PasteFn) {
self.on_paste = Some(f);
}
pub fn set_on_save_colors(&mut self, f: SavedColorsFn) {
self.on_save_colors = Some(f);
}
pub fn set_on_pick_font(&mut self, f: PickFontFn) {
self.on_pick_font = Some(f);
}
pub fn set_on_move_toolbar(&mut self, f: MoveToolbarFn) {
self.on_move_toolbar = Some(f);
}
pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
self.read_only = read_only;
if read_only {
self.tool = Tool::Pan;
self.selected.clear();
self.editing = None;
self.pending = None;
self.connecting = None;
self.hovered_connector = None;
self.context_menu = None;
self.open_group = None;
self.font_open = false;
self.width_open = false;
self.templates_open = false;
self.picker = None;
self.format_flyout = false;
self.text_selecting = false;
self.marked_range = None;
}
cx.notify();
}
pub fn read_only(&self) -> bool {
self.read_only
}
pub fn set_toolbar_pos(&mut self, pos: Option<(f32, f32)>, cx: &mut Context<Self>) {
self.toolbar_pos = pos;
cx.notify();
}
pub fn set_toolbar_vertical(&mut self, vertical: bool, cx: &mut Context<Self>) {
self.toolbar_vertical = vertical;
cx.notify();
}
fn toggle_toolbar_orientation(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.toolbar_vertical = !self.toolbar_vertical;
if let Some(f) = self.on_move_toolbar.clone() {
f(self.toolbar_pos, self.toolbar_vertical, window, cx);
}
cx.notify();
}
fn clamp_toolbar(&self, x: f32, y: f32) -> (f32, f32) {
let board = self.bounds.get().size;
let pill = self.toolbar_bounds.get().size;
let maxx = (f32::from(board.width) - f32::from(pill.width)).max(0.0);
let maxy = (f32::from(board.height) - f32::from(pill.height)).max(0.0);
(x.clamp(0.0, maxx), y.clamp(0.0, maxy))
}
fn start_toolbar_drag(
&mut self,
p: Point<Pixels>,
double: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if double {
self.toolbar_drag = None;
self.toolbar_pos = None;
if let Some(f) = self.on_move_toolbar.clone() {
f(None, self.toolbar_vertical, window, cx);
}
cx.notify();
return;
}
self.picker = None;
self.open_group = None;
self.width_open = false;
self.font_open = false;
self.templates_open = false;
self.context_menu = None;
self.focus.focus(window, cx);
let pill = self.toolbar_bounds.get().origin;
self.toolbar_drag = Some((
f32::from(pill.x) - f32::from(p.x),
f32::from(pill.y) - f32::from(p.y),
));
cx.notify();
}
fn drag_toolbar(&mut self, p: Point<Pixels>, cx: &mut Context<Self>) {
let Some((ox, oy)) = self.toolbar_drag else {
return;
};
let board = self.bounds.get().origin;
let x = f32::from(p.x) + ox - f32::from(board.x);
let y = f32::from(p.y) + oy - f32::from(board.y);
self.toolbar_pos = Some(self.clamp_toolbar(x, y));
cx.notify();
}
fn commit_toolbar_drag(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.toolbar_drag.take().is_none() {
return;
}
if let Some(f) = self.on_move_toolbar.clone() {
f(self.toolbar_pos, self.toolbar_vertical, window, cx);
}
}
pub fn set_saved_colors(&mut self, colors: Vec<u32>, cx: &mut Context<Self>) {
self.saved_colors = colors;
cx.notify();
}
fn save_current_color(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(c) = self.picker_u32()
&& !self.saved_colors.contains(&c)
{
self.saved_colors.push(c);
if let Some(f) = self.on_save_colors.clone() {
f(self.saved_colors.clone(), window, cx);
}
}
cx.notify();
}
fn remove_saved_color(&mut self, c: u32, window: &mut Window, cx: &mut Context<Self>) {
self.saved_colors.retain(|&x| x != c);
if let Some(f) = self.on_save_colors.clone() {
f(self.saved_colors.clone(), window, cx);
}
cx.notify();
}
pub fn set_templates(&mut self, templates: Vec<Template>, cx: &mut Context<Self>) {
self.templates = templates;
cx.notify();
}
pub fn set_font(&mut self, font: Font, cx: &mut Context<Self>) {
self.font = font;
self.text_layout_cache.clear();
self.label_layout_cache.clear();
cx.notify();
}
fn tip(
&self,
text: impl Into<SharedString>,
) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static {
let style_fn = self.style.clone();
let text = text.into();
move |_window, cx| {
let s = style_fn();
let text = text.clone();
cx.new(move |_| Tip {
text,
fg: s.ink,
bg: s.panel,
border: s.grid,
})
.into()
}
}
pub fn add_embed(
&mut self,
page_id: i64,
title: impl Into<String>,
x: f32,
y: f32,
cx: &mut Context<Self>,
) {
self.push_undo();
let id = self.next_id;
self.next_id += 1;
let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
self.scene.elements.push(Element {
id,
kind: ElementKind::Embed(EmbedGeom {
page_id,
title: title.into(),
x,
y,
w: EMBED_W / zoom,
h: EMBED_H / zoom,
}),
stroke: None,
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
});
self.selected = vec![id];
self.tool = Tool::Select;
cx.notify();
}
pub fn add_image_at(
&mut self,
src: impl Into<String>,
px_w: f32,
px_h: f32,
cx_world: f32,
cy_world: f32,
cx: &mut Context<Self>,
) {
self.push_undo();
let id = self.next_id;
self.next_id += 1;
let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
let longest = px_w.max(px_h).max(1.0);
let scale = IMAGE_PLACE_PX / longest / zoom;
let (w, h) = (px_w * scale, px_h * scale);
self.scene.elements.push(Element {
id,
kind: ElementKind::Image(ImageGeom {
src: src.into(),
x: cx_world - w / 2.0,
y: cy_world - h / 2.0,
w,
h,
rotation: 0.0,
}),
stroke: None,
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
});
self.selected = vec![id];
self.tool = Tool::Select;
cx.notify();
}
pub fn add_mindmap_seed(&mut self, center_x: f32, center_y: f32, cx: &mut Context<Self>) {
self.push_undo();
let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
let root_w = MINDMAP_ROOT_W / zoom;
let root_h = MINDMAP_ROOT_H / zoom;
let node_w = MINDMAP_NODE_W / zoom;
let node_h = MINDMAP_NODE_H / zoom;
let gap_x = MINDMAP_BRANCH_GAP_X / zoom;
let gap_y = MINDMAP_BRANCH_GAP_Y / zoom;
let stroke = Some(0x2563ebff);
let root_fill = Some(0xdbeafeff);
let node_fill = Some(0xffffffff);
let add_node = |scene: &mut Scene,
next_id: &mut u64,
x: f32,
y: f32,
w: f32,
h: f32,
label: &str,
fill: Option<u32>,
mindmap: Option<MindMapNodeMeta>| {
let id = *next_id;
*next_id += 1;
scene.elements.push(Element {
id,
kind: ElementKind::RoundRect(BoxGeom {
x,
y,
w,
h,
width: NIB / zoom,
rotation: 0.0,
}),
stroke,
fill,
label: Some(label.to_string()),
label_color: Some(0x0f172aff),
styles: Vec::new(),
mindmap,
});
id
};
let root_id = add_node(
&mut self.scene,
&mut self.next_id,
center_x - root_w / 2.0,
center_y - root_h / 2.0,
root_w,
root_h,
"Central topic",
root_fill,
Some(MindMapNodeMeta {
parent: None,
side: MindMapSide::Right,
order: 0,
root_direction: MindMapRootDirection::Both,
connector_style: MindMapConnectorStyle::Bezier,
}),
);
let right_top_id = add_node(
&mut self.scene,
&mut self.next_id,
center_x + root_w / 2.0 + gap_x,
center_y - gap_y - node_h / 2.0,
node_w,
node_h,
"Branch 1",
node_fill,
Some(MindMapNodeMeta {
parent: Some(root_id),
side: MindMapSide::Right,
order: 0,
root_direction: MindMapRootDirection::Both,
connector_style: MindMapConnectorStyle::Bezier,
}),
);
let right_bottom_id = add_node(
&mut self.scene,
&mut self.next_id,
center_x + root_w / 2.0 + gap_x,
center_y + gap_y - node_h / 2.0,
node_w,
node_h,
"Branch 2",
node_fill,
Some(MindMapNodeMeta {
parent: Some(root_id),
side: MindMapSide::Right,
order: 1,
root_direction: MindMapRootDirection::Both,
connector_style: MindMapConnectorStyle::Bezier,
}),
);
let left_top_id = add_node(
&mut self.scene,
&mut self.next_id,
center_x - root_w / 2.0 - gap_x - node_w,
center_y - gap_y - node_h / 2.0,
node_w,
node_h,
"Branch 3",
node_fill,
Some(MindMapNodeMeta {
parent: Some(root_id),
side: MindMapSide::Left,
order: 0,
root_direction: MindMapRootDirection::Both,
connector_style: MindMapConnectorStyle::Bezier,
}),
);
let left_bottom_id = add_node(
&mut self.scene,
&mut self.next_id,
center_x - root_w / 2.0 - gap_x - node_w,
center_y + gap_y - node_h / 2.0,
node_w,
node_h,
"Branch 4",
node_fill,
Some(MindMapNodeMeta {
parent: Some(root_id),
side: MindMapSide::Left,
order: 1,
root_direction: MindMapRootDirection::Both,
connector_style: MindMapConnectorStyle::Bezier,
}),
);
let add_branch = |scene: &mut Scene,
next_id: &mut u64,
from_id: u64,
from_connector: usize,
to_id: u64,
to_connector: usize| {
let start_anchor = SegmentAnchor {
element_id: from_id,
connector: from_connector,
};
let end_anchor = SegmentAnchor {
element_id: to_id,
connector: to_connector,
};
let [x1, y1] =
connector_world_pos_in(&scene.elements, start_anchor).unwrap_or([0.0, 0.0]);
let [x2, y2] =
connector_world_pos_in(&scene.elements, end_anchor).unwrap_or([0.0, 0.0]);
let id = *next_id;
*next_id += 1;
scene.elements.push(Element {
id,
kind: ElementKind::Arrow(SegGeom {
x1,
y1,
x2,
y2,
width: NIB / zoom,
style: SegmentStyle::Solid,
start_anchor: Some(start_anchor),
end_anchor: Some(end_anchor),
}),
stroke,
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
});
};
add_branch(
&mut self.scene,
&mut self.next_id,
root_id,
1,
right_top_id,
3,
);
add_branch(
&mut self.scene,
&mut self.next_id,
root_id,
1,
right_bottom_id,
3,
);
add_branch(
&mut self.scene,
&mut self.next_id,
root_id,
3,
left_top_id,
1,
);
add_branch(
&mut self.scene,
&mut self.next_id,
root_id,
3,
left_bottom_id,
1,
);
self.selected = vec![root_id];
self.tool = Tool::Select;
cx.notify();
}
pub fn add_mindmap_seed_at_viewport_center(&mut self, cx: &mut Context<Self>) {
let center = self.viewport_center();
self.add_mindmap_seed(center[0], center[1], cx);
}
pub fn add_flowchart_seed(&mut self, center_x: f32, center_y: f32, cx: &mut Context<Self>) {
self.push_undo();
let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
let node_w = FLOWCHART_NODE_W / zoom;
let node_h = FLOWCHART_NODE_H / zoom;
let gap_y = FLOWCHART_GAP_Y / zoom;
let branch_gap_x = FLOWCHART_BRANCH_GAP_X / zoom;
let stroke = Some(0x0f172aff);
let fill = Some(0xffffffff);
let add_box = |scene: &mut Scene, next_id: &mut u64, kind: ElementKind, label: &str| {
let id = *next_id;
*next_id += 1;
scene.elements.push(Element {
id,
kind,
stroke,
fill,
label: Some(label.to_string()),
label_color: Some(0x0f172aff),
styles: Vec::new(),
mindmap: None,
});
id
};
let add_arrow = |scene: &mut Scene,
next_id: &mut u64,
from_id: u64,
from_connector: usize,
to_id: u64,
to_connector: usize| {
let start_anchor = SegmentAnchor {
element_id: from_id,
connector: from_connector,
};
let end_anchor = SegmentAnchor {
element_id: to_id,
connector: to_connector,
};
let [x1, y1] =
connector_world_pos_in(&scene.elements, start_anchor).unwrap_or([0.0, 0.0]);
let [x2, y2] =
connector_world_pos_in(&scene.elements, end_anchor).unwrap_or([0.0, 0.0]);
let id = *next_id;
*next_id += 1;
scene.elements.push(Element {
id,
kind: ElementKind::Arrow(SegGeom {
x1,
y1,
x2,
y2,
width: NIB / zoom,
style: SegmentStyle::Solid,
start_anchor: Some(start_anchor),
end_anchor: Some(end_anchor),
}),
stroke,
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
});
};
let start_id = add_box(
&mut self.scene,
&mut self.next_id,
ElementKind::Ellipse(BoxGeom {
x: center_x - node_w / 2.0,
y: center_y - gap_y - node_h * 1.5,
w: node_w,
h: node_h,
width: NIB / zoom,
rotation: 0.0,
}),
"Start",
);
let process_id = add_box(
&mut self.scene,
&mut self.next_id,
ElementKind::RoundRect(BoxGeom {
x: center_x - node_w / 2.0,
y: center_y - node_h / 2.0,
w: node_w,
h: node_h,
width: NIB / zoom,
rotation: 0.0,
}),
"Process",
);
let decision_id = add_box(
&mut self.scene,
&mut self.next_id,
ElementKind::Diamond(BoxGeom {
x: center_x - node_w / 2.0,
y: center_y + gap_y - node_h / 2.0,
w: node_w,
h: node_h,
width: NIB / zoom,
rotation: 0.0,
}),
"Decision",
);
let branch_yes_id = add_box(
&mut self.scene,
&mut self.next_id,
ElementKind::RoundRect(BoxGeom {
x: center_x + branch_gap_x - node_w / 2.0,
y: center_y + gap_y - node_h / 2.0,
w: node_w,
h: node_h,
width: NIB / zoom,
rotation: 0.0,
}),
"Yes",
);
let branch_no_id = add_box(
&mut self.scene,
&mut self.next_id,
ElementKind::RoundRect(BoxGeom {
x: center_x - branch_gap_x - node_w / 2.0,
y: center_y + gap_y - node_h / 2.0,
w: node_w,
h: node_h,
width: NIB / zoom,
rotation: 0.0,
}),
"No",
);
let end_id = add_box(
&mut self.scene,
&mut self.next_id,
ElementKind::Ellipse(BoxGeom {
x: center_x - node_w / 2.0,
y: center_y + gap_y * 2.0 + node_h * 0.5,
w: node_w,
h: node_h,
width: NIB / zoom,
rotation: 0.0,
}),
"End",
);
add_arrow(
&mut self.scene,
&mut self.next_id,
start_id,
2,
process_id,
0,
);
add_arrow(
&mut self.scene,
&mut self.next_id,
process_id,
2,
decision_id,
0,
);
add_arrow(
&mut self.scene,
&mut self.next_id,
decision_id,
1,
branch_yes_id,
3,
);
add_arrow(
&mut self.scene,
&mut self.next_id,
decision_id,
3,
branch_no_id,
1,
);
add_arrow(
&mut self.scene,
&mut self.next_id,
branch_yes_id,
2,
end_id,
1,
);
add_arrow(
&mut self.scene,
&mut self.next_id,
branch_no_id,
2,
end_id,
3,
);
self.selected = vec![process_id];
self.tool = Tool::Select;
cx.notify();
}
pub fn add_flowchart_seed_at_viewport_center(&mut self, cx: &mut Context<Self>) {
let center = self.viewport_center();
self.add_flowchart_seed(center[0], center[1], cx);
}
fn mindmap_meta(&self, id: u64) -> Option<MindMapNodeMeta> {
self.scene
.elements
.iter()
.find(|element| element.id == id)
.and_then(|element| element.mindmap)
}
fn is_mindmap_node(&self, id: u64) -> bool {
self.mindmap_meta(id).is_some()
}
fn is_mindmap_root(&self, id: u64) -> bool {
self.mindmap_meta(id)
.is_some_and(|meta| meta.parent.is_none())
}
fn selected_mindmap_root(&self) -> Option<u64> {
self.selected_single()
.filter(|id| self.is_mindmap_root(*id))
}
fn mindmap_root_direction(&self, root_id: u64) -> MindMapRootDirection {
self.mindmap_meta(root_id)
.map(|meta| meta.root_direction)
.unwrap_or_default()
}
fn mindmap_connector_style_for_root(&self, root_id: u64) -> MindMapConnectorStyle {
self.mindmap_meta(root_id)
.map(|meta| meta.connector_style)
.unwrap_or_default()
}
fn mindmap_root_of(&self, id: u64) -> Option<u64> {
let mut current = id;
loop {
let meta = self.mindmap_meta(current)?;
match meta.parent {
Some(parent) => current = parent,
None => return Some(current),
}
}
}
fn mindmap_children(&self, parent: u64, side: MindMapSide) -> Vec<u64> {
let mut children: Vec<(usize, u64)> = self
.scene
.elements
.iter()
.filter_map(|element| {
let meta = element.mindmap?;
(meta.parent == Some(parent) && meta.side == side)
.then_some((meta.order, element.id))
})
.collect();
children.sort_by_key(|(order, id)| (*order, *id));
children.into_iter().map(|(_, id)| id).collect()
}
fn set_mindmap_node_side(&mut self, id: u64, side: MindMapSide) {
if let Some(element) = self
.scene
.elements
.iter_mut()
.find(|element| element.id == id)
&& let Some(meta) = &mut element.mindmap
{
meta.side = side;
}
self.sync_mindmap_parent_link(id);
}
fn sync_mindmap_parent_link(&mut self, child_id: u64) {
let Some(meta) = self.mindmap_meta(child_id) else {
return;
};
let Some(parent_id) = meta.parent else {
return;
};
let parent_connector = match meta.side {
MindMapSide::Right => 1,
MindMapSide::Left => 3,
};
let child_connector = match meta.side {
MindMapSide::Right => 3,
MindMapSide::Left => 1,
};
for element in &mut self.scene.elements {
let segment = match &mut element.kind {
ElementKind::Line(segment) | ElementKind::Arrow(segment) => segment,
_ => continue,
};
let start_id = segment.start_anchor.map(|anchor| anchor.element_id);
let end_id = segment.end_anchor.map(|anchor| anchor.element_id);
let links_parent_child = matches!((start_id, end_id), (Some(a), Some(b)) if (a == parent_id && b == child_id) || (a == child_id && b == parent_id));
if !links_parent_child {
continue;
}
if let Some(anchor) = &mut segment.start_anchor {
if anchor.element_id == parent_id {
anchor.connector = parent_connector;
} else if anchor.element_id == child_id {
anchor.connector = child_connector;
}
}
if let Some(anchor) = &mut segment.end_anchor {
if anchor.element_id == parent_id {
anchor.connector = parent_connector;
} else if anchor.element_id == child_id {
anchor.connector = child_connector;
}
}
}
self.sync_segment_anchors_for(&[parent_id, child_id]);
}
fn sync_mindmap_links_for_root(&mut self, root_id: u64) {
let child_ids: Vec<u64> = self
.scene
.elements
.iter()
.filter_map(|element| {
let meta = element.mindmap?;
meta.parent?;
(self.mindmap_root_of(element.id) == Some(root_id)).then_some(element.id)
})
.collect();
for child_id in &child_ids {
let Some(meta) = self.mindmap_meta(*child_id) else {
continue;
};
let Some(parent_id) = meta.parent else {
continue;
};
let parent_connector = match meta.side {
MindMapSide::Right => 1,
MindMapSide::Left => 3,
};
let child_connector = match meta.side {
MindMapSide::Right => 3,
MindMapSide::Left => 1,
};
for element in &mut self.scene.elements {
let segment = match &mut element.kind {
ElementKind::Line(segment) | ElementKind::Arrow(segment) => segment,
_ => continue,
};
let start_id = segment.start_anchor.map(|anchor| anchor.element_id);
let end_id = segment.end_anchor.map(|anchor| anchor.element_id);
let links_parent_child = matches!((start_id, end_id), (Some(a), Some(b)) if (a == parent_id && b == *child_id) || (a == *child_id && b == parent_id));
if !links_parent_child {
continue;
}
if let Some(anchor) = &mut segment.start_anchor {
if anchor.element_id == parent_id {
anchor.connector = parent_connector;
} else if anchor.element_id == *child_id {
anchor.connector = child_connector;
}
}
if let Some(anchor) = &mut segment.end_anchor {
if anchor.element_id == parent_id {
anchor.connector = parent_connector;
} else if anchor.element_id == *child_id {
anchor.connector = child_connector;
}
}
}
}
self.sync_segment_anchors_for(&child_ids);
}
fn ordered_mindmap_children(&self, parent: u64) -> Vec<u64> {
let mut children: Vec<(f32, f32, usize, u64)> = self
.scene
.elements
.iter()
.filter_map(|element| {
let meta = element.mindmap?;
(meta.parent == Some(parent)).then(|| {
let (x, y, _, h, _) =
box_like(&element.kind).unwrap_or((0.0, 0.0, 0.0, 0.0, 0.0));
(y + h / 2.0, x, meta.order, element.id)
})
})
.collect();
children.sort_by(|a, b| {
a.0.total_cmp(&b.0)
.then_with(|| a.1.total_cmp(&b.1))
.then_with(|| a.2.cmp(&b.2))
.then_with(|| a.3.cmp(&b.3))
});
children.into_iter().map(|(_, _, _, id)| id).collect()
}
fn set_mindmap_children_side(&mut self, parent: u64, side: MindMapSide) {
for child_id in self.ordered_mindmap_children(parent) {
self.set_mindmap_node_side(child_id, side);
}
}
fn set_mindmap_children_side_alternating(&mut self, parent: u64) {
for (index, child_id) in self
.ordered_mindmap_children(parent)
.into_iter()
.enumerate()
{
let side = if index % 2 == 0 {
MindMapSide::Right
} else {
MindMapSide::Left
};
self.set_mindmap_node_side(child_id, side);
}
}
fn reindex_mindmap_children(&mut self, parent: u64) {
for side in [MindMapSide::Left, MindMapSide::Right] {
let children = self.mindmap_children(parent, side);
for (order, child_id) in children.into_iter().enumerate() {
if let Some(element) = self
.scene
.elements
.iter_mut()
.find(|element| element.id == child_id)
&& let Some(meta) = &mut element.mindmap
{
meta.order = order;
}
self.reindex_mindmap_children(child_id);
}
}
}
fn set_mindmap_root_direction(
&mut self,
root_id: u64,
direction: MindMapRootDirection,
cx: &mut Context<Self>,
) {
if let Some(element) = self
.scene
.elements
.iter_mut()
.find(|element| element.id == root_id)
&& let Some(meta) = &mut element.mindmap
{
meta.root_direction = direction;
}
match direction {
MindMapRootDirection::Left => {
self.set_mindmap_children_side(root_id, MindMapSide::Left)
}
MindMapRootDirection::Right => {
self.set_mindmap_children_side(root_id, MindMapSide::Right)
}
MindMapRootDirection::Both => self.set_mindmap_children_side_alternating(root_id),
}
self.reindex_mindmap_children(root_id);
self.relayout_mindmap_tree(root_id);
cx.notify();
}
fn set_mindmap_connector_style(
&mut self,
root_id: u64,
style: MindMapConnectorStyle,
cx: &mut Context<Self>,
) {
if let Some(element) = self
.scene
.elements
.iter_mut()
.find(|element| element.id == root_id)
&& let Some(meta) = &mut element.mindmap
{
meta.connector_style = style;
}
cx.notify();
}
fn set_mindmap_node_position(&mut self, id: u64, x: f32, y: f32) {
if let Some(element) = self
.scene
.elements
.iter_mut()
.find(|element| element.id == id)
&& let ElementKind::RoundRect(geom) = &mut element.kind
{
geom.x = x;
geom.y = y;
}
}
fn mindmap_node_size(&self, id: u64, zoom: f32) -> (f32, f32) {
self.scene
.elements
.iter()
.find(|element| element.id == id)
.and_then(|element| box_like(&element.kind).map(|(_, _, w, h, _)| (w, h)))
.unwrap_or((MINDMAP_NODE_W / zoom, MINDMAP_NODE_H / zoom))
}
fn side_stack_height(&self, parent: u64, side: MindMapSide, zoom: f32) -> f32 {
let children = self.mindmap_children(parent, side);
if children.is_empty() {
return 0.0;
}
let gap_y = MINDMAP_BRANCH_GAP_Y / zoom;
children
.into_iter()
.enumerate()
.fold(0.0, |acc, (index, child_id)| {
acc + if index > 0 { gap_y } else { 0.0 }
+ self.mindmap_subtree_height(child_id, zoom)
})
}
fn mindmap_subtree_height(&self, id: u64, zoom: f32) -> f32 {
let (_, node_h) = self.mindmap_node_size(id, zoom);
node_h.max(
self.side_stack_height(id, MindMapSide::Left, zoom)
.max(self.side_stack_height(id, MindMapSide::Right, zoom)),
)
}
fn relayout_mindmap_subtree(&mut self, node_id: u64, moved: &mut Vec<u64>, zoom: f32) {
self.relayout_mindmap_children(node_id, MindMapSide::Left, moved, zoom);
self.relayout_mindmap_children(node_id, MindMapSide::Right, moved, zoom);
}
fn relayout_mindmap_children(
&mut self,
parent_id: u64,
side: MindMapSide,
moved: &mut Vec<u64>,
zoom: f32,
) {
let children = self.mindmap_children(parent_id, side);
if children.is_empty() {
return;
}
let Some((px, py, pw, ph, _)) = self
.scene
.elements
.iter()
.find(|element| element.id == parent_id)
.and_then(|element| box_like(&element.kind))
else {
return;
};
let gap_x = MINDMAP_BRANCH_GAP_X / zoom;
let gap_y = MINDMAP_BRANCH_GAP_Y / zoom;
let total_h = children
.iter()
.enumerate()
.fold(0.0, |acc, (index, child_id)| {
acc + if index > 0 { gap_y } else { 0.0 }
+ self.mindmap_subtree_height(*child_id, zoom)
});
let mut cursor_y = py + ph / 2.0 - total_h / 2.0;
for child_id in children {
let subtree_h = self.mindmap_subtree_height(child_id, zoom);
let (cw, ch) = self.mindmap_node_size(child_id, zoom);
let cy = cursor_y + subtree_h / 2.0;
let x = match side {
MindMapSide::Right => px + pw + gap_x,
MindMapSide::Left => px - gap_x - cw,
};
self.set_mindmap_node_position(child_id, x, cy - ch / 2.0);
moved.push(child_id);
self.relayout_mindmap_subtree(child_id, moved, zoom);
cursor_y += subtree_h + gap_y;
}
}
fn relayout_mindmap_tree(&mut self, root_id: u64) {
let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
let mut moved = vec![root_id];
self.relayout_mindmap_subtree(root_id, &mut moved, zoom);
self.sync_mindmap_links_for_root(root_id);
self.sync_segment_anchors_for(&moved);
}
fn bump_mindmap_sibling_orders(&mut self, parent: u64, side: MindMapSide, from_order: usize) {
for element in &mut self.scene.elements {
if let Some(meta) = &mut element.mindmap
&& meta.parent == Some(parent)
&& meta.side == side
&& meta.order >= from_order
{
meta.order += 1;
}
}
}
fn create_mindmap_node(
&mut self,
parent: u64,
side: MindMapSide,
order: usize,
label: &str,
) -> u64 {
self.bump_mindmap_sibling_orders(parent, side, order);
let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
let id = self.next_id;
self.next_id += 1;
let w = MINDMAP_NODE_W / zoom;
let h = MINDMAP_NODE_H / zoom;
let (x, y) = self
.scene
.elements
.iter()
.find(|element| element.id == parent)
.and_then(|element| box_like(&element.kind))
.map(|(px, py, pw, ph, _)| match side {
MindMapSide::Right => (
px + pw + MINDMAP_BRANCH_GAP_X / zoom,
py + ph / 2.0 - h / 2.0,
),
MindMapSide::Left => (
px - MINDMAP_BRANCH_GAP_X / zoom - w,
py + ph / 2.0 - h / 2.0,
),
})
.unwrap_or((0.0, 0.0));
self.scene.elements.push(Element {
id,
kind: ElementKind::RoundRect(BoxGeom {
x,
y,
w,
h,
width: NIB / zoom,
rotation: 0.0,
}),
stroke: Some(0x2563ebff),
fill: Some(0xffffffff),
label: Some(label.to_string()),
label_color: Some(0x0f172aff),
styles: Vec::new(),
mindmap: Some(MindMapNodeMeta {
parent: Some(parent),
side,
order,
root_direction: MindMapRootDirection::Both,
connector_style: MindMapConnectorStyle::Bezier,
}),
});
let start_anchor = SegmentAnchor {
element_id: parent,
connector: match side {
MindMapSide::Right => 1,
MindMapSide::Left => 3,
},
};
let end_anchor = SegmentAnchor {
element_id: id,
connector: match side {
MindMapSide::Right => 3,
MindMapSide::Left => 1,
},
};
let [x1, y1] = connector_world_pos_in(&self.scene.elements, start_anchor).unwrap_or([x, y]);
let [x2, y2] = connector_world_pos_in(&self.scene.elements, end_anchor).unwrap_or([x, y]);
let line_id = self.next_id;
self.next_id += 1;
self.scene.elements.push(Element {
id: line_id,
kind: ElementKind::Arrow(SegGeom {
x1,
y1,
x2,
y2,
width: NIB / zoom,
style: SegmentStyle::Solid,
start_anchor: Some(start_anchor),
end_anchor: Some(end_anchor),
}),
stroke: Some(0x2563ebff),
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
});
if let Some(root_id) = self.mindmap_root_of(parent) {
self.relayout_mindmap_tree(root_id);
}
id
}
fn add_mindmap_relative(
&mut self,
source_id: u64,
sibling: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let Some(meta) = self.mindmap_meta(source_id) else {
return false;
};
let (parent, side, order) = if sibling {
match meta.parent {
Some(parent) => (parent, meta.side, meta.order + 1),
None => (
source_id,
MindMapSide::Right,
self.mindmap_children(source_id, MindMapSide::Right).len(),
),
}
} else {
(
source_id,
meta.side,
self.mindmap_children(source_id, meta.side).len(),
)
};
self.push_undo();
let new_id = self.create_mindmap_node(parent, side, order, "");
self.selected = vec![new_id];
self.begin_text_edit(new_id, 0, window, cx);
self.dirty = true;
cx.notify();
true
}
fn mindmap_connector_style_for_element(
&self,
kind: &ElementKind,
) -> Option<MindMapConnectorStyle> {
let seg = match kind {
ElementKind::Line(seg) | ElementKind::Arrow(seg) => seg,
_ => return None,
};
let start_root = seg
.start_anchor
.and_then(|anchor| self.mindmap_root_of(anchor.element_id));
let end_root = seg
.end_anchor
.and_then(|anchor| self.mindmap_root_of(anchor.element_id));
match (start_root, end_root) {
(Some(a), Some(b)) if a == b => Some(self.mindmap_connector_style_for_root(a)),
_ => None,
}
}
pub fn viewport_center(&self) -> [f32; 2] {
let b = self.bounds.get();
let cam = self.scene.camera;
let z = cam.zoom.max(MIN_ZOOM);
[
cam.x + f32::from(b.size.width) / 2.0 / z,
cam.y + f32::from(b.size.height) / 2.0 / z,
]
}
pub fn scene(&self) -> &Scene {
&self.scene
}
pub fn local_thumbnail_spec(
&self,
width_px: f32,
height_px: f32,
) -> Option<LocalThumbnailSpec> {
self.local_thumbnail_spec_for_mode(LocalThumbnailMode::Auto, width_px, height_px)
}
pub fn local_thumbnail_snapshot(
&self,
width_px: f32,
height_px: f32,
) -> Option<LocalThumbnailSnapshot> {
self.local_thumbnail_snapshot_for_mode(LocalThumbnailMode::Auto, width_px, height_px)
}
pub fn local_thumbnail_spec_for_mode(
&self,
mode: LocalThumbnailMode,
width_px: f32,
height_px: f32,
) -> Option<LocalThumbnailSpec> {
let scene_bounds = self.scene_bbox();
let (anchor_element_id, focus) = match mode {
LocalThumbnailMode::Auto => self
.selection_bbox()
.map(|bb| (self.selected_single(), bb))
.or_else(|| self.viewport_world_bbox().map(|bb| (None, bb)))
.or_else(|| scene_bounds.map(|bb| (None, bb)))?,
LocalThumbnailMode::Selection => {
let bb = self.selection_bbox()?;
(self.selected_single(), bb)
}
LocalThumbnailMode::Viewport => (None, self.viewport_world_bbox()?),
LocalThumbnailMode::AllContent => (None, scene_bounds?),
LocalThumbnailMode::Element(id) => (Some(id), self.element_bbox(id)?),
};
Some(self.thumbnail_spec_from_bbox(
anchor_element_id,
focus,
scene_bounds,
width_px,
height_px,
))
}
pub fn local_thumbnail_snapshot_for_mode(
&self,
mode: LocalThumbnailMode,
width_px: f32,
height_px: f32,
) -> Option<LocalThumbnailSnapshot> {
Some(LocalThumbnailSnapshot {
scene: self.scene.clone(),
spec: self.local_thumbnail_spec_for_mode(mode, width_px, height_px)?,
})
}
fn selected_single(&self) -> Option<u64> {
match self.selected.as_slice() {
[id] => Some(*id),
_ => None,
}
}
fn is_selected(&self, id: u64) -> bool {
self.selected.contains(&id)
}
fn element_bbox(&self, id: u64) -> Option<(f32, f32, f32, f32)> {
self.scene
.elements
.iter()
.find(|e| e.id == id)
.map(|e| bbox(&e.kind))
}
fn scene_bbox(&self) -> Option<(f32, f32, f32, f32)> {
scene_bbox_for_local_thumbnail(&self.scene)
}
fn viewport_world_bbox(&self) -> Option<(f32, f32, f32, f32)> {
let b = self.bounds.get();
let vw = f32::from(b.size.width);
let vh = f32::from(b.size.height);
if vw <= 1.0 || vh <= 1.0 {
return None;
}
let cam = self.scene.camera;
let z = cam.zoom.max(MIN_ZOOM);
Some((cam.x, cam.y, cam.x + vw / z, cam.y + vh / z))
}
fn render_viewport(&self, fallback_size: Option<gpui::Size<Pixels>>) -> Option<WorldViewport> {
let bounds = self.bounds.get();
let size = if f32::from(bounds.size.width) > 1.0 && f32::from(bounds.size.height) > 1.0 {
bounds.size
} else {
fallback_size?
};
let camera = self.scene.camera;
WorldViewport::from_canvas(
f32::from(size.width),
f32::from(size.height),
camera.x,
camera.y,
camera.zoom.max(MIN_ZOOM),
VIEWPORT_CULL_MARGIN_PX,
)
}
fn thumbnail_spec_from_bbox(
&self,
anchor_element_id: Option<u64>,
focus: (f32, f32, f32, f32),
scene_bounds: Option<(f32, f32, f32, f32)>,
width_px: f32,
height_px: f32,
) -> LocalThumbnailSpec {
local_thumbnail_spec_from_bbox(anchor_element_id, focus, scene_bounds, width_px, height_px)
}
fn selection_bbox(&self) -> Option<(f32, f32, f32, f32)> {
let mut it = self
.scene
.elements
.iter()
.filter(|e| self.selected.contains(&e.id))
.map(|e| bbox(&e.kind));
let first = it.next()?;
Some(it.fold(first, |a, b| {
(a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3))
}))
}
fn aligned_move_delta(&self, dx: f32, dy: f32) -> (f32, f32, AlignmentGuides) {
const SNAP_PX: f32 = 6.0;
let Some(selection) = self.selection_bbox() else {
return (dx, dy, AlignmentGuides::default());
};
let threshold = SNAP_PX / self.scene.camera.zoom.max(MIN_ZOOM);
let moving_x = [
selection.0 + dx,
(selection.0 + selection.2) / 2.0 + dx,
selection.2 + dx,
];
let moving_y = [
selection.1 + dy,
(selection.1 + selection.3) / 2.0 + dy,
selection.3 + dy,
];
let mut best_x: Option<(f32, f32)> = None;
let mut best_y: Option<(f32, f32)> = None;
for element in self
.scene
.elements
.iter()
.filter(|element| !self.selected.contains(&element.id))
{
let bb = bbox(&element.kind);
for moving in moving_x {
for target in [bb.0, (bb.0 + bb.2) / 2.0, bb.2] {
let correction = target - moving;
if correction.abs() <= threshold
&& best_x.is_none_or(|(best, _)| correction.abs() < best.abs())
{
best_x = Some((correction, target));
}
}
}
for moving in moving_y {
for target in [bb.1, (bb.1 + bb.3) / 2.0, bb.3] {
let correction = target - moving;
if correction.abs() <= threshold
&& best_y.is_none_or(|(best, _)| correction.abs() < best.abs())
{
best_y = Some((correction, target));
}
}
}
}
(
dx + best_x.map_or(0.0, |(correction, _)| correction),
dy + best_y.map_or(0.0, |(correction, _)| correction),
AlignmentGuides {
vertical: best_x.map(|(_, target)| target),
horizontal: best_y.map(|(_, target)| target),
},
)
}
fn sync_segment_anchors_for(&mut self, changed_ids: &[u64]) {
if changed_ids.is_empty() {
return;
}
let elements = self.scene.elements.clone();
for element in &mut self.scene.elements {
let segment = match &mut element.kind {
ElementKind::Line(segment) | ElementKind::Arrow(segment) => segment,
_ => continue,
};
if let Some(anchor) = segment.start_anchor
&& changed_ids.contains(&anchor.element_id)
&& let Some(pos) = connector_world_pos_in(&elements, anchor)
{
segment.x1 = pos[0];
segment.y1 = pos[1];
}
if let Some(anchor) = segment.end_anchor
&& changed_ids.contains(&anchor.element_id)
&& let Some(pos) = connector_world_pos_in(&elements, anchor)
{
segment.x2 = pos[0];
segment.y2 = pos[1];
}
}
}
fn detach_segment_bindings_for_move(&mut self, ids: &[u64]) {
for element in &mut self.scene.elements {
if !ids.contains(&element.id) {
continue;
}
if let ElementKind::Line(segment) | ElementKind::Arrow(segment) = &mut element.kind {
if segment
.start_anchor
.is_some_and(|anchor| !ids.contains(&anchor.element_id))
{
segment.start_anchor = None;
}
if segment
.end_anchor
.is_some_and(|anchor| !ids.contains(&anchor.element_id))
{
segment.end_anchor = None;
}
}
}
}
fn set_segment_endpoint_anchor(
&mut self,
segment_id: u64,
endpoint: usize,
anchor: Option<SegmentAnchor>,
) {
let pos = anchor.and_then(|anchor| {
connector_world_pos_in(&self.scene.elements, anchor).map(|pos| (anchor, pos))
});
let Some(element) = self
.scene
.elements
.iter_mut()
.find(|element| element.id == segment_id)
else {
return;
};
let segment = match &mut element.kind {
ElementKind::Line(segment) | ElementKind::Arrow(segment) => segment,
_ => return,
};
if endpoint == 0 {
segment.start_anchor = anchor;
if let Some((_, pos)) = pos {
segment.x1 = pos[0];
segment.y1 = pos[1];
}
} else {
segment.end_anchor = anchor;
if let Some((_, pos)) = pos {
segment.x2 = pos[0];
segment.y2 = pos[1];
}
}
}
fn group_rotatable(&self) -> bool {
self.selected.len() > 1
&& self
.scene
.elements
.iter()
.any(|e| self.selected.contains(&e.id) && rotatable(&e.kind))
}
pub fn tool(&self) -> Tool {
self.tool
}
pub fn set_tool(&mut self, tool: Tool, cx: &mut Context<Self>) {
if self.read_only {
self.tool = Tool::Pan;
self.selected.clear();
self.open_group = None;
cx.notify();
return;
}
self.open_group = None;
if self.tool != tool {
self.tool = tool;
if tool != Tool::Select {
self.selected.clear();
}
}
cx.notify();
}
pub fn reset_view(&mut self, cx: &mut Context<Self>) {
self.scene.camera = Camera::default();
self.dirty = true;
cx.notify();
}
pub fn zoom_in(&mut self, cx: &mut Context<Self>) {
self.zoom_centered(1.2, cx);
}
pub fn zoom_out(&mut self, cx: &mut Context<Self>) {
self.zoom_centered(1.0 / 1.2, cx);
}
fn zoom_centered(&mut self, factor: f32, cx: &mut Context<Self>) {
let b = self.bounds.get();
let rx = f32::from(b.size.width) / 2.0;
let ry = f32::from(b.size.height) / 2.0;
self.scene.camera.zoom_about(rx, ry, factor);
self.dirty = true;
cx.notify();
}
fn push_undo(&mut self) {
self.history.push(self.scene.clone());
if self.history.len() > UNDO_CAP {
self.history.remove(0);
}
self.redo.clear();
}
pub fn undo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(prev) = self.history.pop() {
self.redo.push(std::mem::replace(&mut self.scene, prev));
self.selected.clear();
self.dirty = true;
cx.notify();
self.flush(window, cx);
}
}
pub fn redo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(next) = self.redo.pop() {
self.history.push(std::mem::replace(&mut self.scene, next));
self.selected.clear();
self.dirty = true;
cx.notify();
self.flush(window, cx);
}
}
fn delete_selected(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.selected.is_empty() {
return;
}
self.push_undo();
let gone = std::mem::take(&mut self.selected);
self.scene.elements.retain(|e| !gone.contains(&e.id));
self.editing = None;
self.dirty = true;
cx.notify();
self.flush(window, cx);
}
fn reorder_selection(&mut self, op: ZOrder, window: &mut Window, cx: &mut Context<Self>) {
if self.selected.is_empty() {
return;
}
let sel = self.selected.clone();
let on = |id: u64| sel.contains(&id);
self.push_undo();
let before: Vec<u64> = self.scene.elements.iter().map(|e| e.id).collect();
let els = &mut self.scene.elements;
match op {
ZOrder::ToFront => els.sort_by_key(|e| on(e.id)),
ZOrder::ToBack => els.sort_by_key(|e| !on(e.id)),
ZOrder::Forward => {
for i in (0..els.len().saturating_sub(1)).rev() {
if on(els[i].id) && !on(els[i + 1].id) {
els.swap(i, i + 1);
}
}
}
ZOrder::Backward => {
for i in 1..els.len() {
if on(els[i].id) && !on(els[i - 1].id) {
els.swap(i, i - 1);
}
}
}
}
if self.scene.elements.iter().map(|e| e.id).eq(before) {
self.history.pop(); return;
}
self.dirty = true;
cx.notify();
self.flush(window, cx);
}
fn flush(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.dirty {
return;
}
self.dirty = false;
if let Some(f) = self.on_change.clone() {
f(self.scene.to_json(), window, cx);
}
}
fn selection_json(&self) -> Option<String> {
let sel: Vec<&Element> = self
.scene
.elements
.iter()
.filter(|e| self.selected.contains(&e.id))
.collect();
if sel.is_empty() {
return None;
}
let (minx, miny) = sel
.iter()
.fold((f32::INFINITY, f32::INFINITY), |(mx, my), e| {
let (x0, y0, ..) = bbox(&e.kind);
(mx.min(x0), my.min(y0))
});
let elems: Vec<Element> = sel
.iter()
.map(|e| {
let mut c = (*e).clone();
translate(&mut c.kind, -minx, -miny);
c
})
.collect();
serde_json::to_string(&elems).ok()
}
fn save_selection_as_template(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.context_menu = None;
if let Some(json) = self.selection_json()
&& let Some(f) = self.on_save_template.clone()
{
f(json, window, cx);
}
cx.notify();
}
fn apply_template(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(elems) = self.templates.get(index).map(|t| t.elements.clone()) else {
return;
};
self.templates_open = false;
self.stamp_elements(&elems, window, cx);
}
fn stamp_elements(&mut self, elems: &[Element], window: &mut Window, cx: &mut Context<Self>) {
if elems.is_empty() {
return;
}
self.open_group = None;
self.push_undo();
let b = self.bounds.get();
let cam = self.scene.camera;
let z = cam.zoom.max(MIN_ZOOM);
let (tw, th) = elements_extent(elems);
let off = [
cam.x + (f32::from(b.size.width) / 2.0) / z - tw / 2.0,
cam.y + (f32::from(b.size.height) / 2.0) / z - th / 2.0,
];
let mut new_ids = Vec::with_capacity(elems.len());
for e in elems {
let mut c = e.clone();
translate(&mut c.kind, off[0], off[1]);
c.id = self.next_id;
self.next_id += 1;
new_ids.push(c.id);
self.scene.elements.push(c);
}
self.selected = new_ids;
self.tool = Tool::Select;
self.dirty = true;
self.flush(window, cx);
cx.notify();
}
fn copy_selection(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(json) = self.selection_json() else {
return false;
};
if let Some(f) = self.on_copy.clone() {
f(json, window, cx);
}
true
}
pub fn paste_elements(&mut self, json: &str, window: &mut Window, cx: &mut Context<Self>) {
if let Ok(elems) = serde_json::from_str::<Vec<Element>>(json) {
self.stamp_elements(&elems, window, cx);
}
}
fn delete_template(&mut self, id: i64, window: &mut Window, cx: &mut Context<Self>) {
if let Some(f) = self.on_delete_template.clone() {
f(id, window, cx);
}
}
fn template_card(
&self,
index: usize,
ink: Hsla,
text: Hsla,
grid: Hsla,
bg: Hsla,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
let t = &self.templates[index];
let id = t.id;
let name: SharedString = t.name.clone().into();
let elems = t.elements.clone();
let (tw, th) = elements_extent(&elems);
let preview = canvas(
|_, _, _| {},
move |bounds, _, window: &mut Window, _: &mut App| {
let pad = 8.0;
let aw = f32::from(bounds.size.width) - 2.0 * pad;
let ah = f32::from(bounds.size.height) - 2.0 * pad;
if tw <= 0.0 || th <= 0.0 || aw <= 0.0 || ah <= 0.0 {
return;
}
let scale = (aw / tw).min(ah / th).min(1.0);
let ox = (f32::from(bounds.size.width) - tw * scale) / 2.0;
let oy = (f32::from(bounds.size.height) - th * scale) / 2.0;
let cam = Camera {
x: -ox / scale,
y: -oy / scale,
zoom: scale,
};
for e in &elems {
let stroke = e.stroke.map_or(ink, u32_to_hsla);
let fill = e.fill.map(u32_to_hsla);
paint_element(&e.kind, None, cam, bounds.origin, stroke, fill, window);
}
},
)
.size_full();
div()
.id(("wb-template", index))
.flex()
.flex_col()
.items_center()
.gap(px(5.0))
.p(px(6.0))
.rounded(px(8.0))
.hover(|s| s.bg(grid))
.child(
div()
.w(px(150.0))
.h(px(104.0))
.rounded(px(6.0))
.bg(bg)
.border_1()
.border_color(grid)
.child(preview),
)
.child(
div()
.w(px(150.0))
.h(px(15.0))
.overflow_hidden()
.text_size(px(11.0))
.text_color(text)
.child(name),
)
.on_click(
cx.listener(move |this, _ev, window, cx| this.apply_template(index, window, cx)),
)
.on_mouse_down(
MouseButton::Right,
cx.listener(move |this, _ev, window, cx| this.delete_template(id, window, cx)),
)
.into_any_element()
}
fn seed_color(&self, target: PickerTarget) -> u32 {
let from_sel = self
.selected_single()
.and_then(|id| self.scene.elements.iter().find(|e| e.id == id))
.and_then(|e| match target {
PickerTarget::Stroke => e.stroke,
PickerTarget::Fill => e.fill,
PickerTarget::Text => e.label_color,
});
let active = match target {
PickerTarget::Stroke => self.active_stroke,
PickerTarget::Fill => self.active_fill,
PickerTarget::Text => self.active_text,
};
from_sel.or(active).unwrap_or(0x4080f0ff)
}
fn seed_picker(&mut self, target: PickerTarget) {
let c = self.seed_color(target);
let (h, s, v) = u32_to_hsv(c);
self.picker = Some(Picker {
target,
h,
s,
v,
a: u32_alpha(c),
});
}
fn toggle_picker(&mut self, cx: &mut Context<Self>) {
self.open_group = None;
self.templates_open = false;
self.width_open = false;
self.font_open = false;
if self.picker.is_some() {
self.picker = None;
} else {
self.seed_picker(PickerTarget::Stroke);
}
cx.notify();
}
fn toggle_width(&mut self, cx: &mut Context<Self>) {
self.picker = None;
self.open_group = None;
self.templates_open = false;
self.context_menu = None;
self.font_open = false;
self.width_open = !self.width_open;
cx.notify();
}
fn set_width_live(&mut self, w: f32, cx: &mut Context<Self>) {
self.active_width = w;
if !self.selected.is_empty() {
let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
let sel = self.selected.clone();
for e in self.scene.elements.iter_mut() {
if sel.contains(&e.id) {
set_kind_width(&mut e.kind, w / zoom);
}
}
self.dirty = true;
}
cx.notify();
}
fn set_width(&mut self, w: f32, window: &mut Window, cx: &mut Context<Self>) {
self.width_open = false;
if !self.selected.is_empty() {
self.push_undo();
}
self.set_width_live(w, cx);
self.flush(window, cx);
}
fn width_from_frac(frac: f32) -> f32 {
let w = WIDTH_MIN + frac.clamp(0.0, 1.0) * (WIDTH_MAX - WIDTH_MIN);
(w * 2.0).round() / 2.0
}
fn toggle_group(&mut self, group: ToolGroup, cx: &mut Context<Self>) {
self.picker = None;
self.templates_open = false;
self.width_open = false;
self.font_open = false;
self.open_group = if self.open_group == Some(group) {
None
} else {
Some(group)
};
cx.notify();
}
fn toggle_templates(&mut self, cx: &mut Context<Self>) {
self.picker = None;
self.open_group = None;
self.width_open = false;
self.context_menu = None;
self.font_open = false;
self.templates_open = !self.templates_open;
cx.notify();
}
fn toggle_font(&mut self, cx: &mut Context<Self>) {
self.picker = None;
self.open_group = None;
self.width_open = false;
self.templates_open = false;
self.context_menu = None;
self.font_open = !self.font_open;
cx.notify();
}
fn set_picker_target(&mut self, target: PickerTarget, cx: &mut Context<Self>) {
if self.picker.map(|p| p.target) != Some(target) {
self.seed_picker(target);
cx.notify();
}
}
fn picker_target(&self) -> PickerTarget {
self.picker.map_or(PickerTarget::Stroke, |p| p.target)
}
fn set_color_live(&mut self, color: Option<u32>, cx: &mut Context<Self>) {
let target = self.picker_target();
match target {
PickerTarget::Stroke => self.active_stroke = color,
PickerTarget::Fill => self.active_fill = color,
PickerTarget::Text => self.active_text = color,
}
if !self.selected.is_empty() {
let sel = self.selected.clone();
for e in self.scene.elements.iter_mut() {
if !sel.contains(&e.id) {
continue;
}
match target {
PickerTarget::Stroke => e.stroke = color,
PickerTarget::Fill => {
if is_closed_shape(&e.kind) {
e.fill = color;
}
}
PickerTarget::Text => {
if is_closed_shape(&e.kind) {
e.label_color = color;
}
}
}
}
self.dirty = true;
}
cx.notify();
}
fn pick_color(&mut self, color: Option<u32>, window: &mut Window, cx: &mut Context<Self>) {
if !self.selected.is_empty() {
self.push_undo();
}
if let (Some(c), Some(p)) = (color, self.picker.as_mut()) {
let (h, s, v) = u32_to_hsv(c);
if s > 0.0 {
p.h = h;
}
p.s = s;
p.v = v;
p.a = u32_alpha(c);
}
self.set_color_live(color, cx);
self.flush(window, cx);
}
fn sv_from_pos(&self, pos: Point<Pixels>) -> (f32, f32) {
let b = self.sv_bounds.get();
let w = f32::from(b.size.width).max(1.0);
let h = f32::from(b.size.height).max(1.0);
let s = ((f32::from(pos.x) - f32::from(b.origin.x)) / w).clamp(0.0, 1.0);
let v = 1.0 - ((f32::from(pos.y) - f32::from(b.origin.y)) / h).clamp(0.0, 1.0);
(s, v)
}
fn frac_x(&self, bounds: Bounds<Pixels>, pos: Point<Pixels>) -> f32 {
let w = f32::from(bounds.size.width).max(1.0);
((f32::from(pos.x) - f32::from(bounds.origin.x)) / w).clamp(0.0, 1.0)
}
fn picker_u32(&self) -> Option<u32> {
self.picker.map(|p| hsva_to_u32(p.h, p.s, p.v, p.a))
}
fn event_to_world(&self, p: Point<Pixels>) -> [f32; 2] {
let (rx, ry) = self.relative(p);
let (wx, wy) = self.scene.camera.screen_to_world(rx, ry);
[wx, wy]
}
fn handle_hit(&self, pos: Point<Pixels>) -> Option<HandleGrab> {
let cam = self.scene.camera;
let origin = self.bounds.get().origin;
let cursor = self.event_to_world(pos);
let near = |wx: f32, wy: f32, ox: f32, oy: f32| {
let s = to_screen(wx, wy, cam, origin);
let (dx, dy) = (
f32::from(pos.x) - (f32::from(s.x) + ox),
f32::from(pos.y) - (f32::from(s.y) + oy),
);
dx * dx + dy * dy <= HANDLE_GRAB * HANDLE_GRAB
};
if self.selected.len() > 1 {
let bb = self.selection_bbox()?;
if self.group_rotatable() {
let (rx, ry) = rotate_handle_for_bbox(bb, cam, origin);
let (dx, dy) = (f32::from(pos.x) - rx, f32::from(pos.y) - ry);
if dx * dx + dy * dy <= HANDLE_GRAB * HANDLE_GRAB {
return Some(HandleGrab::Rotate);
}
}
let wc = [(bb.0, bb.1), (bb.2, bb.1), (bb.0, bb.3), (bb.2, bb.3)];
let collect_orig = || -> Vec<(u64, ElementKind)> {
self.scene
.elements
.iter()
.filter(|e| self.is_selected(e.id))
.map(|e| (e.id, e.kind.clone()))
.collect()
};
for i in 0..4 {
if near(wc[i].0, wc[i].1, 0.0, 0.0) {
let opp = wc[3 - i];
return Some(HandleGrab::GroupCorner(GroupResizing {
handle: ResizeHandle::Corner,
anchor: [opp.0, opp.1],
from: [wc[i].0, wc[i].1],
grab: [wc[i].0 - cursor[0], wc[i].1 - cursor[1]],
orig: collect_orig(),
}));
}
}
let (mx, my) = ((bb.0 + bb.2) / 2.0, (bb.1 + bb.3) / 2.0);
let edges = [
(ResizeHandle::EdgeX, [bb.0, my], (0.0, 0.0), [bb.2, my]),
(ResizeHandle::EdgeX, [bb.2, my], (0.0, 0.0), [bb.0, my]),
(ResizeHandle::EdgeY, [mx, bb.1], (0.0, 0.0), [mx, bb.3]),
(ResizeHandle::EdgeY, [mx, bb.3], (0.0, 0.0), [mx, bb.1]),
];
for (handle, from, (ox, oy), anchor) in edges {
if near(from[0], from[1], ox, oy) {
return Some(HandleGrab::GroupCorner(GroupResizing {
handle,
anchor,
from,
grab: [from[0] - cursor[0], from[1] - cursor[1]],
orig: collect_orig(),
}));
}
}
return None;
}
let id = self.selected_single()?;
let kind = &self.scene.elements.iter().find(|e| e.id == id)?.kind;
if rotatable(kind) {
let (rx, ry) = rotate_handle_screen(kind, cam, origin);
let (dx, dy) = (f32::from(pos.x) - rx, f32::from(pos.y) - ry);
if dx * dx + dy * dy <= HANDLE_GRAB * HANDLE_GRAB {
return Some(HandleGrab::Rotate);
}
}
if let ElementKind::Line(s) | ElementKind::Arrow(s) = kind {
for (which, (wx, wy)) in [(s.x1, s.y1), (s.x2, s.y2)].into_iter().enumerate() {
if near(wx, wy, 0.0, 0.0) {
return Some(HandleGrab::Endpoint(EndpointDrag { id, which }));
}
}
return None;
}
if let Some((x, y, w, h, rot)) = box_like(kind) {
let cu = box_padded_corners(x, y, w, h, rot, 0.0);
let cp = cu;
let center = [x + w / 2.0, y + h / 2.0];
let rotated = rot.abs() > ROT_EPS;
for i in 0..4 {
if near(cp[i][0], cp[i][1], 0.0, 0.0) {
let anchor = if rotated { center } else { cu[(i + 2) % 4] };
return Some(HandleGrab::Corner(Resizing {
id,
handle: ResizeHandle::Corner,
anchor,
from: cu[i],
grab: [cu[i][0] - cursor[0], cu[i][1] - cursor[1]],
orig: kind.clone(),
}));
}
}
if !rotated && !matches!(kind, ElementKind::Text(_)) {
let mid = |a: [f32; 2], b: [f32; 2]| [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0];
if let Some(r) = self.edge_handle_hit(
id,
kind,
&near,
cursor,
mid(cu[0], cu[1]),
mid(cu[1], cu[2]),
mid(cu[2], cu[3]),
mid(cu[3], cu[0]),
) {
return Some(r);
}
}
return None;
}
let bb = bbox(kind);
let wc = [(bb.0, bb.1), (bb.2, bb.1), (bb.0, bb.3), (bb.2, bb.3)];
for i in 0..4 {
if near(wc[i].0, wc[i].1, 0.0, 0.0) {
let opp = wc[3 - i];
return Some(HandleGrab::Corner(Resizing {
id,
handle: ResizeHandle::Corner,
anchor: [opp.0, opp.1],
from: [wc[i].0, wc[i].1],
grab: [wc[i].0 - cursor[0], wc[i].1 - cursor[1]],
orig: kind.clone(),
}));
}
}
let (mx, my) = ((bb.0 + bb.2) / 2.0, (bb.1 + bb.3) / 2.0);
self.edge_handle_hit(
id,
kind,
&near,
cursor,
[mx, bb.1],
[bb.2, my],
[mx, bb.3],
[bb.0, my],
)
}
#[allow(clippy::too_many_arguments)]
fn edge_handle_hit(
&self,
id: u64,
kind: &ElementKind,
near: &dyn Fn(f32, f32, f32, f32) -> bool,
cursor: [f32; 2],
top: [f32; 2],
right: [f32; 2],
bottom: [f32; 2],
left: [f32; 2],
) -> Option<HandleGrab> {
let edges = [
(ResizeHandle::EdgeY, top, (0.0, 0.0), bottom),
(ResizeHandle::EdgeY, bottom, (0.0, 0.0), top),
(ResizeHandle::EdgeX, right, (0.0, 0.0), left),
(ResizeHandle::EdgeX, left, (0.0, 0.0), right),
];
for (handle, from, (ox, oy), anchor) in edges {
if near(from[0], from[1], ox, oy) {
return Some(HandleGrab::Corner(Resizing {
id,
handle,
anchor,
from,
grab: [from[0] - cursor[0], from[1] - cursor[1]],
orig: kind.clone(),
}));
}
}
None
}
fn connector_at(&self, pos: Point<Pixels>) -> Option<ConnectPoint> {
let origin = self.bounds.get().origin;
let near_px = CONNECTOR_BUTTON_SIZE * 0.65;
let (sx, sy) = (f32::from(pos.x), f32::from(pos.y));
let id = self.selected_single()?;
let element = self
.scene
.elements
.iter()
.find(|element| element.id == id && connector_capable(&element.kind))?;
let points = connector_points(&element.kind);
let buttons = connector_button_centers(&element.kind, self.scene.camera, origin);
buttons.into_iter().enumerate().find_map(|(index, button)| {
let dx = f32::from(button.x) - sx;
let dy = f32::from(button.y) - sy;
(dx * dx + dy * dy <= near_px * near_px).then_some(ConnectPoint {
id,
index,
pos: points[index],
})
})
}
fn update_hover_connector(&mut self, pos: Point<Pixels>, cx: &mut Context<Self>) {
let next = if self.tool == Tool::Select
&& self.editing.is_none()
&& self.pending.is_none()
&& self.connecting.is_none()
&& self.drag_from.is_none()
&& self.resizing.is_none()
&& self.group_resizing.is_none()
&& self.endpoint.is_none()
&& self.rotating.is_none()
&& self.marquee.is_none()
{
self.connector_at(pos)
} else {
None
};
if self.hovered_connector != next {
self.hovered_connector = next;
cx.notify();
}
}
fn text_at(&self, p: [f32; 2], pad: f32) -> Option<u64> {
self.scene
.elements
.iter()
.rev()
.find(|e| matches!(e.kind, ElementKind::Text(_)) && hit_test(&e.kind, p[0], p[1], pad))
.map(|e| e.id)
}
fn shape_at(&self, p: [f32; 2], pad: f32) -> Option<u64> {
self.scene
.elements
.iter()
.rev()
.find(|e| is_closed_shape(&e.kind) && hit_test(&e.kind, p[0], p[1], pad))
.map(|e| e.id)
}
fn embed_at(&self, p: [f32; 2], pad: f32) -> Option<(u64, i64)> {
self.scene
.elements
.iter()
.rev()
.find_map(|e| match &e.kind {
ElementKind::Embed(em) if hit_test(&e.kind, p[0], p[1], pad) => {
Some((e.id, em.page_id))
}
_ => None,
})
}
fn snap_connector_at(
&self,
pos: Point<Pixels>,
source_id: u64,
) -> Option<(ConnectPoint, bool)> {
const SHOW_DISTANCE_PX: f32 = 64.0;
const SNAP_DISTANCE_PX: f32 = 20.0;
let origin = self.bounds.get().origin;
let (sx, sy) = (f32::from(pos.x), f32::from(pos.y));
let world = self.event_to_world(pos);
let target = self
.scene
.elements
.iter()
.rev()
.filter(|element| element.id != source_id && connector_capable(&element.kind))
.find(|element| {
hit_test(
&element.kind,
world[0],
world[1],
SHOW_DISTANCE_PX / self.scene.camera.zoom.max(MIN_ZOOM),
)
})?;
connector_points(&target.kind)
.into_iter()
.enumerate()
.map(|(index, point)| {
let screen = to_screen(point[0], point[1], self.scene.camera, origin);
let dx = f32::from(screen.x) - sx;
let dy = f32::from(screen.y) - sy;
let distance_sq = dx * dx + dy * dy;
(
distance_sq,
ConnectPoint {
id: target.id,
index,
pos: point,
},
)
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(distance_sq, connector)| {
(
connector,
distance_sq <= SNAP_DISTANCE_PX * SNAP_DISTANCE_PX,
)
})
}
fn on_left_down(&mut self, ev: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
if self.panning {
return;
}
if self.read_only {
self.panning = true;
self.last = ev.position;
return;
}
if self.toolbar_grip_bounds.get().contains(&ev.position) {
self.start_toolbar_drag(ev.position, ev.click_count >= 2, window, cx);
return;
}
if self.toolbar_bounds.get().contains(&ev.position) {
return;
}
if self.context_menu.take().is_some() {
cx.notify();
return;
}
if self.open_group.is_some() {
self.open_group = None;
cx.notify();
return;
}
if self.font_open {
self.font_open = false;
cx.notify();
return;
}
if self.width_open {
let pos = ev.position;
if self.width_bounds.get().contains(&pos) {
if !self.selected.is_empty() {
self.push_undo();
}
self.picker_drag = Some(PickerDrag::Width);
let w = Self::width_from_frac(self.frac_x(self.width_bounds.get(), pos));
self.set_width_live(w, cx);
return;
}
if self.width_panel_bounds.get().contains(&pos) {
return;
}
self.width_open = false;
cx.notify();
return;
}
if self.picker.is_some() {
let pos = ev.position;
if self.sv_bounds.get().contains(&pos) {
if !self.selected.is_empty() {
self.push_undo();
}
self.picker_drag = Some(PickerDrag::Sv);
let (s, v) = self.sv_from_pos(pos);
if let Some(p) = self.picker.as_mut() {
(p.s, p.v) = (s, v);
}
if let Some(c) = self.picker_u32() {
self.set_color_live(Some(c), cx);
}
return;
}
if self.hue_bounds.get().contains(&pos) {
if !self.selected.is_empty() {
self.push_undo();
}
self.picker_drag = Some(PickerDrag::Hue);
let h = self.frac_x(self.hue_bounds.get(), pos);
if let Some(p) = self.picker.as_mut() {
p.h = h;
}
if let Some(c) = self.picker_u32() {
self.set_color_live(Some(c), cx);
}
return;
}
if self.alpha_bounds.get().contains(&pos) {
if !self.selected.is_empty() {
self.push_undo();
}
self.picker_drag = Some(PickerDrag::Alpha);
let a = self.frac_x(self.alpha_bounds.get(), pos);
if let Some(p) = self.picker.as_mut() {
p.a = a;
}
if let Some(c) = self.picker_u32() {
self.set_color_live(Some(c), cx);
}
return;
}
if self.picker_bounds.get().contains(&pos) {
return;
}
self.picker = None;
cx.notify();
return;
}
self.focus.focus(window, cx);
let p = self.event_to_world(ev.position);
let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
if let Some(id) = self.editing {
if self.point_in_editing_text(id, p) {
self.place_caret_from_click(id, p, ev, window, cx);
return;
}
self.commit_text(window, cx);
}
if ev.modifiers.control {
self.panning = true;
self.last = ev.position;
return;
}
if ev.click_count >= 2 {
self.pending = None;
if let Some(id) = self.text_at(p, SELECT_PAD / zoom) {
self.selected = vec![id];
self.editing = Some(id);
self.place_caret_from_click(id, p, ev, window, cx);
return;
}
if let Some(id) = self.shape_at(p, SELECT_PAD / zoom) {
self.selected = vec![id];
self.editing = Some(id);
self.place_caret_from_click(id, p, ev, window, cx);
return;
}
if self.tool == Tool::Select {
if let Some((id, page_id)) = self.embed_at(p, SELECT_PAD / zoom) {
self.selected = vec![id];
if let Some(f) = self.on_open.clone() {
f(page_id, window, cx);
}
cx.notify();
return;
}
}
self.reset_view(cx);
return;
}
if self.tool != Tool::Select {
let pad = SELECT_PAD / zoom;
let hit = self
.scene
.elements
.iter()
.rev()
.find(|element| hit_test(&element.kind, p[0], p[1], pad))
.map(|element| element.id);
if let Some(id) = hit {
self.tool = Tool::Select;
if ev.modifiers.shift {
if let Some(index) = self.selected.iter().position(|&selected| selected == id) {
self.selected.remove(index);
} else {
self.selected.push(id);
}
} else {
self.selected = vec![id];
}
self.drag_from = None;
cx.notify();
return;
}
}
if self.tool == Tool::Pan {
self.panning = true;
self.last = ev.position;
return;
}
if self.tool == Tool::Text {
if let Some(id) = self.text_at(p, SELECT_PAD / zoom) {
self.selected = vec![id];
cx.notify();
} else {
self.push_undo();
let id = self.next_id;
self.next_id += 1;
self.scene.elements.push(Element {
id,
kind: ElementKind::Text(TextGeom {
x: p[0],
y: p[1],
content: String::new(),
size: TEXT_SIZE / zoom,
rotation: 0.0,
measured_w: 0.0,
measured_h: 0.0,
}),
stroke: self.active_stroke,
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
});
self.selected = vec![id];
self.begin_text_edit(id, 0, window, cx);
self.dirty = true;
cx.notify();
}
return;
}
if self.tool == Tool::MindMap {
self.add_mindmap_seed(p[0], p[1], cx);
return;
}
if self.tool == Tool::Flowchart {
self.add_flowchart_seed(p[0], p[1], cx);
return;
}
if self.tool == Tool::Embed {
if let Some(f) = self.on_place_embed.clone() {
f(p[0], p[1], window, cx);
}
return;
}
if self.tool == Tool::Image {
if let Some(f) = self.on_place_image.clone() {
f(p[0], p[1], window, cx);
}
return;
}
if self.tool == Tool::Select {
if let Some(cp) = self.connector_at(ev.position) {
let width = self.active_width / zoom;
self.pending = Some(Pending {
anchor: cp.pos,
kind: ElementKind::Arrow(SegGeom {
x1: cp.pos[0],
y1: cp.pos[1],
x2: cp.pos[0],
y2: cp.pos[1],
width,
style: SegmentStyle::Solid,
start_anchor: Some(SegmentAnchor {
element_id: cp.id,
connector: cp.index,
}),
end_anchor: None,
}),
});
self.connecting = Some(ConnectDrag { from: cp });
self.hovered_connector = Some(cp);
cx.notify();
return;
}
if let Some(grab) = self.handle_hit(ev.position) {
self.push_undo();
match grab {
HandleGrab::Corner(rs) => self.resizing = Some(rs),
HandleGrab::GroupCorner(gr) => self.group_resizing = Some(gr),
HandleGrab::Endpoint(ep) => self.endpoint = Some(ep),
HandleGrab::Rotate => {
if let Some(bb) = self.selection_bbox() {
let center = [(bb.0 + bb.2) / 2.0, (bb.1 + bb.3) / 2.0];
let base = match self.selected_single() {
Some(id) => self
.scene
.elements
.iter()
.find(|e| e.id == id)
.and_then(|e| reference_angle(&e.kind)),
None => self
.scene
.elements
.iter()
.filter(|e| self.is_selected(e.id))
.find_map(|e| reference_angle(&e.kind))
.or(Some(0.0)),
};
let start_pointer = (p[1] - center[1]).atan2(p[0] - center[0]);
self.rotating = Some(Rotating {
center,
start_pointer,
applied: 0.0,
base,
});
}
}
}
cx.notify();
return;
}
let pad = SELECT_PAD / zoom;
let hit = self
.scene
.elements
.iter()
.rev()
.find(|e| hit_test(&e.kind, p[0], p[1], pad))
.map(|e| e.id);
match hit {
Some(id) if ev.modifiers.shift => {
if let Some(pos) = self.selected.iter().position(|&s| s == id) {
self.selected.remove(pos);
} else {
self.selected.push(id);
}
self.drag_from = None;
}
Some(id) => {
if !self.is_selected(id) {
self.selected = vec![id];
}
self.drag_from = Some(p);
self.move_origin = self
.selected
.first()
.and_then(|&pid| self.scene.elements.iter().find(|e| e.id == pid))
.map(|e| {
let (x, y, ..) = bbox(&e.kind);
[x, y]
})
.unwrap_or(p);
self.moved = false;
}
None => {
if !ev.modifiers.shift {
self.selected.clear();
}
self.marquee = Some((p, p));
self.drag_from = None;
}
}
cx.notify();
return;
}
let width = self.active_width / zoom;
let anchor = if ev.modifiers.alt {
[snap_grid(p[0]), snap_grid(p[1])]
} else {
p
};
let box0 = BoxGeom {
x: anchor[0],
y: anchor[1],
w: 0.0,
h: 0.0,
width,
rotation: 0.0,
};
let kind = match self.tool {
Tool::Pen => ElementKind::Draw(Stroke {
points: vec![p],
width,
}),
Tool::Rect => ElementKind::Rect(box0),
Tool::Ellipse => ElementKind::Ellipse(box0),
Tool::Diamond => ElementKind::Diamond(box0),
Tool::Triangle => ElementKind::Triangle(box0),
Tool::RoundRect => ElementKind::RoundRect(box0),
Tool::Star => ElementKind::Star(box0),
Tool::Hexagon => ElementKind::Hexagon(box0),
Tool::Line => ElementKind::Line(SegGeom {
x1: anchor[0],
y1: anchor[1],
x2: anchor[0],
y2: anchor[1],
width,
style: SegmentStyle::Solid,
start_anchor: None,
end_anchor: None,
}),
Tool::Arrow => ElementKind::Arrow(SegGeom {
x1: anchor[0],
y1: anchor[1],
x2: anchor[0],
y2: anchor[1],
width,
style: SegmentStyle::Solid,
start_anchor: None,
end_anchor: None,
}),
Tool::DashedArrow => ElementKind::Arrow(SegGeom {
x1: anchor[0],
y1: anchor[1],
x2: anchor[0],
y2: anchor[1],
width,
style: SegmentStyle::Dashed,
start_anchor: None,
end_anchor: None,
}),
Tool::Pan
| Tool::Select
| Tool::Text
| Tool::MindMap
| Tool::Flowchart
| Tool::Embed
| Tool::Image => return,
};
self.pending = Some(Pending { anchor, kind });
cx.notify();
}
fn on_left_up(&mut self, _ev: &MouseUpEvent, window: &mut Window, cx: &mut Context<Self>) {
if self.toolbar_drag.is_some() {
self.commit_toolbar_drag(window, cx);
return;
}
if self.text_selecting {
self.text_selecting = false;
return;
}
if self.panning {
self.panning = false;
cx.notify();
self.flush(window, cx);
return;
}
if self.picker_drag.take().is_some() {
self.flush(window, cx);
return;
}
if self.resizing.take().is_some()
|| self.group_resizing.take().is_some()
|| self.endpoint.take().is_some()
|| self.rotating.take().is_some()
{
self.dirty = true;
cx.notify();
self.flush(window, cx);
return;
}
if self.drag_from.take().is_some() {
if self.moved {
self.dirty = true;
}
self.moved = false;
self.alignment_guides = AlignmentGuides::default();
cx.notify();
self.flush(window, cx);
return;
}
if let Some((a, b)) = self.marquee.take() {
let (x0, x1) = (a[0].min(b[0]), a[0].max(b[0]));
let (y0, y1) = (a[1].min(b[1]), a[1].max(b[1]));
for e in &self.scene.elements {
let bb = bbox(&e.kind);
let hits = bb.0 <= x1 && bb.2 >= x0 && bb.1 <= y1 && bb.3 >= y0;
if hits && !self.selected.contains(&e.id) {
self.selected.push(e.id);
}
}
cx.notify();
return;
}
if let Some(pending) = self.pending.take() {
let completed_connection = self.connecting.take().is_some();
if committable(&pending.kind) {
self.push_undo();
let id = self.next_id;
self.next_id += 1;
let fill = if is_closed_shape(&pending.kind) {
self.active_fill
} else {
None
};
self.scene.elements.push(Element {
id,
kind: pending.kind,
stroke: self.active_stroke,
fill,
label: None,
label_color: self.active_text,
styles: Vec::new(),
mindmap: None,
});
if completed_connection {
self.selected = vec![id];
self.focus.focus(window, cx);
}
self.dirty = true;
}
cx.notify();
}
self.flush(window, cx);
}
fn on_right_down(&mut self, ev: &MouseDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
if self.read_only {
self.context_menu = None;
cx.notify();
return;
}
if self.picker.is_some() && self.picker_bounds.get().contains(&ev.position) {
return;
}
if self.selected.is_empty() && self.on_paste.is_none() {
self.context_menu = None;
} else {
let b = self.bounds.get();
self.context_menu = Some(point(
ev.position.x - b.origin.x,
ev.position.y - b.origin.y,
));
self.ctx_text_sub = false;
}
cx.notify();
}
fn try_paste(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
if let Some(f) = self.on_paste.clone()
&& let Some(json) = f(window, cx)
{
self.paste_elements(&json, window, cx);
true
} else {
false
}
}
fn paste_from_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.context_menu = None;
self.try_paste(window, cx);
}
fn on_middle_down(
&mut self,
ev: &MouseDownEvent,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
if self.pending.is_some()
|| self.drag_from.is_some()
|| self.resizing.is_some()
|| self.group_resizing.is_some()
|| self.endpoint.is_some()
|| self.rotating.is_some()
|| self.picker_drag.is_some()
|| self.marquee.is_some()
{
return;
}
self.panning = true;
self.last = ev.position;
}
fn on_middle_up(&mut self, _ev: &MouseUpEvent, window: &mut Window, cx: &mut Context<Self>) {
if self.panning {
self.panning = false;
cx.notify();
}
self.flush(window, cx);
}
fn on_move(&mut self, ev: &MouseMoveEvent, _window: &mut Window, cx: &mut Context<Self>) {
if self.toolbar_drag.is_some() {
self.drag_toolbar(ev.position, cx);
return;
}
if self.text_selecting
&& let Some(id) = self.editing
&& let Some(tg) = self.edit_target(id)
{
let local = block_local(
tg.x,
tg.y,
tg.rotation,
tg.pivot,
self.event_to_world(ev.position),
);
self.caret = self
.font
.index_at_wrapped(&tg.content, tg.size, tg.wrap, local);
cx.notify();
return;
}
if let Some(conn) = self.connecting {
let target = self.snap_connector_at(ev.position, conn.from.id);
let cur = if let Some((target, snapped)) = target {
self.hovered_connector = Some(target);
if snapped {
target.pos
} else {
self.event_to_world(ev.position)
}
} else {
self.hovered_connector = None;
self.event_to_world(ev.position)
};
if let Some(pending) = self.pending.as_mut()
&& let ElementKind::Line(s) | ElementKind::Arrow(s) = &mut pending.kind
{
s.x1 = conn.from.pos[0];
s.y1 = conn.from.pos[1];
s.x2 = cur[0];
s.y2 = cur[1];
s.start_anchor = Some(SegmentAnchor {
element_id: conn.from.id,
connector: conn.from.index,
});
s.end_anchor = target.and_then(|(target, snapped)| {
snapped.then_some(SegmentAnchor {
element_id: target.id,
connector: target.index,
})
});
}
cx.notify();
return;
}
if let Some(drag) = self.picker_drag {
let pos = ev.position;
if drag == PickerDrag::Width {
let w = Self::width_from_frac(self.frac_x(self.width_bounds.get(), pos));
self.set_width_live(w, cx);
return;
}
match drag {
PickerDrag::Sv => {
let (s, v) = self.sv_from_pos(pos);
if let Some(p) = self.picker.as_mut() {
(p.s, p.v) = (s, v);
}
}
PickerDrag::Hue => {
let h = self.frac_x(self.hue_bounds.get(), pos);
if let Some(p) = self.picker.as_mut() {
p.h = h;
}
}
PickerDrag::Alpha => {
let a = self.frac_x(self.alpha_bounds.get(), pos);
if let Some(p) = self.picker.as_mut() {
p.a = a;
}
}
PickerDrag::Width => unreachable!("handled above"),
}
if let Some(c) = self.picker_u32() {
self.set_color_live(Some(c), cx);
}
return;
}
if let Some(mut rot) = self.rotating.take() {
let cur = self.event_to_world(ev.position);
let ang = (cur[1] - rot.center[1]).atan2(cur[0] - rot.center[0]);
let mut total = ang - rot.start_pointer;
match rot.base {
Some(base) => total = snap_angle(base + total, ev.modifiers.shift) - base,
None => {
if ev.modifiers.shift {
let step = std::f32::consts::PI / 12.0;
total = (total / step).round() * step;
}
}
}
let tau = std::f32::consts::TAU;
let mut delta = total - rot.applied;
delta -= (delta / tau).round() * tau;
let sel = self.selected.clone();
for e in self.scene.elements.iter_mut() {
if sel.contains(&e.id) {
rotate_element(&mut e.kind, rot.center[0], rot.center[1], delta);
}
}
self.sync_segment_anchors_for(&sel);
rot.applied += delta;
self.rotating = Some(rot);
cx.notify();
return;
}
if let Some(gr) = self.group_resizing.take() {
let cur = self.event_to_world(ev.position);
let mut target = [cur[0] + gr.grab[0], cur[1] + gr.grab[1]];
if ev.modifiers.alt {
target = [snap_grid(target[0]), snap_grid(target[1])];
}
let (sx, sy) = match gr.handle {
ResizeHandle::Corner => {
let s = diagonal_scale(gr.anchor, gr.from, target);
(s, s)
}
ResizeHandle::EdgeX => (axis_scale(gr.anchor[0], gr.from[0], target[0]), 1.0),
ResizeHandle::EdgeY => (1.0, axis_scale(gr.anchor[1], gr.from[1], target[1])),
};
let font = self.font.clone();
for (id, orig) in &gr.orig {
let mut kind = orig.clone();
resize_about(&mut kind, gr.anchor[0], gr.anchor[1], sx, sy);
if let ElementKind::Text(t) = &mut kind {
let (w, h) = font.measure(&t.content, t.size);
(t.measured_w, t.measured_h) = (w, h);
}
if let Some(e) = self.scene.elements.iter_mut().find(|e| e.id == *id) {
e.kind = kind;
}
}
let changed: Vec<u64> = gr.orig.iter().map(|(id, _)| *id).collect();
self.sync_segment_anchors_for(&changed);
self.group_resizing = Some(gr);
cx.notify();
return;
}
if let Some(r) = self.resizing.as_ref() {
let (id, handle, anchor, from, grab, mut kind) =
(r.id, r.handle, r.anchor, r.from, r.grab, r.orig.clone());
let cur = self.event_to_world(ev.position);
let mut target = [cur[0] + grab[0], cur[1] + grab[1]];
if ev.modifiers.alt {
target = [snap_grid(target[0]), snap_grid(target[1])];
}
let (sx, sy) = match handle {
ResizeHandle::EdgeX => (axis_scale(anchor[0], from[0], target[0]), 1.0),
ResizeHandle::EdgeY => (1.0, axis_scale(anchor[1], from[1], target[1])),
ResizeHandle::Corner => {
let rotated = box_like(&kind).is_some_and(|(.., r)| r.abs() > ROT_EPS);
let proportional = ev.modifiers.shift
|| rotated
|| matches!(kind, ElementKind::Text(_) | ElementKind::Image(_));
if proportional {
let s = diagonal_scale(anchor, from, target);
(s, s)
} else {
(
axis_scale(anchor[0], from[0], target[0]),
axis_scale(anchor[1], from[1], target[1]),
)
}
}
};
resize_about(&mut kind, anchor[0], anchor[1], sx, sy);
let font = self.font.clone();
if let Some(e) = self.scene.elements.iter_mut().find(|e| e.id == id) {
e.kind = kind;
if let ElementKind::Text(t) = &mut e.kind {
let (w, h) = font.measure(&t.content, t.size);
t.measured_w = w;
t.measured_h = h;
}
}
self.sync_segment_anchors_for(&[id]);
cx.notify();
return;
}
if let Some(ep) = self.endpoint {
let cur = self.event_to_world(ev.position);
let shift = ev.modifiers.shift;
if let Some(e) = self.scene.elements.iter_mut().find(|e| e.id == ep.id)
&& let ElementKind::Line(s) | ElementKind::Arrow(s) = &mut e.kind
{
let (ox, oy) = if ep.which == 0 {
(s.x2, s.y2)
} else {
(s.x1, s.y1)
};
let (nx, ny) = if shift {
snap_45(ox, oy, cur[0], cur[1])
} else if ev.modifiers.alt {
(snap_grid(cur[0]), snap_grid(cur[1]))
} else {
(cur[0], cur[1])
};
if ep.which == 0 {
s.x1 = nx;
s.y1 = ny;
s.start_anchor = None;
} else {
s.x2 = nx;
s.y2 = ny;
s.end_anchor = None;
}
}
if !ev.modifiers.shift && !ev.modifiers.alt {
if let Some((target, snapped)) = self.snap_connector_at(ev.position, ep.id)
&& snapped
{
self.hovered_connector = Some(target);
self.set_segment_endpoint_anchor(
ep.id,
ep.which,
Some(SegmentAnchor {
element_id: target.id,
connector: target.index,
}),
);
} else {
self.hovered_connector = None;
}
} else {
self.hovered_connector = None;
}
cx.notify();
return;
}
if let Some(from) = self.drag_from {
let cur = self.event_to_world(ev.position);
let target = move_target(self.move_origin, from, cur, ev.modifiers.alt);
let cur_min = self
.selected
.first()
.and_then(|&pid| self.scene.elements.iter().find(|e| e.id == pid))
.map(|e| {
let (x, y, ..) = bbox(&e.kind);
[x, y]
})
.unwrap_or(self.move_origin);
let (raw_dx, raw_dy) = (target[0] - cur_min[0], target[1] - cur_min[1]);
let (dx, dy, guides) = if ev.modifiers.alt {
(raw_dx, raw_dy, AlignmentGuides::default())
} else {
self.aligned_move_delta(raw_dx, raw_dy)
};
self.alignment_guides = guides;
if dx != 0.0 || dy != 0.0 {
if !self.moved {
self.push_undo();
self.moved = true;
}
let sel = self.selected.clone();
self.detach_segment_bindings_for_move(&sel);
for e in self.scene.elements.iter_mut() {
if sel.contains(&e.id) {
translate(&mut e.kind, dx, dy);
}
}
self.sync_segment_anchors_for(&sel);
cx.notify();
}
return;
}
if let Some((start, _)) = self.marquee {
let cur = self.event_to_world(ev.position);
self.marquee = Some((start, cur));
cx.notify();
return;
}
if self.pending.is_some() {
let cur = self.event_to_world(ev.position);
let z = self.scene.camera.zoom.max(MIN_ZOOM);
let Some(pending) = self.pending.as_mut() else {
return;
};
let anchor = pending.anchor;
let c = if ev.modifiers.alt {
[snap_grid(cur[0]), snap_grid(cur[1])]
} else {
cur
};
match &mut pending.kind {
ElementKind::Draw(s) => {
if let Some(last) = s.points.last() {
let (ddx, ddy) = ((cur[0] - last[0]) * z, (cur[1] - last[1]) * z);
if ddx * ddx + ddy * ddy < MIN_POINT_PX * MIN_POINT_PX {
return;
}
}
s.points.push(cur);
}
ElementKind::Rect(b)
| ElementKind::Ellipse(b)
| ElementKind::Diamond(b)
| ElementKind::Triangle(b)
| ElementKind::RoundRect(b)
| ElementKind::Star(b)
| ElementKind::Hexagon(b) => {
b.x = anchor[0].min(c[0]);
b.y = anchor[1].min(c[1]);
b.w = (c[0] - anchor[0]).abs();
b.h = (c[1] - anchor[1]).abs();
}
ElementKind::Line(s) | ElementKind::Arrow(s) => {
s.x2 = c[0];
s.y2 = c[1];
}
ElementKind::Text(_) | ElementKind::Embed(_) | ElementKind::Image(_) => {}
}
cx.notify();
return;
}
self.update_hover_connector(ev.position, cx);
if self.panning {
let dx = f32::from(ev.position.x - self.last.x);
let dy = f32::from(ev.position.y - self.last.y);
self.last = ev.position;
self.scene.camera.pan_by(dx, dy);
self.dirty = true;
cx.notify();
}
}
fn on_scroll(&mut self, ev: &ScrollWheelEvent, _window: &mut Window, cx: &mut Context<Self>) {
let (dx, dy) = match ev.delta {
ScrollDelta::Pixels(p) => (f32::from(p.x), f32::from(p.y)),
ScrollDelta::Lines(p) => (p.x * LINE_PX, p.y * LINE_PX),
};
if ev.modifiers.platform || ev.modifiers.control {
let (rx, ry) = self.relative(ev.position);
let factor = (1.0 + dy * 0.0025).clamp(0.5, 2.0);
self.scene.camera.zoom_about(rx, ry, factor);
} else {
self.scene.camera.pan_by(dx, dy);
}
self.dirty = true;
cx.notify();
}
fn on_pinch(&mut self, ev: &PinchEvent, _window: &mut Window, cx: &mut Context<Self>) {
let (rx, ry) = self.relative(ev.position);
self.scene.camera.zoom_about(rx, ry, 1.0 + ev.delta);
self.dirty = true;
cx.notify();
}
fn relative(&self, p: Point<Pixels>) -> (f32, f32) {
let o = self.bounds.get().origin;
(f32::from(p.x - o.x), f32::from(p.y - o.y))
}
fn edit_target(&self, id: u64) -> Option<EditTarget> {
let e = self.scene.elements.iter().find(|e| e.id == id)?;
match &e.kind {
ElementKind::Text(t) => Some(EditTarget {
content: t.content.clone(),
size: t.size,
wrap: None,
x: t.x,
y: t.y,
rotation: t.rotation,
pivot: [t.x + t.measured_w / 2.0, t.y + t.measured_h / 2.0],
}),
kind if is_closed_shape(kind) => {
let (bx, by, bw, bh, rot) = box_like(kind)?;
let label = e.label.clone().unwrap_or_default();
let blk = shape_label_block(&self.font, kind, bx, by, bw, bh, &label);
Some(EditTarget {
content: label,
size: blk.size,
wrap: Some(blk.wrap),
x: blk.x,
y: blk.y,
rotation: rot,
pivot: [bx + bw / 2.0, by + bh / 2.0],
})
}
_ => None,
}
}
fn sel_range(&self) -> (usize, usize) {
(
self.caret.min(self.sel_anchor),
self.caret.max(self.sel_anchor),
)
}
fn move_caret(&mut self, to: usize, extend: bool, cx: &mut Context<Self>) {
self.caret = to;
if !extend {
self.sel_anchor = to;
}
self.pending_style = None; cx.notify();
}
fn replace_range(&mut self, id: u64, s: usize, e: usize, ins: &str, cx: &mut Context<Self>) {
let pending = self.pending_style;
let Some(el) = self.scene.elements.iter_mut().find(|el| el.id == id) else {
return;
};
let insert_style = pending.unwrap_or_else(|| style_at(&el.styles, s.saturating_sub(1)));
let edited = if let ElementKind::Text(t) = &mut el.kind {
t.content.replace_range(s..e, ins);
true
} else if is_closed_shape(&el.kind) {
el.label
.get_or_insert_with(String::new)
.replace_range(s..e, ins);
true
} else {
false
};
if edited {
el.styles = splice_styles(&el.styles, s, e, ins.len(), insert_style);
self.caret = s + ins.len();
self.sel_anchor = self.caret;
self.marked_range = None;
self.dirty = true;
cx.notify();
}
}
fn replace_selection(&mut self, id: u64, ins: &str, cx: &mut Context<Self>) {
let (s, e) = self.sel_range();
self.replace_range(id, s, e, ins, cx);
}
fn editing_content(&self) -> Option<String> {
self.editing
.and_then(|id| self.edit_target(id).map(|tg| tg.content))
}
fn utf16_to_utf8_in(text: &str, offset: usize) -> usize {
let mut utf8_offset = 0;
let mut utf16_count = 0;
for ch in text.chars() {
if utf16_count >= offset {
break;
}
utf16_count += ch.len_utf16();
utf8_offset += ch.len_utf8();
}
utf8_offset
}
fn utf8_to_utf16_in(text: &str, offset: usize) -> usize {
let mut utf16_offset = 0;
let mut utf8_count = 0;
for ch in text.chars() {
if utf8_count >= offset {
break;
}
utf8_count += ch.len_utf8();
utf16_offset += ch.len_utf16();
}
utf16_offset
}
fn utf16_range_to_utf8_in(text: &str, range_utf16: &Range<usize>) -> Range<usize> {
Self::utf16_to_utf8_in(text, range_utf16.start)
..Self::utf16_to_utf8_in(text, range_utf16.end)
}
fn utf8_range_to_utf16_in(text: &str, range: &Range<usize>) -> Range<usize> {
Self::utf8_to_utf16_in(text, range.start)..Self::utf8_to_utf16_in(text, range.end)
}
fn replace_text_in_visible_range(
&mut self,
visible_range: Range<usize>,
new_text: &str,
selected_range_relative: Option<Range<usize>>,
mark_inserted_text: bool,
cx: &mut Context<Self>,
) {
let Some(id) = self.editing else {
return;
};
let insert_start = visible_range.start;
self.replace_range(id, visible_range.start, visible_range.end, new_text, cx);
self.marked_range = if mark_inserted_text && !new_text.is_empty() {
Some(insert_start..insert_start + new_text.len())
} else {
None
};
let selected_range = selected_range_relative
.map(|relative| insert_start + relative.start..insert_start + relative.end);
self.caret = selected_range
.as_ref()
.map(|range| range.end)
.unwrap_or(insert_start + new_text.len());
self.sel_anchor = selected_range
.as_ref()
.map(|range| range.start)
.unwrap_or(self.caret);
cx.notify();
}
fn selection_style(&self) -> RunStyle {
let Some(id) = self.editing else {
return RunStyle::default();
};
let (s, e) = self.sel_range();
if s >= e
&& let Some(p) = self.pending_style
{
return p;
}
self.scene
.elements
.iter()
.find(|el| el.id == id)
.map_or(RunStyle::default(), |el| active_style(&el.styles, s, e))
}
fn apply_format(&mut self, format: Format, cx: &mut Context<Self>) {
let Some(id) = self.editing else {
return;
};
let (s, e) = self.sel_range();
if s < e {
if let Some(el) = self.scene.elements.iter_mut().find(|el| el.id == id) {
el.styles = toggle_format(&el.styles, s, e, format);
self.dirty = true;
}
} else {
let mut p = self.selection_style();
let on = !format.get(&p);
format.set(&mut p, on);
self.pending_style = Some(p);
}
cx.notify();
}
fn apply_highlight(&mut self, color: u32, cx: &mut Context<Self>) {
let Some(id) = self.editing else {
return;
};
let (s, e) = self.sel_range();
if s < e {
if let Some(el) = self.scene.elements.iter_mut().find(|el| el.id == id) {
el.styles = toggle_highlight(&el.styles, s, e, color);
self.dirty = true;
}
} else {
let mut p = self.selection_style();
p.highlight = (p.highlight != Some(color)).then_some(color);
self.pending_style = Some(p);
}
cx.notify();
}
fn format_menu(
&self,
ink: Hsla,
text: Hsla,
grid: Hsla,
bg: Hsla,
cx: &mut Context<Self>,
) -> Div {
let st = self.selection_style();
let frow = |id: &'static str, label: &'static str, sc: &'static str, on: bool| {
div()
.id(id)
.flex()
.items_center()
.gap(px(8.0))
.px(px(10.0))
.py(px(5.0))
.mx(px(4.0))
.rounded(px(6.0))
.text_size(px(12.0))
.text_color(ink)
.hover(|s| s.bg(grid))
.child(div().w(px(12.0)).child(if on { "✓" } else { "" }))
.child(div().flex_1().child(label))
.child(div().text_size(px(11.0)).text_color(text).child(sc))
};
div()
.min_w(px(184.0))
.py(px(4.0))
.rounded(px(8.0))
.bg(bg)
.shadow_lg()
.border_1()
.border_color(grid)
.flex()
.flex_col()
.child(
frow("wb-fmt-bold", "Bold", "⌘B", st.bold)
.on_click(cx.listener(|this, _ev, _w, cx| this.apply_format(Format::Bold, cx))),
)
.child(
frow("wb-fmt-italic", "Italic", "⌘I", st.italic).on_click(
cx.listener(|this, _ev, _w, cx| this.apply_format(Format::Italic, cx)),
),
)
.child(
frow("wb-fmt-underline", "Underline", "⌘U", st.underline).on_click(
cx.listener(|this, _ev, _w, cx| this.apply_format(Format::Underline, cx)),
),
)
.child(
frow("wb-fmt-strike", "Strikethrough", "⇧⌘X", st.strike).on_click(
cx.listener(|this, _ev, _w, cx| this.apply_format(Format::Strike, cx)),
),
)
.child(
frow(
"wb-fmt-highlight",
"Highlight",
"⇧⌘H",
st.highlight.is_some(),
)
.on_click(
cx.listener(|this, _ev, _w, cx| this.apply_highlight(HIGHLIGHT_DEFAULT, cx)),
),
)
}
fn caret_vertical(&self, content: &str, size: f32, wrap: Option<f32>, dir: i32) -> usize {
let pos = self.font.caret_pos_wrapped(content, size, wrap, self.caret);
let lh = self.font.measure("", size).1.max(1.0);
let y = (pos[1] + dir as f32 * lh + lh * 0.5).max(0.0);
self.font.index_at_wrapped(content, size, wrap, [pos[0], y])
}
fn text_edit_key(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
let Some(id) = self.editing else {
return;
};
let Some(tg) = self.edit_target(id) else {
self.commit_text(window, cx);
return;
};
let (content, size, wrap) = (tg.content, tg.size, tg.wrap);
self.caret = floor_boundary(&content, self.caret);
self.sel_anchor = floor_boundary(&content, self.sel_anchor);
let ks = &ev.keystroke;
if ks.is_ime_in_progress() {
return;
}
let cmd = ks.modifiers.platform || ks.modifiers.control;
let shift = ks.modifiers.shift;
if ks.key == "escape" {
self.commit_text(window, cx);
return;
}
if cmd {
match ks.key.as_str() {
"a" => {
self.sel_anchor = 0;
self.caret = content.len();
cx.notify();
}
"b" => self.apply_format(Format::Bold, cx),
"i" => self.apply_format(Format::Italic, cx),
"u" => self.apply_format(Format::Underline, cx),
"x" if shift => self.apply_format(Format::Strike, cx),
"h" if shift => self.apply_highlight(HIGHLIGHT_DEFAULT, cx),
"c" | "x" => {
let (s, e) = self.sel_range();
if s < e {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(
content[s..e].into(),
));
if ks.key == "x" {
self.replace_range(id, s, e, "", cx);
}
}
}
"v" => {
if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) {
self.replace_selection(id, &text, cx);
}
}
_ => cx.propagate(), }
return;
}
if ks.key == "tab" && self.is_mindmap_node(id) {
self.commit_text(window, cx);
self.add_mindmap_relative(id, false, window, cx);
return;
}
if ks.key == "enter" && self.is_mindmap_node(id) {
self.commit_text(window, cx);
self.add_mindmap_relative(id, true, window, cx);
return;
}
match ks.key.as_str() {
"left" => {
let (s, e) = self.sel_range();
let to = if !shift && s < e {
s
} else {
caret_left(&content, self.caret)
};
self.move_caret(to, shift, cx);
}
"right" => {
let (s, e) = self.sel_range();
let to = if !shift && s < e {
e
} else {
caret_right(&content, self.caret)
};
self.move_caret(to, shift, cx);
}
"up" => {
let to = self.caret_vertical(&content, size, wrap, -1);
self.move_caret(to, shift, cx);
}
"down" => {
let to = self.caret_vertical(&content, size, wrap, 1);
self.move_caret(to, shift, cx);
}
"home" => self.move_caret(line_start(&content, self.caret), shift, cx),
"end" => self.move_caret(line_end(&content, self.caret), shift, cx),
"backspace" => {
let (s, e) = self.sel_range();
if s < e {
self.replace_range(id, s, e, "", cx);
} else if self.caret > 0 {
self.replace_range(id, caret_left(&content, self.caret), self.caret, "", cx);
}
}
"delete" => {
let (s, e) = self.sel_range();
if s < e {
self.replace_range(id, s, e, "", cx);
} else if self.caret < content.len() {
self.replace_range(id, self.caret, caret_right(&content, self.caret), "", cx);
}
}
"enter" => self.replace_selection(id, "\n", cx),
"tab" => cx.propagate(),
_ => {
if ks
.key_char
.as_deref()
.is_none_or(|c| c.chars().next().is_none_or(|ch| ch.is_control()))
{
cx.propagate();
}
}
}
}
fn begin_text_edit(&mut self, id: u64, at: usize, window: &mut Window, cx: &mut Context<Self>) {
self.editing = Some(id);
self.caret = at;
self.sel_anchor = at;
self.marked_range = None;
self.focus.focus(window, cx);
}
fn point_in_editing_text(&self, id: u64, p: [f32; 2]) -> bool {
let pad = SELECT_PAD / self.scene.camera.zoom.max(MIN_ZOOM);
self.scene
.elements
.iter()
.find(|e| e.id == id)
.is_some_and(|e| {
let (x0, y0, x1, y1) = bbox(&e.kind);
p[0] >= x0 - pad && p[0] <= x1 + pad && p[1] >= y0 - pad && p[1] <= y1 + pad
})
}
fn place_caret_from_click(
&mut self,
id: u64,
p: [f32; 2],
ev: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(tg) = self.edit_target(id) else {
return;
};
let local = block_local(tg.x, tg.y, tg.rotation, tg.pivot, p);
let idx = self
.font
.index_at_wrapped(&tg.content, tg.size, tg.wrap, local);
if ev.click_count >= 2 {
let (s, e) = word_range(&tg.content, idx);
self.sel_anchor = s;
self.caret = e;
self.text_selecting = false;
} else {
self.caret = idx;
if !ev.modifiers.shift {
self.sel_anchor = idx;
}
self.text_selecting = true;
}
self.marked_range = None;
self.focus.focus(window, cx);
cx.notify();
}
fn commit_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.text_selecting = false;
self.pending_style = None;
self.marked_range = None;
self.format_flyout = false;
let Some(id) = self.editing.take() else {
return;
};
if let Some(e) = self.scene.elements.iter_mut().find(|e| e.id == id)
&& is_closed_shape(&e.kind)
&& e.label.as_deref().is_none_or(|s| s.trim().is_empty())
{
e.label = None;
}
self.scene.elements.retain(|e| {
e.id != id || !matches!(&e.kind, ElementKind::Text(t) if t.content.trim().is_empty())
});
self.dirty = true;
cx.notify();
self.flush(window, cx);
}
fn handle_shortcut(
&mut self,
ev: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let ks = &ev.keystroke;
let cmd = ks.modifiers.platform || ks.modifiers.control;
if cmd && ks.key == "z" {
if ks.modifiers.shift {
self.redo(window, cx);
} else {
self.undo(window, cx);
}
return true;
}
let close = ks.key == "]" || ks.key == "}";
let open = ks.key == "[" || ks.key == "{";
if cmd && (close || open) {
if self.selected.is_empty() {
return false;
}
let all_the_way = ks.modifiers.shift || ks.key == "}" || ks.key == "{";
let op = match (close, all_the_way) {
(true, true) => ZOrder::ToFront,
(true, false) => ZOrder::Forward,
(false, true) => ZOrder::ToBack,
(false, false) => ZOrder::Backward,
};
self.reorder_selection(op, window, cx);
return true;
}
if cmd && ks.key == "c" {
self.copy_selection(window, cx);
return true;
}
if cmd && ks.key == "x" {
if self.copy_selection(window, cx) {
self.delete_selected(window, cx);
}
return true;
}
if cmd && ks.key == "v" {
return self.try_paste(window, cx);
}
if cmd || ks.modifiers.alt {
return false;
}
if let Some(tool) = Tool::shortcut(&ks.key) {
self.set_tool(tool, cx);
return true;
}
match ks.key.as_str() {
"backspace" | "delete" => self.delete_selected(window, cx),
"escape" if !self.selected.is_empty() => {
self.selected.clear();
cx.notify();
}
_ => return false,
}
true
}
fn on_key(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
if self.read_only {
cx.propagate();
return;
}
if ev.keystroke.key == "escape" && self.connecting.is_some() {
self.connecting = None;
self.pending = None;
self.hovered_connector = None;
cx.notify();
return;
}
if ev.keystroke.key == "escape" && (self.picker.is_some() || self.templates_open) {
self.picker = None;
self.templates_open = false;
cx.notify();
return;
}
if self.toolbar_drag.is_some() {
let ks = &ev.keystroke;
if ks.key == "r" && !(ks.modifiers.platform || ks.modifiers.control || ks.modifiers.alt)
{
self.toggle_toolbar_orientation(window, cx);
}
return;
}
if self.editing.is_none() {
if !self.handle_shortcut(ev, window, cx) {
cx.propagate();
}
return;
}
self.text_edit_key(ev, window, cx);
}
}
impl BoardEmbedView {
pub fn new(scene: Scene, style: WhiteboardStyleFn, cx: &mut Context<Self>) -> Self {
let board_style = style.clone();
let board = cx.new(|cx| WhiteboardView::new_read_only(scene, board_style, cx));
Self {
board,
style,
on_expand: None,
}
}
pub fn board(&self) -> Entity<WhiteboardView> {
self.board.clone()
}
pub fn set_on_expand(&mut self, f: ExpandEmbedFn) {
self.on_expand = Some(f);
}
}
impl Render for BoardEmbedView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let st = (self.style)();
let ink = st.ink;
let panel = st.panel_strong;
let grid = st.grid;
let accent = st.accent;
let button = self.on_expand.as_ref().map(|_| {
div()
.id("board-embed-expand")
.absolute()
.top(px(10.0))
.right(px(10.0))
.h(px(30.0))
.px(px(10.0))
.flex()
.items_center()
.justify_center()
.gap(px(6.0))
.rounded(px(8.0))
.bg(panel)
.border_1()
.border_color(grid.opacity(0.5))
.hover(|s| s.bg(accent))
.text_size(px(12.0))
.text_color(ink)
.child("↗")
.child("Edit")
.on_click(cx.listener(|this, _ev, window, cx| {
if let Some(f) = this.on_expand.clone() {
f(window, cx);
}
}))
});
div()
.size_full()
.relative()
.child(self.board.clone())
.children(button)
}
}
impl BoardThumbnailView {
pub fn new(snapshot: LocalThumbnailSnapshot, style: WhiteboardStyleFn) -> Self {
Self {
snapshot,
style,
font: Font::default(),
}
}
pub fn snapshot(&self) -> &LocalThumbnailSnapshot {
&self.snapshot
}
pub fn set_snapshot(&mut self, snapshot: LocalThumbnailSnapshot) {
self.snapshot = snapshot;
}
}
impl Render for BoardThumbnailView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let WhiteboardStyle {
bg,
grid,
text,
ink,
panel,
..
} = (self.style)();
let cam = self.snapshot.spec.camera;
let layers = build_thumbnail_layers(
&self.snapshot.scene,
&self.font,
cam,
ink,
text,
grid,
panel,
None,
None,
None,
);
let board_layer = canvas(
|_, _, _| {},
move |bounds, _, window, _| paint_board(bounds, cam, bg, grid, window),
)
.absolute()
.size_full();
let element_layers: Vec<gpui::AnyElement> = layers
.into_iter()
.map(|l| match l {
Layer::Band(es) => band_canvas(es, cam).into_any_element(),
Layer::Overlay(el) => el,
})
.collect();
div()
.size_full()
.relative()
.overflow_hidden()
.child(board_layer)
.children(element_layers)
}
}
#[derive(Clone, Copy)]
enum ZOrder {
ToFront,
Forward,
Backward,
ToBack,
}
fn caret_left(content: &str, at: usize) -> usize {
content[..at.min(content.len())]
.chars()
.next_back()
.map_or(0, |c| at - c.len_utf8())
}
fn caret_right(content: &str, at: usize) -> usize {
content[at.min(content.len())..]
.chars()
.next()
.map_or(content.len(), |c| at + c.len_utf8())
}
fn line_start(content: &str, at: usize) -> usize {
content[..at.min(content.len())]
.rfind('\n')
.map_or(0, |i| i + 1)
}
fn line_end(content: &str, at: usize) -> usize {
let at = at.min(content.len());
content[at..].find('\n').map_or(content.len(), |i| at + i)
}
fn floor_boundary(content: &str, idx: usize) -> usize {
let mut i = idx.min(content.len());
while i > 0 && !content.is_char_boundary(i) {
i -= 1;
}
i
}
fn word_range(content: &str, at: usize) -> (usize, usize) {
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let at = floor_boundary(content, at);
let start = content[..at]
.char_indices()
.rev()
.take_while(|(_, c)| is_word(*c))
.last()
.map_or(at, |(i, _)| i);
let end = content[at..]
.char_indices()
.take_while(|(_, c)| is_word(*c))
.last()
.map_or(at, |(i, c)| at + i + c.len_utf8());
(start, end)
}
struct EditTarget {
content: String,
size: f32,
wrap: Option<f32>,
x: f32,
y: f32,
rotation: f32,
pivot: [f32; 2],
}
struct LabelBlock {
x: f32,
y: f32,
size: f32,
wrap: f32,
}
fn glyph_style(s: RunStyle) -> font::GlyphStyle {
font::GlyphStyle {
bold: s.bold,
italic: s.italic,
underline: s.underline,
strike: s.strike,
highlight: s.highlight,
}
}
fn shape_label_block(
font: &Font,
kind: &ElementKind,
bx: f32,
by: f32,
bw: f32,
bh: f32,
label: &str,
) -> LabelBlock {
let (wf, hf, bottom) = match kind {
ElementKind::Ellipse(_) => (
std::f32::consts::FRAC_1_SQRT_2,
std::f32::consts::FRAC_1_SQRT_2,
false,
),
ElementKind::Diamond(_) => (0.5, 0.5, false),
ElementKind::Triangle(_) => (0.5, 0.5, true),
ElementKind::Star(_) => (0.5, 0.4, false),
ElementKind::Hexagon(_) => (0.8, 0.5, false),
_ => (1.0, 1.0, false),
};
let wrap = (bw * wf - 2.0 * LABEL_PAD).max(1.0);
let ih = (bh * hf - 2.0 * LABEL_PAD).max(1.0);
let size = font.fit_size(label, wrap, ih, TEXT_SIZE);
let (w, h) = font.measure_wrapped(label, size, Some(wrap));
let x = bx + (bw - w) / 2.0;
let y = if bottom {
by + bh - LABEL_PAD - h
} else {
by + (bh - h) / 2.0
};
LabelBlock { x, y, size, wrap }
}
impl WhiteboardView {
fn render_read_only(&mut self, window: &Window, cx: &mut Context<Self>) -> AnyElement {
let WhiteboardStyle {
bg,
grid,
text,
ink,
panel,
..
} = (self.style)();
let camera = self.scene.camera;
let bounds_cell = self.bounds.clone();
let render_viewport = self.render_viewport(Some(window.viewport_size()));
let visible_element_ids = self
.scene
.elements
.iter()
.filter(|element| {
render_viewport.is_none_or(|viewport| viewport.intersects(bbox(&element.kind)))
})
.map(|element| element.id)
.collect::<HashSet<_>>();
self.text_layout_cache
.retain(|element_id, _| visible_element_ids.contains(element_id));
self.label_layout_cache
.retain(|element_id, _| visible_element_ids.contains(element_id));
let layers = build_thumbnail_layers(
&self.scene,
&self.font,
camera,
ink,
text,
grid,
panel,
render_viewport,
Some(&mut self.text_layout_cache),
Some(&mut self.label_layout_cache),
);
let board_layer = canvas(
move |bounds, _, _| bounds_cell.set(bounds),
move |bounds, _, window, _| paint_board(bounds, camera, bg, grid, window),
)
.absolute()
.size_full();
let element_layers = layers.into_iter().map(|layer| match layer {
Layer::Band(elements) => band_canvas(elements, camera).into_any_element(),
Layer::Overlay(element) => element,
});
div()
.size_full()
.relative()
.overflow_hidden()
.cursor(if self.panning {
CursorStyle::ClosedHand
} else {
CursorStyle::OpenHand
})
.child(board_layer)
.children(element_layers)
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_left_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_left_up))
.on_mouse_down(MouseButton::Middle, cx.listener(Self::on_middle_down))
.on_mouse_up(MouseButton::Middle, cx.listener(Self::on_middle_up))
.on_mouse_move(cx.listener(Self::on_move))
.on_pinch(cx.listener(Self::on_pinch))
.child(
div()
.absolute()
.left(px(10.0))
.bottom(px(8.0))
.text_size(px(11.0))
.text_color(text)
.child(SharedString::from(format!("{:.0}%", camera.zoom * 100.0))),
)
.into_any_element()
}
}
impl Render for WhiteboardView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.read_only {
return self.render_read_only(window, cx);
}
let WhiteboardStyle {
bg,
grid,
text,
ink,
panel,
panel_strong,
accent,
selection,
swatches,
} = (self.style)();
let cam = self.scene.camera;
let zoom = cam.zoom.max(MIN_ZOOM);
let bounds_cell = self.bounds.clone();
let board_bounds = self.bounds.get();
let render_viewport = self.render_viewport(Some(window.viewport_size()));
let visible_element_ids = self
.scene
.elements
.iter()
.filter(|element| {
render_viewport.is_none_or(|viewport| viewport.intersects(bbox(&element.kind)))
})
.map(|element| element.id)
.collect::<HashSet<_>>();
self.text_layout_cache
.retain(|element_id, _| visible_element_ids.contains(element_id));
self.label_layout_cache
.retain(|element_id, _| visible_element_ids.contains(element_id));
let img_sources: HashMap<u64, gpui::ImageSource> = {
let items: Vec<(u64, String, f32)> = self
.scene
.elements
.iter()
.filter(|element| visible_element_ids.contains(&element.id))
.filter_map(|e| match &e.kind {
ElementKind::Image(im) => {
Some((e.id, im.src.clone(), snap_quarter(im.rotation)))
}
_ => None,
})
.collect();
let mut map = HashMap::new();
if let Some(f) = self.on_image.clone() {
for (id, src, rot) in items {
if let Some(s) = f(&src, rot, window, cx) {
map.insert(id, s);
}
}
}
map
};
let font = self.font.clone();
let editing = self.editing;
let (caret_at, sel_anchor) = (self.caret, self.sel_anchor);
let sel_fill = gpui::hsla(selection.h, selection.s, selection.l, 0.30);
let mindmap_connector_styles: HashMap<u64, MindMapConnectorStyle> = self
.scene
.elements
.iter()
.filter(|element| visible_element_ids.contains(&element.id))
.filter_map(|element| {
self.mindmap_connector_style_for_element(&element.kind)
.map(|style| (element.id, style))
})
.collect();
let text_layout_cache = &mut self.text_layout_cache;
let label_layout_cache = &mut self.label_layout_cache;
let mut layers: Vec<Layer> = Vec::new();
let mut band: Vec<ElemPaint> = Vec::new();
for e in self.scene.elements.iter_mut() {
if !visible_element_ids.contains(&e.id) {
continue;
}
let id = e.id;
let stroke = e.stroke.map_or(ink, u32_to_hsla);
let fill = e.fill.map(u32_to_hsla);
let label = e.label.as_deref();
let label_color = e.label_color;
let styles = e.styles.as_slice();
match &mut e.kind {
ElementKind::Embed(em) => {
if !band.is_empty() {
layers.push(Layer::Band(std::mem::take(&mut band)));
}
layers.push(Layer::Overlay(
div()
.absolute()
.left(px((em.x - cam.x) * zoom))
.top(px((em.y - cam.y) * zoom))
.w(px(em.w * zoom))
.h(px(em.h * zoom))
.bg(panel)
.border_1()
.border_color(grid)
.rounded(px(8.0))
.overflow_hidden()
.p(px(10.0 * zoom))
.flex()
.flex_col()
.gap(px(3.0 * zoom))
.child(
div()
.flex()
.items_center()
.gap(px(6.0 * zoom))
.text_size(px(14.0 * zoom))
.text_color(ink)
.child(div().text_color(accent).child("▤"))
.child(SharedString::from(em.title.clone())),
)
.child(
div()
.text_size(px(11.0 * zoom))
.text_color(text)
.child("Double-click to open"),
)
.into_any_element(),
));
}
ElementKind::Image(im) => {
if !band.is_empty() {
layers.push(Layer::Band(std::mem::take(&mut band)));
}
let rot = snap_quarter(im.rotation);
let (bx, by, bw, bh) = if rot.abs() < ROT_EPS {
(im.x, im.y, im.w, im.h)
} else {
let c = box_padded_corners(im.x, im.y, im.w, im.h, rot, 0.0);
let (x0, y0, x1, y1) = aabb(&c);
(x0, y0, x1 - x0, y1 - y0)
};
let frame = div()
.absolute()
.left(px((bx - cam.x) * zoom))
.top(px((by - cam.y) * zoom))
.w(px(bw * zoom))
.h(px(bh * zoom))
.overflow_hidden()
.rounded(px(2.0));
let el = match img_sources.get(&id) {
Some(src) => frame.child(
gpui::img(src.clone())
.w(px(bw * zoom))
.object_fit(ObjectFit::Contain),
),
None => frame
.bg(panel)
.border_1()
.border_color(grid)
.flex()
.items_center()
.justify_center()
.child(
div()
.text_size(px(11.0 * zoom))
.text_color(text)
.child("Loading…"),
),
};
layers.push(Layer::Overlay(el.into_any_element()));
}
kind => {
let text = if let ElementKind::Text(t) = kind {
let layout = cached_text_layout(
text_layout_cache,
&font,
id,
&t.content,
t.size,
None,
styles,
);
t.measured_w = layout.width;
t.measured_h = layout.height;
let active = editing == Some(id);
let caret = active.then(|| font.caret_pos(&t.content, t.size, caret_at));
let (s, e) = (caret_at.min(sel_anchor), caret_at.max(sel_anchor));
let selection = if active {
font.selection_rects(&t.content, t.size, s, e)
} else {
Vec::new()
};
Some(TextOutline {
segs: layout.segs.clone(),
bold_segs: layout.bold_segs.clone(),
bold_width: layout.bold_width,
color: stroke,
x: t.x,
y: t.y,
rotation: t.rotation,
pivot: [t.x + layout.width / 2.0, t.y + layout.height / 2.0],
line_height: layout.line_height,
caret,
selection,
sel_color: sel_fill,
decorations: layout.decorations.clone(),
})
} else if is_closed_shape(kind)
&& let Some((bx, by, bw, bh, rot)) = box_like(kind)
&& (editing == Some(id) || label.is_some_and(|s| !s.trim().is_empty()))
{
let active = editing == Some(id);
let text = label.map_or("", str::trim);
let label_layout = cached_label_layout(
label_layout_cache,
&font,
id,
kind,
bx,
by,
bw,
bh,
text,
styles,
);
let caret = active.then(|| {
font.caret_pos_wrapped(
text,
label_layout.size,
Some(label_layout.wrap),
caret_at,
)
});
let (s, e) = (caret_at.min(sel_anchor), caret_at.max(sel_anchor));
let selection = if active {
font.selection_rects_wrapped(
text,
label_layout.size,
Some(label_layout.wrap),
s,
e,
)
} else {
Vec::new()
};
Some(TextOutline {
segs: label_layout.text.segs.clone(),
bold_segs: label_layout.text.bold_segs.clone(),
bold_width: label_layout.text.bold_width,
color: label_color.map_or(stroke, u32_to_hsla),
x: bx + label_layout.offset_x,
y: by + label_layout.offset_y,
rotation: rot,
pivot: [bx + bw / 2.0, by + bh / 2.0],
line_height: label_layout.text.line_height,
caret,
selection,
sel_color: sel_fill,
decorations: label_layout.text.decorations.clone(),
})
} else {
None
};
band.push(ElemPaint {
kind: kind.clone(),
stroke,
fill,
text,
mindmap_connector_style: mindmap_connector_styles.get(&id).copied(),
});
}
}
}
if !band.is_empty() {
layers.push(Layer::Band(band));
}
let pending_ink = self.active_stroke.map_or(ink, u32_to_hsla);
let pending_fill = self.active_fill.map(u32_to_hsla);
let pending = self.pending.as_ref().map(|p| p.kind.clone());
let single_sel = self
.selected_single()
.filter(|id| Some(*id) != self.editing)
.and_then(|id| self.scene.elements.iter().find(|e| e.id == id))
.map(|e| e.kind.clone());
let group_sel = (self.selected.len() > 1)
.then(|| self.selection_bbox())
.flatten()
.map(|bb| (bb, self.group_rotatable()));
let marquee = self.marquee;
let alignment_guides = self.alignment_guides;
let snap_target = self.connecting.and_then(|connection| {
self.hovered_connector
.filter(|target| target.id != connection.from.id)
.and_then(|target| {
self.scene
.elements
.iter()
.find(|element| element.id == target.id)
.map(|element| (element.kind.clone(), target.index))
})
});
const CONNECTOR_ICONS: [(&str, &[u8]); 4] = [
("wb-connector-up", include_bytes!("../assets/icons/up.svg")),
(
"wb-connector-right",
include_bytes!("../assets/icons/right.svg"),
),
(
"wb-connector-down",
include_bytes!("../assets/icons/down.svg"),
),
(
"wb-connector-left",
include_bytes!("../assets/icons/left.svg"),
),
];
let connector_buttons: Vec<gpui::AnyElement> = single_sel
.as_ref()
.filter(|_| self.connecting.is_none() && self.pending.is_none())
.filter(|kind| connector_capable(kind))
.map(|kind| {
connector_button_centers(kind, cam, board_bounds.origin)
.into_iter()
.enumerate()
.map(|(index, center)| {
let (key, bytes) = CONNECTOR_ICONS[index];
div()
.id(("wb-connector-button", index))
.absolute()
.left(
center.x - board_bounds.origin.x - px(CONNECTOR_BUTTON_SIZE / 2.0),
)
.top(center.y - board_bounds.origin.y - px(CONNECTOR_BUTTON_SIZE / 2.0))
.size(px(CONNECTOR_BUTTON_SIZE))
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(panel_strong)
.text_color(selection)
.shadow_sm()
.cursor_pointer()
.child(svg_icon(key, bytes, selection, CONNECTOR_BUTTON_SIZE))
.into_any_element()
})
.collect()
})
.unwrap_or_default();
const ROTATE_ICON: &[u8] = include_bytes!("../assets/icons/refresh.svg");
let rotate_position = single_sel
.as_ref()
.filter(|kind| rotatable(kind))
.map(|kind| rotate_handle_screen(kind, cam, board_bounds.origin))
.or_else(|| {
group_sel
.filter(|(_, can_rotate)| *can_rotate)
.map(|(bounds, _)| rotate_handle_for_bbox(bounds, cam, board_bounds.origin))
});
let rotate_button = rotate_position.map(|(x, y)| {
div()
.id("wb-rotate-button")
.absolute()
.left(px(x) - board_bounds.origin.x - px(CONNECTOR_BUTTON_SIZE / 2.0))
.top(px(y) - board_bounds.origin.y - px(CONNECTOR_BUTTON_SIZE / 2.0))
.size(px(CONNECTOR_BUTTON_SIZE))
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(panel_strong)
.shadow_sm()
.cursor_pointer()
.child(svg_icon(
"wb-icon-refresh",
ROTATE_ICON,
selection,
CONNECTOR_BUTTON_SIZE - 4.0,
))
});
let selected_mindmap_root = self.selected_mindmap_root();
let active = self.tool;
let open_group = self.open_group;
let tool_btn = |t: Tool| {
let icon: gpui::AnyElement = match t.icon() {
Some((key, bytes)) => svg_icon(key, bytes, ink, 16.0).into_any_element(),
None => t.glyph().into_any_element(),
};
let mut b = div()
.id(("wb-tool", t as usize))
.size(px(30.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0))
.text_size(px(15.0))
.text_color(ink)
.child(icon);
if t == active {
b = b.bg(accent);
} else {
b = b.hover(|s| s.bg(grid));
}
b
};
let cat_btn = |g: ToolGroup| {
let shown = if g.contains(active) {
active
} else {
g.representative()
};
let icon: gpui::AnyElement = match shown.icon() {
Some((key, bytes)) => svg_icon(key, bytes, ink, 16.0).into_any_element(),
None => shown.glyph().into_any_element(),
};
let mut b = div()
.id(("wb-group", g as usize))
.h(px(30.0))
.px(px(6.0))
.flex()
.items_center()
.justify_center()
.gap(px(1.0))
.rounded(px(6.0))
.text_color(ink)
.child(icon)
.child(div().text_size(px(8.0)).text_color(text).child("▾"));
if open_group == Some(g) || g.contains(active) {
b = b.bg(accent);
} else {
b = b.hover(|s| s.bg(grid));
}
b
};
let mut cats: Vec<gpui::AnyElement> = Vec::with_capacity(ToolGroup::ALL.len() + 1);
for &g in ToolGroup::ALL.iter() {
cats.push(
cat_btn(g)
.tooltip(self.tip(g.label()))
.on_click(cx.listener(move |this, _ev, window, cx| {
this.focus.focus(window, cx);
this.toggle_group(g, cx);
}))
.into_any_element(),
);
if g == ToolGroup::Lines {
cats.push(
tool_btn(Tool::Text)
.tooltip(self.tip(Tool::Text.label()))
.on_click(cx.listener(|this, _ev, _w, cx| this.set_tool(Tool::Text, cx)))
.into_any_element(),
);
}
}
const UNDO_ICON: &[u8] = include_bytes!("../assets/icons/undo.svg");
const REDO_ICON: &[u8] = include_bytes!("../assets/icons/redo.svg");
const DELETE_ICON: &[u8] = include_bytes!("../assets/icons/delete.svg");
let act = |id: usize, key: &'static str, bytes: &'static [u8]| {
div()
.id(("wb-act", id))
.size(px(30.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0))
.hover(|s| s.bg(grid))
.child(svg_icon(key, bytes, ink, 16.0))
};
let cur_swatch = self.active_stroke.map_or(ink, u32_to_hsla);
let mut color_btn = div()
.id("wb-color")
.size(px(30.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0));
if self.picker.is_some() {
color_btn = color_btn.bg(accent);
} else {
color_btn = color_btn.hover(|s| s.bg(grid));
}
let color_btn = color_btn
.child(
div()
.size(px(16.0))
.rounded(px(4.0))
.bg(cur_swatch)
.border_1()
.border_color(grid),
)
.tooltip(self.tip("Color"))
.on_click(cx.listener(|this, _ev, window, cx| {
this.focus.focus(window, cx);
this.toggle_picker(cx);
}));
let mut width_btn = div()
.id("wb-width")
.size(px(30.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0));
if self.width_open {
width_btn = width_btn.bg(accent);
} else {
width_btn = width_btn.hover(|s| s.bg(grid));
}
let width_btn = width_btn
.child(
div()
.w(px(16.0))
.h(px(self.active_width.clamp(1.0, 8.0)))
.rounded_full()
.bg(cur_swatch),
)
.tooltip(self.tip("Thickness"))
.on_click(cx.listener(|this, _ev, window, cx| {
this.focus.focus(window, cx);
this.toggle_width(cx);
}));
let font_btn = self.on_pick_font.is_some().then(|| {
let mut b = div()
.id("wb-font")
.size(px(30.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0));
if self.font_open {
b = b.bg(accent);
} else {
b = b.hover(|s| s.bg(grid));
}
b.child(div().text_size(px(15.0)).text_color(ink).child("Aa"))
.tooltip(self.tip("Font"))
.on_click(cx.listener(|this, _ev, window, cx| {
this.focus.focus(window, cx);
this.toggle_font(cx);
}))
});
const TEMPLATES_ICON: &[u8] = include_bytes!("../assets/icons/templates.svg");
let mut templates_btn = div()
.id("wb-templates")
.size(px(30.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0))
.child(svg_icon("wb-icon-templates", TEMPLATES_ICON, ink, 16.0));
if self.templates_open {
templates_btn = templates_btn.bg(accent);
} else {
templates_btn = templates_btn.hover(|s| s.bg(grid));
}
let templates_btn = templates_btn
.tooltip(self.tip("Templates"))
.on_click(cx.listener(|this, _ev, window, cx| {
this.focus.focus(window, cx);
this.toggle_templates(cx);
}));
let grip_cell = self.toolbar_grip_bounds.clone();
let pill_cell = self.toolbar_bounds.clone();
let vertical = self.toolbar_vertical;
let dot_row = move || {
div()
.flex()
.gap(px(3.0))
.child(div().size(px(2.5)).rounded_full().bg(text))
.child(div().size(px(2.5)).rounded_full().bg(text))
};
let grip = div()
.id("wb-grip")
.relative()
.flex()
.flex_col()
.justify_center()
.gap(px(3.0))
.px(px(4.0))
.h(px(30.0))
.cursor(CursorStyle::OpenHand)
.tooltip(self.tip("Drag to move · Tap R to flip · double-click to reset"))
.child(
canvas(move |b, _, _| grip_cell.set(b), |_, _, _, _| {})
.absolute()
.size_full(),
)
.child(dot_row())
.child(dot_row())
.child(dot_row());
let format_btn = self.editing.is_some().then(|| {
let mut b = div()
.id("wb-format-btn")
.size(px(30.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0))
.text_size(px(14.0))
.text_color(ink)
.tooltip(self.tip("Text formatting"))
.child("A");
if self.format_flyout {
b = b.bg(accent);
} else {
b = b.hover(|s| s.bg(grid));
}
b.on_click(cx.listener(|this, _ev, _w, cx| {
this.format_flyout = !this.format_flyout;
cx.notify();
}))
});
let mut pill = div()
.relative()
.flex()
.items_center()
.gap(px(2.0))
.p(px(3.0))
.rounded(px(9.0))
.bg(panel);
if vertical {
pill = pill.flex_col();
}
let mut pill = pill
.child(
canvas(move |b, _, _| pill_cell.set(b), |_, _, _, _| {})
.absolute()
.size_full(),
)
.child(grip)
.child(toolbar_divider(grid, vertical))
.child(
tool_btn(Tool::Pan)
.tooltip(self.tip(Tool::Pan.label()))
.on_click(cx.listener(|this, _ev, _w, cx| this.set_tool(Tool::Pan, cx))),
)
.child(
tool_btn(Tool::Select)
.tooltip(self.tip(Tool::Select.label()))
.on_click(cx.listener(|this, _ev, _w, cx| this.set_tool(Tool::Select, cx))),
);
if let Some(root_id) = selected_mindmap_root {
let direction = self.mindmap_root_direction(root_id);
let connector_style = self.mindmap_connector_style_for_root(root_id);
let chip = |id: &'static str, active: bool, icon: gpui::AnyElement| {
let mut d = div()
.id(id)
.px(px(8.0))
.h(px(24.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0))
.text_size(px(11.0))
.text_color(ink);
if active {
d = d.bg(accent);
} else {
d = d.hover(|s| s.bg(grid));
}
d.child(icon)
};
let icon_color = ink;
let draw_mm_icon = move |_id: &'static str, kind: &'static str| -> gpui::AnyElement {
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let w = f32::from(bounds.size.width);
let h = f32::from(bounds.size.height);
let ox = f32::from(bounds.origin.x);
let oy = f32::from(bounds.origin.y);
let p = |x: f32, y: f32| point(px(ox + x), px(oy + y));
let mut stroke = |segments: &[([f32; 2], [f32; 2])]| {
let mut pb = PathBuilder::stroke(px(1.75));
for &([x1, y1], [x2, y2]) in segments {
pb.move_to(p(x1, y1));
pb.line_to(p(x2, y2));
}
if let Ok(path) = pb.build() {
window.paint_path(path, icon_color);
}
};
match kind {
"dir-both" => stroke(&[
([3.0, h / 2.0], [w - 3.0, h / 2.0]),
([3.0, h / 2.0], [6.0, h / 2.0 - 3.0]),
([3.0, h / 2.0], [6.0, h / 2.0 + 3.0]),
([w - 3.0, h / 2.0], [w - 6.0, h / 2.0 - 3.0]),
([w - 3.0, h / 2.0], [w - 6.0, h / 2.0 + 3.0]),
]),
"dir-right" => stroke(&[
([3.0, h / 2.0], [w - 3.0, h / 2.0]),
([w - 3.0, h / 2.0], [w - 6.0, h / 2.0 - 3.0]),
([w - 3.0, h / 2.0], [w - 6.0, h / 2.0 + 3.0]),
]),
"dir-left" => stroke(&[
([3.0, h / 2.0], [w - 3.0, h / 2.0]),
([3.0, h / 2.0], [6.0, h / 2.0 - 3.0]),
([3.0, h / 2.0], [6.0, h / 2.0 + 3.0]),
]),
"line-straight" => stroke(&[([3.0, h / 2.0], [w - 3.0, h / 2.0])]),
"line-bezier" => {
let mut pb = PathBuilder::stroke(px(1.75));
pb.move_to(p(2.5, h - 4.0));
pb.cubic_bezier_to(
p(w - 2.5, 4.0),
p(w * 0.35, h - 4.0),
p(w * 0.65, 4.0),
);
if let Ok(path) = pb.build() {
window.paint_path(path, icon_color);
}
}
"line-orthogonal" => stroke(&[
([3.0, h - 4.0], [w * 0.45, h - 4.0]),
([w * 0.45, h - 4.0], [w * 0.45, 4.0]),
([w * 0.45, 4.0], [w - 3.0, 4.0]),
]),
_ => {}
}
},
)
.w(px(14.0))
.h(px(14.0))
.into_any_element()
};
pill = pill
.child(toolbar_divider(grid, vertical))
.child(
div()
.px(px(4.0))
.text_size(px(11.0))
.text_color(text)
.child("Direction"),
)
.child(
chip(
"wb-mm-dir-both",
direction == MindMapRootDirection::Both,
draw_mm_icon("wb-mm-dir-both-icon", "dir-both"),
)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.set_mindmap_root_direction(root_id, MindMapRootDirection::Both, cx);
})),
)
.child(
chip(
"wb-mm-dir-right",
direction == MindMapRootDirection::Right,
draw_mm_icon("wb-mm-dir-right-icon", "dir-right"),
)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.set_mindmap_root_direction(root_id, MindMapRootDirection::Right, cx);
})),
)
.child(
chip(
"wb-mm-dir-left",
direction == MindMapRootDirection::Left,
draw_mm_icon("wb-mm-dir-left-icon", "dir-left"),
)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.set_mindmap_root_direction(root_id, MindMapRootDirection::Left, cx);
})),
)
.child(toolbar_divider(grid, vertical))
.child(
div()
.px(px(4.0))
.text_size(px(11.0))
.text_color(text)
.child("Connector"),
)
.child(
chip(
"wb-mm-line-straight",
connector_style == MindMapConnectorStyle::Straight,
draw_mm_icon("wb-mm-line-straight-icon", "line-straight"),
)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.set_mindmap_connector_style(
root_id,
MindMapConnectorStyle::Straight,
cx,
);
})),
)
.child(
chip(
"wb-mm-line-bezier",
connector_style == MindMapConnectorStyle::Bezier,
draw_mm_icon("wb-mm-line-bezier-icon", "line-bezier"),
)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.set_mindmap_connector_style(
root_id,
MindMapConnectorStyle::Bezier,
cx,
);
})),
)
.child(
chip(
"wb-mm-line-orthogonal",
connector_style == MindMapConnectorStyle::Orthogonal,
draw_mm_icon("wb-mm-line-orthogonal-icon", "line-orthogonal"),
)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.set_mindmap_connector_style(
root_id,
MindMapConnectorStyle::Orthogonal,
cx,
);
})),
);
} else {
pill = pill
.child(color_btn)
.child(width_btn)
.children(font_btn)
.children(format_btn)
.child(toolbar_divider(grid, vertical))
.children(cats)
.child(templates_btn);
}
let pill = pill
.child(toolbar_divider(grid, vertical))
.child(
act(0, "wb-icon-undo", UNDO_ICON)
.tooltip(self.tip("Undo (⌘Z)"))
.on_click(cx.listener(|this, _ev, window, cx| this.undo(window, cx))),
)
.child(
act(1, "wb-icon-redo", REDO_ICON)
.tooltip(self.tip("Redo (⌘⇧Z)"))
.on_click(cx.listener(|this, _ev, window, cx| this.redo(window, cx))),
)
.child(
act(2, "wb-icon-delete", DELETE_ICON)
.tooltip(self.tip("Delete selection (⌫)"))
.on_click(
cx.listener(|this, _ev, window, cx| this.delete_selected(window, cx)),
),
);
let tb_pos = self.toolbar_pos.map(|(x, y)| self.clamp_toolbar(x, y));
let toolbar = match tb_pos {
Some((x, y)) => div().absolute().left(px(x)).top(px(y)).child(pill),
None => div()
.absolute()
.top(px(10.0))
.left_0()
.right_0()
.flex()
.justify_center()
.child(pill),
};
let pill_b = self.toolbar_bounds.get();
let board_o = self.bounds.get().origin;
let pill_top = f32::from(pill_b.origin.y) - f32::from(board_o.y);
let pill_right =
f32::from(pill_b.origin.x) - f32::from(board_o.x) + f32::from(pill_b.size.width);
let has_bounds = f32::from(pill_b.size.width) > 1.0;
let popover_anchor = move || -> Div {
if vertical && has_bounds {
div()
.absolute()
.left(px(pill_right + 6.0))
.top(px(pill_top))
} else {
match tb_pos {
Some((x, y)) => div().absolute().left(px(x)).top(px(y + 42.0)),
None => div()
.absolute()
.top(px(52.0))
.left_0()
.right_0()
.flex()
.justify_center(),
}
}
};
let flyout =
open_group.map(|g| {
let mut row = div()
.flex()
.items_center()
.gap(px(2.0))
.p(px(3.0))
.rounded(px(9.0))
.bg(panel_strong)
.shadow_lg()
.occlude();
for &t in g.tools() {
row = row.child(tool_btn(t).tooltip(self.tip(t.label())).on_click(
cx.listener(move |this, _ev, window, cx| {
this.focus.focus(window, cx);
this.set_tool(t, cx);
}),
));
}
popover_anchor().child(row)
});
let format_panel = (self.editing.is_some() && self.format_flyout).then(|| {
popover_anchor().child(
self.format_menu(ink, text, grid, panel_strong, cx)
.occlude(),
)
});
let width_cell = self.width_bounds.clone();
let width_panel_cell = self.width_panel_bounds.clone();
let width_frac =
((self.active_width - WIDTH_MIN) / (WIDTH_MAX - WIDTH_MIN)).clamp(0.0, 1.0);
let width_flyout = self.width_open.then(|| {
let mut presets = div().flex().items_center().gap(px(2.0));
for (i, w) in WIDTH_PRESETS.into_iter().enumerate() {
let active = (self.active_width - w).abs() < 0.01;
let mut opt = div()
.id(("wb-width-opt", i))
.size(px(30.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0));
if active {
opt = opt.bg(accent);
} else {
opt = opt.hover(|s| s.bg(grid));
}
presets = presets.child(
opt.child(
div()
.w(px(18.0))
.h(px(w.clamp(1.0, 9.0)))
.rounded_full()
.bg(cur_swatch),
)
.on_click(
cx.listener(move |this, _ev, window, cx| this.set_width(w, window, cx)),
),
);
}
let slider = div()
.relative()
.w(px(WIDTH_SLIDER_W))
.h(px(WIDTH_MAX + 6.0))
.flex()
.items_center()
.child(
canvas(move |b, _, _| width_cell.set(b), |_, _, _, _| {})
.absolute()
.size_full(),
)
.child(
div()
.w_full()
.h(px(self.active_width.clamp(1.0, WIDTH_MAX)))
.rounded_full()
.bg(cur_swatch),
)
.child(
div()
.absolute()
.top_0()
.bottom_0()
.left(px(width_frac * WIDTH_SLIDER_W - 1.5))
.w(px(3.0))
.rounded(px(2.0))
.bg(hsla(0.0, 0.0, 1.0, 1.0))
.border_1()
.border_color(hsla(0.0, 0.0, 0.0, 0.45)),
);
let panel = div()
.relative()
.flex()
.flex_col()
.items_center()
.gap(px(6.0))
.p(px(6.0))
.rounded(px(9.0))
.bg(panel_strong)
.shadow_lg()
.child(
canvas(move |b, _, _| width_panel_cell.set(b), |_, _, _, _| {})
.absolute()
.size_full(),
)
.child(presets)
.child(slider);
popover_anchor().child(panel)
});
let font_flyout = (self.font_open && self.on_pick_font.is_some()).then(|| {
let row = |id: &'static str, label: &'static str| {
div()
.id(id)
.px(px(12.0))
.py(px(6.0))
.mx(px(4.0))
.rounded(px(6.0))
.text_size(px(12.0))
.text_color(ink)
.hover(|s| s.bg(grid))
.child(label)
};
let panel = div()
.occlude()
.py(px(4.0))
.min_w(px(168.0))
.rounded(px(9.0))
.bg(panel_strong)
.shadow_lg()
.border_1()
.border_color(grid)
.flex()
.flex_col()
.child(row("wb-font-upload", "Upload font…").on_click(cx.listener(
|this, _ev, window, cx| {
this.font_open = false;
if let Some(f) = this.on_pick_font.clone() {
f(FontPick::Upload, window, cx);
}
cx.notify();
},
)))
.child(row("wb-font-default", "Use default").on_click(cx.listener(
|this, _ev, window, cx| {
this.font_open = false;
if let Some(f) = this.on_pick_font.clone() {
f(FontPick::Default, window, cx);
}
cx.notify();
},
)));
popover_anchor().child(panel)
});
let menu =
self.context_menu.map(|pos| {
let row = |id: &'static str, label: &'static str, shortcut: &'static str| {
div()
.id(id)
.flex()
.items_center()
.justify_between()
.gap(px(16.0))
.px(px(10.0))
.py(px(5.0))
.mx(px(4.0))
.rounded(px(6.0))
.text_size(px(12.0))
.text_color(ink)
.hover(|s| s.bg(grid))
.child(label)
.child(div().text_size(px(11.0)).text_color(text).child(shortcut))
};
let divider = || div().my(px(4.0)).mx(px(8.0)).h(px(1.0)).bg(grid);
let has_sel = !self.selected.is_empty();
let mut panel = div()
.absolute()
.left(pos.x)
.top(pos.y)
.occlude()
.min_w(px(176.0))
.py(px(4.0))
.rounded(px(8.0))
.bg(panel_strong)
.shadow_lg()
.border_1()
.border_color(grid)
.flex()
.flex_col();
if self.editing.is_some() {
panel = panel
.child(row("wb-ctx-text", "Text", "▸").on_click(cx.listener(
|this, _ev, _w, cx| {
this.ctx_text_sub = !this.ctx_text_sub;
cx.notify();
},
)))
.child(divider());
}
if has_sel {
panel =
panel
.child(row("wb-ctx-front", "Bring to Front", "⌘⇧]").on_click(
cx.listener(|this, _ev, window, cx| {
this.context_menu = None;
this.reorder_selection(ZOrder::ToFront, window, cx);
}),
))
.child(row("wb-ctx-forward", "Bring Forward", "⌘]").on_click(
cx.listener(|this, _ev, window, cx| {
this.context_menu = None;
this.reorder_selection(ZOrder::Forward, window, cx);
}),
))
.child(row("wb-ctx-backward", "Send Backward", "⌘[").on_click(
cx.listener(|this, _ev, window, cx| {
this.context_menu = None;
this.reorder_selection(ZOrder::Backward, window, cx);
}),
))
.child(
row("wb-ctx-back", "Send to Back", "⌘⇧[").on_click(cx.listener(
|this, _ev, window, cx| {
this.context_menu = None;
this.reorder_selection(ZOrder::ToBack, window, cx);
},
)),
)
.child(divider())
.child(row("wb-ctx-copy", "Copy", "⌘C").on_click(cx.listener(
|this, _ev, window, cx| {
this.context_menu = None;
this.copy_selection(window, cx);
},
)))
.child(row("wb-ctx-cut", "Cut", "⌘X").on_click(cx.listener(
|this, _ev, window, cx| {
this.context_menu = None;
if this.copy_selection(window, cx) {
this.delete_selected(window, cx);
}
},
)));
}
if self.on_paste.is_some() {
panel = panel.child(row("wb-ctx-paste", "Paste", "⌘V").on_click(
cx.listener(|this, _ev, window, cx| this.paste_from_menu(window, cx)),
));
}
if has_sel && self.on_save_template.is_some() {
panel = panel.child(divider()).child(
row("wb-ctx-save-template", "Save as template", "").on_click(cx.listener(
|this, _ev, window, cx| {
this.context_menu = None;
this.save_selection_as_template(window, cx);
},
)),
);
}
panel
});
let text_submenu = self
.context_menu
.filter(|_| self.ctx_text_sub && self.editing.is_some())
.map(|pos| {
self.format_menu(ink, text, grid, panel_strong, cx)
.absolute()
.left(pos.x + px(184.0))
.top(pos.y)
.occlude()
});
let templates_modal = self.templates_open.then(|| {
let body = if self.templates.is_empty() {
div()
.flex_1()
.flex()
.items_center()
.justify_center()
.p(px(28.0))
.child(
div()
.max_w(px(320.0))
.text_size(px(12.0))
.text_color(text)
.child(
"No templates yet. Select shapes on the canvas, right-click, \
and choose “Save as template”.",
),
)
.into_any_element()
} else {
let mut grid_el = div().flex().flex_wrap().gap(px(8.0)).justify_center();
for i in 0..self.templates.len() {
grid_el = grid_el.child(self.template_card(i, ink, text, grid, bg, cx));
}
div()
.id("wb-tmpl-scroll")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.p(px(12.0))
.child(grid_el)
.into_any_element()
};
let panel = div()
.w(px(540.0))
.max_h(px(460.0))
.flex()
.flex_col()
.rounded(px(12.0))
.bg(panel_strong)
.shadow_lg()
.border_1()
.border_color(grid)
.occlude()
.child(
div()
.flex()
.items_center()
.justify_between()
.px(px(14.0))
.py(px(10.0))
.border_b_1()
.border_color(grid)
.child(div().text_size(px(14.0)).text_color(ink).child("Templates"))
.child(
div()
.id("wb-tmpl-close")
.size(px(22.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0))
.text_size(px(15.0))
.text_color(text)
.hover(|s| s.bg(grid))
.child("✕")
.on_click(cx.listener(|this, _ev, _w, cx| {
this.templates_open = false;
cx.notify();
})),
),
)
.child(body)
.child(
div()
.px(px(14.0))
.py(px(8.0))
.border_t_1()
.border_color(grid)
.text_size(px(10.0))
.text_color(text)
.child("Click to add · right-click to delete"),
);
div()
.absolute()
.size_full()
.flex()
.items_center()
.justify_center()
.bg(hsla(0.0, 0.0, 0.0, 0.35))
.occlude()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _ev, _w, cx| {
this.templates_open = false;
cx.notify();
}),
)
.child(panel)
});
let sv_cell = self.sv_bounds.clone();
let hue_cell = self.hue_bounds.clone();
let alpha_cell = self.alpha_bounds.clone();
let panel_cell = self.picker_bounds.clone();
let swatch_list = swatches;
let white = hsla(0.0, 0.0, 1.0, 1.0);
let stroke_disp = self
.selected_single()
.and_then(|id| self.scene.elements.iter().find(|e| e.id == id))
.and_then(|e| e.stroke)
.or(self.active_stroke);
let fill_disp = self
.selected_single()
.and_then(|id| self.scene.elements.iter().find(|e| e.id == id))
.and_then(|e| e.fill)
.or(self.active_fill);
let text_disp = self
.selected_single()
.and_then(|id| self.scene.elements.iter().find(|e| e.id == id))
.and_then(|e| e.label_color)
.or(self.active_text);
let picker_panel = self.picker.map(|p| {
let cur = hsva_to_u32(p.h, p.s, p.v, p.a);
let hex = format!("#{:06X}", cur >> 8);
let clear = hsla(0.0, 0.0, 0.0, 0.0);
let tab = |active: bool, sw: Hsla, label: &'static str, id: &'static str| {
let mut d = div()
.id(id)
.flex()
.items_center()
.gap(px(6.0))
.px(px(8.0))
.py(px(4.0))
.rounded(px(6.0))
.text_size(px(12.0))
.text_color(ink);
if active {
d = d.bg(accent);
}
d.child(
div()
.size(px(12.0))
.rounded(px(3.0))
.bg(sw)
.border_1()
.border_color(grid),
)
.child(label)
};
let tabs = div()
.flex()
.gap(px(6.0))
.child(
tab(
p.target == PickerTarget::Stroke,
stroke_disp.map_or(ink, u32_to_hsla),
"Stroke",
"wb-tab-stroke",
)
.on_click(cx.listener(|this, _ev, _w, cx| {
this.set_picker_target(PickerTarget::Stroke, cx)
})),
)
.child(
tab(
p.target == PickerTarget::Fill,
fill_disp.map_or(clear, u32_to_hsla),
"Fill",
"wb-tab-fill",
)
.on_click(cx.listener(|this, _ev, _w, cx| {
this.set_picker_target(PickerTarget::Fill, cx)
})),
)
.child(
tab(
p.target == PickerTarget::Text,
text_disp.map_or(ink, u32_to_hsla),
"Text",
"wb-tab-text",
)
.on_click(cx.listener(|this, _ev, _w, cx| {
this.set_picker_target(PickerTarget::Text, cx)
})),
);
let sv_square = div()
.relative()
.w(px(SV_W))
.h(px(SV_H))
.rounded(px(5.0))
.overflow_hidden()
.bg(hsla(p.h, 1.0, 0.5, 1.0))
.child(div().absolute().size_full().bg(linear_gradient(
90.0,
linear_color_stop(white, 0.0),
linear_color_stop(hsla(0.0, 0.0, 1.0, 0.0), 1.0),
)))
.child(div().absolute().size_full().bg(linear_gradient(
180.0,
linear_color_stop(hsla(0.0, 0.0, 0.0, 0.0), 0.0),
linear_color_stop(hsla(0.0, 0.0, 0.0, 1.0), 1.0),
)))
.child(
canvas(move |b, _, _| sv_cell.set(b), |_, _, _, _| {})
.absolute()
.size_full(),
)
.child(
div()
.absolute()
.left(px(p.s * SV_W - 7.0))
.top(px((1.0 - p.v) * SV_H - 7.0))
.size(px(14.0))
.rounded_full()
.border_2()
.border_color(white),
);
let seg = |from: f32, to: f32| {
div().flex_1().h_full().bg(linear_gradient(
90.0,
linear_color_stop(hsla(from, 1.0, 0.5, 1.0), 0.0),
linear_color_stop(hsla(to, 1.0, 0.5, 1.0), 1.0),
))
};
let hue_strip = div()
.relative()
.w(px(SV_W))
.h(px(HUE_H))
.rounded(px(4.0))
.overflow_hidden()
.flex()
.child(seg(0.0, 1.0 / 6.0))
.child(seg(1.0 / 6.0, 2.0 / 6.0))
.child(seg(2.0 / 6.0, 3.0 / 6.0))
.child(seg(3.0 / 6.0, 4.0 / 6.0))
.child(seg(4.0 / 6.0, 5.0 / 6.0))
.child(seg(5.0 / 6.0, 1.0))
.child(
canvas(move |b, _, _| hue_cell.set(b), |_, _, _, _| {})
.absolute()
.size_full(),
)
.child(
div()
.absolute()
.left(px(p.h * SV_W - 1.5))
.top(px(-2.0))
.w(px(3.0))
.h(px(HUE_H + 4.0))
.rounded(px(2.0))
.bg(white)
.border_1()
.border_color(hsla(0.0, 0.0, 0.0, 0.5)),
);
let alpha_strip = div()
.relative()
.w(px(SV_W))
.h(px(HUE_H))
.rounded(px(4.0))
.overflow_hidden()
.bg(linear_gradient(
90.0,
linear_color_stop(clear, 0.0),
linear_color_stop(u32_to_hsla(hsv_to_u32(p.h, p.s, p.v)), 1.0),
))
.child(
canvas(move |b, _, _| alpha_cell.set(b), |_, _, _, _| {})
.absolute()
.size_full(),
)
.child(
div()
.absolute()
.left(px(p.a * SV_W - 1.5))
.top(px(-2.0))
.w(px(3.0))
.h(px(HUE_H + 4.0))
.rounded(px(2.0))
.bg(white)
.border_1()
.border_color(hsla(0.0, 0.0, 0.0, 0.5)),
);
let reset_label = if p.target == PickerTarget::Fill {
"None"
} else {
"Auto"
};
let info_row = div()
.flex()
.items_center()
.gap(px(8.0))
.child(
div()
.size(px(22.0))
.rounded(px(4.0))
.bg(u32_to_hsla(cur))
.border_1()
.border_color(grid),
)
.child(
div()
.flex_1()
.text_size(px(12.0))
.text_color(text)
.child(SharedString::from(hex)),
)
.child(
div()
.id("wb-color-auto")
.px(px(8.0))
.py(px(3.0))
.rounded(px(5.0))
.border_1()
.border_color(grid)
.text_size(px(12.0))
.text_color(ink)
.child(reset_label)
.on_click(
cx.listener(|this, _ev, window, cx| this.pick_color(None, window, cx)),
),
);
let mut swatch_views = Vec::with_capacity(swatch_list.len());
for (i, c) in swatch_list.iter().enumerate() {
let col = *c;
swatch_views.push(
div()
.id(("wb-swatch", i))
.size(px(20.0))
.rounded(px(4.0))
.bg(col)
.border_1()
.border_color(grid)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.pick_color(Some(hsla_to_u32(col)), window, cx)
})),
);
}
let theme_row_w = (swatch_views.len() as f32 * 26.0 - 6.0).max(0.0);
let saved_col_w = (theme_row_w - SV_W - 12.0).max(64.0);
let swatch_grid = div().flex().flex_wrap().gap(px(6.0)).children(swatch_views);
let controls_col = div()
.flex()
.flex_col()
.gap(px(10.0))
.child(tabs)
.child(sv_square)
.child(hue_strip)
.child(alpha_strip)
.child(info_row);
let mut saved_grid = div().flex().flex_wrap().gap(px(6.0));
if self.saved_colors.is_empty() {
saved_grid = saved_grid.child(
div()
.w_full()
.text_size(px(11.0))
.text_color(text)
.child("Tap + to save a color"),
);
} else {
for (i, &c) in self.saved_colors.iter().enumerate() {
saved_grid = saved_grid.child(
div()
.id(("wb-saved", i))
.size(px(20.0))
.rounded(px(4.0))
.bg(u32_to_hsla(c))
.border_1()
.border_color(grid)
.tooltip(self.tip("Click to use · right-click to remove"))
.on_click(cx.listener(move |this, _ev, window, cx| {
this.pick_color(Some(c), window, cx)
}))
.on_mouse_down(
MouseButton::Right,
cx.listener(move |this, _ev, window, cx| {
this.remove_saved_color(c, window, cx)
}),
),
);
}
}
let saved_col = div()
.flex()
.flex_col()
.flex_none()
.w(px(saved_col_w))
.gap(px(8.0))
.child(
div()
.flex()
.items_center()
.justify_between()
.child(div().text_size(px(11.0)).text_color(text).child("Saved"))
.child(
div()
.id("wb-save-color")
.size(px(20.0))
.flex()
.items_center()
.justify_center()
.rounded(px(4.0))
.border_1()
.border_color(grid)
.text_size(px(14.0))
.text_color(ink)
.hover(|s| s.bg(grid))
.child("+")
.tooltip(self.tip("Save current color"))
.on_click(cx.listener(|this, _ev, window, cx| {
this.save_current_color(window, cx)
})),
),
)
.child(saved_grid);
let top_row = div()
.flex()
.flex_row()
.items_start()
.gap(px(12.0))
.child(controls_col)
.child(saved_col);
popover_anchor().child(
div()
.relative()
.flex()
.flex_col()
.gap(px(10.0))
.p(px(10.0))
.rounded(px(10.0))
.bg(panel_strong)
.shadow_lg()
.border_1()
.border_color(grid)
.child(
canvas(move |b, _, _| panel_cell.set(b), |_, _, _, _| {})
.absolute()
.size_full(),
)
.child(top_row)
.child(swatch_grid),
)
});
let board_cursor = if self.panning {
CursorStyle::ClosedHand
} else if self.tool == Tool::Pan {
CursorStyle::OpenHand
} else {
CursorStyle::Arrow
};
let board_layer = canvas(
move |bounds, _, _| bounds_cell.set(bounds),
move |bounds, _, window, _| paint_board(bounds, cam, bg, grid, window),
)
.absolute()
.size_full();
let element_layers: Vec<gpui::AnyElement> = layers
.into_iter()
.map(|l| match l {
Layer::Band(es) => band_canvas(es, cam).into_any_element(),
Layer::Overlay(el) => el,
})
.collect();
let chrome_layer = canvas(
|_, _, _| {},
move |bounds, _, window, _| {
if let Some(k) = &pending {
paint_element(
k,
None,
cam,
bounds.origin,
pending_ink,
pending_fill,
window,
);
}
if let Some(k) = &single_sel {
paint_selection(k, cam, bounds.origin, selection, window);
}
if let Some((kind, active)) = &snap_target {
paint_snap_points(kind, *active, cam, bounds.origin, selection, window);
}
if let Some((bb, _can_rotate)) = group_sel {
let tl = to_screen(bb.0, bb.1, cam, bounds.origin);
let br = to_screen(bb.2, bb.3, cam, bounds.origin);
let m = 0.0;
let (x0, y0) = (f32::from(tl.x) - m, f32::from(tl.y) - m);
let (x1, y1) = (f32::from(br.x) + m, f32::from(br.y) + m);
let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
for (hx, hy) in [
(x0, y0),
(x1, y0),
(x0, y1),
(x1, y1),
(mx, y0),
(mx, y1),
(x0, my),
(x1, my),
] {
draw_handle(hx, hy, selection, window);
}
}
if let Some((a, b)) = marquee {
paint_marquee(a, b, cam, bounds.origin, selection, window);
}
paint_alignment_guides(alignment_guides, bounds, cam, selection, window);
},
)
.absolute()
.size_full();
let root = div()
.track_focus(&self.focus)
.size_full()
.relative()
.overflow_hidden()
.cursor(board_cursor)
.child(board_layer)
.children(connector_buttons)
.children(rotate_button)
.child(
div()
.absolute()
.size_full()
.child(WhiteboardInputElement::new(cx.entity())),
)
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_left_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_left_up))
.on_mouse_down(MouseButton::Right, cx.listener(Self::on_right_down))
.on_mouse_down(MouseButton::Middle, cx.listener(Self::on_middle_down))
.on_mouse_up(MouseButton::Middle, cx.listener(Self::on_middle_up))
.on_mouse_move(cx.listener(Self::on_move));
let root = if accepts_wheel_input(self.read_only) {
root.on_scroll_wheel(cx.listener(Self::on_scroll))
} else {
root
};
root.on_pinch(cx.listener(Self::on_pinch))
.on_key_down(cx.listener(Self::on_key))
.on_drop::<gpui::ExternalPaths>(cx.listener(
|this, paths: &gpui::ExternalPaths, window, cx| {
if let Some(f) = this.on_drop_files.clone() {
let w = this.event_to_world(window.mouse_position());
f(paths.paths().to_vec(), w[0], w[1], window, cx);
}
},
))
.children(element_layers)
.child(chrome_layer)
.child(toolbar)
.children(flyout)
.children(format_panel)
.children(width_flyout)
.children(font_flyout)
.children(menu)
.children(text_submenu)
.children(picker_panel)
.children(templates_modal)
.child(
div()
.absolute()
.left(px(10.0))
.bottom(px(8.0))
.text_size(px(11.0))
.text_color(text)
.child(SharedString::from(format!("{:.0}%", cam.zoom * 100.0))),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_layout_cache_reuses_outlines_until_content_changes() {
let font = Font::default();
let mut cache = HashMap::new();
let first = cached_text_layout(&mut cache, &font, 9, "hello", 16.0, None, &[]);
let reused = cached_text_layout(&mut cache, &font, 9, "hello", 16.0, None, &[]);
let changed = cached_text_layout(&mut cache, &font, 9, "hello!", 16.0, None, &[]);
assert!(Arc::ptr_eq(&first.segs, &reused.segs));
assert!(!Arc::ptr_eq(&first.segs, &changed.segs));
}
#[test]
fn shape_label_cache_survives_movement_and_invalidates_on_resize() {
let font = Font::default();
let mut cache = HashMap::new();
let kind = ElementKind::RoundRect(BoxGeom {
x: 10.0,
y: 20.0,
w: 180.0,
h: 60.0,
width: 2.0,
rotation: 0.0,
});
let first = cached_label_layout(
&mut cache,
&font,
3,
&kind,
10.0,
20.0,
180.0,
60.0,
"Node",
&[],
);
let moved = cached_label_layout(
&mut cache,
&font,
3,
&kind,
80.0,
90.0,
180.0,
60.0,
"Node",
&[],
);
let resized = cached_label_layout(
&mut cache,
&font,
3,
&kind,
80.0,
90.0,
240.0,
80.0,
"Node",
&[],
);
assert!(Arc::ptr_eq(&first.text.segs, &moved.text.segs));
assert!(!Arc::ptr_eq(&first.text.segs, &resized.text.segs));
}
#[test]
fn read_only_embed_does_not_accept_wheel_input() {
assert!(!accepts_wheel_input(true));
assert!(accepts_wheel_input(false));
}
#[test]
fn empty_or_garbage_loads_a_blank_board() {
for s in ["", " ", "not json", "{}", r#"{"camera":{"zoom":0}}"#] {
let scene = Scene::from_json(s);
assert_eq!(scene.camera.zoom, 1.0, "input {s:?}");
assert!(scene.elements.is_empty(), "input {s:?}");
}
}
#[test]
fn ime_offsets_bridge_utf16_and_utf8() {
let text = "A中😀B";
let utf8_boundaries = [0, 1, 4, 8, 9];
let utf16_boundaries = [0, 1, 2, 4, 5];
for (utf8, utf16) in utf8_boundaries.into_iter().zip(utf16_boundaries) {
assert_eq!(WhiteboardView::utf8_to_utf16_in(text, utf8), utf16);
assert_eq!(WhiteboardView::utf16_to_utf8_in(text, utf16), utf8);
}
assert_eq!(WhiteboardView::utf8_to_utf16_in(text, 3), 2);
assert_eq!(WhiteboardView::utf16_to_utf8_in(text, 3), 8);
}
#[test]
fn camera_round_trips_through_json() {
let scene = Scene {
camera: Camera {
x: 12.5,
y: -4.0,
zoom: 2.0,
},
..Default::default()
};
let restored = Scene::from_json(&scene.to_json());
assert_eq!(restored.camera.x, 12.5);
assert_eq!(restored.camera.zoom, 2.0);
}
#[test]
fn all_content_thumbnail_snapshot_uses_scene_bounds_without_mounting_view() {
let scene = Scene {
elements: vec![Element {
id: 1,
kind: ElementKind::Rect(BoxGeom {
x: 10.0,
y: 20.0,
w: 100.0,
h: 60.0,
width: 2.0,
rotation: 0.0,
}),
stroke: None,
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
}],
..Scene::default()
};
let snapshot = LocalThumbnailSnapshot::for_scene_all_content(scene, 320.0, 180.0);
assert_eq!(snapshot.spec.scene_bounds, Some([10.0, 20.0, 110.0, 80.0]));
assert_eq!(snapshot.spec.focus_bounds, [10.0, 20.0, 110.0, 80.0]);
assert!(snapshot.spec.camera.zoom > 0.0);
}
#[test]
fn empty_scene_still_builds_a_renderable_thumbnail_snapshot() {
let snapshot =
LocalThumbnailSnapshot::for_scene_all_content(Scene::default(), 320.0, 180.0);
assert_eq!(snapshot.spec.scene_bounds, None);
assert_eq!(snapshot.spec.focus_bounds, [0.0, 0.0, 320.0, 180.0]);
assert_eq!(snapshot.spec.camera.zoom, 1.0);
}
#[test]
fn every_element_kind_round_trips_through_json() {
let scene = Scene {
camera: Camera::default(),
elements: vec![
Element {
id: 1,
kind: ElementKind::Draw(Stroke {
points: vec![[0.0, 0.0], [10.0, 5.0]],
width: 3.0,
}),
stroke: None,
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
},
Element {
id: 2,
kind: ElementKind::Rect(BoxGeom {
x: 1.0,
y: 2.0,
w: 30.0,
h: 40.0,
width: 2.0,
rotation: 0.0,
}),
stroke: Some(0xff0000ff),
fill: Some(0x00ff0080),
label: Some("hi".into()),
label_color: Some(0x112233ff),
styles: Vec::new(),
mindmap: None,
},
Element {
id: 3,
kind: ElementKind::Arrow(SegGeom {
x1: 1.0,
y1: 1.0,
x2: 2.0,
y2: 8.0,
width: 2.5,
style: SegmentStyle::Solid,
start_anchor: None,
end_anchor: None,
}),
stroke: None,
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
},
],
};
let restored = Scene::from_json(&scene.to_json());
assert_eq!(restored.elements.len(), 3);
match &restored.elements[2].kind {
ElementKind::Arrow(s) => assert_eq!(s.y2, 8.0),
other => panic!("expected arrow, got {other:?}"),
}
assert_eq!(restored.elements[1].stroke, Some(0xff0000ff));
assert_eq!(restored.elements[1].fill, Some(0x00ff0080));
assert_eq!(restored.elements[1].label.as_deref(), Some("hi"));
assert_eq!(restored.elements[1].label_color, Some(0x112233ff));
assert_eq!(restored.elements[0].stroke, None);
assert_eq!(restored.elements[0].fill, None);
}
#[test]
fn label_defaults_to_none_for_older_boards() {
let old = r#"{"id":2,"kind":{"rect":{"x":0.0,"y":0.0,"w":1.0,"h":1.0,"width":1.0}}}"#;
let back: Element = serde_json::from_str(old).unwrap();
assert_eq!(back.label, None);
assert!(!serde_json::to_string(&back).unwrap().contains("label"));
}
#[test]
fn shape_label_block_fits_inscribed_region() {
let font = Font::default();
let bg = BoxGeom {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
width: 0.0,
rotation: 0.0,
};
let (bx, by, bw, bh) = (10.0, 20.0, 200.0, 120.0);
let rect = shape_label_block(&font, &ElementKind::Rect(bg), bx, by, bw, bh, "hello world");
assert!(rect.size <= TEXT_SIZE + 0.01, "size {}", rect.size);
assert!(
(rect.wrap - (bw - 2.0 * LABEL_PAD)).abs() < 0.5,
"rect wraps full width: {}",
rect.wrap
);
let dia = shape_label_block(
&font,
&ElementKind::Diamond(bg),
bx,
by,
bw,
bh,
"hello world",
);
assert!(
(dia.wrap - (bw * 0.5 - 2.0 * LABEL_PAD)).abs() < 0.5,
"diamond half width: {}",
dia.wrap
);
assert!(
dia.size <= rect.size,
"diamond shrinks ≥ rect: {} vs {}",
dia.size,
rect.size
);
let tri = shape_label_block(
&font,
&ElementKind::Triangle(bg),
bx,
by,
bw,
bh,
"hello world",
);
assert!(
tri.y >= by + bh / 2.0 - 0.5,
"triangle label low: y={}",
tri.y
);
let tiny = shape_label_block(
&font,
&ElementKind::Rect(bg),
0.0,
0.0,
44.0,
28.0,
"a long label that must shrink",
);
assert!(tiny.size < TEXT_SIZE, "shrinks: {}", tiny.size);
}
#[test]
fn style_span_toggle_and_layer() {
let bold = RunStyle {
bold: true,
..Default::default()
};
let s = toggle_format(&[], 0, 4, Format::Bold);
assert_eq!(s.len(), 1);
assert_eq!((s[0].start, s[0].end, s[0].style), (0, 4, bold));
assert!(style_at(&s, 2).bold && !style_at(&s, 4).bold);
assert!(toggle_format(&s, 0, 4, Format::Bold).is_empty());
let s2 = toggle_format(&s, 2, 6, Format::Bold);
assert_eq!((s2.len(), s2[0].start, s2[0].end), (1, 0, 6));
let s3 = toggle_format(&s, 1, 3, Format::Italic);
assert_eq!(s3.len(), 3, "{s3:?}");
assert!(style_at(&s3, 1).bold && style_at(&s3, 1).italic);
assert!(style_at(&s3, 0).bold && !style_at(&s3, 0).italic);
let h = toggle_highlight(&[], 0, 3, 0xffff00ff);
assert_eq!(style_at(&h, 1).highlight, Some(0xffff00ff));
assert!(toggle_highlight(&h, 0, 3, 0xffff00ff).is_empty());
}
#[test]
fn active_style_reports_common_formatting() {
let s = toggle_format(&[], 2, 5, Format::Bold); assert!(active_style(&s, 2, 5).bold, "whole selection bold");
assert!(
!active_style(&s, 0, 5).bold,
"selection spills onto plain text"
);
assert!(
active_style(&s, 5, 5).bold,
"just after the run inherits bold"
);
assert!(!active_style(&s, 2, 2).bold, "just before it is plain");
}
#[test]
fn splice_keeps_runs_aligned() {
let plain = RunStyle::default();
let s = toggle_format(&[], 2, 5, Format::Bold);
let a = splice_styles(&s, 0, 0, 2, plain);
assert_eq!((a[0].start, a[0].end), (4, 7));
let b = splice_styles(&s, 3, 4, 0, plain);
assert_eq!((b[0].start, b[0].end), (2, 4), "{b:?}");
let full = toggle_format(&[], 0, 6, Format::Bold);
let c = splice_styles(&full, 2, 4, 2, plain);
assert_eq!(c.len(), 2, "{c:?}");
assert_eq!(
((c[0].start, c[0].end), (c[1].start, c[1].end)),
((0, 2), (4, 6))
);
}
#[test]
fn styles_round_trip_and_back_compat() {
let el = Element {
id: 1,
kind: ElementKind::Text(TextGeom {
x: 0.0,
y: 0.0,
content: "hello world".into(),
size: 12.0,
rotation: 0.0,
measured_w: 0.0,
measured_h: 0.0,
}),
stroke: None,
fill: None,
label: None,
label_color: None,
styles: vec![StyleSpan {
start: 0,
end: 5,
style: RunStyle {
bold: true,
highlight: Some(0xffff00ff),
..Default::default()
},
}],
mindmap: None,
};
let back: Element = serde_json::from_str(&serde_json::to_string(&el).unwrap()).unwrap();
assert_eq!(back.styles.len(), 1);
assert!(back.styles[0].style.bold);
assert_eq!(back.styles[0].style.highlight, Some(0xffff00ff));
let old = r#"{"id":2,"kind":{"rect":{"x":0.0,"y":0.0,"w":1.0,"h":1.0,"width":1.0}}}"#;
assert!(
serde_json::from_str::<Element>(old)
.unwrap()
.styles
.is_empty()
);
}
#[test]
fn pan_and_zoom_math() {
let mut c = Camera::default();
c.pan_by(50.0, -20.0);
assert_eq!((c.x, c.y), (-50.0, 20.0));
let mut c = Camera {
x: 10.0,
y: 5.0,
zoom: 1.0,
};
let before = c.screen_to_world(300.0, 200.0);
c.zoom_about(300.0, 200.0, 2.5);
let after = c.screen_to_world(300.0, 200.0);
assert!((before.0 - after.0).abs() < 1e-3);
assert!((before.1 - after.1).abs() < 1e-3);
assert_eq!(c.zoom, 2.5);
}
#[test]
fn bbox_translate_and_hit_test() {
let mut k = ElementKind::Line(SegGeom {
x1: 0.0,
y1: 0.0,
x2: 10.0,
y2: 4.0,
width: 1.0,
style: SegmentStyle::Solid,
start_anchor: None,
end_anchor: None,
});
assert_eq!(bbox(&k), (0.0, 0.0, 10.0, 4.0));
translate(&mut k, 5.0, -2.0);
assert_eq!(bbox(&k), (5.0, -2.0, 15.0, 2.0));
assert!(hit_test(&k, 5.0, -2.0, 1.0));
assert!(hit_test(&k, 4.5, -2.5, 1.0)); assert!(!hit_test(&k, 100.0, 100.0, 1.0));
}
#[test]
fn diagonal_scale_projects_the_cursor_onto_the_diagonal() {
let s = diagonal_scale([0.0, 0.0], [10.0, 10.0], [20.0, 20.0]);
assert!((s - 2.0).abs() < 1e-4, "{s}");
let s = diagonal_scale([0.0, 0.0], [10.0, 10.0], [20.0, 0.0]);
assert!((s - 1.0).abs() < 1e-4, "{s}");
}
#[test]
fn snap_45_locks_angle_and_keeps_length() {
let (x, y) = snap_45(0.0, 0.0, 10.0, 9.0);
assert!((x - y).abs() < 1e-3, "{x} vs {y}");
let (x, y) = snap_45(0.0, 0.0, 10.0, 1.0);
assert!(y.abs() < 1e-3);
assert!((x - 101.0f32.sqrt()).abs() < 1e-2);
}
#[test]
fn snap_grid_rounds_to_nearest_line() {
assert_eq!(snap_grid(0.0), 0.0);
assert_eq!(snap_grid(11.0), 0.0);
assert_eq!(snap_grid(13.0), GRID);
assert_eq!(snap_grid(GRID), GRID);
assert_eq!(snap_grid(-13.0), -GRID);
assert_eq!(snap_grid(1.5 * GRID), 2.0 * GRID);
}
#[test]
fn move_target_drives_an_absolute_snapped_target() {
let origin = [100.0, 100.0];
let anchor = [0.0, 0.0];
assert_eq!(
move_target(origin, anchor, [37.0, -11.0], false),
[137.0, 89.0]
);
assert_eq!(
move_target(origin, anchor, [50.0, 50.0], true),
[144.0, 144.0]
);
let mut cursor = [0.0, 0.0];
for _ in 0..12 {
cursor = [cursor[0] + 4.0, cursor[1] + 4.0];
}
assert_eq!(move_target(origin, anchor, cursor, true), [144.0, 144.0]);
}
#[test]
fn resize_scales_geometry_about_the_anchor() {
let mut k = ElementKind::Rect(BoxGeom {
x: 10.0,
y: 10.0,
w: 20.0,
h: 20.0,
width: 1.0,
rotation: 0.0,
});
resize_about(&mut k, 10.0, 10.0, 2.0, 2.0);
match k {
ElementKind::Rect(b) => {
assert_eq!((b.x, b.y), (10.0, 10.0));
assert_eq!((b.w, b.h), (40.0, 40.0));
}
other => panic!("expected rect, got {other:?}"),
}
}
#[test]
fn axis_scale_measures_one_axis_about_the_anchor() {
assert!((axis_scale(0.0, 10.0, 20.0) - 2.0).abs() < 1e-4);
assert!((axis_scale(0.0, 10.0, 5.0) - 0.5).abs() < 1e-4);
assert!((axis_scale(7.0, 7.0, 99.0) - 1.0).abs() < 1e-6);
}
#[test]
fn per_axis_resize_stretches_one_axis_and_keeps_text_uniform() {
let mut k = ElementKind::Rect(BoxGeom {
x: 10.0,
y: 10.0,
w: 20.0,
h: 20.0,
width: 1.0,
rotation: 0.0,
});
resize_about(&mut k, 10.0, 10.0, 2.0, 1.0);
match k {
ElementKind::Rect(b) => {
assert_eq!((b.x, b.y), (10.0, 10.0));
assert_eq!((b.w, b.h), (40.0, 20.0));
}
other => panic!("expected rect, got {other:?}"),
}
let mut t = ElementKind::Text(TextGeom {
x: 0.0,
y: 0.0,
content: "hi".into(),
size: 10.0,
rotation: 0.0,
measured_w: 0.0,
measured_h: 0.0,
});
resize_about(&mut t, 0.0, 0.0, 4.0, 1.0);
match t {
ElementKind::Text(t) => assert!((t.size - 20.0).abs() < 1e-3, "{}", t.size),
other => panic!("expected text, got {other:?}"),
}
}
#[test]
fn color_round_trips_through_hsv_and_packed_ints() {
for c in [0xff0000ff, 0x00ff00ff, 0x0000ffff, 0x808080ff, 0xffffffff] {
let (h, s, v) = u32_to_hsv(c);
assert_eq!(hsv_to_u32(h, s, v), c, "{c:#010x}");
}
assert_eq!(hsv_to_u32(0.0, 1.0, 1.0), 0xff0000ff);
assert_eq!(hsv_to_u32(1.0, 1.0, 1.0), 0xff0000ff);
assert_eq!(hsv_to_u32(2.0 / 3.0, 1.0, 1.0), 0x0000ffff);
assert_eq!(pack_rgba(1.5, -0.2, 0.5, 1.0), 0xff0080ff);
}
#[test]
fn rotation_accumulates_on_boxes_and_bakes_into_segments() {
use std::f32::consts::FRAC_PI_2;
let mut k = ElementKind::Rect(BoxGeom {
x: -10.0,
y: -10.0,
w: 20.0,
h: 20.0,
width: 1.0,
rotation: 0.0,
});
rotate_element(&mut k, 0.0, 0.0, FRAC_PI_2);
match &k {
ElementKind::Rect(b) => assert!((b.rotation - FRAC_PI_2).abs() < 1e-5),
other => panic!("expected rect, got {other:?}"),
}
let bb = bbox(&k);
assert!(
(bb.0 + 10.0).abs() < 1e-3 && (bb.2 - 10.0).abs() < 1e-3,
"{bb:?}"
);
let mut seg = ElementKind::Line(SegGeom {
x1: 0.0,
y1: 0.0,
x2: 10.0,
y2: 0.0,
width: 1.0,
style: SegmentStyle::Solid,
start_anchor: None,
end_anchor: None,
});
rotate_element(&mut seg, 0.0, 0.0, FRAC_PI_2);
match seg {
ElementKind::Line(s) => {
assert!(s.x2.abs() < 1e-3 && (s.y2 - 10.0).abs() < 1e-3, "{s:?}");
}
other => panic!("expected line, got {other:?}"),
}
let mut txt = ElementKind::Text(TextGeom {
x: -20.0,
y: -8.0,
content: "hi".into(),
size: 16.0,
rotation: 0.0,
measured_w: 40.0,
measured_h: 16.0,
});
rotate_element(&mut txt, 0.0, 0.0, FRAC_PI_2);
match txt {
ElementKind::Text(t) => {
assert!((t.rotation - FRAC_PI_2).abs() < 1e-5);
assert!(
(t.x + 20.0).abs() < 1e-3 && (t.y + 8.0).abs() < 1e-3,
"{t:?}"
);
}
other => panic!("expected text, got {other:?}"),
}
let mut orb = ElementKind::Rect(BoxGeom {
x: 0.5,
y: -0.5,
w: 1.0,
h: 1.0,
width: 1.0,
rotation: 0.0,
});
rotate_element(&mut orb, 0.0, 0.0, FRAC_PI_2);
match orb {
ElementKind::Rect(b) => {
let (ccx, ccy) = (b.x + 0.5, b.y + 0.5);
assert!(ccx.abs() < 1e-3 && (ccy - 1.0).abs() < 1e-3, "{b:?}");
}
other => panic!("expected rect, got {other:?}"),
}
}
#[test]
fn rotation_snaps_to_horizontal_and_vertical() {
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4};
let step = std::f32::consts::PI / 12.0;
assert!((snap_angle(FRAC_PI_2 - 0.05, false) - FRAC_PI_2).abs() < 1e-6);
assert!(snap_angle(0.04, false).abs() < 1e-6);
assert!((snap_angle(-FRAC_PI_2 + 0.03, false) + FRAC_PI_2).abs() < 1e-6);
assert!((snap_angle(FRAC_PI_2 - 0.2, false) - (FRAC_PI_2 - 0.2)).abs() < 1e-6);
assert!((snap_angle(FRAC_PI_4, false) - FRAC_PI_4).abs() < 1e-6);
assert!((snap_angle(0.30, true) - step).abs() < 1e-4);
}
#[test]
fn caret_navigation_walks_chars_and_lines() {
let s = "aébc";
assert_eq!(caret_right(s, 0), 1); assert_eq!(caret_right(s, 1), 3); assert_eq!(caret_left(s, 3), 1); assert_eq!(caret_left(s, 0), 0); assert_eq!(caret_right(s, s.len()), s.len()); let m = "ab\ncde";
assert_eq!(line_start(m, 5), 3); assert_eq!(line_end(m, 0), 2); assert_eq!(line_start(m, 1), 0);
assert_eq!(line_end(m, 4), m.len());
assert_eq!(floor_boundary(s, 2), 1); assert_eq!(floor_boundary(s, 99), s.len());
}
#[test]
fn word_range_selects_the_word_under_the_caret() {
let s = "foo bar_baz qux";
assert_eq!(word_range(s, 1), (0, 3)); assert_eq!(word_range(s, 7), (4, 11)); assert_eq!(word_range(s, 3), (0, 3));
assert_eq!(word_range("a b", 2), (2, 2));
}
#[test]
fn text_bbox_anchors_at_origin_and_grows() {
let t = TextGeom {
x: 5.0,
y: 6.0,
content: "ab\ncde".into(),
size: 10.0,
rotation: 0.0,
measured_w: 0.0,
measured_h: 0.0,
};
let bb = bbox(&ElementKind::Text(t));
assert_eq!((bb.0, bb.1), (5.0, 6.0));
assert!(bb.2 > bb.0 && bb.3 > bb.1);
}
#[test]
fn tiny_drags_are_not_committed() {
assert!(!committable(&ElementKind::Draw(Stroke {
points: vec![[0.0, 0.0]],
width: 1.0,
})));
assert!(committable(&ElementKind::Rect(BoxGeom {
x: 0.0,
y: 0.0,
w: 20.0,
h: 5.0,
width: 1.0,
rotation: 0.0,
})));
}
#[test]
fn image_round_trips_and_behaves_like_a_box() {
let kind = ElementKind::Image(ImageGeom {
src: "images/x.png".into(),
x: 10.0,
y: 20.0,
w: 100.0,
h: 60.0,
rotation: 0.0,
});
assert_eq!(bbox(&kind), (10.0, 20.0, 110.0, 80.0));
assert!(!is_closed_shape(&kind));
let elem = Element {
id: 1,
kind,
stroke: None,
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
};
let json = serde_json::to_string(&elem).unwrap();
assert!(json.contains("\"image\""), "{json}");
assert!(json.contains("images/x.png"));
let mut back = serde_json::from_str::<Element>(&json).unwrap().kind;
assert_eq!(bbox(&back), (10.0, 20.0, 110.0, 80.0));
translate(&mut back, 5.0, -3.0);
assert_eq!(bbox(&back), (15.0, 17.0, 115.0, 77.0));
}
#[test]
fn new_box_shapes_share_box_behavior_and_round_trip() {
let b = BoxGeom {
x: 1.0,
y: 2.0,
w: 30.0,
h: 40.0,
width: 2.0,
rotation: 0.5,
};
let cases = [
("diamond", ElementKind::Diamond(b)),
("triangle", ElementKind::Triangle(b)),
("round_rect", ElementKind::RoundRect(b)),
("star", ElementKind::Star(b)),
("hexagon", ElementKind::Hexagon(b)),
];
for (tag, kind) in cases {
assert!(is_closed_shape(&kind), "{tag} should be fillable");
assert!(committable(&kind), "{tag} should commit");
assert_eq!(
box_like(&kind),
Some((1.0, 2.0, 30.0, 40.0, 0.5)),
"{tag} box_like"
);
let elem = Element {
id: 7,
kind,
stroke: None,
fill: None,
label: None,
label_color: None,
styles: Vec::new(),
mindmap: None,
};
let json = serde_json::to_string(&elem).unwrap();
assert!(json.contains(tag), "{tag} not in json: {json}");
let back: Element = serde_json::from_str(&json).unwrap();
assert_eq!(box_like(&back.kind), Some((1.0, 2.0, 30.0, 40.0, 0.5)));
}
}
}