diff --git a/Cargo.lock b/Cargo.lock index 66a98ba..faa5d47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,6 +208,18 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block" version = "0.1.6" @@ -519,17 +531,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" -[[package]] -name = "d3d12" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" -dependencies = [ - "bitflags 2.4.2", - "libloading 0.8.1", - "winapi", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -572,9 +573,9 @@ checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" [[package]] name = "either" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" [[package]] name = "equivalent" @@ -629,7 +630,7 @@ dependencies = [ [[package]] name = "flax" version = "0.6.2" -source = "git+https://github.com/ten3roberts/flax#aa6cf3489d4ffe3e66ec8fe4937c47f7f6df0e1b" +source = "git+https://github.com/ten3roberts/flax#39c01757b9ea5f142faeaf9d8b1473e1745ffcca" dependencies = [ "anyhow", "atomic_refcell", @@ -647,7 +648,7 @@ dependencies = [ [[package]] name = "flax-derive" version = "0.6.0" -source = "git+https://github.com/ten3roberts/flax#aa6cf3489d4ffe3e66ec8fe4937c47f7f6df0e1b" +source = "git+https://github.com/ten3roberts/flax#39c01757b9ea5f142faeaf9d8b1473e1745ffcca" dependencies = [ "itertools 0.11.0", "proc-macro-crate", @@ -724,6 +725,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.30" @@ -749,6 +756,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6712e11cdeed5c8cf21ea0b90fec40fbe64afc9bbf2339356197eeca829fc3" +dependencies = [ + "bitvec", + "futures-core", + "pin-project", + "slab", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.30" @@ -1732,6 +1752,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -1747,12 +1773,6 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -[[package]] -name = "range-alloc" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8a99fddc9f0ba0a85884b8d14e3592853e787d581ca1816c91349b10e4eeab" - [[package]] name = "rangemap" version = "1.4.0" @@ -2147,6 +2167,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "termcolor" version = "1.4.1" @@ -2475,6 +2501,7 @@ name = "violet-core" version = "0.0.1" dependencies = [ "anyhow", + "arrayvec", "atomic_refcell", "bytemuck", "bytes", @@ -2484,6 +2511,7 @@ dependencies = [ "flax", "flume", "futures", + "futures-concurrency", "futures-signals", "glam", "gloo-timers", @@ -2498,6 +2526,7 @@ dependencies = [ "serde", "slab", "slotmap", + "tokio", "tracing", "tynm", "unicode-segmentation", @@ -2507,12 +2536,15 @@ dependencies = [ ] [[package]] -name = "violet-web-example" +name = "violet-demo" version = "0.0.1" dependencies = [ "console_error_panic_hook", + "futures", "glam", + "itertools 0.12.1", "tracing-subscriber", + "tracing-tree", "tracing-web", "violet", "wasm-bindgen", @@ -2828,11 +2860,9 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", "bitflags 2.4.2", "cfg_aliases", "core-graphics-types", - "d3d12", "glow", "glutin_wgl_sys", "gpu-alloc", @@ -2850,7 +2880,6 @@ dependencies = [ "once_cell", "parking_lot", "profiling", - "range-alloc", "raw-window-handle", "renderdoc-sys", "rustc-hash", @@ -3184,6 +3213,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/Cargo.toml b/Cargo.toml index 6c5622c..305e3b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [ "violet-wgpu", "violet-core", "violet-web-example" ] +members = [ "violet-wgpu", "violet-core", "violet-demo" ] [workspace.package] version = "0.0.1" @@ -33,6 +33,7 @@ futures-signals = "0.3" itertools = "0.12" glam = { version = "0.24", features = ["bytemuck"] } futures = "0.3" +futures-concurrency = "7.0" flume = "0.11" parking_lot = "0.12" slotmap = "1.0" @@ -40,10 +41,12 @@ anyhow = "1.0" once_cell = "1.18" slab = "0.4" tynm ="0.1" +tokio = { version = "1.0", default-features = false, features = ["macros", "rt"] } +arrayvec = "0.7" bytemuck = { version = "1.13", features = ["derive"] } winit = "0.29" -wgpu = { version = "0.19", default-features = false, features = ["dx12", "fragile-send-sync-non-atomic-wasm", "webgl", "wgsl"] } +wgpu = { version = "0.19", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "webgl", "wgsl"] } palette = { version = "0.7", features = ["serializing"] } dashmap = "5.4" image = { version = "0.24", default_features = false, features = ["png", "jpeg"] } @@ -67,6 +70,7 @@ web-time = "1.0" wasm-bindgen-futures = "0.4" wasm-bindgen = "0.2" web-sys = "0.3" +tracing-tree = "0.3" [dependencies] violet-wgpu = { path = "violet-wgpu" } @@ -89,7 +93,7 @@ futures.workspace = true tokio = { version = "1.0", default_features= false, features = ["macros"] } serde_json = "1.0" -tracing-tree = "0.3" +tracing-tree.workspace = true tracing-subscriber = { version = "0.3", features = [ "env-filter", ] } diff --git a/examples/basic.rs b/examples/basic.rs index 3b24a56..f7d3ba1 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -2,7 +2,7 @@ use flax::{components::name, FetchExt, Query}; use futures_signals::signal::Mutable; use glam::{vec2, Vec2}; use itertools::Itertools; -use palette::{Hsva, IntoColor, Srgba}; +use palette::{Hsva, IntoColor}; use std::time::Duration; use tracing_subscriber::{ prelude::__tracing_subscriber_SubscriberExt, registry, util::SubscriberInitExt, EnvFilter, @@ -19,35 +19,16 @@ use violet::core::{ Scope, StreamEffect, Widget, }; use violet_core::{ - style::Background, + style::{ + colors::{DARK_CYAN_DEFAULT, JADE_DEFAULT, LION_DEFAULT}, + danger_item, primary_background, secondary_background, spacing_medium, spacing_small, + Background, SizeExt, ValueOrRef, + }, widget::{BoxSized, ContainerStyle}, - Edges, }; struct MainApp; -macro_rules! srgba { - ($color:literal) => {{ - let [r, g, b] = color_hex::color_from_hex!($color); - - Srgba::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0) - }}; -} - -const MARGIN: Edges = Edges::even(10.0); -const MARGIN_SM: Edges = Edges::even(5.0); - -pub const EERIE_BLACK: Srgba = srgba!("#222525"); -pub const EERIE_BLACK_300: Srgba = srgba!("#151616"); -pub const EERIE_BLACK_400: Srgba = srgba!("#1b1e1e"); -pub const EERIE_BLACK_600: Srgba = srgba!("#4c5353"); -pub const PLATINUM: Srgba = srgba!("#dddddf"); -pub const VIOLET: Srgba = srgba!("#8000ff"); -pub const TEAL: Srgba = srgba!("#247b7b"); -pub const EMERALD: Srgba = srgba!("#50c878"); -pub const BRONZE: Srgba = srgba!("#cd7f32"); -pub const CHILI_RED: Srgba = srgba!("#d34131"); - impl Widget for MainApp { fn mount(self, scope: &mut Scope) { scope @@ -73,16 +54,13 @@ impl Widget for MainApp { .with_min_size(Unit::px(size)) .with_aspect_ratio(1.0), ) - .with_style(ContainerStyle { - margin: MARGIN, - ..Default::default() - }) + .with_margin(spacing_medium()) }) .collect_vec(), ) .with_name("Images"), Stack::new((Text::rich([ - TextSegment::new("Violet").with_color(VIOLET), + TextSegment::new("Violet"), TextSegment::new(" now has support for "), TextSegment::new("rich ").with_style(Style::Italic), TextSegment::new("text. I wanted to "), @@ -95,22 +73,20 @@ impl Widget for MainApp { TextSegment::new(" also show off the different font loadings: \n"), TextSegment::new("Monospace:") .with_family(FontFamily::named("JetBrainsMono Nerd Font")) - .with_color(TEAL), + .with_color(DARK_CYAN_DEFAULT), TextSegment::new("\n\nfn main() { \n println!(") .with_family(FontFamily::named("JetBrainsMono Nerd Font")), TextSegment::new("\"Hello, world!\"") .with_family(FontFamily::named("JetBrainsMono Nerd Font")) - .with_color(BRONZE) + .with_color(LION_DEFAULT) .with_style(Style::Italic), TextSegment::new("); \n}") .with_family(FontFamily::named("JetBrainsMono Nerd Font")), ]) .with_font_size(18.0),)) - .with_style(ContainerStyle { - background: Some(Background::new(EERIE_BLACK)), - padding: MARGIN, - margin: MARGIN, - }), + .with_margin(spacing_small()) + .with_margin(spacing_small()) + .with_background(Background::new(primary_background())), Stack::new( Text::rich([ TextSegment::new("The quick brown fox 🦊 jumps over the lazy dog 🐕") @@ -120,30 +96,25 @@ impl Widget for MainApp { // .with_family("Inter") .with_font_size(18.0), ) - .with_style(ContainerStyle { - background: Some(Background::new(EERIE_BLACK)), - padding: MARGIN, - margin: MARGIN, - }), + .with_margin(spacing_small()) + .with_padding(spacing_small()) + .with_background(Background::new(primary_background())), Stack::new(( - BoxSized::new(Rectangle::new(CHILI_RED)) + BoxSized::new(Rectangle::new(danger_item())) .with_min_size(Unit::px(vec2(100.0, 30.0))) .with_size(Unit::px(vec2(50.0, 30.0))), - BoxSized::new(Rectangle::new(TEAL)) + BoxSized::new(Rectangle::new(danger_item())) .with_min_size(Unit::px(vec2(200.0, 10.0))) .with_size(Unit::px(vec2(50.0, 10.0))), Text::new("This is some text").with_font_size(16.0), )) .with_vertical_alignment(Alignment::Center) .with_horizontal_alignment(Alignment::Center) - .with_background(Background::new(EERIE_BLACK_300)) - .with_padding(MARGIN) - .with_margin(MARGIN), + .with_background(Background::new(secondary_background())) + .with_padding(spacing_small()) + .with_margin(spacing_small()), )) - .with_style(ContainerStyle { - background: Some(Background::new(EERIE_BLACK_600)), - ..Default::default() - }) + .with_background(Background::new(secondary_background())) .contain_margins(true) .with_direction(Direction::Vertical) .mount(scope); @@ -182,12 +153,12 @@ struct StackTest {} impl Widget for StackTest { fn mount(self, scope: &mut Scope<'_>) { - Stack::new((Text::new("This is an overlaid text").with_color(EMERALD),)) + Stack::new((Text::new("This is an overlaid text").with_color(JADE_DEFAULT),)) .with_style(ContainerStyle { - background: Some(Background::new(EERIE_BLACK_300)), - padding: MARGIN, - margin: MARGIN, + background: Some(Background::new(secondary_background())), }) + .with_margin(spacing_small()) + .with_padding(spacing_small()) .mount(scope) } } @@ -202,16 +173,13 @@ impl Widget for LayoutFlexTest { let size = vec2(100.0, 20.0); Stack::new( - BoxSized::new(Rectangle::new( + BoxSized::new(Rectangle::new(ValueOrRef::value( Hsva::new(i as f32 * 30.0, 1.0, 1.0, 1.0).into_color(), - )) + ))) .with_min_size(Unit::px(size)) .with_size(Unit::px(size * vec2(i as f32, 1.0))), ) - .with_style(ContainerStyle { - margin: MARGIN, - ..Default::default() - }) + .with_margin(spacing_small()) }) .collect_vec(), ) @@ -234,15 +202,14 @@ impl Widget for LayoutTest { TextSegment::new("This is "), TextSegment::new("sparta") .with_style(Style::Italic) - .with_color(BRONZE), + .with_color(LION_DEFAULT), ]) .with_font_size(16.0) .with_wrap(Wrap::None), ) + .with_margin(spacing_small()) .with_style(ContainerStyle { - background: Some(Background::new(EERIE_BLACK)), - padding: MARGIN_SM, - margin: MARGIN_SM, + background: Some(Background::new(primary_background())), }), )) .on_press({ @@ -271,19 +238,19 @@ impl Widget for LayoutTest { )) .contain_margins(self.contain_margins) .with_cross_align(Alignment::Center) + .with_margin(spacing_small()) + .with_padding(spacing_small()) .with_style(ContainerStyle { - background: Some(Background::new(EERIE_BLACK)), - padding: MARGIN, - margin: MARGIN, + background: Some(Background::new(primary_background())), }); // row_1.mount(scope); List::new((row_1,)) .contain_margins(self.contain_margins) + .with_margin(spacing_small()) + .with_padding(spacing_small()) .with_style(ContainerStyle { - background: Some(Background::new(EERIE_BLACK_300)), - padding: MARGIN, - margin: MARGIN, + background: Some(Background::new(secondary_background())), }) .mount(scope); } diff --git a/examples/color.rs b/examples/color.rs new file mode 100644 index 0000000..79eaf18 --- /dev/null +++ b/examples/color.rs @@ -0,0 +1,142 @@ +use futures::StreamExt; +use futures_signals::signal::{Mutable, SignalExt}; +use glam::Vec3; +use itertools::Itertools; +use palette::{FromColor, IntoColor, Oklch, Srgb}; +use tracing_subscriber::{layer::SubscriberExt, registry, util::SubscriberInitExt, EnvFilter}; +use tracing_tree::HierarchicalLayer; +use violet_core::{ + state::{Map, MapRef, StateStream, StateStreamRef}, + style::{SizeExt, ValueOrRef}, + unit::Unit, + utils::zip_latest, + widget::{ + card, column, row, Rectangle, SignalWidget, SliderWithLabel, Stack, StreamWidget, Text, + }, + Edges, Scope, Widget, +}; +use violet_wgpu::renderer::RendererConfig; + +pub fn main() -> anyhow::Result<()> { + registry() + .with( + HierarchicalLayer::default() + .with_deferred_spans(true) + .with_span_retrace(true) + .with_indent_lines(true) + .with_indent_amount(4), + ) + .with(EnvFilter::from_default_env()) + .init(); + + violet_wgpu::App::new() + .with_renderer_config(RendererConfig { debug_mode: false }) + .run(MainApp) +} + +struct MainApp; + +impl Widget for MainApp { + fn mount(self, scope: &mut Scope<'_>) { + let color = Mutable::new(Vec3::new(0.5, 0.27, 153.0)); + let color_oklch = Map::new( + color.clone(), + |v| Oklch::new(v.x, v.y, v.z), + |v| Vec3::new(v.l, v.chroma, v.hue.into_positive_degrees()), + ); + + let lightness = MapRef::new(color.clone(), |v| &v.x, |v| &mut v.x); + let chroma = MapRef::new(color.clone(), |v| &v.y, |v| &mut v.y); + let hue = MapRef::new(color.clone(), |v| &v.z, |v| &mut v.z); + + let color_rect = color.signal().map(|v| { + let color = Oklch::new(v.x, v.y, v.z).into_color(); + Rectangle::new(ValueOrRef::value(color)).with_min_size(Unit::px2(200.0, 100.0)) + }); + + let falloff = Mutable::new(50.0); + + card( + column(( + row(( + Text::new("Lightness"), + SliderWithLabel::new(lightness, 0.0, 1.0) + .editable(true) + .round(0.01), + )), + row(( + Text::new("Chroma"), + SliderWithLabel::new(chroma, 0.0, 0.37) + .editable(true) + .round(0.005), + )), + row(( + Text::new("Hue"), + SliderWithLabel::new(hue, 0.0, 360.0) + .editable(true) + .round(1.0), + )), + StreamWidget(color.stream_ref(|v| { + let hex: Srgb = Srgb::from_color(Oklch::new(v.x, v.y, v.z)).into_format(); + Text::new(format!( + "#{:0>2x}{:0>2x}{:0>2x}", + hex.red, hex.green, hex.blue + )) + })), + SignalWidget(color.signal().map(|v| Text::new(format!("{}", v)))), + SignalWidget(color_rect), + row(( + Text::new("Chroma falloff"), + SliderWithLabel::new(falloff.clone(), 0.0, 100.0) + .editable(true) + .round(1.0), + )), + StreamWidget( + zip_latest(color_oklch.stream(), falloff.stream()) + .map(|(color, falloff)| Tints::new(color, falloff)), + ), + )) + .with_stretch(true) + .with_margin(Edges::even(4.0)), + ) + .with_size(Unit::rel2(1.0, 1.0)) + .mount(scope); + } +} + +struct Tints { + base: Oklch, + falloff: f32, +} + +impl Tints { + fn new(base: Oklch, falloff: f32) -> Self { + Self { base, falloff } + } +} + +impl Widget for Tints { + fn mount(self, scope: &mut Scope<'_>) { + row((1..=9) + .map(|i| { + let f = (i as f32) / 10.0; + let chroma = self.base.chroma * (1.0 / (1.0 + self.falloff * (f - 0.5).powi(2))); + + // let color = self.base.lighten(f); + let color = Oklch { + chroma, + l: f, + ..self.base + }; + + Stack::new(column(( + Rectangle::new(ValueOrRef::value(color.into_color())) + .with_min_size(Unit::px2(60.0, 60.0)), + Text::new(format!("{:.2}", f)), + ))) + .with_margin(Edges::even(4.0)) + }) + .collect_vec()) + .mount(scope) + } +} diff --git a/examples/counter.rs b/examples/counter.rs index d8fcafd..dbd8192 100644 --- a/examples/counter.rs +++ b/examples/counter.rs @@ -7,12 +7,11 @@ use tracing_tree::HierarchicalLayer; use violet::core::{ components::size, layout::Alignment, - style::StyleExt, unit::Unit, widget::{Button, List, SignalWidget, Stack, Text}, Scope, Widget, }; -use violet_core::{style::Background, widget::ContainerStyle, Edges}; +use violet_core::style::{accent_item, secondary_background, spacing_small, Background, SizeExt}; macro_rules! srgba { ($color:literal) => {{ @@ -22,8 +21,6 @@ macro_rules! srgba { }}; } -const MARGIN_SM: Edges = Edges::even(5.0); - pub const EERIE_BLACK: Srgba = srgba!("#222525"); pub const EERIE_BLACK_300: Srgba = srgba!("#151616"); pub const EERIE_BLACK_400: Srgba = srgba!("#1b1e1e"); @@ -36,11 +33,10 @@ pub const BRONZE: Srgba = srgba!("#cd7f32"); pub const CHILI_RED: Srgba = srgba!("#d34131"); fn pill(widget: impl Widget) -> impl Widget { - Stack::new(widget).with_style(ContainerStyle { - background: Some(Background::new(EERIE_BLACK_300)), - padding: MARGIN_SM, - margin: MARGIN_SM, - }) + Stack::new(widget) + .with_background(Background::new(secondary_background())) + .with_margin(spacing_small()) + .with_padding(spacing_small()) } struct MainApp; @@ -64,10 +60,7 @@ impl Widget for MainApp { "Please click the button to increment the counter", )), )) - .with_style(ContainerStyle { - background: Some(Background::new(EMERALD)), - ..Default::default() - }) + .with_background(Background::new(accent_item())) .with_cross_align(Alignment::Center) .mount(scope); } diff --git a/examples/flow.rs b/examples/flow.rs index a7e4aec..d98b8e0 100644 --- a/examples/flow.rs +++ b/examples/flow.rs @@ -1,66 +1,42 @@ use std::usize; -use futures_signals::{ - map_ref, - signal::{self, Mutable, SignalExt}, -}; +use futures_signals::{map_ref, signal::Mutable}; -use glam::{vec2, Vec2}; use itertools::Itertools; -use palette::{num::Round, FromColor, Hsva, IntoColor, Oklcha, Srgba}; +use palette::{FromColor, Hsva, IntoColor, Oklcha, Srgba}; use tracing_subscriber::{layer::SubscriberExt, registry, util::SubscriberInitExt, EnvFilter}; use tracing_tree::HierarchicalLayer; -use futures::stream::StreamExt; use violet::core::{ - components::{self, screen_rect}, - editor::{self, EditAction, EditorAction, TextEditor}, - input::{focusable, on_keyboard_input, on_mouse_input}, layout::Alignment, style::StyleExt, - text::{LayoutGlyphs, TextSegment}, - to_owned, unit::Unit, - widget::{List, NoOp, Rectangle, SignalWidget, Stack, Text, WidgetExt}, + widget::{List, Rectangle, SignalWidget, Stack, Text}, Scope, Widget, }; use violet_core::{ - input::{ - event::ElementState, - focus_sticky, - keyboard::{Key, NamedKey}, - KeyboardInput, - }, style::{ self, - colors::{ - EERIE_BLACK_300, EERIE_BLACK_600, EERIE_BLACK_DEFAULT, JADE_DEFAULT, LION_DEFAULT, - }, - Background, SizeExt, + colors::{EERIE_BLACK_600, EERIE_BLACK_DEFAULT}, + secondary_background, spacing_small, Background, SizeExt, }, text::Wrap, widget::{ - card, column, row, BoxSized, Button, ButtonStyle, ContainerStyle, Positioned, Slider, - SliderWithLabel, TextInput, + card, column, row, BoxSized, Button, ButtonStyle, ContainerStyle, SliderWithLabel, + TextInput, }, - Edges, Rect, }; use violet_wgpu::renderer::RendererConfig; -const MARGIN: Edges = Edges::even(8.0); -const MARGIN_SM: Edges = Edges::even(4.0); - fn label(text: impl Into) -> Stack { Stack::new(Text::new(text.into())) - .with_padding(MARGIN_SM) - .with_margin(MARGIN_SM) + .with_padding(spacing_small()) + .with_margin(spacing_small()) } fn pill(widget: impl Widget) -> impl Widget { Stack::new(widget).with_style(ContainerStyle { - background: Some(Background::new(EERIE_BLACK_300)), - padding: MARGIN, - margin: MARGIN, + background: Some(Background::new(secondary_background())), }) } @@ -99,24 +75,20 @@ impl Widget for MainApp { }}); column(( - row((Text::new("Input: "), TextInput::new(content))).with_style(ContainerStyle { - margin: MARGIN_SM, - padding: MARGIN_SM, - ..Default::default() - }), + row((Text::new("Input: "), TextInput::new(content))), card( column(( Button::with_label("Button"), Button::with_label("Button").with_style(ButtonStyle { - normal_color: style::success_element(), + normal_color: style::success_item().into(), ..Default::default() }), Button::with_label("Warning").with_style(ButtonStyle { - normal_color: style::warning_element(), + normal_color: style::warning_item().into(), ..Default::default() }), Button::with_label("Error").with_style(ButtonStyle { - normal_color: style::error_element(), + normal_color: style::danger_item().into(), ..Default::default() }), )) @@ -126,8 +98,14 @@ impl Widget for MainApp { .with_size(Unit::rel2(1.0, 0.0) + Unit::px2(0.0, 1.0)), card(column(( column(( - row((Text::new("Size"), SliderWithLabel::new(value, 20.0, 200.0))), - row((Text::new("Count"), SliderWithLabel::new(count, 1, 20))), + row(( + Text::new("Size"), + SliderWithLabel::new(value, 20.0, 200.0).editable(true), + )), + row(( + Text::new("Count"), + SliderWithLabel::new(count, 1, 2).editable(true), + )), )), SignalWidget::new(item_list), ))), @@ -202,11 +180,14 @@ impl Widget for ItemList { )) .with_wrap(Wrap::None), ) - .with_background(Background::new( - Hsva::new(i as f32 * 30.0, 0.6, 0.7, 1.0).into_color(), - )) - .with_padding(MARGIN_SM) - .with_margin(MARGIN_SM) + .with_background(Background::new(Srgba::from_color(Hsva::new( + i as f32 * 30.0, + 0.6, + 0.7, + 1.0, + )))) + .with_padding(spacing_small()) + .with_margin(spacing_small()) // .with_cross_align(Alignment::Center) .with_vertical_alignment(Alignment::Center) .with_horizontal_alignment(Alignment::Center) diff --git a/examples/sizing.rs b/examples/sizing.rs index 4a2cb39..5e95038 100644 --- a/examples/sizing.rs +++ b/examples/sizing.rs @@ -20,14 +20,13 @@ use violet::core::{ Edges, Scope, Widget, }; use violet_core::{ + state::MapRef, style::{colors::DARK_CYAN_DEFAULT, SizeExt}, text::Wrap, - to_owned, widget::{card, centered, column, row, Slider}, }; use violet_wgpu::renderer::RendererConfig; -const MARGIN: Edges = Edges::even(8.0); const MARGIN_SM: Edges = Edges::even(4.0); fn label(text: impl Into) -> Stack { @@ -74,21 +73,8 @@ impl Widget for Vec2Editor { fn mount(self, scope: &mut Scope<'_>) { let value = self.value; - let x = Mutable::new(value.get().x); - let y = Mutable::new(value.get().y); - - scope.spawn(x.signal().for_each({ - to_owned![value]; - move |x| { - value.lock_mut().x = x.round(); - async {} - } - })); - - scope.spawn(y.signal().for_each(move |y| { - value.lock_mut().y = y.round(); - async {} - })); + let x = MapRef::new(value.clone(), |v| &v.x, |v| &mut v.x); + let y = MapRef::new(value.clone(), |v| &v.y, |v| &mut v.y); column(( row((label(self.x_label), Slider::new(x, 0.0, 200.0))), @@ -134,7 +120,7 @@ impl Widget for FlowSizing { Unit::rel2(0.0, 0.0) + Unit::px2(10.0, 50.0), ) .with_name("DARK_CYAN"), - // AnimatedSize, + AnimatedSize, ); column(( @@ -235,6 +221,8 @@ impl Widget for AnimatedSize { }), ); - Rectangle::new(LION_DEFAULT).mount(scope) + Rectangle::new(LION_DEFAULT) + .with_size(Default::default()) + .mount(scope) } } diff --git a/recipes.json b/recipes.json index 7450cb6..e4397fb 100644 --- a/recipes.json +++ b/recipes.json @@ -2,6 +2,9 @@ "check": { "cmd": "cargo check --all-targets --all-features" }, + "run demo": { + "cmd": "cargo run --package violet-demo" + }, "run basic": { "cmd": "cargo run --package violet --example basic" }, diff --git a/violet-core/Cargo.toml b/violet-core/Cargo.toml index cb2d53c..04f14bc 100644 --- a/violet-core/Cargo.toml +++ b/violet-core/Cargo.toml @@ -18,6 +18,7 @@ futures-signals.workspace = true itertools.workspace = true glam.workspace = true futures.workspace = true +futures-concurrency.workspace = true flume.workspace = true parking_lot.workspace = true slotmap.workspace = true @@ -25,6 +26,7 @@ anyhow.workspace = true once_cell.workspace = true slab.workspace = true tynm.workspace = true +arrayvec.workspace = true bytemuck.workspace = true palette.workspace = true @@ -44,6 +46,9 @@ unicode-segmentation.workspace = true puffin.workspace = true web-time.workspace = true +[dev-dependencies] +tokio.workspace = true + [target.'cfg(target_arch = "wasm32")'.dependencies] gloo-timers.workspace = true wasm-bindgen-futures.workspace = true diff --git a/violet-core/src/editor.rs b/violet-core/src/editor.rs index 52fde1c..941649e 100644 --- a/violet-core/src/editor.rs +++ b/violet-core/src/editor.rs @@ -302,11 +302,22 @@ impl TextEditor { } pub fn set_text<'a>(&mut self, text: impl IntoIterator) { + let at_end_col = self.cursor.col >= self.text[self.cursor.row].len(); + let at_end_row = self.cursor.row >= self.text.len() - 1; + self.text.clear(); self.text.extend(text.into_iter().map(EditorLine::new)); self.cursor.row = self.cursor.row.min(self.text.len() - 1); self.cursor.col = self.cursor.col.min(self.text[self.cursor.row].len()); + + if at_end_row { + self.cursor.row = self.text.len() - 1; + } + + if at_end_col { + self.cursor.col = self.text[self.cursor.row].len(); + } } pub fn set_cursor(&mut self, row: usize, col: usize) { diff --git a/violet-core/src/input.rs b/violet-core/src/input.rs index e5a93b5..c205731 100644 --- a/violet-core/src/input.rs +++ b/violet-core/src/input.rs @@ -1,6 +1,8 @@ +use std::str::FromStr; + use flax::{ component, components::child_of, entity_ids, fetch::Satisfied, filter::All, Component, Entity, - EntityIds, EntityRef, Fetch, FetchExt, Query, Topo, + EntityIds, EntityRef, Fetch, FetchExt, Mutable, Query, Topo, World, }; use glam::Vec2; @@ -85,7 +87,6 @@ impl InputState { // Released after focusing a widget (ElementState::Released, Some(cur), _) => { if !cur.sticky { - tracing::info!(?cur, "focus lost on release"); self.set_focused(frame, None); } } @@ -97,7 +98,6 @@ impl InputState { if let Some((id, origin)) = intersect { let entity = frame.world().entity(id).unwrap(); - tracing::info!(%entity, "sending input event"); let cursor = CursorMove { modifiers: self.modifiers, absolute_pos: self.pos, @@ -121,14 +121,12 @@ impl InputState { pub fn on_cursor_move(&mut self, frame: &mut Frame, pos: Vec2) { self.pos = pos; - if let Some(cur) = &self.focused { - let entity = frame.world.entity(cur.id).unwrap(); - + 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()) { on_input( frame, - &entity, + entity, CursorMove { modifiers: self.modifiers, absolute_pos: pos, @@ -144,14 +142,11 @@ impl InputState { } pub fn on_keyboard_input(&mut self, frame: &mut Frame, event: KeyEvent) { - if let Some(cur) = &self.focused { - tracing::info!(?cur, "sending keyboard input event"); - let entity = frame.world.entity(cur.id).unwrap(); - + if let Some(entity) = &self.focused(frame.world()) { if let Ok(mut on_input) = entity.get_mut(on_keyboard_input()) { on_input( frame, - &entity, + entity, KeyboardInput { modifiers: self.modifiers, event, @@ -161,18 +156,20 @@ impl InputState { } } + fn focused<'a>(&self, world: &'a World) -> Option> { + self.focused.as_ref().and_then(|v| world.entity(v.id).ok()) + } + fn set_focused(&mut self, frame: &Frame, focused: Option) { - let cur = self.focused.as_ref().map(|v| v.id); + let cur = self.focused(&frame.world); - if cur == focused { + if cur.map(|v| v.id()) == focused { return; } - if let Some(cur) = &self.focused { - let entity = frame.world().entity(cur.id).unwrap(); - - if let Ok(mut on_focus) = entity.get_mut(on_focus()) { - on_focus(frame, &entity, false); + if let Some(cur) = cur { + if let Ok(mut on_focus) = cur.get_mut(on_focus()) { + on_focus(frame, &cur, false); } } diff --git a/violet-core/src/layout/mod.rs b/violet-core/src/layout/mod.rs index 60f4fcf..d16adf9 100644 --- a/violet-core/src/layout/mod.rs +++ b/violet-core/src/layout/mod.rs @@ -310,7 +310,7 @@ pub(crate) fn query_size( let _span = tracing::trace_span!("cached").entered(); // validate_sizing(entity, &cache.value, limits); tracing::debug!(%entity, "found valid cached query"); - // return cache.value; + return cache.value; // } } } @@ -352,6 +352,9 @@ pub(crate) fn query_size( preferred: sizing.preferred.pad(&padding), hints: sizing.hints.combine(hints), } + } else if let [child] = children { + let child = world.entity(*child).unwrap(); + query_size(world, &child, content_area, limits, direction) } else { let (instrisic_min_size, intrinsic_size, intrinsic_hints) = size_resolver .map(|v| v.query(entity, content_area, limits, direction)) @@ -453,7 +456,7 @@ pub(crate) fn update_subtree( if validate_cached_layout(value, limits, content_area, cache.fixed_size) { tracing::debug!(%entity, ?value, "found valid cached layout"); // validate_block(entity, &value.value, limits); - // return value.value; + return value.value; } } @@ -490,6 +493,12 @@ pub(crate) fn update_subtree( block.margin = (block.margin - padding).max(margin); + block + } else if let [child] = children { + let child = world.entity(*child).unwrap(); + let block = update_subtree(world, &child, content_area, limits); + + child.update_dedup(components::rect(), block.rect); block } else { assert_eq!(children, [], "Widget with children must have a layout"); diff --git a/violet-core/src/lib.rs b/violet-core/src/lib.rs index 92b6427..753ad89 100644 --- a/violet-core/src/lib.rs +++ b/violet-core/src/lib.rs @@ -11,6 +11,8 @@ pub mod input; pub mod layout; mod scope; pub mod shape; +mod sink; +pub mod state; pub mod stored; pub mod style; pub mod systems; diff --git a/violet-core/src/scope.rs b/violet-core/src/scope.rs index b35586f..e63fd9e 100644 --- a/violet-core/src/scope.rs +++ b/violet-core/src/scope.rs @@ -13,7 +13,7 @@ use pin_project::pin_project; use crate::{ assets::AssetCache, components::children, effect::Effect, input::InputEventHandler, - stored::Handle, Frame, FutureEffect, StreamEffect, Widget, + stored::Handle, style::get_stylesheet_from_entity, Frame, FutureEffect, StreamEffect, Widget, }; /// The scope within a [`Widget`][crate::Widget] is mounted or modified @@ -96,7 +96,7 @@ impl<'a> Scope<'a> { } pub fn entity(&self) -> EntityRef { - assert!(self.data.is_empty(), "EntityBuilder not flushed"); + // assert!(self.data.is_empty(), "EntityBuilder not flushed"); self.frame.world().entity(self.id).unwrap() } @@ -163,8 +163,13 @@ impl<'a> Scope<'a> { self.spawn_effect(FutureEffect::new(fut, |_: &mut Scope<'_>, _| {})) } - pub fn spawn_stream(&mut self, stream: impl 'static + Stream) { - self.spawn_effect(StreamEffect::new(stream, |_: &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 @@ -222,6 +227,11 @@ impl<'a> Scope<'a> { ) -> &mut Self { self.set(event, Box::new(func) as _) } + + /// Returns the active stylesheet for this scope + pub fn stylesheet(&self) -> EntityRef { + get_stylesheet_from_entity(&self.entity()) + } } impl Drop for Scope<'_> { diff --git a/violet-core/src/sink.rs b/violet-core/src/sink.rs new file mode 100644 index 0000000..0295f16 --- /dev/null +++ b/violet-core/src/sink.rs @@ -0,0 +1,41 @@ +// use futures::Stream; +// use futures_signals::signal::Mutable; + +// struct Closed; + +// /// A sink is a type which allows sending values. +// /// +// /// Values are sent synchronously. +// pub trait Sink { +// fn send(&self, value: T) -> Result<(), Closed>; +// } + +// pub trait DuplexSink { +// type Sink: Sink; +// type Stream: Stream; + +// fn sink(&self) -> Self::Sink; +// fn stream(&self) -> Self::Stream; +// } + +// pub struct DuplexMutable { +// inner: Mutable, + +// to_value: Box T>, +// from_value: Box U>, +// } + +// pub struct MappedSink { +// inner: S, +// func: F, +// } + +// impl Sink for MappedSink +// where +// S: Sink, +// F: Fn(U) -> U, +// { +// fn send(&self, value: U) -> Result<(), Closed> { +// self.inner.send((self.func)(value)) +// } +// } diff --git a/violet-core/src/state/bridge.rs b/violet-core/src/state/bridge.rs new file mode 100644 index 0000000..c0dc6ab --- /dev/null +++ b/violet-core/src/state/bridge.rs @@ -0,0 +1,9 @@ +/// Bridge two different states together. +/// +/// This is a bidirectional bridge, meaning that it can be used to bridge two states together +/// +/// State updates from `A` will be sent to `B` and vice versa. +pub struct Bridge { + a: A, + b: B, +} diff --git a/violet-core/src/state/dedup.rs b/violet-core/src/state/dedup.rs new file mode 100644 index 0000000..302c482 --- /dev/null +++ b/violet-core/src/state/dedup.rs @@ -0,0 +1,84 @@ +use std::{future::ready, sync::Arc}; + +use futures::{FutureExt, StreamExt}; +use futures_signals::signal::{Mutable, SignalExt}; +use parking_lot::Mutex; +use tracing::info; + +use super::{State, StateSink, StateStream, StateStreamRef}; + +/// Deduplicates a state updates for receiving streams. +/// +/// **NOTE**: Does not deduplicate for sending to sinks as it is not possible to know if the item +/// has been set by another sink or not without readback. +pub struct Dedup { + inner: T, +} + +impl Dedup { + pub fn new(inner: T) -> Self { + Self { inner } + } +} + +impl State for Dedup { + type Item = T::Item; +} + +impl StateStreamRef for Dedup +where + T: StateStreamRef, + T::Item: 'static + Send + Sync + Clone + PartialEq, +{ + fn stream_ref V, V: 'static + Send + Sync>( + &self, + mut func: F, + ) -> impl futures::prelude::Stream + 'static + Send + where + Self: Sized, + { + let mut last_seen = None; + + self.inner + .stream_ref(move |item| { + if last_seen.as_ref() != Some(item) { + last_seen = Some(item.clone()); + Some(func(item)) + } else { + None + } + }) + .filter_map(ready) + } +} + +impl StateStream for Dedup +where + T: StateStream, + T::Item: 'static + Send + Sync + PartialEq + Clone, +{ + fn stream(&self) -> futures::prelude::stream::BoxStream<'static, Self::Item> { + let mut last_seen = None; + self.inner + .stream() + .filter_map(move |v| { + if last_seen.as_ref() != Some(&v) { + last_seen = Some(v.clone()); + ready(Some(v)) + } else { + ready(None) + } + }) + .boxed() + } +} + +impl StateSink for Dedup +where + T: StateSink, + T::Item: 'static + Send + Sync + PartialEq + Clone, +{ + fn send(&self, item: Self::Item) { + self.inner.send(item); + } +} diff --git a/violet-core/src/state/feedback.rs b/violet-core/src/state/feedback.rs new file mode 100644 index 0000000..b57d95b --- /dev/null +++ b/violet-core/src/state/feedback.rs @@ -0,0 +1,84 @@ +use std::future::ready; + +use futures::{FutureExt, StreamExt}; +use futures_signals::signal::{Mutable, SignalExt}; + +use super::{State, StateSink, StateStream, StateStreamRef}; + +/// Prevents feedback loops by dropping items in the receiving stream that were sent to the sink. +pub struct PreventFeedback { + last_sent: Mutable>, + inner: T, +} + +impl PreventFeedback { + pub fn new(inner: T) -> Self { + Self { + inner, + last_sent: Default::default(), + } + } +} + +impl State for PreventFeedback { + type Item = T::Item; +} + +impl StateStreamRef for PreventFeedback +where + T: StateStreamRef, + T::Item: 'static + Send + Sync + Clone + PartialEq, +{ + fn stream_ref V, V: 'static + Send + Sync>( + &self, + mut func: F, + ) -> impl futures::prelude::Stream + 'static + Send + where + Self: Sized, + { + let mut last_sent = self.last_sent.signal_cloned().to_stream().fuse(); + + self.inner + .stream_ref(move |item| { + let last_sent = last_sent.select_next_some().now_or_never().flatten(); + if last_sent.as_ref() != Some(item) { + Some(func(item)) + } else { + None + } + }) + .filter_map(ready) + } +} + +impl StateStream for PreventFeedback +where + T: StateStream, + T::Item: 'static + Send + Sync + PartialEq + Clone, +{ + fn stream(&self) -> futures::prelude::stream::BoxStream<'static, Self::Item> { + let mut last_sent = self.last_sent.signal_cloned().to_stream().fuse(); + self.inner + .stream() + .filter_map(move |v| { + let last_sent = last_sent.select_next_some().now_or_never().flatten(); + if last_sent.as_ref() != Some(&v) { + ready(Some(v)) + } else { + ready(None) + } + }) + .boxed() + } +} + +impl StateSink for PreventFeedback +where + T: StateSink, + T::Item: 'static + Send + Sync + PartialEq + Clone, +{ + fn send(&self, item: Self::Item) { + self.last_sent.set(Some(item.clone())); + self.inner.send(item); + } +} diff --git a/violet-core/src/state/filter.rs b/violet-core/src/state/filter.rs new file mode 100644 index 0000000..2bd49ab --- /dev/null +++ b/violet-core/src/state/filter.rs @@ -0,0 +1,59 @@ +use std::{future::ready, marker::PhantomData, sync::Arc}; + +use futures::{stream::BoxStream, StreamExt}; + +use super::{State, StateSink, StateStream}; + +/// Transforms one type to another through fallible conversion. +pub struct FilterMap { + inner: C, + conv_to: Arc, + conv_from: G, + _marker: PhantomData, +} + +impl State for FilterMap { + type Item = U; +} + +impl Option, G: Fn(U) -> Option> + FilterMap +{ + pub fn new(inner: C, conv_to: F, conv_from: G) -> Self { + Self { + inner, + conv_to: Arc::new(conv_to), + conv_from, + _marker: PhantomData, + } + } +} + +impl StateStream for FilterMap +where + C: StateStream, + C::Item: 'static + Send, + U: 'static + Send + Sync + Clone, + F: 'static + Send + Sync + Fn(C::Item) -> Option, +{ + fn stream(&self) -> BoxStream<'static, Self::Item> { + let project = self.conv_to.clone(); + self.inner + .stream() + .filter_map(move |v| ready(project(v))) + .boxed() + } +} + +/// Bridge update-by-reference to update-by-value +impl StateSink for FilterMap +where + C: StateSink, + G: Fn(U) -> Option, +{ + fn send(&self, value: Self::Item) { + if let Some(v) = (self.conv_from)(value) { + self.inner.send(v) + } + } +} diff --git a/violet-core/src/state/map.rs b/violet-core/src/state/map.rs new file mode 100644 index 0000000..9dd5e0c --- /dev/null +++ b/violet-core/src/state/map.rs @@ -0,0 +1,69 @@ +use std::{marker::PhantomData, sync::Arc}; + +use futures::{stream::BoxStream, StreamExt}; + +use super::{State, StateOwned, StateSink, StateStream}; + +/// Transforms one state to another through type conversion +/// +/// +/// This allows deriving state from another where the derived state is not present in the original. +/// +/// However, as this does not assume the derived state is contained withing the original state is +/// does not allow in-place mutation. +pub struct Map { + inner: C, + conv_to: Arc, + conv_from: G, + _marker: PhantomData, +} + +impl State for Map { + type Item = U; +} + +impl U, G: Fn(U) -> C::Item> Map { + pub fn new(inner: C, project: F, project_mut: G) -> Self { + Self { + inner, + conv_to: Arc::new(project), + conv_from: project_mut, + _marker: PhantomData, + } + } +} + +impl StateOwned for Map +where + C: StateOwned, + F: Fn(C::Item) -> U, +{ + fn read(&self) -> Self::Item { + (self.conv_to)(self.inner.read()) + } +} + +impl StateStream for Map +where + C: StateStream, + C::Item: 'static + Send, + U: 'static + Send + Sync, + F: 'static + Fn(C::Item) -> U + Sync + Send, +{ + fn stream(&self) -> BoxStream<'static, Self::Item> { + let project = self.conv_to.clone(); + self.inner.stream().map(move |v| (project)(v)).boxed() + } +} + +/// Bridge update-by-reference to update-by-value +impl StateSink for Map +where + C: StateSink, + F: Fn(C::Item) -> U, + G: Fn(U) -> C::Item, +{ + fn send(&self, value: Self::Item) { + self.inner.send((self.conv_from)(value)) + } +} diff --git a/violet-core/src/state/mod.rs b/violet-core/src/state/mod.rs new file mode 100644 index 0000000..0dab0a0 --- /dev/null +++ b/violet-core/src/state/mod.rs @@ -0,0 +1,442 @@ +//! Module for state projection and transformation +//! +//! This simplifies the process of working with signals and mapping state from different types or +//! smaller parts of a larger state. +use std::{marker::PhantomData, rc::Rc, sync::Arc}; + +use futures::{stream::BoxStream, Stream, StreamExt}; +use futures_signals::signal::{Mutable, SignalExt}; + +mod dedup; +mod feedback; +mod filter; +mod map; + +pub use dedup::*; +pub use feedback::*; +pub use filter::*; +pub use map::*; + +pub trait State { + type Item; + + /// Map a state from one type to another through reference projection + fn map_ref &U, G: Fn(&mut Self::Item) -> &mut U, U>( + self, + f: F, + g: G, + ) -> MapRef + where + Self: Sized, + { + MapRef::new(self, f, g) + } + + /// Map a state from one type to another + fn map U, G: Fn(U) -> Self::Item, U>( + self, + f: F, + g: G, + ) -> Map + where + Self: Sized, + { + Map::new(self, f, g) + } + + /// Map a state from one type to another through fallible conversion + fn filter_map Option, G: Fn(U) -> Option, U>( + self, + f: F, + g: G, + ) -> FilterMap + where + Self: Sized, + { + FilterMap::new(self, f, g) + } + + fn dedup(self) -> Dedup + where + Self: Sized, + Self::Item: PartialEq + Clone, + { + Dedup::new(self) + } + + fn prevent_feedback(self) -> PreventFeedback + where + Self: Sized, + Self::Item: PartialEq + Clone, + { + PreventFeedback::new(self) + } +} + +/// A trait to read a reference from a generic state +pub trait StateRef { + type Item; + fn read_ref V, V>(&self, f: F) -> V; +} + +/// Allows reading an owned value from a state +pub trait StateOwned: State { + fn read(&self) -> Self::Item; +} + +/// A trait to read a mutable reference from a generic state. +/// +/// As opposed to [`StateSink`], this allows in place mutation or partial mutation of the state. +/// +/// Used as a building block for sinks to target e.g; specific fields of a struct. +pub trait StateMut: StateRef { + fn write_mut V, V>(&self, f: F) -> V; +} + +/// Convert a state to a stream of state changes through reference projection. +/// +/// This is only available for some states as is used to lower or transform non-cloneable states into smaller parts. +pub trait StateStreamRef: State { + /// Subscribe to a stream of the state + /// + /// The passed function is used to transform the value in the stream, to allow for handling + /// non-static or non-cloneable types. + fn stream_ref V, V: 'static + Send + Sync>( + &self, + func: F, + ) -> impl Stream + 'static + Send + where + Self: Sized; +} + +/// Convert a state to a stream of state changes. +pub trait StateStream: State { + fn stream(&self) -> BoxStream<'static, Self::Item>; +} + +/// A trait to send a value to a generic state +pub trait StateSink: State { + /// Send a value to the state + fn send(&self, value: Self::Item); +} + +/// Allows sending and receiving a value to a state +pub trait StateDuplex: StateStream + StateSink {} + +impl StateDuplex for T where T: StateStream + StateSink {} + +impl State for Mutable { + type Item = T; +} + +impl StateRef for Mutable { + type Item = T; + fn read_ref V, V>(&self, f: F) -> V { + f(&self.lock_ref()) + } +} + +impl StateOwned for Mutable { + fn read(&self) -> Self::Item { + self.get_cloned() + } +} + +impl StateMut for Mutable { + fn write_mut V, V>(&self, f: F) -> V { + f(&mut self.lock_mut()) + } +} + +impl StateStreamRef for Mutable +where + T: 'static + Send + Sync, +{ + fn stream_ref V, V: 'static + Send + Sync>( + &self, + func: F, + ) -> impl Stream + 'static + Send { + self.signal_ref(func).to_stream() + } +} + +impl StateStream for Mutable +where + T: 'static + Send + Sync + Clone, +{ + fn stream(&self) -> BoxStream<'static, Self::Item> { + self.signal_cloned().to_stream().boxed() + } +} + +impl StateSink for Mutable { + fn send(&self, value: Self::Item) { + self.set(value); + } +} + +/// Transforms one state of to another type through reference projection. +/// +/// This is used to lower a state `T` of a struct to a state `U` of a field of that struct. +/// +/// Can be used both to mutate and read the state, as well as to operate as a duplex sink and +/// stream. +pub struct MapRef { + inner: C, + project: Arc, + project_mut: G, + _marker: PhantomData, +} + +impl &U, G: Fn(&mut C::Item) -> &mut U> MapRef { + pub fn new(inner: C, project: F, project_mut: G) -> Self { + Self { + inner, + project: Arc::new(project), + project_mut, + _marker: PhantomData, + } + } +} + +impl State for MapRef { + type Item = U; +} + +impl StateRef for MapRef +where + C: StateRef, + F: Fn(&C::Item) -> &U, +{ + type Item = U; + fn read_ref V, V>(&self, f: H) -> V { + self.inner.read_ref(|v| f((self.project)(v))) + } +} + +impl StateOwned for MapRef +where + C: StateRef, + U: Clone, + F: Fn(&C::Item) -> &U, +{ + fn read(&self) -> Self::Item { + self.read_ref(|v| v.clone()) + } +} + +impl StateMut for MapRef +where + C: StateMut, + F: Fn(&C::Item) -> &U, + G: Fn(&mut C::Item) -> &mut U, +{ + fn write_mut V, V>(&self, f: H) -> V { + self.inner.write_mut(|v| f((self.project_mut)(v))) + } +} + +impl StateStreamRef for MapRef +where + C: StateStreamRef, + F: 'static + Fn(&C::Item) -> &U + Sync + Send, +{ + fn stream_ref V, V: 'static + Send + Sync>( + &self, + mut func: I, + ) -> impl Stream + 'static + Send { + let project = self.project.clone(); + self.inner.stream_ref(move |v| func(project(v))) + } +} + +impl StateStream for MapRef +where + C: StateStreamRef, + U: 'static + Send + Sync + Clone, + F: 'static + Fn(&C::Item) -> &U + Sync + Send, +{ + fn stream(&self) -> BoxStream<'static, Self::Item> { + self.stream_ref(|v| v.clone()).boxed() + } +} + +/// Bridge update-by-reference to update-by-value +impl StateSink for MapRef +where + C: StateMut, + F: Fn(&C::Item) -> &U, + G: Fn(&mut C::Item) -> &mut U, +{ + fn send(&self, value: Self::Item) { + self.write_mut(|v| *v = value); + } +} + +// type MappedMutableStream = +// SignalStream V>>>; +// pub struct MappedStream { +// inner: MappedMutableStream, +// } + +// impl Stream for MappedStream { +// type Item = V; + +// fn poll_next( +// mut self: std::pin::Pin<&mut Self>, +// cx: &mut std::task::Context<'_>, +// ) -> std::task::Poll> { +// self.inner.poll_next_unpin(cx) +// } +// } + +macro_rules! impl_container { + ($ty: ident) => { + impl State for $ty + where + T: ?Sized + State, + { + type Item = T::Item; + } + + impl StateRef for $ty + where + T: StateRef, + { + type Item = T::Item; + fn read_ref V, V>(&self, f: F) -> V { + (**self).read_ref(f) + } + } + + impl StateOwned for $ty + where + T: StateOwned, + { + fn read(&self) -> Self::Item { + (**self).read() + } + } + + impl StateMut for $ty + where + T: StateMut, + { + fn write_mut V, V>(&self, f: F) -> V { + (**self).write_mut(f) + } + } + + impl StateStreamRef for $ty + where + T: StateStreamRef, + { + fn stream_ref< + F: 'static + Send + Sync + FnMut(&Self::Item) -> V, + V: 'static + Send + Sync, + >( + &self, + func: F, + ) -> impl Stream + 'static + Send { + (**self).stream_ref(func) + } + } + + impl StateStream for $ty + where + T: ?Sized + StateStream, + { + fn stream(&self) -> BoxStream<'static, Self::Item> { + (**self).stream() + } + } + + impl StateSink for $ty + where + T: ?Sized + StateSink, + { + fn send(&self, value: Self::Item) { + (**self).send(value) + } + } + }; +} + +impl_container!(Box); +impl_container!(Arc); +impl_container!(Rc); + +impl State for flume::Receiver { + type Item = T; +} + +impl State for flume::Sender { + type Item = T; +} + +impl StateStreamRef for flume::Receiver +where + T: 'static + Send + Sync, +{ + fn stream_ref V, V: 'static + Send + Sync>( + &self, + mut func: F, + ) -> impl 'static + Send + Stream { + self.clone().into_stream().map(move |v| func(&v)) + } +} + +impl StateStream for flume::Receiver +where + T: 'static + Send + Sync, +{ + fn stream(&self) -> BoxStream<'static, Self::Item> { + self.clone().into_stream().boxed() + } +} + +impl StateSink for flume::Sender { + fn send(&self, value: T) { + self.send(value).unwrap(); + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[tokio::test] + async fn mapped_mutable() { + let state = Mutable::new((1, 2)); + + let a = MapRef::new(state.clone(), |v| &v.0, |v| &mut v.0); + + assert_eq!(a.read(), 1); + + let mut stream1 = a.stream_ref(|v| *v); + let mut stream2 = a.stream_ref(|v| *v); + + assert_eq!(stream1.next().await, Some(1)); + a.write_mut(|v| *v = 2); + assert_eq!(stream1.next().await, Some(2)); + assert_eq!(stream2.next().await, Some(2)); + } + + #[tokio::test] + async fn project_duplex() { + let state = Mutable::new((1, 2)); + + let a = MapRef::new(state.clone(), |v| &v.0, |v| &mut v.0); + + let a = Box::new(a) as Box>; + + let mut stream1 = a.stream(); + let mut stream2 = a.stream(); + + assert_eq!(stream1.next().await, Some(1)); + a.send(2); + assert_eq!(stream1.next().await, Some(2)); + assert_eq!(stream2.next().await, Some(2)); + } +} diff --git a/violet-core/src/style/mod.rs b/violet-core/src/style/mod.rs index 423868e..3db7190 100644 --- a/violet-core/src/style/mod.rs +++ b/violet-core/src/style/mod.rs @@ -1,25 +1,22 @@ pub mod colors; use flax::{ - components::child_of, Entity, EntityBuilder, EntityRef, EntityRefMut, Exclusive, FetchExt, - RelationExt, -}; -use glam::{vec2, IVec2, Vec2}; -use palette::{ - named::{BLACK, GRAY, GREEN, LIMEGREEN, ORANGE, RED, SLATEGRAY, WHITE}, - IntoColor, Oklab, Srgba, WithAlpha, + component::ComponentValue, components::child_of, Component, Entity, EntityBuilder, EntityRef, + Exclusive, FetchExt, RelationExt, }; +use glam::Vec2; +use palette::{IntoColor, Oklab, Srgba}; use crate::{ - components::{color, draw_shape, max_size, min_size, size}, + components::{color, draw_shape, margin, max_size, min_size, padding, size}, shape::shape_rectangle, unit::Unit, Edges, Scope, }; use self::colors::{ - EERIE_BLACK_600, EERIE_BLACK_700, EERIE_BLACK_800, EERIE_BLACK_DEFAULT, JADE_400, JADE_600, - JADE_DEFAULT, LION_DEFAULT, PLATINUM_DEFAULT, REDWOOD_DEFAULT, + EERIE_BLACK_600, EERIE_BLACK_700, EERIE_BLACK_DEFAULT, JADE_400, JADE_600, JADE_DEFAULT, + LION_DEFAULT, PLATINUM_DEFAULT, REDWOOD_DEFAULT, }; #[macro_export] @@ -52,12 +49,14 @@ pub trait StyleExt { fn with_style(self, style: Self::Style) -> Self; } -/// Base properties for widget size +/// Base properties for widget size and spacing #[derive(Debug, Clone, Default)] pub struct WidgetSize { pub size: Option>, pub min_size: Option>, pub max_size: Option>, + pub margin: Option>, + pub padding: Option>, } impl WidgetSize { @@ -66,15 +65,61 @@ impl WidgetSize { } pub fn mount(&self, scope: &mut Scope<'_>) { + let stylesheet = scope.stylesheet(); + + let m = self.margin.map(|v| v.resolve(stylesheet)); + let p = self.padding.map(|v| v.resolve(stylesheet)); + scope + .set_opt(margin(), m) + .set_opt(padding(), p) .set_opt(size(), self.size) .set_opt(min_size(), self.min_size) .set_opt(max_size(), self.max_size); } + + /// Set the size + pub fn with_size(mut self, size: Unit) -> Self { + self.size = Some(size); + self + } + + /// Set the min size + pub fn with_min_size(mut self, size: Unit) -> Self { + self.min_size = Some(size); + self + } + + /// Set the max size + pub fn with_max_size(mut self, size: Unit) -> Self { + self.max_size = Some(size); + self + } + + /// Set the margin + pub fn with_margin(mut self, margin: impl Into>) -> Self { + self.margin = Some(margin.into()); + self + } + + /// Set the padding around inner content. + pub fn with_padding(mut self, padding: impl Into>) -> Self { + self.padding = Some(padding.into()); + self + } } /// A widget that allows you to set its sizing properties pub trait SizeExt { + /// Override all the size properties of the widget + fn with_size_props(mut self, size: WidgetSize) -> Self + where + Self: Sized, + { + *self.size_mut() = size; + self + } + /// Set the preferred size fn with_size(mut self, size: Unit) -> Self where @@ -102,161 +147,134 @@ pub trait SizeExt { self } - fn size_mut(&mut self) -> &mut WidgetSize; -} - -#[derive(Debug, Clone, Copy)] -pub struct Background { - pub color: Srgba, -} - -impl Background { - pub fn new(color: Srgba) -> Self { - Self { color } + /// Set the margin + fn with_margin(mut self, margin: impl Into>) -> Self + where + Self: Sized, + { + self.size_mut().margin = Some(margin.into()); + self } - pub fn mount(self, scope: &mut Scope) { - scope - .set(draw_shape(shape_rectangle()), ()) - .set(color(), self.color); + /// Set the padding around inner content. + /// + /// **NOTE**: Padding has no effect on widgets without children. Padding strictly affect + /// the distance between the widget and the contained children. Notable examples include lists + /// and stacks. This is merely added for consistency and not adding **too** many different + /// traits to implement :P + fn with_padding(mut self, padding: impl Into>) -> Self + where + Self: Sized, + { + self.size_mut().padding = Some(padding.into()); + self } -} -#[derive(Debug, Clone, Copy)] -pub struct SpacingConfig { - /// The size of the default spacing unit - pub base_scale: f32, + fn size_mut(&mut self) -> &mut WidgetSize; } -impl Default for SpacingConfig { - fn default() -> Self { - Self { base_scale: 4.0 } - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValueOrRef { + Value(T), + Ref(Component), } -impl SpacingConfig { - pub fn small>(&self) -> T { - T::from_spacing(self.base_scale, 1) +impl ValueOrRef { + pub fn value(value: T) -> Self { + Self::Value(value) } - pub fn medium>(&self) -> T { - T::from_spacing(self.base_scale, 2) + pub fn ref_(component: Component) -> Self { + Self::Ref(component) } - - pub fn large>(&self) -> T { - T::from_spacing(self.base_scale, 4) - } - - pub fn size, S>(&self, size: S) -> T { - T::from_spacing(self.base_scale, size) - } -} - -/// Converts a size to a pixel value -pub trait FromSize { - fn from_spacing(base_scale: f32, size: S) -> Self; } -impl FromSize> for Unit -where - T: FromSize, -{ - fn from_spacing(base_scale: f32, size: Unit) -> Self { - Unit::new( - T::from_spacing(base_scale, size.px), - T::from_spacing(base_scale, size.rel), - ) +impl Default for ValueOrRef { + fn default() -> Self { + Self::Value(Default::default()) } } -impl FromSize for Vec2 { - fn from_spacing(base_scale: f32, size: IVec2) -> Self { - vec2(base_scale * size.x as f32, base_scale * size.y as f32) +impl From> for ValueOrRef { + fn from(v: Component) -> Self { + Self::Ref(v) } } -impl FromSize for f32 { - fn from_spacing(base_scale: f32, size: usize) -> Self { - base_scale * size as f32 +impl From for ValueOrRef { + fn from(v: T) -> Self { + Self::Value(v) } } -impl FromSize for Edges { - fn from_spacing(base_scale: f32, size: usize) -> Self { - Edges::even(base_scale * size as f32) +impl ValueOrRef { + pub(crate) fn resolve(self, stylesheet: EntityRef<'_>) -> T { + match self { + ValueOrRef::Value(value) => value, + ValueOrRef::Ref(component) => { + let value = stylesheet.get_copy(component).unwrap(); + value + } + } } } #[derive(Debug, Clone, Copy)] -pub struct SemanticColors { - pub primary_element: Srgba, - pub secondary_element: Srgba, - pub accent_element: Srgba, - pub success_element: Srgba, - pub warning_element: Srgba, - pub error_element: Srgba, - - pub primary_surface: Srgba, - pub secondary_surface: Srgba, - pub accent_surface: Srgba, - pub success_surface: Srgba, - pub warning_surface: Srgba, - pub error_surface: Srgba, +pub struct Background { + pub color: ValueOrRef, } -impl Default for SemanticColors { - fn default() -> Self { - SemanticColors { - primary_element: WHITE.with_alpha(1.0).into_format(), - secondary_element: GRAY.with_alpha(1.0).into_format(), - accent_element: LIMEGREEN.with_alpha(1.0).into_format(), - - success_element: GREEN.with_alpha(1.0).into_format(), - warning_element: ORANGE.with_alpha(1.0).into_format(), - error_element: RED.with_alpha(1.0).into_format(), - - primary_surface: BLACK.with_alpha(1.0).into_format(), - secondary_surface: SLATEGRAY.with_alpha(1.0).into_format(), - accent_surface: SLATEGRAY.with_alpha(1.0).into_format(), - success_surface: SLATEGRAY.with_alpha(1.0).into_format(), - warning_surface: SLATEGRAY.with_alpha(1.0).into_format(), - error_surface: SLATEGRAY.with_alpha(1.0).into_format(), +impl Background { + pub fn new(color: impl Into>) -> Self { + Self { + color: color.into(), } } + + pub fn mount(self, scope: &mut Scope) { + let c = self.color.resolve(scope.stylesheet()); + scope.set(draw_shape(shape_rectangle()), ()).set(color(), c); + } +} + +pub enum Spacing { + Small, + Medium, + Large, } -pub fn get_stylesheet<'a>(scope: &'a Scope<'_>) -> EntityRef<'a> { +pub fn get_stylesheet_from_entity<'a>(entity: &EntityRef<'a>) -> EntityRef<'a> { let query = stylesheet.first_relation().traverse(child_of); - let (id, _) = scope - .entity() - .query(&query) - .get() - .expect("No stylesheet found"); + let (id, _) = entity.query(&query).get().expect("No stylesheet found"); - scope.frame().world.entity(id).unwrap() + entity.world().entity(id).unwrap() } pub fn setup_stylesheet() -> EntityBuilder { let mut builder = Entity::builder(); builder - .set(primary_surface(), EERIE_BLACK_DEFAULT) - .set(primary_element(), PLATINUM_DEFAULT) - .set(secondary_surface(), EERIE_BLACK_600) - .set(accent_surface(), EERIE_BLACK_DEFAULT) - .set(accent_element(), JADE_DEFAULT) - .set(success_surface(), EERIE_BLACK_DEFAULT) - .set(success_element(), JADE_DEFAULT) - .set(warning_surface(), EERIE_BLACK_DEFAULT) - .set(warning_element(), LION_DEFAULT) - .set(error_surface(), EERIE_BLACK_DEFAULT) - .set(error_element(), REDWOOD_DEFAULT) + // colors + .set(primary_background(), EERIE_BLACK_DEFAULT) + .set(primary_item(), PLATINUM_DEFAULT) + .set(secondary_background(), EERIE_BLACK_600) + .set(accent_background(), EERIE_BLACK_DEFAULT) + .set(accent_item(), JADE_DEFAULT) + .set(success_background(), EERIE_BLACK_DEFAULT) + .set(success_item(), JADE_DEFAULT) + .set(warning_background(), EERIE_BLACK_DEFAULT) + .set(warning_item(), LION_DEFAULT) + .set(danger_background(), EERIE_BLACK_DEFAULT) + .set(danger_item(), REDWOOD_DEFAULT) .set(interactive_active(), JADE_DEFAULT) .set(interactive_hover(), JADE_600) .set(interactive_pressed(), JADE_400) .set(interactive_inactive(), EERIE_BLACK_700) - .set(spacing(), SpacingConfig { base_scale: 4.0 }); + // spacing + .set(spacing_small(), 4.0.into()) + .set(spacing_medium(), 8.0.into()) + .set(spacing_large(), 16.0.into()); builder } @@ -268,30 +286,33 @@ pub fn setup_stylesheet() -> EntityBuilder { flax::component! { pub stylesheet(id): () => [ Exclusive ], /// The primary surface color - pub primary_surface: Srgba, - pub primary_element: Srgba, + pub primary_background: Srgba, + pub primary_item: Srgba, /// Used for secondary surfaces, such as card backgrounds - pub secondary_surface: Srgba, - pub secondary_element: Srgba, + pub secondary_background: Srgba, + pub secondary_item: Srgba, - pub accent_surface: Srgba, - pub accent_element: Srgba, + pub accent_background: Srgba, + pub accent_item: Srgba, - pub success_surface: Srgba, - pub success_element: Srgba, + pub success_background: Srgba, + pub success_item: Srgba, - pub warning_surface: Srgba, - pub warning_element: Srgba, + pub warning_background: Srgba, + pub warning_item: Srgba, - pub error_surface: Srgba, - pub error_element: Srgba, + pub danger_background: Srgba, + pub danger_item: Srgba, - pub spacing: SpacingConfig, /// Used for the main parts of interactive elements pub interactive_active: Srgba, pub interactive_inactive: Srgba, pub interactive_hover: Srgba, pub interactive_pressed: Srgba, + + pub spacing_small: Edges, + pub spacing_medium: Edges, + pub spacing_large: Edges, } diff --git a/violet-core/src/types.rs b/violet-core/src/types.rs index 487d9f9..cf38e93 100644 --- a/violet-core/src/types.rs +++ b/violet-core/src/types.rs @@ -11,6 +11,12 @@ pub struct Edges { pub bottom: f32, } +impl From for Edges { + fn from(value: f32) -> Self { + Self::even(value) + } +} + impl std::ops::Sub for Edges { type Output = Self; diff --git a/violet-core/src/utils.rs b/violet-core/src/utils.rs index e6b8610..04b487e 100644 --- a/violet-core/src/utils.rs +++ b/violet-core/src/utils.rs @@ -1,6 +1,83 @@ +use std::task::Poll; + +use futures::Stream; + #[macro_export] macro_rules! to_owned { ($($ident: ident),*) => ( $(let $ident = $ident.to_owned();)* ) } + +/// Combines two streams yielding the latest value from each stream +pub fn zip_latest_ref(a: A, b: B, func: F) -> ZipLatest { + ZipLatest::new(a, b, func) +} + +/// Combines two streams yielding the latest value from each stream +pub fn zip_latest( + a: A, + b: B, +) -> ZipLatest (A::Item, B::Item)> +where + A: Stream, + B: Stream, + A::Item: Clone, + B::Item: Clone, +{ + ZipLatest::new(a, b, |a: &A::Item, b: &B::Item| (a.clone(), b.clone())) +} +#[pin_project::pin_project] +pub struct ZipLatest { + #[pin] + a: A, + #[pin] + b: B, + b_item: Option, + a_item: Option, + func: F, +} + +impl ZipLatest { + pub fn new(a: A, b: B, func: F) -> Self { + Self { + a, + b, + a_item: None, + b_item: None, + func, + } + } +} + +impl Stream for ZipLatest +where + A: Stream, + B: Stream, + F: FnMut(&A::Item, &B::Item) -> V, +{ + type Item = V; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let p = self.project(); + let mut ready = false; + + if let Poll::Ready(value) = p.a.poll_next(cx) { + *p.a_item = value; + ready = true; + } + + if let Poll::Ready(value) = p.b.poll_next(cx) { + *p.b_item = value; + ready = true; + } + + match (&p.a_item, &p.b_item) { + (Some(a), Some(b)) if ready => Poll::Ready(Some((p.func)(a, b))), + _ => Poll::Pending, + } + } +} diff --git a/violet-core/src/view.rs b/violet-core/src/view.rs new file mode 100644 index 0000000..db88843 --- /dev/null +++ b/violet-core/src/view.rs @@ -0,0 +1 @@ +///! This module provides utility functions for signals for projection. diff --git a/violet-core/src/widget/basic.rs b/violet-core/src/widget/basic.rs index b27575b..af2335b 100644 --- a/violet-core/src/widget/basic.rs +++ b/violet-core/src/widget/basic.rs @@ -8,7 +8,7 @@ use crate::{ self, aspect_ratio, color, draw_shape, font_size, min_size, size, text, text_wrap, }, shape, - style::{SizeExt, StyleExt, WidgetSize}, + style::{spacing_large, spacing_small, SizeExt, StyleExt, ValueOrRef, WidgetSize}, text::{TextSegment, Wrap}, unit::Unit, Scope, Widget, @@ -17,14 +17,14 @@ use crate::{ /// A rectangular widget #[derive(Debug, Clone)] pub struct Rectangle { - color: Srgba, + color: ValueOrRef, size: WidgetSize, } impl Rectangle { - pub fn new(color: Srgba) -> Self { + pub fn new(color: impl Into>) -> Self { Self { - color, + color: color.into(), size: Default::default(), } } @@ -34,9 +34,11 @@ impl Widget for Rectangle { fn mount(self, scope: &mut Scope) { self.size.mount(scope); + let c = self.color.resolve(scope.stylesheet()); + scope .set(draw_shape(shape::shape_rectangle()), ()) - .set(color(), self.color); + .set(color(), c); } } @@ -95,20 +97,19 @@ impl Default for TextStyle { pub struct Text { text: Vec, style: TextStyle, + size: WidgetSize, } impl Text { pub fn new(text: impl Into) -> Self { - Self { - text: vec![TextSegment::new(text.into())], - style: TextStyle::default(), - } + Self::rich([TextSegment::new(text.into())]) } pub fn rich(text: impl IntoIterator) -> Self { Self { text: text.into_iter().collect(), style: TextStyle::default(), + size: Default::default(), } } @@ -139,8 +140,16 @@ impl StyleExt for Text { } } +impl SizeExt for Text { + fn size_mut(&mut self) -> &mut WidgetSize { + &mut self.size + } +} + impl Widget for Text { fn mount(self, scope: &mut Scope) { + self.size.mount(scope); + scope .set(draw_shape(shape::shape_text()), ()) .set(font_size(), self.style.font_size) @@ -150,6 +159,11 @@ impl Widget for Text { } } +/// A text with a margin +pub fn label(text: impl Into) -> Text { + Text::new(text).with_margin(spacing_small()) +} + /// Allows a widget to be manually positioned and offset pub struct Positioned { offset: Unit, diff --git a/violet-core/src/widget/container.rs b/violet-core/src/widget/container.rs index f1045d0..e31e859 100644 --- a/violet-core/src/widget/container.rs +++ b/violet-core/src/widget/container.rs @@ -6,7 +6,10 @@ use crate::{ components::{anchor, layout, margin, max_size, min_size, offset, padding, rect}, input::{focusable, on_cursor_move, on_mouse_input}, layout::{Alignment, Direction, FlowLayout, Layout, StackLayout}, - style::{colors::EERIE_BLACK_400, Background, SizeExt, StyleExt, WidgetSize}, + style::{ + colors::{EERIE_BLACK_300, EERIE_BLACK_400}, + Background, SizeExt, StyleExt, WidgetSize, + }, unit::Unit, Edges, Frame, Scope, Widget, WidgetCollection, }; @@ -18,8 +21,6 @@ use crate::{ /// **NOTE**: direction and alignment are not included here, and should be given on a per-widget basis. #[derive(Default, Debug, Clone)] pub struct ContainerStyle { - pub margin: Edges, - pub padding: Edges, pub background: Option, } @@ -28,10 +29,6 @@ impl ContainerStyle { if let Some(background) = self.background { background.mount(scope); } - - scope - .set(margin(), self.margin) - .set(padding(), self.padding); } } @@ -65,16 +62,6 @@ impl Stack { self } - pub fn with_margin(mut self, margin: Edges) -> Self { - self.style.margin = margin; - self - } - - pub fn with_padding(mut self, padding: Edges) -> Self { - self.style.padding = padding; - self - } - pub fn with_background(mut self, background: Background) -> Self { self.style.background = Some(background); self @@ -150,16 +137,6 @@ impl List { self } - pub fn with_margin(mut self, margin: Edges) -> Self { - self.style.margin = margin; - self - } - - pub fn with_padding(mut self, padding: Edges) -> Self { - self.style.padding = padding; - self - } - pub fn with_background(mut self, background: Background) -> Self { self.style.background = Some(background); self @@ -273,3 +250,11 @@ pub fn card(widget: W) -> Stack { .with_padding(Edges::even(4.0)) .with_margin(Edges::even(4.0)) } + +pub fn card2(widget: W) -> Stack { + Stack::new(widget) + // TODO: semantic color and sizing increment + .with_background(Background::new(EERIE_BLACK_300)) + .with_padding(Edges::even(4.0)) + .with_margin(Edges::even(4.0)) +} diff --git a/violet-core/src/widget/future.rs b/violet-core/src/widget/future.rs index 9e29971..afb72e1 100644 --- a/violet-core/src/widget/future.rs +++ b/violet-core/src/widget/future.rs @@ -1,7 +1,7 @@ -use futures::Stream; +use futures::{Future, Stream}; use futures_signals::signal::{self, SignalExt}; -use crate::{components::layout, layout::Layout, Scope, StreamEffect, Widget}; +use crate::{components::layout, layout::Layout, FutureEffect, Scope, StreamEffect, Widget}; pub struct SignalWidget(pub S); @@ -24,22 +24,23 @@ where let mut child = None; let stream = self.0.to_stream(); - scope - .set(layout(), Layout::Stack(Default::default())) - .spawn_effect(StreamEffect::new( - stream, - move |scope: &mut Scope<'_>, v| { - if let Some(child) = child { - scope.detach(child); - } + scope.spawn_effect(StreamEffect::new( + stream, + move |scope: &mut Scope<'_>, v| { + if let Some(child) = child { + scope.detach(child); + } - child = Some(scope.attach(v)); - }, - )); + child = Some(scope.attach(v)); + }, + )); } } -pub struct StreamWidget(pub S); +pub struct StreamWidget(pub S) +where + S: Stream, + S::Item: Widget; impl Widget for StreamWidget where @@ -49,17 +50,35 @@ where fn mount(self, scope: &mut crate::Scope<'_>) { let mut child = None; - scope - .set(layout(), Layout::Stack(Default::default())) - .spawn_effect(StreamEffect::new( - self.0, - move |scope: &mut Scope<'_>, v| { - if let Some(child) = child { - scope.detach(child); - } + scope.spawn_effect(StreamEffect::new( + self.0, + move |scope: &mut Scope<'_>, v| { + if let Some(child) = child { + scope.detach(child); + } + + child = Some(scope.attach(v)); + }, + )); + } +} + +pub struct FutureWidget(pub S) +where + S: Future, + S::Output: Widget; - child = Some(scope.attach(v)); - }, - )); +impl Widget for FutureWidget +where + S: 'static + Future, + W: Widget, +{ + fn mount(self, scope: &mut crate::Scope<'_>) { + scope.spawn_effect(FutureEffect::new( + self.0, + move |scope: &mut Scope<'_>, v| { + scope.attach(v); + }, + )); } } diff --git a/violet-core/src/widget/interactive/button.rs b/violet-core/src/widget/interactive/button.rs index d255f35..0f1c8c4 100644 --- a/violet-core/src/widget/interactive/button.rs +++ b/violet-core/src/widget/interactive/button.rs @@ -1,32 +1,31 @@ -use flax::Component; use palette::Srgba; use winit::event::{ElementState, MouseButton}; use crate::{ components::color, - input::{focusable, on_focus, on_mouse_input}, + input::{focusable, on_mouse_input}, layout::Alignment, style::{ - get_stylesheet, interactive_active, interactive_inactive, interactive_pressed, spacing, - Background, StyleExt, + danger_item, interactive_inactive, interactive_pressed, spacing_medium, success_item, + warning_item, Background, SizeExt, StyleExt, ValueOrRef, WidgetSize, }, widget::{ContainerStyle, Stack, Text}, - Edges, Frame, Scope, Widget, + Frame, Scope, Widget, }; type ButtonCallback = Box; #[derive(Debug, Clone)] pub struct ButtonStyle { - pub normal_color: Component, - pub pressed_color: Component, + pub normal_color: ValueOrRef, + pub pressed_color: ValueOrRef, } impl Default for ButtonStyle { fn default() -> Self { Self { - normal_color: interactive_inactive(), - pressed_color: interactive_pressed(), + normal_color: interactive_inactive().into(), + pressed_color: interactive_pressed().into(), } } } @@ -36,14 +35,19 @@ pub struct Button { on_press: ButtonCallback, label: W, style: ButtonStyle, + size: WidgetSize, } impl Button { - pub fn new(label: W) -> Self { + pub fn new(label: W) -> Self + where + W: Widget, + { Self { on_press: Box::new(|_, _| {}), label, style: Default::default(), + size: WidgetSize::default().with_padding(spacing_medium()), } } @@ -55,6 +59,21 @@ impl Button { self.on_press = Box::new(on_press); self } + + pub fn success(mut self) -> Self { + self.style.normal_color = success_item().into(); + self + } + + pub fn danger(mut self) -> Self { + self.style.normal_color = danger_item().into(); + self + } + + pub fn warning(mut self) -> Self { + self.style.normal_color = warning_item().into(); + self + } } impl Button { @@ -72,40 +91,37 @@ impl StyleExt for Button { } } +impl SizeExt for Button { + fn size_mut(&mut self) -> &mut WidgetSize { + &mut self.size + } +} + impl Widget for Button { fn mount(mut self, scope: &mut Scope<'_>) { - let stylesheet = get_stylesheet(scope); - - let spacing = stylesheet.get_copy(spacing()).unwrap_or_default(); - let margin = spacing.medium(); - let padding = spacing.medium(); + let stylesheet = scope.stylesheet(); - let pressed_color = stylesheet - .get_copy(self.style.pressed_color) - .unwrap_or_default(); + let pressed_color = self.style.pressed_color.resolve(stylesheet); + let normal_color = self.style.normal_color.resolve(stylesheet); - let normal_color = stylesheet - .get_copy(self.style.normal_color) - .unwrap_or_default(); scope .set(focusable(), ()) - .on_event(on_focus(), move |_, entity, focus| { - entity.update_dedup(color(), if focus { pressed_color } else { normal_color }); - }) - .on_event(on_mouse_input(), move |frame, _, input| { + .on_event(on_mouse_input(), move |frame, entity, input| { if input.state == ElementState::Pressed { + entity.update_dedup(color(), pressed_color); (self.on_press)(frame, input.button); + } else { + entity.update_dedup(color(), normal_color); } }); Stack::new(self.label) .with_style(ContainerStyle { - margin, - padding, background: Some(Background::new(normal_color)), }) .with_horizontal_alignment(Alignment::Center) .with_vertical_alignment(Alignment::Center) + .with_size_props(self.size) .mount(scope); } } diff --git a/violet-core/src/widget/interactive/input.rs b/violet-core/src/widget/interactive/input.rs index 789df9a..d071d37 100644 --- a/violet-core/src/widget/interactive/input.rs +++ b/violet-core/src/widget/interactive/input.rs @@ -1,4 +1,6 @@ -use flax::Component; +use core::panic; +use std::{fmt::Display, future::ready, str::FromStr, sync::Arc}; + use futures::{FutureExt, StreamExt}; use futures_signals::signal::{self, Mutable, SignalExt}; use glam::{vec2, Vec2}; @@ -12,20 +14,23 @@ use winit::{ use crate::{ components::{self, screen_rect}, editor::{CursorMove, EditAction, EditorAction, TextEditor}, - input::{focus_sticky, focusable, on_keyboard_input, on_mouse_input, KeyboardInput}, + input::{focus_sticky, focusable, on_focus, on_keyboard_input, on_mouse_input, KeyboardInput}, + state::{State, StateDuplex, StateSink, StateStream}, style::{ - colors::EERIE_BLACK_300, get_stylesheet, interactive_active, spacing, Background, SizeExt, - StyleExt, WidgetSize, + colors::EERIE_BLACK_300, interactive_active, spacing_small, Background, SizeExt, StyleExt, + ValueOrRef, WidgetSize, }, text::{LayoutGlyphs, TextSegment}, to_owned, unit::Unit, - widget::{NoOp, Positioned, Rectangle, SignalWidget, Stack, Text, WidgetExt}, + widget::{ + row, NoOp, Positioned, Rectangle, SignalWidget, Stack, StreamWidget, Text, WidgetExt, + }, Rect, Scope, Widget, }; pub struct TextInputStyle { - pub cursor_color: Component, + pub cursor_color: ValueOrRef, pub background: Background, pub font_size: f32, } @@ -33,25 +38,28 @@ pub struct TextInputStyle { impl Default for TextInputStyle { fn default() -> Self { Self { - cursor_color: interactive_active(), + cursor_color: interactive_active().into(), background: Background::new(EERIE_BLACK_300), - font_size: 18.0, + font_size: 16.0, } } } +/// Text field allowing arbitrary user input pub struct TextInput { style: TextInputStyle, - content: Mutable, + content: Arc>, size: WidgetSize, } impl TextInput { - pub fn new(content: Mutable) -> Self { + pub fn new(content: impl 'static + Send + Sync + StateDuplex) -> Self { Self { - content, + content: Arc::new(content), style: Default::default(), - size: Default::default(), + size: WidgetSize::default() + .with_margin(spacing_small()) + .with_padding(spacing_small()), } } } @@ -73,54 +81,65 @@ impl SizeExt for TextInput { impl Widget for TextInput { fn mount(self, scope: &mut Scope<'_>) { - let stylesheet = get_stylesheet(scope); - let spacing = stylesheet.get_copy(spacing()).unwrap_or_default(); - let cursor_color = stylesheet - .get_copy(self.style.cursor_color) - .unwrap_or_default(); + let stylesheet = scope.stylesheet(); + + let cursor_color = self.style.cursor_color.resolve(stylesheet); let (tx, rx) = flume::unbounded(); - let content = self.content.clone(); + let focused = Mutable::new(false); + // Internal text to keep track of non-bijective text changes, such as incomplete numeric + // input + let text_content = Mutable::new(String::new()); let mut editor = TextEditor::new(); let layout_glyphs = Mutable::new(None); let text_bounds: Mutable> = Mutable::new(None); - editor.set_text(content.lock_mut().split('\n')); + // 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; scope.spawn({ let mut layout_glyphs = layout_glyphs.signal_cloned().to_stream(); + let mut focused_signal = focused.stream().fuse(); + to_owned![text_content]; async move { - let mut rx = rx.into_stream(); + let mut rx = rx.into_stream().fuse(); let mut glyphs: LayoutGlyphs; let mut cursor_pos = Vec2::ZERO; + let mut new_text = content.stream().fuse(); + let mut focused = false; + loop { futures::select! { - action = rx.next().fuse() => { - if let Some(action) = action { + focus = focused_signal.select_next_some() => { + focused = focus; + } + new_text = new_text.select_next_some() => { + editor.set_text(new_text.split('\n')); + text_content.send(new_text); + } + action = rx.select_next_some() => { + editor.apply_action(action); - editor.apply_action(action); + let mut text = text_content.lock_mut(); + text.clear(); + #[allow(unstable_name_collisions)] + text.extend(editor.lines().iter().map(|v| v.text()).intersperse("\n")); - let mut c = content.lock_mut(); - c.clear(); - for line in editor.lines() { - c.push_str(line.text()); - c.push('\n'); - } - } + 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 { glyphs = new_glyphs; - tracing::info!("{:?}", glyphs.lines().iter().map(|v| v.glyphs.len()).collect_vec()); if let Some(loc) = glyphs.to_glyph_boundary(editor.cursor()) { cursor_pos = loc; @@ -139,12 +158,12 @@ impl Widget for TextInput { } } - editor_props_tx + editor_props_tx .send(Box::new(Stack::new( ( - Positioned::new(Rectangle::new(cursor_color) - .with_min_size(Unit::px2(2.0, 18.0))) - .with_offset(Unit::px(cursor_pos)), + focused.then(|| Positioned::new(Rectangle::new(cursor_color) + .with_min_size(Unit::px2(2.0, 16.0))) + .with_offset(Unit::px(cursor_pos))), ) ))) .ok(); @@ -155,6 +174,9 @@ impl Widget for TextInput { scope .set(focusable(), ()) .set(focus_sticky(), ()) + .on_event(on_focus(), move |_, _, focus| { + focused.set(focus); + }) .on_event(on_mouse_input(), { to_owned![layout_glyphs, text_bounds, tx]; move |_, _, input| { @@ -186,7 +208,7 @@ impl Widget for TextInput { }); Stack::new(( - SignalWidget(self.content.signal_cloned().map(move |v| { + StreamWidget(text_content.stream().map(move |v| { to_owned![text_bounds]; Text::rich([TextSegment::new(v)]) .with_font_size(self.style.font_size) @@ -196,8 +218,7 @@ impl Widget for TextInput { SignalWidget(editor_props_rx), )) .with_background(self.style.background) - .with_padding(spacing.small()) - .with_margin(spacing.small()) + .with_size_props(self.size) .mount(scope) } } @@ -231,3 +252,55 @@ fn handle_input(input: KeyboardInput) -> Option { None } + +pub struct InputField { + label: String, + value: Arc>, +} + +impl InputField { + pub fn new( + label: impl Into, + value: impl 'static + Send + Sync + StateDuplex, + ) -> Self { + Self { + label: label.into(), + value: Arc::new(value), + } + } +} + +impl Widget for InputField { + fn mount(self, scope: &mut Scope<'_>) { + let text_value = Mutable::new(String::new()); + let value = self.value.clone(); + + scope.spawn( + text_value + .signal_cloned() + .dedupe_cloned() + .to_stream() + .filter_map(|v| { + tracing::info!(?v, "Parsing"); + ready(v.trim().parse().ok()) + }) + .for_each(move |v| { + tracing::info!("Parsed: {}", v); + value.send(v); + async {} + }), + ); + + scope.spawn(self.value.stream().map(|v| v.to_string()).for_each({ + to_owned![text_value]; + move |v| { + text_value.set(v); + async {} + } + })); + + let editor = TextInput::new(text_value); + + row((Text::new(self.label), editor)).mount(scope); + } +} diff --git a/violet-core/src/widget/interactive/slider.rs b/violet-core/src/widget/interactive/slider.rs index c143401..03b4734 100644 --- a/violet-core/src/widget/interactive/slider.rs +++ b/violet-core/src/widget/interactive/slider.rs @@ -1,10 +1,9 @@ -use cosmic_text::Wrap; +use std::{fmt::Display, str::FromStr, sync::Arc}; + use flax::{Component, Entity, EntityRef}; -use futures_signals::{ - map_ref, - signal::{Mutable, MutableSignal, SignalExt}, -}; -use glam::{IVec2, Vec2}; +use futures::{stream::BoxStream, StreamExt}; +use futures_signals::signal::Mutable; +use glam::Vec2; use palette::Srgba; use winit::event::ElementState; @@ -12,19 +11,23 @@ use crate::{ components::{offset, rect}, input::{focusable, on_cursor_move, on_mouse_input, CursorMove}, layout::Alignment, - style::{get_stylesheet, interactive_active, interactive_inactive, spacing, StyleExt}, - text::TextSegment, + state::{State, StateDuplex, StateStream}, + style::{interactive_active, interactive_inactive, spacing_small, SizeExt, StyleExt}, + to_owned, unit::Unit, - widget::{row, BoxSized, ContainerStyle, Positioned, Rectangle, SignalWidget, Stack, Text}, - Edges, Scope, StreamEffect, Widget, + utils::zip_latest, + widget::{row, BoxSized, ContainerStyle, Positioned, Rectangle, Stack, StreamWidget, Text}, + Scope, StreamEffect, Widget, }; +use super::input::TextInput; + #[derive(Debug, Clone, Copy)] pub struct SliderStyle { pub track_color: Component, pub handle_color: Component, - pub track_size: Unit, - pub handle_size: Unit, + pub track_size: Unit, + pub handle_size: Unit, } impl Default for SliderStyle { @@ -32,50 +35,50 @@ impl Default for SliderStyle { Self { track_color: interactive_inactive(), handle_color: interactive_active(), - track_size: Unit::px2i(64, 1), - handle_size: Unit::px2i(1, 4), + track_size: Unit::px2(256.0, 4.0), + handle_size: Unit::px2(4.0, 16.0), } } } pub struct Slider { style: SliderStyle, - value: Mutable, + value: Arc>, min: V, max: V, - label: bool, + transform: Option V>>, } impl Slider { - pub fn new(value: Mutable, min: V, max: V) -> Self + pub fn new(value: impl 'static + Send + Sync + StateDuplex, min: V, max: V) -> Self where V: Copy, { Self { - value, + value: Arc::new(value), min, max, style: Default::default(), - label: false, + transform: None, } } - /// Set the label visibility - pub fn with_label(mut self, label: bool) -> Self { - self.label = label; - self - } - /// Set the style pub fn with_style(mut self, style: SliderStyle) -> Self { self.style = style; self } + + /// Set the transform + pub fn with_transform(mut self, transform: impl 'static + Send + Sync + Fn(V) -> V) -> Self { + self.transform = Some(Box::new(transform)); + self + } } impl Widget for Slider { fn mount(self, scope: &mut Scope<'_>) { - let stylesheet = get_stylesheet(scope); + let stylesheet = scope.stylesheet(); let track_color = stylesheet .get_copy(self.style.track_color) @@ -84,10 +87,8 @@ impl Widget for Slider { .get_copy(self.style.handle_color) .unwrap_or_default(); - let spacing = stylesheet.get_copy(spacing()).unwrap_or_default(); - - let handle_size = spacing.size(self.style.handle_size); - let track_size = spacing.size(self.style.track_size); + let handle_size = self.style.handle_size; + let track_size = self.style.track_size; let track = scope.attach(BoxSized::new(Rectangle::new(track_color)).with_size(track_size)); @@ -99,15 +100,15 @@ impl Widget for Slider { input: CursorMove, min: f32, max: f32, - dst: &Mutable, + dst: &dyn StateDuplex, ) { let rect = entity.get_copy(rect()).unwrap(); let value = (input.local_pos.x / rect.size().x).clamp(0.0, 1.0) * (max - min) + min; - dst.set(V::from_progress(value)); + dst.send(V::from_progress(value)); } let handle = SliderHandle { - value: self.value.signal(), + value: self.value.stream(), min, max, rect_id: track, @@ -115,44 +116,38 @@ impl Widget for Slider { handle_size, }; + let value = Arc::new(self.value.map( + |v| v, + move |v| self.transform.as_ref().map(|f| f(v)).unwrap_or(v), + )); + scope .set(focusable(), ()) .on_event(on_mouse_input(), { - let value = self.value.clone(); + to_owned![value]; move |_, entity, input| { if input.state == ElementState::Pressed { - update(entity, input.cursor, min, max, &value); + update(entity, input.cursor, min, max, &*value); } } }) .on_event(on_cursor_move(), { - let value = self.value.clone(); - move |_, entity, input| update(entity, input, min, max, &value) + to_owned![value]; + move |_, entity, input| update(entity, input, min, max, &*value) }); - let slider = Stack::new(handle) + Stack::new(handle) .with_vertical_alignment(Alignment::Center) .with_style(ContainerStyle { - margin: Edges::even(5.0), ..Default::default() - }); - - if self.label { - row(( - slider, - SignalWidget(self.value.signal().map(|v| { - Text::rich([TextSegment::new(format!("{:>4.2}", v))]).with_wrap(Wrap::None) - })), - )) + }) + .with_margin(spacing_small()) .mount(scope) - } else { - slider.mount(scope) - } } } struct SliderHandle { - value: MutableSignal, + value: BoxStream<'static, V>, handle_color: Srgba, handle_size: Unit, min: f32, @@ -164,20 +159,16 @@ impl Widget for SliderHandle { fn mount(self, scope: &mut Scope<'_>) { let rect_size = Mutable::new(None); - let update_signal = map_ref! { - let value = self.value, - let size = rect_size.signal() => - (value.to_progress(), *size) - }; + let update = zip_latest(self.value, rect_size.stream()); scope.frame_mut().monitor(self.rect_id, rect(), move |v| { rect_size.set(v.map(|v| v.size())); }); - scope.spawn_effect(StreamEffect::new(update_signal.to_stream(), { - move |scope: &mut Scope<'_>, (value, size): (f32, Option)| { + scope.spawn_effect(StreamEffect::new(update, { + move |scope: &mut Scope<'_>, (value, size): (V, Option)| { if let Some(size) = size { - let pos = (value - self.min) * size.x / (self.max - self.min); + let pos = (value.to_progress() - self.min) * size.x / (self.max - self.min); scope.entity().update_dedup(offset(), Unit::px2(pos, 0.0)); } @@ -234,16 +225,44 @@ num_impl!(usize); /// A slider with label displaying the value pub struct SliderWithLabel { + text_value: Box>, slider: Slider, + editable: bool, } -impl SliderWithLabel { - pub fn new(value: Mutable, min: V, max: V) -> Self +impl SliderWithLabel { + pub fn new(value: impl 'static + Send + Sync + StateDuplex, min: V, max: V) -> Self where V: Copy, { + // Wrap in dedup to prevent updating equal numeric values like `0` and `0.` etc when typing + let value = Arc::new(value); + + let text_value = Box::new(value.clone().dedup().prevent_feedback().filter_map( + move |v: V| Some(format!("{v}")), + move |v| { + v.parse::().ok().map(|v| { + if v < min { + min + } else if v > max { + max + } else { + v + } + }) + }, + )); + Self { - slider: Slider::new(value, min, max), + text_value, + slider: Slider { + style: Default::default(), + value, + min, + max, + transform: None, + }, + editable: false, } } @@ -252,15 +271,39 @@ impl SliderWithLabel { self.slider = self.slider.with_style(style); self } + + pub fn with_transform(mut self, transform: impl 'static + Send + Sync + Fn(V) -> V) -> Self { + self.slider.transform = Some(Box::new(transform)); + self + } + + pub fn editable(mut self, editable: bool) -> Self { + self.editable = editable; + self + } +} + +impl SliderWithLabel { + pub fn round(mut self, round: f32) -> Self { + let recip = round.recip(); + self.slider.transform = Some(Box::new(move |v| (v * recip).round() / recip)); + self + } } impl Widget for SliderWithLabel { fn mount(self, scope: &mut Scope<'_>) { - let label = - SignalWidget(self.slider.value.signal().map(|v| { - Text::rich([TextSegment::new(format!("{:>4.2}", v))]).with_wrap(Wrap::None) - })); - - crate::widget::List::new((self.slider, label)).mount(scope) + if self.editable { + row((self.slider, TextInput::new(self.text_value))) + .with_cross_align(Alignment::Center) + .mount(scope) + } else { + row(( + self.slider, + StreamWidget(self.text_value.stream().map(Text::new)), + )) + .with_cross_align(Alignment::Center) + .mount(scope) + } } } diff --git a/violet-core/src/widget/mod.rs b/violet-core/src/widget/mod.rs index d52e129..babdc1e 100644 --- a/violet-core/src/widget/mod.rs +++ b/violet-core/src/widget/mod.rs @@ -4,16 +4,16 @@ mod container; mod future; mod interactive; -pub use basic::{BoxSized, Image, Positioned, Rectangle, Text}; +pub use basic::*; pub use container::*; use flax::{component::ComponentValue, components::name, Component}; pub use future::{SignalWidget, StreamWidget}; use futures_signals::signal::Mutable; pub use interactive::{button::*, input::*, slider::*}; -/// Represents a widget in the UI tree which can mount itself into the frame. +/// A widget is a description of a part of the Ui with the capability to mount itself into the world. /// -/// Is inert before mounting +/// This trait rarely required Send nor Sync, or a static lifetime as it does remain in the world after it is mounted. pub trait Widget: BoxedWidget { /// Mount the widget into the world, returning a handle to refer to it fn mount(self, scope: &mut Scope<'_>); @@ -179,3 +179,8 @@ tuple_impl! { 0 => A, 1 => B, 2 => C, 3 => D, 4 => E } tuple_impl! { 0 => A, 1 => B, 2 => C, 3 => D, 4 => E, 5 => F } tuple_impl! { 0 => A, 1 => B, 2 => C, 3 => D, 4 => E, 5 => F, 6 => G } tuple_impl! { 0 => A, 1 => B, 2 => C, 3 => D, 4 => E, 5 => F, 6 => G, 7 => H } +tuple_impl! { 0 => A, 1 => B, 2 => C, 3 => D, 4 => E, 5 => F, 6 => G, 7 => H, 8 => I } +tuple_impl! { 0 => A, 1 => B, 2 => C, 3 => D, 4 => E, 5 => F, 6 => G, 7 => H, 8 => I, 9 => J } +tuple_impl! { 0 => A, 1 => B, 2 => C, 3 => D, 4 => E, 5 => F, 6 => G, 7 => H, 8 => I, 9 => J, 10 => K } +tuple_impl! { 0 => A, 1 => B, 2 => C, 3 => D, 4 => E, 5 => F, 6 => G, 7 => H, 8 => I, 9 => J, 10 => K, 11 => L } +tuple_impl! { 0 => A, 1 => B, 2 => C, 3 => D, 4 => E, 5 => F, 6 => G, 7 => H, 8 => I, 9 => J, 10 => K, 11 => L, 12 => M } diff --git a/violet-web-example/Cargo.toml b/violet-demo/Cargo.toml similarity index 74% rename from violet-web-example/Cargo.toml rename to violet-demo/Cargo.toml index 39ae215..88e6b3f 100644 --- a/violet-web-example/Cargo.toml +++ b/violet-demo/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "violet-web-example" +name = "violet-demo" version.workspace = true edition.workspace = true authors.workspace = true @@ -11,17 +11,20 @@ repository.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] violet = { path = ".." } wasm-bindgen = "0.2.91" console_error_panic_hook = "0.1.6" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-web = "0.1" web-sys = { version = "0.3", features = ["Gpu", "CanvasRenderingContext2d", "GpuCanvasContext", "GpuRenderBundle"] } wgpu.workspace = true glam.workspace = true +futures.workspace = true wasm-bindgen-futures = "0.4" +itertools.workspace = true +tracing-tree.workspace = true diff --git a/violet-web-example/index.html b/violet-demo/index.html similarity index 100% rename from violet-web-example/index.html rename to violet-demo/index.html diff --git a/violet-demo/src/lib.rs b/violet-demo/src/lib.rs new file mode 100644 index 0000000..b39ffcd --- /dev/null +++ b/violet-demo/src/lib.rs @@ -0,0 +1,263 @@ +use futures::StreamExt; +use glam::{Vec2, Vec3}; +use itertools::Itertools; +use tracing_subscriber::{ + filter::LevelFilter, fmt::format::Pretty, layer::SubscriberExt, util::SubscriberInitExt, Layer, +}; +use tracing_web::{performance_layer, MakeWebConsoleWriter}; +use violet::{ + core::{ + components, + layout::{Alignment, Direction}, + state::{Map, MapRef, State, StateStream, StateStreamRef}, + style::{ + colors::{ + EERIE_BLACK_400, EERIE_BLACK_DEFAULT, JADE_200, JADE_DEFAULT, LION_DEFAULT, + REDWOOD_DEFAULT, + }, + danger_item, success_item, Background, SizeExt, StyleExt, ValueOrRef, + }, + text::Wrap, + to_owned, + unit::Unit, + utils::zip_latest, + widget::{ + card, column, label, row, Button, ButtonStyle, List, Rectangle, SignalWidget, + SliderWithLabel, Stack, StreamWidget, Text, WidgetExt, + }, + Edges, Scope, Widget, WidgetCollection, + }, + flax::components::name, + futures_signals::signal::{Mutable, SignalExt}, + glam::vec2, + palette::{FromColor, IntoColor, Oklch, Srgb, Srgba}, +}; +use wasm_bindgen::prelude::*; + +#[cfg(target_arch = "wasm32")] +fn setup() { + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .without_time() + .with_writer(MakeWebConsoleWriter::new()) + .with_filter(LevelFilter::INFO); + + let perf_layer = performance_layer().with_details_from_fields(Pretty::default()); + + tracing_subscriber::registry() + .with(fmt_layer) + .with(perf_layer) + .init(); + + console_error_panic_hook::set_once(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn setup() { + tracing_subscriber::registry() + .with( + tracing_tree::HierarchicalLayer::default() + .with_deferred_spans(true) + .with_span_retrace(true) + .with_indent_lines(true) + .with_indent_amount(4), + ) + .with(tracing_subscriber::EnvFilter::from_default_env()) + .init(); +} + +#[wasm_bindgen] +pub fn run() { + setup(); + + violet::wgpu::App::new().run(MainApp).unwrap(); +} + +struct MainApp; + +impl Widget for MainApp { + fn mount(self, scope: &mut Scope<'_>) { + let color = Mutable::new(Vec3::new(0.5, 0.27, 153.0)); + let color_oklch = Map::new( + color.clone(), + |v| Oklch::new(v.x, v.y, v.z), + |v| Vec3::new(v.l, v.chroma, v.hue.into_positive_degrees()), + ); + + let lightness = color.clone().map_ref(|v| &v.x, |v| &mut v.x); + let chroma = color.clone().map_ref(|v| &v.y, |v| &mut v.y); + let hue = color.clone().map_ref(|v| &v.z, |v| &mut v.z); + + let color_rect = color.signal().map(|v| { + let color = Oklch::new(v.x, v.y, v.z).into_color(); + Rectangle::new(ValueOrRef::value(color)) + .with_size(Unit::new(vec2(0.0, 100.0), vec2(1.0, 0.0))) + }); + + let falloff = Mutable::new(50.0); + + let history = Mutable::new(Vec::new()); + + let save_button = Button::new(Text::new("Save color")) + .with_style(ButtonStyle { + normal_color: success_item().into(), + ..Default::default() + }) + .on_press({ + to_owned![history, falloff, color]; + move |_, _| { + let color = color.get(); + history.lock_mut().push(HistoryItem { + color: Oklch::new(color.x, color.y, color.z), + falloff: falloff.get(), + }); + } + }); + + card( + column(( + row(( + Text::new("Lightness"), + SliderWithLabel::new(lightness, 0.0, 1.0) + .editable(true) + .round(0.01), + )), + row(( + Text::new("Chroma"), + SliderWithLabel::new(chroma, 0.0, 0.37) + .editable(true) + .round(0.005), + )), + row(( + Text::new("Hue"), + SliderWithLabel::new(hue, 0.0, 360.0) + .editable(true) + .round(1.0), + )), + StreamWidget(color.stream_ref(|v| { + let hex: Srgb = Srgb::from_color(Oklch::new(v.x, v.y, v.z)).into_format(); + Text::new(format!( + "#{:0>2x}{:0>2x}{:0>2x}", + hex.red, hex.green, hex.blue + )) + })), + SignalWidget(color.signal().map(|v| Text::new(format!("{}", v)))), + SignalWidget(color_rect), + row(( + Text::new("Chroma falloff"), + SliderWithLabel::new(falloff.clone(), 0.0, 100.0) + .editable(true) + .round(1.0), + )), + StreamWidget( + zip_latest(color_oklch.stream(), falloff.stream()) + .map(|(color, falloff)| Tints::new(color, falloff)), + ), + save_button, + HistoryView::new(history), + )) + .with_margin(Edges::even(4.0)), + ) + .with_size(Unit::rel2(1.0, 1.0)) + .mount(scope); + } +} + +struct Tints { + base: Oklch, + falloff: f32, +} + +impl Tints { + fn new(base: Oklch, falloff: f32) -> Self { + Self { base, falloff } + } +} + +impl Widget for Tints { + fn mount(self, scope: &mut Scope<'_>) { + row((1..=9) + .map(|i| { + let f = (i as f32) / 10.0; + let chroma = self.base.chroma * (1.0 / (1.0 + self.falloff * (f - 0.5).powi(2))); + + // let color = self.base.lighten(f); + let color = Oklch { + chroma, + l: f, + ..self.base + }; + + Stack::new(column(( + Rectangle::new(ValueOrRef::value(color.into_color())) + .with_min_size(Unit::px2(60.0, 60.0)), + Text::new(format!("{:.2}", f)), + ))) + .with_margin(Edges::even(4.0)) + }) + .collect_vec()) + .mount(scope) + } +} + +pub fn color_hex(color: impl IntoColor) -> String { + let hex: Srgb = color.into_color().into_format(); + format!("#{:0>2x}{:0>2x}{:0>2x}", hex.red, hex.green, hex.blue) +} + +pub struct HistoryView { + items: Mutable>, +} + +impl HistoryView { + pub fn new(items: Mutable>) -> Self { + Self { items } + } +} + +impl Widget for HistoryView { + fn mount(self, scope: &mut Scope<'_>) { + let items = self.items.clone(); + let discard = move |i| { + let items = items.clone(); + Button::new(Text::new("X")) + .on_press({ + move |_, _| { + items.lock_mut().remove(i); + } + }) + .danger() + }; + + StreamWidget(self.items.stream_ref(move |items| { + let items = items + .iter() + .enumerate() + .map(|(i, item)| card(row((discard(i), *item)))) + .collect_vec(); + + column(items) + })) + .mount(scope) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct HistoryItem { + color: Oklch, + falloff: f32, +} + +impl Widget for HistoryItem { + fn mount(self, scope: &mut Scope<'_>) { + column(( + label(color_hex(self.color)), + row(( + Rectangle::new(ValueOrRef::value(self.color.into_color())) + .with_size(Unit::px2(100.0, 50.0)), + Tints::new(self.color, self.falloff), + )), + )) + .mount(scope) + } +} diff --git a/violet-demo/src/main.rs b/violet-demo/src/main.rs new file mode 100644 index 0000000..e7646f4 --- /dev/null +++ b/violet-demo/src/main.rs @@ -0,0 +1,5 @@ +use violet_demo::run; + +fn main() { + run(); +} diff --git a/violet-web-example/src/lib.rs b/violet-web-example/src/lib.rs deleted file mode 100644 index ffa7e19..0000000 --- a/violet-web-example/src/lib.rs +++ /dev/null @@ -1,245 +0,0 @@ -use glam::Vec2; -use tracing_subscriber::{ - filter::LevelFilter, fmt::format::Pretty, layer::SubscriberExt, util::SubscriberInitExt, Layer, -}; -use tracing_web::{performance_layer, MakeWebConsoleWriter}; -use violet::{ - core::{ - components, - layout::{Alignment, Direction}, - style::{ - colors::{ - EERIE_BLACK_400, EERIE_BLACK_DEFAULT, JADE_200, JADE_DEFAULT, LION_DEFAULT, - REDWOOD_DEFAULT, - }, - Background, SizeExt, - }, - text::Wrap, - unit::Unit, - widget::{List, Rectangle, SignalWidget, SliderWithLabel, Stack, Text, WidgetExt}, - Edges, Scope, Widget, WidgetCollection, - }, - flax::components::name, - futures_signals::signal::{Mutable, SignalExt}, - glam::vec2, - palette::Srgba, -}; -use wasm_bindgen::prelude::*; - -#[wasm_bindgen] -pub async fn run() { - let fmt_layer = tracing_subscriber::fmt::layer() - .with_ansi(false) - .without_time() - .with_writer(MakeWebConsoleWriter::new()) - .with_filter(LevelFilter::INFO); - - let perf_layer = performance_layer().with_details_from_fields(Pretty::default()); - - tracing_subscriber::registry() - .with(fmt_layer) - .with(perf_layer) - .init(); - - console_error_panic_hook::set_once(); - - violet::wgpu::App::new().run(MainApp).unwrap(); -} - -struct Vec2Editor { - value: Mutable, - x_label: String, - y_label: String, -} - -impl Vec2Editor { - fn new(value: Mutable, x_label: impl Into, y_label: impl Into) -> Self { - Self { - value, - x_label: x_label.into(), - y_label: y_label.into(), - } - } -} - -impl Widget for Vec2Editor { - fn mount(self, scope: &mut Scope<'_>) { - let value = self.value; - - column(( - row(( - label(self.x_label), - SliderWithLabel::new_with_transform( - value.clone(), - 0.0, - 200.0, - |v| v.x, - |v, x| v.x = x.round(), - ), - )), - row(( - label(self.y_label), - SliderWithLabel::new_with_transform( - value.clone(), - 0.0, - 200.0, - |v| v.y, - |v, y| v.y = y.round(), - ), - )), - )) - .mount(scope) - } -} -struct MainApp; - -impl Widget for MainApp { - fn mount(self, scope: &mut Scope<'_>) { - let size = Mutable::new(vec2(100.0, 100.0)); - - column(( - card(column(( - Vec2Editor::new(size.clone(), "width", "height"), - SignalWidget::new(size.signal().map(|size| label(format!("Rectangle size: {size}")))), - ))), - row((label("This is a row of longer text that is wrapped. When the text wraps it will take up more vertical space in the layout, and will as such increase the overall height"), card(Text::new(":P").with_wrap(Wrap::None)))), - SignalWidget::new(size.signal().map(|size| FlowSizing { size })), - // AnimatedSize, - )) - .contain_margins(true) - .with_background(Background::new(EERIE_BLACK_DEFAULT)) - .mount(scope) - } -} - -struct FlowSizing { - size: Vec2, -} - -impl Widget for FlowSizing { - fn mount(self, scope: &mut Scope<'_>) { - let bg = Background::new(JADE_200); - - let content = ( - SizedBox::new(JADE_DEFAULT, Unit::px(self.size)).with_name("EMERALD"), - SizedBox::new(REDWOOD_DEFAULT, Unit::px2(50.0, 40.0)).with_name("REDWOOD"), - AnimatedSize, - ); - - column(( - row(( - card(column(( - label("Unconstrained list"), - row(content.clone()).with_background(bg), - ))), - card(column(( - label("Constrained list with min size"), - row(content.clone()) - .with_background(bg) - .with_min_size(Unit::px2(100.0, 100.0)), - ))), - card(column(( - label("Constrained list with max size"), - row(content.clone()) - .with_background(bg) - .with_max_size(Unit::px2(100.0, 100.0)), - ))), - )), - row(( - card(column(( - label("Unconstrained list"), - centered(content.clone()).with_background(bg), - ))), - card(column(( - label("Constrained list with min size"), - centered(content.clone()) - .with_background(bg) - .with_min_size(Unit::px2(100.0, 100.0)), - ))), - card(column(( - label("Constrained list with max size"), - centered(content.clone()) - .with_background(bg) - .with_max_size(Unit::px2(100.0, 100.0)), - ))), - )), - )) - .mount(scope) - } -} - -#[derive(Debug, Clone)] -struct SizedBox { - color: Srgba, - size: Unit, -} - -impl SizedBox { - fn new(color: Srgba, size: Unit) -> Self { - Self { color, size } - } -} - -impl Widget for SizedBox { - fn mount(self, scope: &mut Scope<'_>) { - // Stack::new(( - Rectangle::new(self.color) - .with_size(self.size) - // column(( - // Text::new(format!("{}", self.size.px)), - // Text::new(format!("{}", self.size.rel)), - // )), - // )) - .mount(scope) - } -} - -#[derive(Debug, Clone)] -pub struct AnimatedSize; - -impl Widget for AnimatedSize { - fn mount(self, scope: &mut Scope<'_>) { - scope.set(name(), "AnimatedBox".into()); - scope.set( - components::on_animation_frame(), - Box::new(move |_, entity, t| { - let t = t.as_secs_f32(); - - let size = vec2(t.sin() * 50.0, (t * 2.5).cos() * 50.0) + vec2(100.0, 100.0); - entity.update_dedup(components::size(), Unit::px(size)); - }), - ); - - Rectangle::new(LION_DEFAULT).mount(scope) - } -} -fn label(text: impl Into) -> Stack { - Stack::new(Text::new(text.into())) - .with_padding(MARGIN_SM) - .with_margin(MARGIN_SM) - .with_background(Background::new(EERIE_BLACK_400)) -} - -fn row(widgets: W) -> List { - List::new(widgets).with_direction(Direction::Horizontal) -} - -fn column(widgets: W) -> List { - List::new(widgets).with_direction(Direction::Vertical) -} - -fn centered(widget: W) -> Stack { - Stack::new(widget) - .with_horizontal_alignment(Alignment::Center) - .with_vertical_alignment(Alignment::Center) -} - -fn card(widget: W) -> Stack { - Stack::new(widget) - .with_background(Background::new(EERIE_BLACK_400)) - .with_padding(MARGIN) - .with_margin(MARGIN) -} - -const MARGIN: Edges = Edges::even(8.0); -const MARGIN_SM: Edges = Edges::even(4.0); diff --git a/violet-wgpu/src/renderer/debug_renderer.rs b/violet-wgpu/src/renderer/debug_renderer.rs index adbbf76..0619e5f 100644 --- a/violet-wgpu/src/renderer/debug_renderer.rs +++ b/violet-wgpu/src/renderer/debug_renderer.rs @@ -122,41 +122,41 @@ impl DebugRenderer { let mut query = Query::new((entity_refs(), layout_cache())); let mut query = query.borrow(&frame.world); - let clamped_indicators = query.iter().filter_map(|(entity, v)| { - let clamped_query_vertical = - if v.query()[0].as_ref().is_some_and(|v| v.value.hints.can_grow) { - vec3(0.5, 0.0, 0.0) - } else { - Vec3::ZERO - }; - - let clamped_query_horizontal = - if v.query()[1].as_ref().is_some_and(|v| v.value.hints.can_grow) { - vec3(0.0, 0.5, 0.0) - } else { - Vec3::ZERO - }; - - let clamped_layout = if v.layout().map(|v| v.value.can_grow).unwrap_or(false) { - vec3(0.0, 0.0, 0.5) - } else { - Vec3::ZERO - }; + // let clamped_indicators = query.iter().filter_map(|(entity, v)| { + // let clamped_query_vertical = + // if v.query()[0].as_ref().is_some_and(|v| v.value.hints.can_grow) { + // vec3(0.5, 0.0, 0.0) + // } else { + // Vec3::ZERO + // }; + + // let clamped_query_horizontal = + // if v.query()[1].as_ref().is_some_and(|v| v.value.hints.can_grow) { + // vec3(0.0, 0.5, 0.0) + // } else { + // Vec3::ZERO + // }; + + // let clamped_layout = if v.layout().map(|v| v.value.can_grow).unwrap_or(false) { + // vec3(0.0, 0.0, 0.5) + // } else { + // Vec3::ZERO + // }; - let color: Vec3 = [ - clamped_query_vertical, - clamped_query_horizontal, - clamped_layout, - ] - .into_iter() - .sum(); - - if color == Vec3::ZERO { - None - } else { - Some((entity, color.extend(1.0))) - } - }); + // let color: Vec3 = [ + // clamped_query_vertical, + // clamped_query_horizontal, + // clamped_layout, + // ] + // .into_iter() + // .sum(); + + // if color == Vec3::ZERO { + // None + // } else { + // Some((entity, color.extend(1.0))) + // } + // }); let mut query = Query::new((entity_refs(), layout_cache())); let mut query = query.borrow(&frame.world); @@ -185,32 +185,30 @@ impl DebugRenderer { Some((entity, color)) }); - let objects = clamped_indicators - .chain(objects) - .filter_map(|(entity, color)| { - let screen_rect = entity.get(screen_rect()).ok()?.align_to_grid(); - - let model_matrix = Mat4::from_scale_rotation_translation( - screen_rect.size().extend(1.0), - Quat::IDENTITY, - screen_rect.pos().extend(0.2), - ); - - let object_data = ObjectData { - model_matrix, - color, - }; - - Some(( - DrawCommand { - shader: self.shader.clone(), - bind_group: self.bind_group.clone(), - mesh: self.mesh.clone(), - index_count: 6, - }, - object_data, - )) - }); + let objects = objects.filter_map(|(entity, color)| { + let screen_rect = entity.get(screen_rect()).ok()?.align_to_grid(); + + let model_matrix = Mat4::from_scale_rotation_translation( + screen_rect.size().extend(1.0), + Quat::IDENTITY, + screen_rect.pos().extend(0.2), + ); + + let object_data = ObjectData { + model_matrix, + color, + }; + + Some(( + DrawCommand { + shader: self.shader.clone(), + bind_group: self.bind_group.clone(), + mesh: self.mesh.clone(), + index_count: 6, + }, + object_data, + )) + }); self.objects.clear(); self.objects.extend(objects);