diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..18ffca9 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = [ "--cfg=web_sys_unstable_apis" ] diff --git a/Cargo.lock b/Cargo.lock index b9c8b0e..a093c2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,25 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2041f1943049c7978768d84e6d0fd95de98b76d6c4727b09e78ec253d29fa58" +dependencies = [ + "clipboard-win", + "core-graphics", + "image 0.24.9", + "log", + "objc", + "objc-foundation", + "objc_id", + "parking_lot", + "thiserror", + "windows-sys 0.48.0", + "x11rb", +] + [[package]] name = "arrayref" version = "0.3.7" @@ -544,6 +563,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "clipboard-win" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d517d4b86184dbb111d3556a10f1c8a04da7428d2987bf1081602bf11c3aa9ee" +dependencies = [ + "error-code", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -560,6 +588,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecdffb913a326b6c642290a0d0ec8e8d6597291acdc07cc4c9cb4b3635d44cf9" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "com" version = "0.6.0" @@ -861,6 +895,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "error-code" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" + [[package]] name = "euclid" version = "0.22.9" @@ -1413,6 +1453,20 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", + "png", + "tiff", +] + [[package]] name = "image" version = "0.25.0" @@ -1493,6 +1547,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + [[package]] name = "js-sys" version = "0.3.67" @@ -2752,6 +2812,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -3097,6 +3168,7 @@ name = "violet-core" version = "0.0.1" dependencies = [ "anyhow", + "arboard", "arrayvec", "atomic_refcell", "bytemuck", @@ -3111,7 +3183,7 @@ dependencies = [ "futures-signals", "glam", "gloo-timers", - "image", + "image 0.25.0", "itertools 0.12.1", "more-asserts", "once_cell", @@ -3128,6 +3200,7 @@ dependencies = [ "tynm", "unicode-segmentation", "wasm-bindgen-futures", + "web-sys", "web-time 1.0.0", "winit", ] @@ -3169,7 +3242,7 @@ dependencies = [ "futures", "glam", "guillotiere", - "image", + "image 0.25.0", "itertools 0.12.1", "palette", "parking_lot", @@ -3406,6 +3479,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "wgpu" version = "0.19.1" diff --git a/Cargo.toml b/Cargo.toml index e428727..57e8422 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ tokio = { version = "1.0", default-features = false, features = ["macros", "rt"] arrayvec = "0.7" sync_wrapper = "1.0" smallvec = "1.0" +arboard = "3.0" bytemuck = { version = "1.13", features = ["derive"] } winit = "0.29" @@ -75,7 +76,7 @@ gloo-timers = "0.3" web-time = "1.0" wasm-bindgen-futures = "0.4" wasm-bindgen = "0.2" -web-sys = "0.3" +web-sys = { version = "0.3", features = ["Clipboard"] } tracing-tree = "0.3" [dependencies] diff --git a/violet-core/Cargo.toml b/violet-core/Cargo.toml index da41b50..4dfef1d 100644 --- a/violet-core/Cargo.toml +++ b/violet-core/Cargo.toml @@ -54,3 +54,7 @@ tokio.workspace = true gloo-timers.workspace = true wasm-bindgen-futures.workspace = true cosmic-text = { workspace = true, features = ["wasm-web"] } +web-sys.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +arboard.workspace = true diff --git a/violet-core/src/components.rs b/violet-core/src/components.rs index 8c34937..7f83022 100644 --- a/violet-core/src/components.rs +++ b/violet-core/src/components.rs @@ -8,6 +8,7 @@ use palette::Srgba; use crate::{ assets::Asset, layout::{Layout, SizeResolver}, + stored::UntypedHandle, text::{LayoutGlyphs, TextSegment, Wrap}, unit::Unit, Edges, Frame, Rect, @@ -97,6 +98,8 @@ component! { pub(crate) atoms, pub on_animation_frame: OnAnimationFrame, + + pub handles: Vec, } pub type OnAnimationFrame = Box; diff --git a/violet-core/src/editor.rs b/violet-core/src/editor.rs index 941649e..06b1894 100644 --- a/violet-core/src/editor.rs +++ b/violet-core/src/editor.rs @@ -1,7 +1,8 @@ +use glam::vec2; use itertools::Itertools; use unicode_segmentation::UnicodeSegmentation; -use crate::text::CursorLocation; +use crate::text::{CursorLocation, LayoutGlyphs}; #[derive(Default, Debug)] pub struct EditorLine { @@ -76,6 +77,7 @@ pub struct TextEditor { /// The current cursor position /// cursor: CursorLocation, + selection: Option, } /// Movement action for the cursor @@ -99,6 +101,8 @@ pub enum EditAction { pub enum EditorAction { CursorMove(CursorMove), + SelectionMove(CursorMove), + SelectionClear, Edit(EditAction), SetText(Vec), } @@ -108,17 +112,28 @@ impl TextEditor { Self { cursor: CursorLocation { row: 0, col: 0 }, text: vec![EditorLine::default()], + selection: None, } } - pub fn move_cursor(&mut self, direction: CursorMove) { - match direction { - CursorMove::Up => { - self.cursor.row = self.cursor.row.saturating_sub(1); - } - CursorMove::Down => { - self.cursor.row = (self.cursor.row + 1).min(self.text.len() - 1); - } + pub fn move_cursor(&mut self, m: CursorMove) { + self.cursor = self.get_new_cursor(m, self.cursor) + } + + pub fn move_selection(&mut self, m: CursorMove) { + self.selection = Some(self.get_new_cursor(m, self.selection.unwrap_or(self.cursor))) + } + + fn get_new_cursor(&self, m: CursorMove, cursor: CursorLocation) -> CursorLocation { + match m { + CursorMove::Up => CursorLocation { + row: cursor.row.saturating_sub(1), + col: cursor.col, + }, + CursorMove::Down => CursorLocation { + row: (cursor.row + 1).min(self.text.len() - 1), + col: cursor.col, + }, CursorMove::Left => { if let Some((i, _)) = self .line() @@ -126,20 +141,34 @@ impl TextEditor { .take_while(|(i, _)| *i < self.cursor.col) .last() { - self.cursor.col = i; + CursorLocation { + row: cursor.row, + col: i, + } } else if self.cursor.row > 0 { - self.cursor.row -= 1; - self.cursor.col = self.line().len(); + CursorLocation { + row: cursor.row - 1, + col: self.line().len(), + } + } else { + cursor } } CursorMove::Right => { let next_glyph = self.line().graphemes().find(|(i, _)| *i == self.cursor.col); if let Some((i, g)) = next_glyph { - self.cursor.col = i + g.len(); + CursorLocation { + row: cursor.row, + col: i + g.len(), + } } else if self.cursor.row < self.text.len() - 1 { - self.cursor.row += 1; - self.cursor.col = 0; + CursorLocation { + row: cursor.row + 1, + col: 0, + } + } else { + cursor } } CursorMove::ForwardWord => { @@ -149,7 +178,12 @@ impl TextEditor { .find_or_last(|(i, _)| *i >= self.cursor.col); tracing::info!(?word, "current word"); if let Some((i, word)) = word { - self.cursor.col = i + word.len(); + CursorLocation { + row: cursor.row, + col: i + word.len(), + } + } else { + cursor } } CursorMove::BackwardWord => { @@ -161,20 +195,29 @@ impl TextEditor { .find(|(i, _)| *i < self.cursor.col); tracing::info!(?word, "current word"); if let Some((i, _)) = word { - self.cursor.col = i; + CursorLocation { + row: cursor.row, + col: i, + } + } else { + cursor } } else if self.cursor.row > 0 { - self.cursor.row -= 1; - self.cursor.col = self.line().len(); + CursorLocation { + row: cursor.row - 1, + col: self.line().len(), + } + } else { + cursor } } CursorMove::SetPosition(pos) => { if (pos.row > self.text.len() - 1) || (pos.col > self.text[pos.row].len()) { tracing::error!(?pos, "invalid cursor position"); - return; + cursor + } else { + pos } - - self.cursor = pos; } } } @@ -286,6 +329,8 @@ impl TextEditor { EditorAction::CursorMove(m) => self.move_cursor(m), EditorAction::Edit(e) => self.edit(e), EditorAction::SetText(v) => self.set_text(v.iter().map(|v| v.as_ref())), + EditorAction::SelectionMove(m) => self.move_selection(m), + EditorAction::SelectionClear => self.clear_selection(), } } @@ -341,6 +386,56 @@ impl TextEditor { fn insert_column(&self) -> usize { self.cursor.col.min(self.line().len()) } + + pub fn selection_bounds(&self) -> Option<(CursorLocation, CursorLocation)> { + let sel = self.selection?; + if sel < self.cursor { + Some((sel, self.cursor)) + } else { + Some((self.cursor, sel)) + } + } + + pub fn selected_text(&self) -> Option> { + if let Some(sel) = &self.selection { + let (start, end) = if sel < &self.cursor { + (sel, &self.cursor) + } else { + (&self.cursor, sel) + }; + + let mut text = Vec::new(); + for (i, line) in self.text[start.row..=end.row].iter().enumerate() { + let row = start.row + i; + + if row == start.row && row == end.row { + text.push(&line.text[start.col..end.col]); + } else if row == start.row { + text.push(&line.text[start.col..]); + } else if row == end.row { + text.push(&line.text[..end.col]); + } else { + text.push(&line.text); + } + } + + Some(text) + } else { + None + } + } + + pub fn set_selection(&mut self, sel: Option) { + self.selection = sel; + } + + pub fn clear_selection(&mut self) { + self.selection = None; + } + + pub fn selection(&self) -> Option { + self.selection + } } impl Default for TextEditor { diff --git a/violet-core/src/input.rs b/violet-core/src/input.rs index 611c28b..07e8914 100644 --- a/violet-core/src/input.rs +++ b/violet-core/src/input.rs @@ -13,6 +13,7 @@ use winit::{ use crate::{ components::{rect, screen_position, screen_rect}, + scope::ScopeRef, Frame, Rect, }; @@ -102,9 +103,9 @@ impl InputState { local_pos: self.pos - origin, }; if let Ok(mut on_input) = entity.get_mut(on_mouse_input()) { + let s = ScopeRef::new(frame, entity); on_input( - frame, - &entity, + &s, MouseInput { modifiers: self.modifiers, state, @@ -122,9 +123,9 @@ impl InputState { if let Some(entity) = &self.focused(&frame.world) { let screen_rect = entity.get_copy(screen_rect()).unwrap_or_default(); if let Ok(mut on_input) = entity.get_mut(on_cursor_move()) { + let s = ScopeRef::new(frame, *entity); on_input( - frame, - entity, + &s, CursorMove { modifiers: self.modifiers, absolute_pos: pos, @@ -142,9 +143,9 @@ impl InputState { pub fn on_keyboard_input(&mut self, frame: &mut Frame, event: KeyEvent) { if let Some(entity) = &self.focused(frame.world()) { if let Ok(mut on_input) = entity.get_mut(on_keyboard_input()) { + let s = ScopeRef::new(frame, *entity); on_input( - frame, - entity, + &s, KeyboardInput { modifiers: self.modifiers, event, @@ -167,15 +168,17 @@ impl InputState { if let Some(cur) = cur { if let Ok(mut on_focus) = cur.get_mut(on_focus()) { - on_focus(frame, &cur, false); + let s = ScopeRef::new(frame, cur); + on_focus(&s, false); } } if let Some(new) = focused { let entity = frame.world().entity(new).unwrap(); + let s = ScopeRef::new(frame, entity); if let Ok(mut on_focus) = entity.get_mut(on_focus()) { - on_focus(frame, &entity, true); + on_focus(&s, true); } let sticky = entity.has(focus_sticky()); @@ -209,7 +212,7 @@ pub struct KeyboardInput { pub event: KeyEvent, } -pub type InputEventHandler = Box; +pub type InputEventHandler = Box, T)>; component! { pub focus_sticky: (), diff --git a/violet-core/src/io.rs b/violet-core/src/io.rs new file mode 100644 index 0000000..d262898 --- /dev/null +++ b/violet-core/src/io.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use parking_lot::Mutex; + +use crate::{declare_atom, stored::Handle}; + +pub struct Clipboard { + inner: ClipboardInner, +} + +impl Clipboard { + pub fn new() -> Self { + Self { + inner: ClipboardInner::new(), + } + } + + pub async fn get_text(&self) -> Option { + self.inner.get_text().await + } + + pub async fn set_text(&self, text: String) { + self.inner.set_text(text).await + } +} + +impl Default for Clipboard { + fn default() -> Self { + Self::new() + } +} + +#[cfg(not(target_arch = "wasm32"))] +struct ClipboardInner { + clipboard: Mutex, +} + +#[cfg(not(target_arch = "wasm32"))] +impl ClipboardInner { + pub fn new() -> Self { + Self { + clipboard: Mutex::new(arboard::Clipboard::new().unwrap()), + } + } + + pub async fn get_text(&self) -> Option { + self.clipboard.lock().get_text().ok() + } + + pub async fn set_text(&self, text: String) { + self.clipboard.lock().set_text(text).ok(); + } +} + +#[cfg(target_arch = "wasm32")] +struct ClipboardInner { + clipboard: Option, +} + +#[cfg(target_arch = "wasm32")] +impl ClipboardInner { + pub fn new() -> Self { + Self { + clipboard: web_sys::window().unwrap().navigator().clipboard(), + } + } + + pub async fn get_text(&self) -> Option { + Some( + wasm_bindgen_futures::JsFuture::from(self.clipboard.as_ref()?.read_text()) + .await + .ok()? + .as_string() + .expect("Result should be a string"), + ) + } + + pub async fn set_text(&self, text: String) { + if let Some(clipboard) = &self.clipboard { + wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)) + .await + .ok(); + } + } +} + +declare_atom! { + pub clipboard: Handle>, +} diff --git a/violet-core/src/layout/float.rs b/violet-core/src/layout/float.rs new file mode 100644 index 0000000..7e3a029 --- /dev/null +++ b/violet-core/src/layout/float.rs @@ -0,0 +1,91 @@ +use flax::{Entity, EntityRef, World}; +use glam::{BVec2, Vec2}; + +use crate::{ + components, + layout::{query_size, Direction, SizingHints}, + Edges, Rect, +}; + +use super::{apply_layout, Block, LayoutLimits, QueryArgs, Sizing}; + +/// A floating layout positions its children similar to the stack layout, but it does grow to accommodate the children. +/// +/// This means that the children are *detached* from the normal flow of the layout, and they can overlap with other neighboring widgets. +/// +/// This is the preferred layout for things like tooltips, popups, and other floating UI elements. +#[derive(Default, Debug, Clone)] +pub struct FloatLayout {} + +impl FloatLayout { + pub(crate) fn apply( + &self, + world: &World, + entity: &EntityRef, + children: &[Entity], + content_area: Rect, + limits: LayoutLimits, + preferred_size: Vec2, + ) -> Block { + puffin::profile_function!(); + let _span = tracing::debug_span!("FloatLayout::apply").entered(); + + let blocks = children.iter().for_each(|&child| { + let entity = world.entity(child).expect("invalid child"); + + // let pos = resolve_pos(&entity, content_area, preferred_size); + + let limits = LayoutLimits { + min_size: Vec2::ZERO, + max_size: Vec2::INFINITY, + }; + + let block = apply_layout(world, &entity, content_area.size(), limits); + + entity.update_dedup(components::rect(), block.rect); + entity.update_dedup(components::local_position(), Vec2::ZERO); + }); + + Block::new(Rect::ZERO, Edges::ZERO, BVec2::FALSE) + } + + pub(crate) fn query_size( + &self, + world: &World, + children: &[Entity], + args: QueryArgs, + preferred_size: Vec2, + ) -> Sizing { + puffin::profile_function!(); + let min_rect = Rect::from_size(args.limits.min_size); + + let mut hints = SizingHints::default(); + + for &child in children.iter() { + let entity = world.entity(child).expect("invalid child"); + + let sizing = query_size( + world, + &entity, + QueryArgs { + limits: LayoutLimits { + min_size: Vec2::ZERO, + max_size: Vec2::INFINITY, + }, + content_area: args.content_area, + direction: Direction::Horizontal, + }, + ); + + hints = hints.combine(sizing.hints); + } + + Sizing { + min: Rect::ZERO, + preferred: Rect::ZERO, + margin: Edges::ZERO, + hints, + maximize: Vec2::ZERO, + } + } +} diff --git a/violet-core/src/layout/mod.rs b/violet-core/src/layout/mod.rs index 78f2332..4012f50 100644 --- a/violet-core/src/layout/mod.rs +++ b/violet-core/src/layout/mod.rs @@ -1,4 +1,5 @@ pub mod cache; +mod float; mod flow; mod stack; @@ -16,6 +17,7 @@ use crate::{ Edges, Rect, }; +pub use float::FloatLayout; pub use flow::{Alignment, FlowLayout}; pub use stack::StackLayout; @@ -75,6 +77,7 @@ impl Direction { pub enum Layout { Stack(StackLayout), Flow(FlowLayout), + Float(FloatLayout), } impl Layout { @@ -106,6 +109,14 @@ impl Layout { limits, preferred_size, ), + Layout::Float(v) => v.apply( + world, + entity, + children, + content_area, + limits, + preferred_size, + ), } } @@ -120,6 +131,7 @@ impl Layout { match self { Layout::Stack(v) => v.query_size(world, children, args, preferred_size), Layout::Flow(v) => v.query_size(world, cache, children, args, preferred_size), + Layout::Float(v) => v.query_size(world, children, args, preferred_size), } } } @@ -172,7 +184,7 @@ impl Display for LayoutLimits { pub struct Block { pub(crate) rect: Rect, pub(crate) margin: Edges, - /// See: [SizingHints::can_grow] + /// See: [`SizingHints::can_grow`] pub can_grow: BVec2, } diff --git a/violet-core/src/lib.rs b/violet-core/src/lib.rs index 753ad89..e927df7 100644 --- a/violet-core/src/lib.rs +++ b/violet-core/src/lib.rs @@ -22,6 +22,7 @@ mod types; pub mod unit; pub mod utils; pub mod widget; +pub mod io; pub use effect::{FutureEffect, StreamEffect}; pub use frame::Frame; diff --git a/violet-core/src/scope.rs b/violet-core/src/scope.rs index 0c41c15..56283b6 100644 --- a/violet-core/src/scope.rs +++ b/violet-core/src/scope.rs @@ -1,4 +1,5 @@ use std::{ + ops::Deref, pin::Pin, task::{Context, Poll}, }; @@ -12,11 +13,16 @@ use futures::{Future, Stream}; use pin_project::pin_project; use crate::{ - assets::AssetCache, components::children, effect::Effect, input::InputEventHandler, - stored::Handle, style::get_stylesheet_from_entity, Frame, FutureEffect, StreamEffect, Widget, + assets::AssetCache, + components::{children, handles}, + effect::Effect, + input::InputEventHandler, + stored::{Handle, UntypedHandle, WeakHandle}, + style::get_stylesheet_from_entity, + Frame, FutureEffect, StreamEffect, Widget, }; -/// The scope within a [`Widget`][crate::Widget] is mounted or modified +/// The scope to modify and mount a widget pub struct Scope<'a> { frame: &'a mut Frame, id: Entity, @@ -152,14 +158,14 @@ impl<'a> Scope<'a> { } /// Spawns an effect scoped to the lifetime of this entity and scope - pub fn spawn_effect(&mut self, effect: impl 'static + for<'x> Effect>) { + pub fn spawn_effect(&self, effect: impl 'static + for<'x> Effect>) { self.frame.spawn(ScopedEffect { id: self.id, effect, }); } - pub fn spawn(&mut self, fut: impl 'static + Future) { + pub fn spawn(&self, fut: impl 'static + Future) { self.spawn_effect(FutureEffect::new(fut, |_: &mut Scope<'_>, _| {})) } @@ -173,7 +179,7 @@ impl<'a> Scope<'a> { } /// Spawns an effect which is *not* scoped to the widget - pub fn spawn_unscoped(&mut self, effect: impl 'static + for<'x> Effect) { + pub fn spawn_unscoped(&self, effect: impl 'static + for<'x> Effect) { self.frame.spawn(effect); } @@ -195,20 +201,30 @@ impl<'a> Scope<'a> { /// Stores an arbitrary value and returns a handle to it. /// - /// The value is stored for the duration of the returned handle, and *not* the widgets scope. + /// The value is stored for the duration of the widgets lifetime. /// /// A handle can be used to safely store state across multiple widgets and will not panic if /// the original widget is despawned. - pub fn store(&mut self, value: T) -> Handle { - self.frame.store_mut().insert(value) + pub fn store(&mut self, value: T) -> WeakHandle { + let handle = self.frame.store_mut().insert(value); + let weak_handle = handle.downgrade(); + self.entity_mut() + .entry(handles()) + .or_default() + .push(UntypedHandle::new(handle)); + weak_handle } - pub fn read(&self, handle: &Handle) -> &T { - self.frame.store().get(handle) + pub fn read(&self, handle: &WeakHandle) -> &T { + let store = self.frame.store().store::().expect("Handle is invalid"); + let handle = handle.upgrade(store).expect("Handle is invalid"); + self.frame.store().get(&handle) } - pub fn write(&mut self, handle: &Handle) -> &mut T { - self.frame.store_mut().get_mut(handle) + pub fn write(&mut self, handle: WeakHandle) -> &mut T { + let store = self.frame.store().store::().expect("Handle is invalid"); + let handle = handle.upgrade(store).expect("Handle is invalid"); + self.frame.store_mut().get_mut(&handle) } pub fn monitor( @@ -223,7 +239,7 @@ impl<'a> Scope<'a> { pub fn on_event( &mut self, event: Component>, - func: impl 'static + Send + Sync + FnMut(&Frame, &EntityRef, T), + func: impl 'static + Send + Sync + FnMut(&ScopeRef<'_>, T), ) -> &mut Self { self.set(event, Box::new(func) as _) } @@ -240,6 +256,86 @@ impl Drop for Scope<'_> { } } +/// A non-mutable view into a widgets scope. +/// +/// This is used for accessing state and modifying components (but not adding) of a widget during +/// callbacks. +pub struct ScopeRef<'a> { + frame: &'a Frame, + entity: EntityRef<'a>, +} + +impl<'a> std::fmt::Debug for ScopeRef<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScopeRef") + .field("id", &self.entity.id()) + .finish_non_exhaustive() + } +} + +impl<'a> Deref for ScopeRef<'a> { + type Target = EntityRef<'a>; + + fn deref(&self) -> &Self::Target { + &self.entity + } +} + +impl<'a> ScopeRef<'a> { + pub fn new(frame: &'a Frame, entity: EntityRef<'a>) -> Self { + Self { frame, entity } + } + + pub fn entity(&self) -> &EntityRef { + &self.entity + } + + /// Returns the active stylesheet for this scope + pub fn stylesheet(&self) -> EntityRef { + get_stylesheet_from_entity(&self.entity()) + } + + /// Spawns an effect scoped to the lifetime of this entity and scope + pub fn spawn_effect(&self, effect: impl 'static + for<'x> Effect>) { + self.frame.spawn(ScopedEffect { + id: self.entity.id(), + effect, + }); + } + + pub fn spawn(&self, fut: impl 'static + Future) { + self.spawn_effect(FutureEffect::new(fut, |_: &mut Scope<'_>, _| {})) + } + + /// Spawns a scoped stream invoking the callback in with the widgets scope for each item + pub fn spawn_stream( + &mut self, + stream: S, + func: impl 'static + FnMut(&mut Scope<'_>, S::Item), + ) { + self.spawn_effect(StreamEffect::new(stream, func)) + } + + /// Spawns an effect which is *not* scoped to the widget + pub fn spawn_unscoped(&self, effect: impl 'static + for<'x> Effect) { + self.frame.spawn(effect); + } + + pub fn id(&self) -> Entity { + self.entity.id() + } + + pub fn frame(&self) -> &Frame { + self.frame + } + + pub fn read(&self, handle: WeakHandle) -> &T { + let store = self.frame.store().store::().expect("Handle is invalid"); + let handle = handle.upgrade(store).expect("Handle is invalid"); + self.frame.store().get(&handle) + } +} + #[pin_project] pub(crate) struct ScopedEffect { pub(crate) id: Entity, diff --git a/violet-core/src/stored.rs b/violet-core/src/stored.rs index b6ef8a7..f35dbee 100644 --- a/violet-core/src/stored.rs +++ b/violet-core/src/stored.rs @@ -178,6 +178,15 @@ pub struct UntypedHandle { } impl UntypedHandle { + pub fn new(handle: Handle) -> Self { + Self { + index: handle.index, + free_tx: handle.free_tx.clone(), + ty: TypeId::of::(), + refs: handle.refs.clone(), + } + } + pub fn downgrade(&self) -> WeakUntypedHandle { WeakUntypedHandle { index: self.index, diff --git a/violet-core/src/style/mod.rs b/violet-core/src/style/mod.rs index 3b09902..8ffd73f 100644 --- a/violet-core/src/style/mod.rs +++ b/violet-core/src/style/mod.rs @@ -15,8 +15,8 @@ use crate::{ }; use self::colors::{ - EERIE_BLACK_300, EERIE_BLACK_700, EERIE_BLACK_DEFAULT, JADE_400, JADE_600, JADE_DEFAULT, - LION_DEFAULT, PLATINUM_DEFAULT, REDWOOD_DEFAULT, + EERIE_BLACK_300, EERIE_BLACK_600, EERIE_BLACK_700, EERIE_BLACK_DEFAULT, JADE_400, JADE_600, + JADE_DEFAULT, LION_DEFAULT, PLATINUM_DEFAULT, REDWOOD_DEFAULT, }; #[macro_export] @@ -283,7 +283,7 @@ pub fn setup_stylesheet() -> EntityBuilder { .set(interactive_active(), JADE_DEFAULT) .set(interactive_hover(), JADE_600) .set(interactive_pressed(), JADE_400) - .set(interactive_inactive(), EERIE_BLACK_700) + .set(interactive_inactive(), EERIE_BLACK_600) // spacing .set(spacing_small(), 4.0.into()) .set(spacing_medium(), 8.0.into()) diff --git a/violet-core/src/text.rs b/violet-core/src/text.rs index e760b84..3dc1993 100644 --- a/violet-core/src/text.rs +++ b/violet-core/src/text.rs @@ -206,6 +206,7 @@ impl LayoutGlyphs { None } + /// Returns all layout lines for the specified row pub fn find_lines(&self, row: usize) -> impl Iterator { self.lines .iter() @@ -257,7 +258,7 @@ impl Index for LayoutGlyphs { } } -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct CursorLocation { /// The row index of the non-wrapped original text pub row: usize, diff --git a/violet-core/src/types.rs b/violet-core/src/types.rs index e696ec4..67a4fba 100644 --- a/violet-core/src/types.rs +++ b/violet-core/src/types.rs @@ -138,6 +138,10 @@ impl Display for Rect { } impl Rect { + pub fn new(min: Vec2, max: Vec2) -> Self { + Self { min, max } + } + pub const ZERO: Self = Self { min: Vec2::ZERO, max: Vec2::ZERO, diff --git a/violet-core/src/widget/container.rs b/violet-core/src/widget/container.rs index 3b0609d..866db0c 100644 --- a/violet-core/src/widget/container.rs +++ b/violet-core/src/widget/container.rs @@ -5,7 +5,7 @@ use winit::event::ElementState; use crate::{ components::{anchor, layout, offset, rect}, input::{focusable, on_cursor_move, on_mouse_input}, - layout::{Alignment, Direction, FlowLayout, Layout, StackLayout}, + layout::{Alignment, Direction, FloatLayout, FlowLayout, Layout, StackLayout}, style::{ primary_background, secondary_background, spacing_medium, spacing_small, Background, SizeExt, StyleExt, WidgetSize, @@ -204,16 +204,16 @@ impl Widget for Movable { .set(offset(), Unit::default()) .on_event(on_mouse_input(), { let start_offset = start_offset.clone(); - move |_, _, input| { + move |_, input| { if input.state == ElementState::Pressed { let cursor_pos = input.cursor.local_pos; *start_offset.lock_mut() = cursor_pos; } } }) - .on_event(on_cursor_move(), move |frame, entity, input| { - let rect = entity.get_copy(rect()).unwrap(); - let anchor = entity + .on_event(on_cursor_move(), move |scope, input| { + let rect = scope.get_copy(rect()).unwrap(); + let anchor = scope .get_copy(anchor()) .unwrap_or_default() .resolve(rect.size()); @@ -221,14 +221,35 @@ impl Widget for Movable { let cursor_pos = input.local_pos + rect.min; let new_offset = cursor_pos - start_offset.get() + anchor; - let new_offset = (self.on_move)(frame, new_offset); - entity.update_dedup(offset(), Unit::px(new_offset)); + let new_offset = (self.on_move)(scope.frame(), new_offset); + scope.update_dedup(offset(), Unit::px(new_offset)); }); Stack::new(self.content).mount(scope) } } +pub struct Float { + items: W, +} + +impl Float { + pub fn new(items: W) -> Self { + Self { items } + } +} + +impl Widget for Float +where + W: WidgetCollection, +{ + fn mount(self, scope: &mut Scope<'_>) { + self.items.attach(scope); + + scope.set(layout(), Layout::Float(FloatLayout {})); + } +} + pub fn row(widgets: W) -> List { List::new(widgets).with_direction(Direction::Horizontal) } diff --git a/violet-core/src/widget/interactive/button.rs b/violet-core/src/widget/interactive/button.rs index b743fb0..0aacf31 100644 --- a/violet-core/src/widget/interactive/button.rs +++ b/violet-core/src/widget/interactive/button.rs @@ -5,6 +5,7 @@ use crate::{ components::{self, color}, input::{focusable, on_mouse_input}, layout::Alignment, + scope::ScopeRef, state::{StateDuplex, StateStream, WatchState}, style::{ danger_item, interactive_inactive, interactive_pressed, spacing_medium, success_item, @@ -15,7 +16,7 @@ use crate::{ Frame, Scope, Widget, }; -type ButtonCallback = Box; +type ButtonCallback = Box, winit::event::MouseButton)>; #[derive(Debug, Clone)] pub struct ButtonStyle { @@ -59,7 +60,7 @@ impl Button { /// Handle the button press pub fn on_press( mut self, - on_press: impl 'static + Send + Sync + FnMut(&Frame, MouseButton), + on_press: impl 'static + Send + Sync + FnMut(&ScopeRef<'_>, MouseButton), ) -> Self { self.on_press = Box::new(on_press); self @@ -111,12 +112,12 @@ impl Widget for Button { scope .set(focusable(), ()) - .on_event(on_mouse_input(), move |frame, entity, input| { + .on_event(on_mouse_input(), move |scope, input| { if input.state == ElementState::Pressed { - entity.update_dedup(color(), pressed_color); - (self.on_press)(frame, input.button); + scope.update_dedup(color(), pressed_color); + (self.on_press)(scope, input.button); } else { - entity.update_dedup(color(), normal_color); + scope.update_dedup(color(), normal_color); } }); @@ -169,7 +170,7 @@ impl Widget for Checkbox { scope .set(focusable(), ()) - .on_event(on_mouse_input(), move |_, _, input| { + .on_event(on_mouse_input(), move |_, input| { if input.state == ElementState::Pressed { if let Some(state) = last_state.get() { self.state.send(!state) @@ -225,7 +226,7 @@ impl Widget for Radio { scope .set(focusable(), ()) - .on_event(on_mouse_input(), move |_, _, input| { + .on_event(on_mouse_input(), move |_, input| { if input.state == ElementState::Pressed { self.state.send(true) } diff --git a/violet-core/src/widget/interactive/input.rs b/violet-core/src/widget/interactive/input.rs index bde662a..fc53734 100644 --- a/violet-core/src/widget/interactive/input.rs +++ b/violet-core/src/widget/interactive/input.rs @@ -1,11 +1,11 @@ use core::panic; use std::{fmt::Display, future::ready, str::FromStr, sync::Arc}; -use futures::{FutureExt, StreamExt}; +use futures::StreamExt; use futures_signals::signal::{self, Mutable, SignalExt}; use glam::{vec2, Vec2}; use itertools::Itertools; -use palette::Srgba; +use palette::{Srgba, WithAlpha}; use web_time::Duration; use winit::{ event::ElementState, @@ -15,25 +15,30 @@ use winit::{ use crate::{ components::{self, screen_rect}, editor::{CursorMove, EditAction, EditorAction, TextEditor}, - input::{focus_sticky, focusable, on_focus, on_keyboard_input, on_mouse_input, KeyboardInput}, + input::{ + focus_sticky, focusable, on_cursor_move, on_focus, on_keyboard_input, on_mouse_input, + KeyboardInput, + }, + io, state::{State, StateDuplex, StateSink, StateStream}, style::{ - interactive_active, interactive_inactive, spacing_small, Background, SizeExt, StyleExt, - ValueOrRef, WidgetSize, + interactive_active, interactive_hover, interactive_inactive, spacing_small, Background, + SizeExt, StyleExt, ValueOrRef, WidgetSize, }, - text::{LayoutGlyphs, TextSegment}, + text::{CursorLocation, LayoutGlyphs, TextSegment}, time::sleep, to_owned, unit::Unit, utils::throttle, widget::{ - row, NoOp, Positioned, Rectangle, SignalWidget, Stack, StreamWidget, Text, WidgetExt, + row, Float, NoOp, Positioned, Rectangle, SignalWidget, Stack, StreamWidget, Text, WidgetExt, }, Rect, Scope, Widget, }; pub struct TextInputStyle { pub cursor_color: ValueOrRef, + pub selection_color: ValueOrRef, pub background: Background, pub font_size: f32, } @@ -42,6 +47,7 @@ impl Default for TextInputStyle { fn default() -> Self { Self { cursor_color: interactive_active().into(), + selection_color: interactive_hover().into(), background: Background::new(interactive_inactive()), font_size: 16.0, } @@ -87,6 +93,11 @@ impl Widget for TextInput { let stylesheet = scope.stylesheet(); let cursor_color = self.style.cursor_color.resolve(stylesheet); + let selection_color = self + .style + .selection_color + .resolve(stylesheet) + .with_alpha(0.5); let (tx, rx) = flume::unbounded(); @@ -100,24 +111,30 @@ impl Widget for TextInput { let layout_glyphs = Mutable::new(None); let text_bounds: Mutable> = Mutable::new(None); - // editor.set_text(content.lock_mut().split('\n')); editor.set_cursor_at_end(); let (editor_props_tx, editor_props_rx) = signal::channel(Box::new(NoOp) as Box); let content = self.content.prevent_feedback(); + let clipboard = scope + .frame() + .get_atom(io::clipboard()) + .expect("Missing clipboard") + .clone(); + + let clipboard = scope.frame().store().get(&clipboard).clone(); + scope.spawn({ - let mut layout_glyphs = layout_glyphs.signal_cloned().to_stream(); + let mut layout_glyphs = layout_glyphs.signal_cloned().to_stream().fuse(); let mut focused_signal = focused.stream().fuse(); to_owned![text_content]; async move { let mut rx = rx.into_stream().fuse(); - let mut glyphs: LayoutGlyphs; + let mut glyphs: Option = None; - let mut cursor_pos = Vec2::ZERO; - - let mut new_text = throttle(content.stream(), || sleep(Duration::from_millis(100))).fuse(); + let mut new_text = + throttle(content.stream(), || sleep(Duration::from_millis(100))).fuse(); let mut focused = false; loop { @@ -130,7 +147,19 @@ impl Widget for TextInput { text_content.send(new_text); } action = rx.select_next_some() => { - editor.apply_action(action); + match action { + Action::Editor(editor_action) => editor.apply_action(editor_action), + Action::Copy => { + if let Some(sel) = editor.selected_text() { + clipboard.set_text(sel.join("\n")).await; + } + } + Action::Paste => { + if let Some(text) = clipboard.get_text().await { + editor.edit(EditAction::InsertText(text)); + } + } + } let mut text = text_content.lock_mut(); text.clear(); @@ -140,68 +169,142 @@ impl Widget for TextInput { content.send(editor.lines().iter().map(|v| v.text()).join("\n")); // text_content.send(editor.lines().iter().map(|v| v.text()).join("\n")); } - new_glyphs = layout_glyphs.next().fuse() => { - if let Some(Some(new_glyphs)) = new_glyphs { + new_glyphs = layout_glyphs.select_next_some() => { glyphs = new_glyphs; - if let Some(loc) = glyphs.to_glyph_boundary(editor.cursor()) { - cursor_pos = loc; - } else if editor.past_eol() { - cursor_pos = glyphs - .find_lines_indices(editor.cursor().row) - .last() - .map(|(ln, line)| { - vec2(line.bounds.max.x, ln as f32 * glyphs.line_height()) - }) - .unwrap_or_default(); - } else { - cursor_pos = Vec2::ZERO; - } } - } } - editor_props_tx - .send(Box::new(Stack::new( - ( - focused.then(|| Positioned::new(Rectangle::new(cursor_color) - .with_min_size(Unit::px2(2.0, 16.0))) - .with_offset(Unit::px(cursor_pos))), + if let Some(glyphs) = &glyphs { + let cursor_pos = calculate_position(glyphs, editor.cursor()); + + let selection = if let Some((start, end)) = editor.selection_bounds() { + tracing::info!(?start, ?end, "selection"); + + let selected_lines = + glyphs.lines().iter().enumerate().filter(|(_, v)| { + tracing::info!(?v.row); + v.row >= start.row && v.row <= end.row + }); + + let selection = selected_lines + .filter_map(|(ln, v)| { + tracing::info!(?ln, glyphs = v.glyphs.len()); + + let left = if v.row == start.row { + v.glyphs.iter().find(|v| v.start == start.col) + } else { + v.glyphs.first() + }; + let right = if v.row == end.row { + v.glyphs.iter().rev().find(|v| v.end == end.col) + } else { + v.glyphs.last() + }; + + // dbg!(left, right); + + let rect = match (left, right) { + (None, None) => return None, + (None, Some(r)) => Rect::new(v.bounds.min, r.bounds.max), + (Some(l), None) => Rect::new(l.bounds.min, v.bounds.max), + (Some(l), Some(r)) => Rect::new(l.bounds.min, r.bounds.max), + }; + + Some(rect) + }) + .map(|v| { + Positioned::new( + Rectangle::new(selection_color) + .with_min_size(Unit::px(v.size())), ) - ))) - .ok(); + .with_offset(Unit::px(v.pos())) + }) + .collect_vec(); + + Some(Stack::new(selection)) + } else { + None + }; + let props = Stack::new(( + focused.then(|| { + Positioned::new( + Rectangle::new(cursor_color) + .with_min_size(Unit::px2(2.0, 16.0)), + ) + .with_offset(Unit::px(cursor_pos)) + }), + selection, + )); + + editor_props_tx.send(Box::new(props)).ok(); } + } } }); + let dragging = Mutable::new(None); + scope .set(focusable(), ()) .set(focus_sticky(), ()) - .on_event(on_focus(), move |_, _, focus| { + .on_event(on_focus(), move |_, focus| { focused.set(focus); }) .on_event(on_mouse_input(), { - to_owned![layout_glyphs, text_bounds, tx]; - move |_, _, input| { + to_owned![layout_glyphs, text_bounds, tx, dragging]; + move |_, input| { let glyphs = layout_glyphs.lock_ref(); if let (Some(glyphs), Some(text_bounds)) = (&*glyphs, &*text_bounds.lock_ref()) { if input.state == ElementState::Pressed { let text_pos = input.cursor.absolute_pos - text_bounds.min; + if let Some(hit) = glyphs.hit(text_pos) { - tx.send(EditorAction::CursorMove(CursorMove::SetPosition(hit))) - .ok(); + dragging.set(Some(hit)); + tx.send(Action::Editor(EditorAction::CursorMove( + CursorMove::SetPosition(hit), + ))) + .ok(); + tx.send(Action::Editor(EditorAction::SelectionClear)).ok(); } tracing::info!(?input, "click"); + } else { + dragging.set(None) + } + } + } + }) + .on_event(on_cursor_move(), { + to_owned![layout_glyphs, tx, dragging]; + move |_, input| { + let dragging = dragging.get(); + + if let Some(dragging) = dragging { + let glyphs = layout_glyphs.lock_ref(); + + if let Some(glyphs) = &*glyphs { + let text_pos = input.local_pos; + + if let Some(hit) = glyphs.hit(text_pos) { + tx.send(Action::Editor(EditorAction::SelectionMove( + CursorMove::SetPosition(dragging), + ))) + .ok(); + tx.send(Action::Editor(EditorAction::CursorMove( + CursorMove::SetPosition(hit), + ))) + .ok(); + } } } } }) .on_event(on_keyboard_input(), { to_owned![tx]; - move |_, _, input| { + move |_, input| { if input.event.state == ElementState::Pressed { if let Some(action) = handle_input(input) { tx.send(action).ok(); @@ -218,7 +321,7 @@ impl Widget for TextInput { .monitor_signal(components::layout_glyphs(), layout_glyphs.clone()) .monitor_signal(screen_rect(), text_bounds.clone()) })), - SignalWidget(editor_props_rx), + Float::new(SignalWidget(editor_props_rx)), )) .with_background(self.style.background) .with_size_props(self.size) @@ -226,31 +329,77 @@ impl Widget for TextInput { } } -fn handle_input(input: KeyboardInput) -> Option { +enum Action { + Editor(EditorAction), + Copy, + Paste, +} + +pub fn calculate_position(glyphs: &LayoutGlyphs, cursor: CursorLocation) -> Vec2 { + if let Some(loc) = glyphs.to_glyph_boundary(cursor) { + loc + } else { + glyphs + .find_lines_indices(cursor.row) + .last() + .map(|(ln, line)| vec2(line.bounds.max.x, ln as f32 * glyphs.line_height())) + .unwrap_or_default() + } +} + +fn handle_input(input: KeyboardInput) -> Option { let ctrl = input.modifiers.control_key(); if let Key::Named(key) = input.event.logical_key { match key { NamedKey::Backspace if ctrl => { - return Some(EditorAction::Edit(EditAction::DeleteBackwardWord)) + return Some(Action::Editor(EditorAction::Edit( + EditAction::DeleteBackwardWord, + ))) + } + NamedKey::Backspace => { + return Some(Action::Editor(EditorAction::Edit( + EditAction::DeleteBackwardChar, + ))) + } + NamedKey::Enter => { + return Some(Action::Editor(EditorAction::Edit(EditAction::InsertLine))) } - NamedKey::Backspace => return Some(EditorAction::Edit(EditAction::DeleteBackwardChar)), - NamedKey::Enter => return Some(EditorAction::Edit(EditAction::InsertLine)), NamedKey::ArrowLeft if ctrl => { - return Some(EditorAction::CursorMove(CursorMove::BackwardWord)) + return Some(Action::Editor(EditorAction::CursorMove( + CursorMove::BackwardWord, + ))) } NamedKey::ArrowRight if ctrl => { - return Some(EditorAction::CursorMove(CursorMove::ForwardWord)) + return Some(Action::Editor(EditorAction::CursorMove( + CursorMove::ForwardWord, + ))) + } + NamedKey::ArrowLeft => { + return Some(Action::Editor(EditorAction::CursorMove(CursorMove::Left))) } - NamedKey::ArrowLeft => return Some(EditorAction::CursorMove(CursorMove::Left)), - NamedKey::ArrowRight => return Some(EditorAction::CursorMove(CursorMove::Right)), - NamedKey::ArrowUp => return Some(EditorAction::CursorMove(CursorMove::Up)), - NamedKey::ArrowDown => return Some(EditorAction::CursorMove(CursorMove::Down)), + NamedKey::ArrowRight => { + return Some(Action::Editor(EditorAction::CursorMove(CursorMove::Right))) + } + NamedKey::ArrowUp => { + return Some(Action::Editor(EditorAction::CursorMove(CursorMove::Up))) + } + NamedKey::ArrowDown => { + return Some(Action::Editor(EditorAction::CursorMove(CursorMove::Down))) + } + _ => {} + } + } else if let Key::Character(c) = input.event.logical_key { + match &*c { + "c" if ctrl => return Some(Action::Copy), + "v" if ctrl => return Some(Action::Paste), _ => {} } } if let Some(text) = input.event.text { - return Some(EditorAction::Edit(EditAction::InsertText(text.into()))); + return Some(Action::Editor(EditorAction::Edit(EditAction::InsertText( + text.into(), + )))); } None diff --git a/violet-core/src/widget/interactive/slider.rs b/violet-core/src/widget/interactive/slider.rs index 718b2d9..c15827d 100644 --- a/violet-core/src/widget/interactive/slider.rs +++ b/violet-core/src/widget/interactive/slider.rs @@ -125,15 +125,15 @@ impl Widget for Slider { .set(focusable(), ()) .on_event(on_mouse_input(), { to_owned![value]; - move |_, entity, input| { + move |scope, input| { if input.state == ElementState::Pressed { - update(entity, input.cursor, min, max, &*value); + update(scope, input.cursor, min, max, &*value); } } }) .on_event(on_cursor_move(), { to_owned![value]; - move |_, entity, input| update(entity, input, min, max, &*value) + move |scope, input| update(scope, input, min, max, &*value) }); Stack::new(handle) diff --git a/violet-core/src/widget/mod.rs b/violet-core/src/widget/mod.rs index babdc1e..c997a60 100644 --- a/violet-core/src/widget/mod.rs +++ b/violet-core/src/widget/mod.rs @@ -32,9 +32,9 @@ where } } -impl Widget for Box -where - T: ?Sized + Widget, +impl Widget for Box +// where +// T: ?Sized + Widget, { fn mount(self, scope: &mut Scope<'_>) { self.mount_boxed(scope) @@ -49,6 +49,15 @@ impl Widget for Option { } } +impl Widget for F +where + F: FnOnce(&mut Scope<'_>), +{ + fn mount(self, scope: &mut Scope<'_>) { + self(scope); + } +} + pub trait WidgetExt: Widget + Sized { fn boxed<'a>(self) -> Box where diff --git a/violet-demo/src/lib.rs b/violet-demo/src/lib.rs index 7422540..8b21793 100644 --- a/violet-demo/src/lib.rs +++ b/violet-demo/src/lib.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::Context; use flume::Sender; -use futures::{Stream, StreamExt}; +use futures::{Future, Stream, StreamExt}; use glam::Vec2; use indexmap::IndexMap; use itertools::Itertools; @@ -23,10 +23,10 @@ use violet::{ unit::Unit, utils::zip_latest_ref, widget::{ - card, column, label, row, Button, Radio, Rectangle, SliderWithLabel, Stack, + card, centered, column, label, row, Button, Radio, Rectangle, SliderWithLabel, Stack, StreamWidget, Text, TextInput, WidgetExt, }, - Edges, Frame, FutureEffect, Scope, Widget, + Edges, Scope, Widget, }, futures_signals::signal::Mutable, palette::{FromColor, IntoColor, OklabHue, Oklch, Srgb}, @@ -213,20 +213,22 @@ impl Widget for Palettes { let items = self.items.clone(); + let new_color = Button::label("+").on_press(move |_, _| { + items.write_mut(|v| { + v.push(Mutable::new(PaletteColor { + color: Oklch::new(0.5, 0.27, (v.len() as f32 * 60.0) % 360.0), + falloff: DEFAULT_FALLOFF, + name: format!("color_{}", v.len() + 1), + })); + current_choice.set(Some(v.len() - 1)); + }) + }); + + let editor_column = column((StreamWidget(editor), palettes, new_color)); + column(( menu_bar(self.items.clone(), notify_tx), - StreamWidget(editor), - palettes, - Button::label("+").on_press(move |_, _| { - items.write_mut(|v| { - v.push(Mutable::new(PaletteColor { - color: Oklch::new(0.5, 0.27, (v.len() as f32 * 60.0) % 360.0), - falloff: DEFAULT_FALLOFF, - name: format!("color_{}", v.len() + 1), - })); - current_choice.set(Some(v.len() - 1)); - }) - }), + row((editor_column, description())), )) .mount(scope) } @@ -305,20 +307,31 @@ where } } +fn description() -> impl Widget { + let content = Mutable::new( + r#"This is a palette editor. You can add, remove and select the colors in the list. Edit the color by selecting them and using the sliders or typing in the slider labels +You can then export the various generated tints of the colors to a tailwind style `.json` + +This text is also editable, give it a try :)"#.to_string(), + ); + + card(TextInput::new(content)) +} + fn menu_bar( items: Mutable>>, notify_tx: Sender, ) -> impl Widget { - fn notify_result( - notify_tx: &Sender, + async fn notify_result( + fut: impl Future>, + notify_tx: Sender, on_success: &str, - ) -> impl Fn(&mut Frame, anyhow::Result<()>) { - let notify_tx = notify_tx.clone(); - move |_, result| match result { + ) { + match fut.await { Ok(()) => { notify_tx .send(Notification { - message: "Saved".to_string(), + message: on_success.into(), kind: NotificationKind::Info, }) .unwrap(); @@ -326,7 +339,7 @@ fn menu_bar( Err(e) => { notify_tx .send(Notification { - message: format!("Failed to save: {e}"), + message: format!("{e:?}"), kind: NotificationKind::Error, }) .unwrap(); @@ -370,7 +383,7 @@ fn menu_bar( Ok(()) }; - frame.spawn(FutureEffect::new(fut, notify_result(¬ify_tx, "Saves"))); + frame.spawn(notify_result(fut, notify_tx.clone(), "Saves")); } }); @@ -394,7 +407,7 @@ fn menu_bar( Ok(()) }; - frame.spawn(FutureEffect::new(fut, notify_result(¬ify_tx, "Saves"))); + frame.spawn(notify_result(fut, notify_tx, "Saves")); } }); @@ -416,7 +429,7 @@ fn menu_bar( Ok(()) }; - frame.spawn(FutureEffect::new(fut, notify_result(¬ify_tx, "Loaded"))); + frame.spawn(notify_result(fut, notify_tx, "Loaded")); } }); @@ -433,12 +446,13 @@ fn menu_bar( }); row(( - label("Palette editor"), + centered(label("Palette editor")), save, load, export, test_notification, )) + .with_stretch(true) } #[derive(Clone, Serialize, Deserialize)] diff --git a/violet-wgpu/src/app.rs b/violet-wgpu/src/app.rs index c2391f5..512944a 100644 --- a/violet-wgpu/src/app.rs +++ b/violet-wgpu/src/app.rs @@ -18,6 +18,7 @@ use violet_core::{ components::{self, local_position, rect, screen_position}, executor::Executor, input::InputState, + io::{self, Clipboard}, style::{setup_stylesheet, stylesheet}, systems::{ hydrate_text, invalidate_cached_layout_system, layout_system, templating_system, @@ -124,6 +125,9 @@ impl AppBuilder { let stylesheet = setup_stylesheet().spawn(frame.world_mut()); + let clipboard = frame.store_mut().insert(Arc::new(Clipboard::new())); + frame.set_atom(io::clipboard(), clipboard); + // Mount the root widget let root = frame.new_root(Canvas { stylesheet, diff --git a/violet-wgpu/src/text.rs b/violet-wgpu/src/text.rs index 057a026..60606bd 100644 --- a/violet-wgpu/src/text.rs +++ b/violet-wgpu/src/text.rs @@ -242,15 +242,6 @@ impl TextBufferState { Attrs::new(), Shaping::Advanced, ); - // self.buffer.set_text( - // font_system, - // text, - // Attrs::new() - // .family(cosmic_text::Family::Name("Inter")) - // .style(Style::Normal) - // .weight(400.0) - // Shaping::Advanced, - // ); } fn text(&self) -> Vec { @@ -267,6 +258,7 @@ impl TextBufferState { let mut result = Vec::new(); + let mut ln = 0; for (row, line) in self.buffer.lines.iter().enumerate() { let mut current_offset = 0; @@ -275,10 +267,12 @@ impl TextBufferState { continue; }; - result.extend(layout.iter().enumerate().map(|(i, run)| { - let top = i as f32 * lh; + result.extend(layout.iter().map(|run| { + let top = ln as f32 * lh; let bottom = top + lh; + ln += 1; + let start = current_offset; let glyphs = run .glyphs