├── .envrc ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── nix-flake.check.yml │ └── ci.yml ├── dice.jxl ├── blendtest.png ├── premul_test.png ├── test_color.png ├── feather-ui ├── feather.alc ├── src │ ├── draw.rs │ ├── event.rs │ ├── render │ │ ├── domain │ │ │ ├── mod.rs │ │ │ └── line.rs │ │ ├── line.rs │ │ ├── image.rs │ │ └── mod.rs │ ├── shaders │ │ ├── mipmap.wgsl │ │ ├── mod.rs │ │ ├── feather.wgsl │ │ ├── compositor.wgsl │ │ └── shape.wgsl │ ├── component │ │ ├── line.rs │ │ ├── domain_point.rs │ │ ├── domain_line.rs │ │ ├── flexbox.rs │ │ ├── gridbox.rs │ │ ├── listbox.rs │ │ ├── image.rs │ │ ├── region.rs │ │ ├── button.rs │ │ ├── paragraph.rs │ │ ├── text.rs │ │ └── shape.rs │ ├── layout │ │ ├── root.rs │ │ ├── domain_write.rs │ │ ├── base.rs │ │ ├── text.rs │ │ ├── leaf.rs │ │ ├── fixed.rs │ │ └── grid.rs │ ├── util.rs │ └── input.rs ├── examples │ ├── calculator-rs │ │ ├── src │ │ │ ├── calculator.toml │ │ │ ├── calculator.udl │ │ │ └── bin.rs │ │ ├── calculator-cs │ │ │ ├── calculator-cs.csproj │ │ │ └── Program.cs │ │ ├── Cargo.toml │ │ ├── calculator-cs.sln │ │ └── build.rs │ ├── calculator-alc │ │ ├── build.rs │ │ ├── src │ │ │ └── calculator.udl │ │ └── Cargo.toml │ ├── basic-alc │ │ ├── Cargo.toml │ │ └── basic.rs │ ├── basic-lua.rs │ ├── clock.lua │ ├── basic.lua │ ├── clock-lua.rs │ ├── textbox-rs.rs │ ├── paragraph-rs.rs │ ├── basic-rs.rs │ ├── image-rs.rs │ └── grid-rs.rs ├── .stylua.toml ├── rrb-vector.alc ├── b-tree.alc └── Cargo.toml ├── _typos.toml ├── .gitignore ├── feather-macro └── Cargo.toml ├── LICENSES └── MIT.txt ├── deny.toml ├── Cargo.toml ├── REUSE.toml ├── feather.svg ├── README.md ├── flake.lock ├── FRI_logo.svg └── flake.nix /.envrc: -------------------------------------------------------------------------------- 1 | use flake; 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: feather-ui 2 | github: Fundament-Institute 3 | -------------------------------------------------------------------------------- /dice.jxl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fundament-Institute/feather-ui/HEAD/dice.jxl -------------------------------------------------------------------------------- /blendtest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fundament-Institute/feather-ui/HEAD/blendtest.png -------------------------------------------------------------------------------- /premul_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fundament-Institute/feather-ui/HEAD/premul_test.png -------------------------------------------------------------------------------- /test_color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fundament-Institute/feather-ui/HEAD/test_color.png -------------------------------------------------------------------------------- /feather-ui/feather.alc: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | -------------------------------------------------------------------------------- /feather-ui/src/draw.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | -------------------------------------------------------------------------------- /_typos.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | [default.extend-words] 5 | ot = "ot" 6 | MERCHANTIBILITY = "MERCHANTIBILITY" 7 | -------------------------------------------------------------------------------- /feather-ui/examples/calculator-rs/src/calculator.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | [bindings.csharp] 5 | cdylib_name = "calculator.dll" 6 | -------------------------------------------------------------------------------- /feather-ui/.stylua.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | syntax = "LuaJIT" 5 | call_parentheses = "NoSingleTable" 6 | collapse_simple_statement = "Always" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | .vs/ 5 | .vscode/ 6 | *.exe 7 | /target/ 8 | *.user 9 | /.direnv/ 10 | /feather-ui/examples/calculator-rs/calculator-cs/obj 11 | /feather-ui/examples/calculator-rs/calculator-cs/bin 12 | .rustfmt.toml -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: cargo 7 | directory: "/" 8 | schedule: 9 | interval: daily 10 | - package-ecosystem: github-actions 11 | directory: "/" 12 | schedule: 13 | interval: daily 14 | -------------------------------------------------------------------------------- /feather-ui/examples/calculator-rs/calculator-cs/calculator-cs.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | calculator_cs 7 | enable 8 | enable 9 | True 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /feather-ui/examples/calculator-alc/build.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | fn main() { 5 | println!("cargo::rerun-if-changed=src/calculator.udl"); 6 | println!("cargo::rerun-if-changed=layout.alc"); // avoid cachelighting when rust behaves badly and doesn't realize the file changed 7 | uniffi::generate_scaffolding("src/calculator.udl").unwrap(); 8 | uniffi_alicorn::generate_alicorn_scaffolding("src/calculator.udl").unwrap(); 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/nix-flake.check.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | permissions: 5 | contents: read 6 | 7 | name: nix flake check 8 | 9 | on: 10 | push: 11 | pull_request: 12 | 13 | jobs: 14 | check: 15 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v5 19 | - uses: DeterminateSystems/nix-installer-action@main 20 | - run: nix flake check -L -------------------------------------------------------------------------------- /feather-ui/examples/basic-alc/Cargo.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | [package] 5 | name = "basic-alc" 6 | version.workspace = true 7 | edition.workspace = true 8 | rust-version.workspace = true 9 | authors = ["Erik McClure "] 10 | description = """ 11 | Basic feather window using Alicorn 12 | """ 13 | homepage.workspace = true 14 | readme.workspace = true 15 | license.workspace = true 16 | 17 | [[bin]] 18 | name = "basic-alc" 19 | path = "basic.rs" 20 | 21 | [dependencies] 22 | feather-ui.workspace = true 23 | alicorn.workspace = true 24 | -------------------------------------------------------------------------------- /feather-ui/examples/calculator-rs/src/calculator.udl: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | enum CalcOp { 5 | "None", 6 | "Add", 7 | "Sub", 8 | "Mul", 9 | "Div", 10 | "Mod", 11 | "Pow", 12 | "Square", 13 | "Sqrt", 14 | "Inv", 15 | "Negate", 16 | "Clear", 17 | }; 18 | 19 | [Trait, WithForeign] 20 | interface Calculator { 21 | Calculator copy(); 22 | boolean eq(Calculator rhs); 23 | void add_digit(u8 digit); 24 | void backspace(); 25 | void apply_op(); 26 | void set_op(CalcOp op); 27 | void toggle_decimal(); 28 | double get(); 29 | }; 30 | 31 | namespace calc { 32 | void register(Calculator calc); 33 | }; -------------------------------------------------------------------------------- /feather-ui/examples/calculator-alc/src/calculator.udl: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | enum CalcOp { 5 | "None", 6 | "Add", 7 | "Sub", 8 | "Mul", 9 | "Div", 10 | "Mod", 11 | "Pow", 12 | "Square", 13 | "Sqrt", 14 | "Inv", 15 | "Negate", 16 | "Clear", 17 | }; 18 | 19 | [Trait, WithForeign] 20 | interface Calculator { 21 | Calculator copy(); 22 | boolean eq(Calculator rhs); 23 | Calculator add_digit(u8 digit); 24 | Calculator backspace(); 25 | Calculator apply_op(); 26 | Calculator set_op(CalcOp op); 27 | Calculator toggle_decimal(); 28 | double get(); 29 | }; 30 | 31 | namespace calc { 32 | Calculator register(); 33 | }; -------------------------------------------------------------------------------- /feather-ui/src/event.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use smallvec::SmallVec; 5 | 6 | use crate::{AccessCell, Dispatchable, InputResult, PxRect}; 7 | 8 | pub trait EventRouter 9 | where 10 | // : zerocopy::Immutable 11 | Self: Sized, 12 | { 13 | type Input: Dispatchable; 14 | type Output: Dispatchable; 15 | 16 | #[allow(unused_variables)] 17 | #[allow(clippy::type_complexity)] 18 | fn process( 19 | state: AccessCell, 20 | input: Self::Input, 21 | area: PxRect, 22 | extent: PxRect, 23 | dpi: crate::RelDim, 24 | driver: &std::sync::Weak, 25 | ) -> InputResult> { 26 | InputResult::Forward(SmallVec::new()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /feather-macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | [package] 5 | name = "feather-macro" 6 | version.workspace = true 7 | edition.workspace = true 8 | rust-version.workspace = true 9 | authors = ["Erik McClure "] 10 | description = """ 11 | Helper macros for Feather UI library 12 | """ 13 | homepage.workspace = true 14 | repository = "https://github.com/Fundament-Software/feathergui/tree/main/feather-ui" 15 | readme.workspace = true 16 | keywords = ["macros"] 17 | license.workspace = true 18 | 19 | [lib] 20 | proc-macro = true 21 | 22 | [dependencies] 23 | syn = { version = "2", features = ["parsing"] } 24 | quote = { version = "1" } 25 | proc-macro2 = { version = "1", features = ["span-locations"] } 26 | itertools = { version = "0.14" } 27 | proc_macro_roids = "0.8.0" 28 | 29 | [lints] 30 | workspace = true 31 | -------------------------------------------------------------------------------- /feather-ui/examples/calculator-rs/Cargo.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | [package] 5 | name = "calculator-rs" 6 | version.workspace = true 7 | edition = "2021" # This is stuck on uniffi 0.27 which doesn't emit valid code under 2024 8 | rust-version.workspace = true 9 | authors = ["Erik McClure "] 10 | description = """ 11 | Simple calculator 12 | """ 13 | homepage.workspace = true 14 | readme.workspace = true 15 | license.workspace = true 16 | 17 | [lib] 18 | name = "calculator" 19 | path = "src/lib.rs" 20 | crate-type = ["lib", "cdylib"] 21 | 22 | [[bin]] 23 | name = "calculator-rs" 24 | path = "src/bin.rs" 25 | 26 | [dependencies] 27 | feather-ui.workspace = true 28 | feather-macro.workspace = true 29 | uniffi = "0.27.0" 30 | 31 | [build-dependencies] 32 | uniffi = { version = "0.27.0", features = ["build"] } 33 | -------------------------------------------------------------------------------- /feather-ui/src/render/domain/mod.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use super::Renderable; 5 | use crate::{CrossReferenceDomain, PxRect, SourceID}; 6 | use std::rc::Rc; 7 | use std::sync::Arc; 8 | 9 | pub mod line; 10 | 11 | pub struct Write { 12 | pub(crate) id: std::sync::Weak, 13 | pub(crate) domain: Arc, 14 | pub(crate) base: Option>, 15 | } 16 | 17 | impl Renderable for Write { 18 | fn render( 19 | &self, 20 | area: PxRect, 21 | driver: &crate::graphics::Driver, 22 | compositor: &mut crate::render::CompositorView<'_>, 23 | ) -> Result<(), crate::Error> { 24 | if let Some(idref) = self.id.upgrade() { 25 | self.domain.write_area(idref, area); 26 | } 27 | 28 | self.base 29 | .as_ref() 30 | .map(|x| x.render(area, driver, compositor)) 31 | .unwrap_or(Ok(())) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /feather-ui/examples/basic-lua.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use feather_ui::component::mouse_area; 5 | use feather_ui::lua::LuaApp; 6 | use feather_ui::mlua::Lua; 7 | use feather_ui::{InputResult, WrapEventEx, handlers}; 8 | 9 | const LAYOUT: &[u8] = include_bytes!("./basic.lua"); 10 | 11 | #[derive(PartialEq, Clone, Debug, feather_macro::UserData)] 12 | struct CounterState { 13 | count: i32, 14 | } 15 | 16 | fn main() { 17 | let lua = Lua::new(); 18 | 19 | let onclick = |_: mouse_area::MouseAreaEvent, 20 | mut appdata: feather_ui::AccessCell| 21 | -> InputResult<()> { 22 | { 23 | appdata.count += 1; 24 | InputResult::Forward(()) 25 | } 26 | } 27 | .wrap(); 28 | 29 | let (mut app, event_loop) = LuaApp::::new( 30 | &lua, 31 | CounterState { count: 0 }, 32 | handlers![CounterState, onclick], 33 | LAYOUT, 34 | ) 35 | .unwrap(); 36 | 37 | event_loop.run_app(&mut app).unwrap(); 38 | } 39 | -------------------------------------------------------------------------------- /feather-ui/src/render/line.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::color::sRGB; 5 | 6 | use super::compositor::CompositorView; 7 | 8 | pub struct Instance { 9 | pub start: crate::PxPoint, 10 | pub end: crate::PxPoint, 11 | pub color: sRGB, 12 | } 13 | 14 | impl super::Renderable for Instance { 15 | fn render( 16 | &self, 17 | _: crate::PxRect, 18 | _: &crate::graphics::Driver, 19 | compositor: &mut CompositorView<'_>, 20 | ) -> Result<(), crate::Error> { 21 | let p1 = self.start; 22 | let p2 = self.end; 23 | 24 | let p = p2 - p1; 25 | compositor.append_data( 26 | ((p1 + p2.to_vector()) * 0.5) - (crate::PxVector::new(p.length() * 0.5, 0.0)), 27 | [p.length(), 1.0].into(), 28 | [0.0, 0.0].into(), 29 | [0.0, 0.0].into(), 30 | self.color.as_32bit().rgba, 31 | p.y.atan2(p.x) % std::f32::consts::TAU, 32 | u8::MAX, 33 | false, 34 | ); 35 | Ok(()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | [graph] 5 | targets = [] 6 | all-features = false 7 | no-default-features = false 8 | exclude = [] 9 | 10 | [output] 11 | feature-depth = 1 12 | 13 | [advisories] 14 | db-path = "$CARGO_HOME/advisory-dbs" 15 | db-urls = ["https://github.com/rustsec/advisory-db"] 16 | ignore = ["RUSTSEC-2024-0436"] 17 | [licenses] 18 | allow = [ 19 | "MIT", 20 | "Apache-2.0", 21 | "Apache-2.0 WITH LLVM-exception", 22 | "Zlib", 23 | "MPL-2.0", 24 | "BSD-3-Clause", 25 | "BSD-2-Clause", 26 | "Unicode-3.0", 27 | "CC0-1.0", 28 | "ISC", 29 | "BSL-1.0", 30 | "NCSA", 31 | ] 32 | confidence-threshold = 0.8 33 | 34 | [licenses.private] 35 | ignore = false 36 | registries = [] 37 | 38 | [bans] 39 | multiple-versions = "allow" 40 | allow = [] 41 | deny = [] 42 | skip = [] 43 | skip-tree = [] 44 | 45 | [sources] 46 | unknown-registry = "warn" 47 | unknown-git = "warn" 48 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 49 | allow-git = [ 50 | "https://github.com/pop-os/cosmic-text", 51 | "https://github.com/Fundament-Institute/luajit-src-rs", 52 | ] 53 | -------------------------------------------------------------------------------- /feather-ui/src/shaders/mipmap.wgsl: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | const UNITX = array(0.0, 1.0, 0.0, 1.0, 1.0, 0.0); 5 | const UNITY = array(0.0, 0.0, 1.0, 0.0, 1.0, 1.0); 6 | 7 | @group(0) @binding(0) 8 | var MVP: mat4x4f; 9 | @group(0) @binding(1) 10 | var buf: array, 256>; 11 | @group(0) @binding(2) 12 | var sampling: sampler; 13 | @group(0) @binding(3) 14 | var source: texture_2d; 15 | @group(0) @binding(4) 16 | var basesize: vec2; 17 | 18 | struct VertexOutput { 19 | @builtin(position) position: vec4, 20 | @location(0) uv: vec2, 21 | } 22 | 23 | @vertex 24 | fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput { 25 | let vert = idx % 6; 26 | let index = idx / 6; 27 | var vpos = vec2(UNITX[vert], UNITY[vert]); 28 | let d = buf[index]; 29 | 30 | let uv = d.xy / basesize; 31 | let uvdim = (d.zw - d.xy) / basesize; 32 | 33 | return VertexOutput(MVP * vec4(d.xy + ((d.zw - d.xy) * vpos), 1f, 1f), uv + (uvdim * vpos)); 34 | } 35 | 36 | @fragment 37 | fn fs_main(@location(0) uv: vec2) -> @location(0) vec4 { 38 | return textureSample(source, sampling, uv); 39 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | [workspace] 5 | members = [ 6 | "feather-ui", 7 | # "feather-ui/examples/basic-alc", 8 | "feather-ui/examples/calculator-rs", 9 | # "feather-ui/examples/calculator-alc", # This breaks cargo for some reason 10 | "feather-macro", 11 | ] 12 | resolver = "2" 13 | default-members = ["feather-ui", "feather-macro"] 14 | 15 | [workspace.package] 16 | version = "0.4.0" 17 | edition = "2024" 18 | rust-version = "1.88.0" 19 | license = "Apache-2.0" 20 | homepage = "https://github.com/Fundament-Software/feathergui" 21 | repository = "https://github.com/Fundament-Software/feathergui/" 22 | readme = "README.md" 23 | 24 | [workspace.dependencies] 25 | im = "15.1" 26 | wgpu = "26" 27 | winit = "0.30" 28 | eyre = "0.6" 29 | feather-ui = { path = "feather-ui" } 30 | alicorn = "0.1.2" 31 | feather-macro = { version = "0.4", path = "feather-macro" } 32 | cosmic-text = { version = "0.14.2" } 33 | 34 | [workspace.lints] 35 | 36 | [patch.crates-io] 37 | luajit-src = { git = "https://github.com/Fundament-Institute/luajit-src-rs" } 38 | cosmic-text = { git = "https://github.com/pop-os/cosmic-text", rev = "355b7febb17ecb0522346fcc5aff6ea78e33e78a" } 39 | -------------------------------------------------------------------------------- /feather-ui/examples/calculator-alc/Cargo.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | [package] 5 | name = "calculator-alc" 6 | version.workspace = true 7 | edition.workspace = true 8 | rust-version.workspace = true 9 | authors = ["Erik McClure "] 10 | description = """ 11 | Basic calculator using Alicorn 12 | """ 13 | homepage.workspace = true 14 | readme.workspace = true 15 | license.workspace = true 16 | 17 | [[bin]] 18 | name = "calculator-alc" 19 | path = "src/main.rs" 20 | 21 | [dependencies] 22 | feather-ui.workspace = true 23 | alicorn.workspace = true 24 | uniffi = { version = "0.29", features = ["scaffolding-ffi-buffer-fns"] } 25 | uniffi-alicorn = { version = "0.1.2", features = [ 26 | "scaffolding-ffi-buffer-fns", 27 | ] } 28 | thiserror = "2.0" 29 | 30 | [build-dependencies] 31 | # Add the "scaffolding-ffi-buffer-fns" feature to make sure things can build correctly 32 | uniffi = { version = "0.29", features = [ 33 | "build", 34 | "scaffolding-ffi-buffer-fns", 35 | ] } 36 | uniffi-alicorn = { version = "0.1.2", features = [ 37 | "build", 38 | "scaffolding-ffi-buffer-fns", 39 | ] } 40 | 41 | [dev-dependencies] 42 | uniffi-alicorn = { version = "0.1.2", features = ["bindgen-tests"] } 43 | -------------------------------------------------------------------------------- /feather-ui/examples/calculator-rs/calculator-cs.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | # SPDX-License-Identifier: Apache-2.0 4 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 5 | 6 | VisualStudioVersion = 17.13.35806.99 7 | MinimumVisualStudioVersion = 10.0.40219.1 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "calculator-cs", "calculator-cs\calculator-cs.csproj", "{9D965116-7C3D-4409-A11E-15506F285334}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {9D965116-7C3D-4409-A11E-15506F285334}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {9D965116-7C3D-4409-A11E-15506F285334}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {9D965116-7C3D-4409-A11E-15506F285334}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {9D965116-7C3D-4409-A11E-15506F285334}.Release|Any CPU.Build.0 = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(SolutionProperties) = preSolution 22 | HideSolutionNode = FALSE 23 | EndGlobalSection 24 | GlobalSection(ExtensibilityGlobals) = postSolution 25 | SolutionGuid = {76FCE354-22B6-4164-BB01-7F6A0E8FA418} 26 | EndGlobalSection 27 | EndGlobal 28 | -------------------------------------------------------------------------------- /feather-ui/src/shaders/mod.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | const FILES: [(&str, &str); 3] = [ 5 | ("feather.wgsl", include_str!("feather.wgsl")), 6 | ("shape.wgsl", include_str!("shape.wgsl")), 7 | ("compositor.wgsl", include_str!("compositor.wgsl")), 8 | ]; 9 | 10 | pub fn load_wgsl(device: &wgpu::Device, label: &str, src: &str) -> wgpu::ShaderModule { 11 | /* 12 | const PREFIX: &str = "#include \""; 13 | 14 | let s = src 15 | .find(PREFIX) 16 | .and_then(|idx| { 17 | let start = idx + PREFIX.len(); 18 | src[start..].find('"').and_then(|end| { 19 | get(&src[start..end]).and_then(|s| { 20 | Some(std::borrow::Cow::Owned( 21 | String::from_str(&src[..idx]).unwrap() + s + &src[end..], 22 | )) 23 | }) 24 | }) 25 | }) 26 | .unwrap_or_else(|| std::borrow::Cow::Borrowed(src)); 27 | */ 28 | 29 | let s = std::borrow::Cow::Borrowed(src); 30 | 31 | device.create_shader_module(wgpu::ShaderModuleDescriptor { 32 | label: Some(label), 33 | source: wgpu::ShaderSource::Wgsl(s), 34 | }) 35 | } 36 | 37 | pub fn get(file: &str) -> Option<&str> { 38 | FILES 39 | .iter() 40 | .find(|(name, _)| name.eq_ignore_ascii_case(file)) 41 | .map(|(_, src)| *src) 42 | } 43 | -------------------------------------------------------------------------------- /feather-ui/src/render/image.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use super::compositor::CompositorView; 5 | use crate::resource::Location; 6 | 7 | pub struct Instance { 8 | pub image: Box, 9 | pub padding: crate::PxPerimeter, 10 | pub dpi: f32, 11 | pub resize: bool, 12 | } 13 | 14 | impl super::Renderable for Instance { 15 | fn render( 16 | &self, 17 | area: crate::PxRect, 18 | driver: &crate::graphics::Driver, 19 | compositor: &mut CompositorView<'_>, 20 | ) -> Result<(), crate::Error> { 21 | let dim = area.dim() - self.padding.bottomright(); 22 | if dim.width <= 0.0 || dim.height <= 0.0 { 23 | return Ok(()); 24 | } 25 | 26 | driver.load( 27 | self.image.as_ref(), 28 | dim.ceil().to_i32(), 29 | self.dpi, 30 | self.resize, 31 | |region| { 32 | compositor.append_data( 33 | area.topleft() + self.padding.topleft().to_vector(), 34 | dim, 35 | region.uv.min.to_f32(), 36 | region.uv.size().to_f32(), 37 | 0xFFFFFFFF, 38 | 0.0, 39 | region.index, 40 | false, 41 | ); 42 | Ok(()) 43 | }, 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /feather-ui/src/component/line.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::color::sRGB; 5 | use crate::layout::{Layout, base}; 6 | use crate::{PxPoint, SourceID, layout}; 7 | use derive_where::derive_where; 8 | use std::rc::Rc; 9 | use std::sync::Arc; 10 | 11 | // This draws a line between two points relative to the parent 12 | #[derive(feather_macro::StateMachineChild)] 13 | #[derive_where(Clone)] 14 | pub struct Line { 15 | pub id: Arc, 16 | pub start: PxPoint, 17 | pub end: PxPoint, 18 | pub props: Rc, 19 | pub fill: sRGB, 20 | } 21 | 22 | impl super::Component for Line 23 | where 24 | for<'a> &'a T: Into<&'a (dyn base::Empty + 'static)>, 25 | { 26 | type Props = T; 27 | 28 | fn layout( 29 | &self, 30 | _: &mut crate::StateManager, 31 | _: &crate::graphics::Driver, 32 | _window: &Arc, 33 | ) -> Box> { 34 | Box::new(layout::Node:: { 35 | props: self.props.clone(), 36 | children: Default::default(), 37 | id: Arc::downgrade(&self.id), 38 | renderable: Some(Rc::new(crate::render::line::Instance { 39 | start: self.start, 40 | end: self.end, 41 | color: self.fill, 42 | })), 43 | layer: None, 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /feather-ui/src/component/domain_point.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::SourceID; 5 | use crate::layout::domain_write; 6 | use derive_where::derive_where; 7 | use std::rc::Rc; 8 | use std::sync::Arc; 9 | 10 | // This simply writes it's area to the given cross-reference domain during the 11 | // layout phase 12 | #[derive(feather_macro::StateMachineChild)] 13 | #[derive_where(Clone)] 14 | pub struct DomainPoint { 15 | pub id: Arc, 16 | props: Rc, 17 | } 18 | 19 | impl DomainPoint { 20 | pub fn new(id: Arc, props: T) -> Self { 21 | Self { 22 | id, 23 | props: props.into(), 24 | } 25 | } 26 | } 27 | 28 | impl super::Component for DomainPoint 29 | where 30 | for<'a> &'a T: Into<&'a (dyn domain_write::Prop + 'static)>, 31 | { 32 | type Props = T; 33 | 34 | fn layout( 35 | &self, 36 | _: &mut crate::StateManager, 37 | _: &crate::graphics::Driver, 38 | _: &Arc, 39 | ) -> Box> { 40 | Box::new(crate::layout::Node:: { 41 | props: self.props.clone(), 42 | children: Default::default(), 43 | id: Arc::downgrade(&self.id), 44 | renderable: None, 45 | layer: None, 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /feather-ui/src/layout/root.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use super::{Desc, Layout, Renderable, Staged, base}; 5 | use crate::{PxDim, PxRect}; 6 | use std::rc::Rc; 7 | 8 | // The root node represents some area on the screen that contains a feather 9 | // layout. Later this will turn into an absolute bounding volume. There can be 10 | // multiple root nodes, each mapping to a different window. 11 | pub trait Prop { 12 | fn dim(&self) -> &PxDim; 13 | } 14 | 15 | crate::gen_from_to_dyn!(Prop); 16 | 17 | impl Prop for PxDim { 18 | fn dim(&self) -> &PxDim { 19 | self 20 | } 21 | } 22 | 23 | impl Desc for dyn Prop { 24 | type Props = dyn Prop; 25 | type Child = dyn base::Empty; 26 | type Children = Box>; 27 | 28 | fn stage<'a>( 29 | props: &Self::Props, 30 | _: PxRect, 31 | _: crate::PxLimits, 32 | child: &Self::Children, 33 | _: std::sync::Weak, 34 | _: Option>, 35 | window: &mut crate::component::window::WindowState, 36 | ) -> Box { 37 | // We bypass creating our own node here because we can never have a nonzero 38 | // topleft corner, so our node would be redundant. 39 | child.stage( 40 | (*props.dim()).cast_unit().into(), 41 | Default::default(), 42 | window, 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | SPDX-PackageName = "Feather-UI" 3 | SPDX-PackageSupplier = "Fundament Research Institute " 4 | SPDX-PackageDownloadLocation = "https://github.com/Fundament-Software/feathergui" 5 | 6 | [[annotations]] 7 | path = "feather-ui/examples/calculator-rs/calculator-cs/**" 8 | precedence = "aggregate" 9 | SPDX-FileCopyrightText = "2025 Fundament Research Institute " 10 | SPDX-License-Identifier = "Apache-2.0" 11 | 12 | [[annotations]] 13 | path = "*.lock" 14 | precedence = "aggregate" 15 | SPDX-FileCopyrightText = "2025 Fundament Research Institute " 16 | SPDX-License-Identifier = "Apache-2.0" 17 | 18 | 19 | [[annotations]] 20 | path = ".envrc" 21 | precedence = "aggregate" 22 | SPDX-FileCopyrightText = "2025 Fundament Research Institute " 23 | SPDX-License-Identifier = "Apache-2.0" 24 | 25 | [[annotations]] 26 | path = "*.png" 27 | precedence = "aggregate" 28 | SPDX-FileCopyrightText = "2025 Fundament Research Institute " 29 | SPDX-License-Identifier = "CC-BY-4.0" 30 | 31 | [[annotations]] 32 | path = "*.svg" 33 | precedence = "aggregate" 34 | SPDX-FileCopyrightText = "2025 Fundament Research Institute " 35 | SPDX-License-Identifier = "CC-BY-4.0" 36 | 37 | [[annotations]] 38 | path = "dice.jxl" 39 | precedence = "aggregate" 40 | SPDX-FileCopyrightText = "2005 version by Daniel G. 2009 version by Ed g2s. 2019 version by CyberShadow." 41 | SPDX-License-Identifier = "CC-BY-SA-3.0" 42 | -------------------------------------------------------------------------------- /feather-ui/src/shaders/feather.wgsl: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | const UNITX = array(0.0, 1.0, 0.0, 1.0, 1.0, 0.0); 5 | const UNITY = array(0.0, 0.0, 1.0, 0.0, 1.0, 1.0); 6 | const IDENTITY_MAT4 = mat4x4f(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0); 7 | 8 | fn srgb_to_linear(c: f32) -> f32 { 9 | if c <= 0.04045 { 10 | return c / 12.92; 11 | } 12 | else { 13 | return pow((c + 0.055) / 1.055, 2.4); 14 | } 15 | } 16 | 17 | fn srgb_to_linear_vec4(c: vec4) -> vec4 { 18 | return vec4f(srgb_to_linear(c.x), srgb_to_linear(c.y), srgb_to_linear(c.z), srgb_to_linear(c.w)); 19 | } 20 | 21 | fn scale_matrix(m: mat4x4f, x: f32, y: f32) -> mat4x4f { 22 | var r = m; 23 | r[0][0] *= x; 24 | r[1][1] *= y; 25 | return r; 26 | } 27 | 28 | fn translate_matrix(m: mat4x4f, x: f32, y: f32) -> mat4x4f { 29 | var r = m; 30 | r[3][0] += x; 31 | r[3][1] += y; 32 | return r; 33 | } 34 | 35 | fn rotation_matrix(x: f32, y: f32, r: f32) -> mat4x4f { 36 | let cr = cos(r); 37 | let sr = sin(r); 38 | 39 | return mat4x4f(cr, sr, 0, 0, - sr, cr, 0, 0, 0, 0, 1, 0, x - x * cr + y * sr, y - x * sr - y * cr, 0, 1); 40 | } 41 | 42 | fn u32_to_vec4(c: u32) -> vec4 { 43 | return vec4(f32((c & 0xff000000u) >> 24u) / 255.0, f32((c & 0x00ff0000u) >> 16u) / 255.0, f32((c & 0x0000ff00u) >> 8u) / 255.0, f32(c & 0x000000ffu) / 255.0); 44 | } 45 | 46 | fn linearstep(low: f32, high: f32, x: f32) -> f32 { 47 | return clamp((x - low) / (high - low), 0.0f, 1.0f); 48 | } -------------------------------------------------------------------------------- /feather-ui/src/render/mod.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::render::compositor::CompositorView; 5 | use crate::{PxRect, graphics}; 6 | use std::any::Any; 7 | use std::rc::Rc; 8 | 9 | pub mod atlas; 10 | pub mod compositor; 11 | pub mod domain; 12 | pub mod image; 13 | pub mod line; 14 | pub mod shape; 15 | pub mod text; 16 | pub mod textbox; 17 | 18 | pub trait Renderable { 19 | fn render( 20 | &self, 21 | area: PxRect, 22 | driver: &crate::graphics::Driver, 23 | compositor: &mut CompositorView<'_>, 24 | ) -> Result<(), crate::Error>; 25 | } 26 | 27 | pub trait Pipeline: Any + std::fmt::Debug + Send + Sync { 28 | #[allow(unused_variables)] 29 | fn prepare( 30 | &mut self, 31 | driver: &graphics::Driver, 32 | encoder: &mut wgpu::CommandEncoder, 33 | config: &wgpu::SurfaceConfiguration, 34 | ) { 35 | } 36 | fn draw(&mut self, driver: &graphics::Driver, pass: &mut wgpu::RenderPass<'_>, layer: u8); 37 | #[allow(unused_variables)] 38 | fn destroy(&mut self, driver: &graphics::Driver) {} 39 | } 40 | 41 | pub struct Chain(pub [Rc; N]); 42 | 43 | impl Renderable for Chain { 44 | fn render( 45 | &self, 46 | area: PxRect, 47 | driver: &crate::graphics::Driver, 48 | compositor: &mut CompositorView<'_>, 49 | ) -> Result<(), crate::Error> { 50 | for x in &self.0 { 51 | x.render(area, driver, compositor)?; 52 | } 53 | Ok(()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /feather-ui/src/component/domain_line.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::color::sRGB; 5 | use crate::layout::{Layout, base}; 6 | use crate::{CrossReferenceDomain, SourceID, layout, render}; 7 | use derive_where::derive_where; 8 | use std::rc::Rc; 9 | use std::sync::Arc; 10 | 11 | // This draws a line between two points that were previously stored in a 12 | // Cross-reference Domain 13 | #[derive(feather_macro::StateMachineChild)] 14 | #[derive_where(Clone)] 15 | pub struct DomainLine { 16 | pub id: Arc, 17 | pub domain: Arc, 18 | pub start: Arc, 19 | pub end: Arc, 20 | pub props: Rc, 21 | pub fill: sRGB, 22 | } 23 | 24 | impl super::Component for DomainLine 25 | where 26 | for<'a> &'a T: Into<&'a (dyn base::Empty + 'static)>, 27 | { 28 | type Props = T; 29 | 30 | fn layout( 31 | &self, 32 | _: &mut crate::StateManager, 33 | _: &crate::graphics::Driver, 34 | _: &Arc, 35 | ) -> Box> { 36 | Box::new(layout::Node:: { 37 | props: self.props.clone(), 38 | children: Default::default(), 39 | id: Arc::downgrade(&self.id), 40 | renderable: Some(Rc::new(render::domain::line::Instance { 41 | domain: self.domain.clone(), 42 | start: self.start.clone(), 43 | end: self.end.clone(), 44 | color: self.fill, 45 | })), 46 | layer: None, 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /feather-ui/rrb-vector.alc: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | enum Option(T) 5 | Some(v: T) 6 | None 7 | 8 | enum Result(T, E) 9 | Ok(v: T) 10 | Err(e: E) 11 | 12 | enum NodeValue(N, V) 13 | Child(node: N) 14 | Value(value: V) 15 | Empty 16 | 17 | struct Node(K, V, N) 18 | keys: Array(K, N-1) 19 | children: Array(NodeValue(Node(K, V, N), V), N-1) 20 | 21 | struct BTree(K, V, N) 22 | root: Node(K, V, N) 23 | size: usize 24 | 25 | def new(K, V, N) 26 | let self = BTree(K, V, N){ root = Node(K, V, N), size = 0 } 27 | return self 28 | 29 | def is_empty('K, 'V, 'N, self : BTree(K, V, N)) -> bool 30 | return self.size == 0 31 | 32 | def len('K, 'V, 'N, self : BTree(K, V, N)) -> usize 33 | return self.size 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | #[cfg(has_specialisation)] 52 | impl BTreeValue for (K, V) { 53 | default fn search_key(slice: &[Self], key: &BK) -> Result 54 | where 55 | BK: Ord + ?Sized, 56 | Self::Key: Borrow, 57 | { 58 | slice.binary_search_by(|value| Self::Key::borrow(&value.0).cmp(key)) 59 | } 60 | 61 | default fn search_value(slice: &[Self], key: &Self) -> Result { 62 | slice.binary_search_by(|value| value.0.cmp(&key.0)) 63 | } 64 | 65 | fn cmp_keys(&self, other: &BK) -> Ordering 66 | where 67 | BK: Ord + ?Sized, 68 | Self::Key: Borrow, 69 | { 70 | Self::Key::borrow(&self.0).cmp(other) 71 | } 72 | 73 | fn cmp_values(&self, other: &Self) -> Ordering { 74 | self.0.cmp(&other.0) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /feather-ui/examples/clock.lua: -------------------------------------------------------------------------------- 1 | -- SPDX-License-Identifier: Apache-2.0 2 | -- SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | local f = require("feather") 5 | local abs, rel, px, NONE = f.abs, f.rel, f.px, f.NONE 6 | 7 | local clockring = f.component { 8 | radius = f.required, 9 | width = f.required, 10 | progress = f.required, 11 | pos = f.required, 12 | color = f.required, 13 | }( 14 | function(args) 15 | return f.shape.arc { 16 | props = { 17 | area = abs(args.pos.x, args.pos.y, args.pos.x + args.radius * 2.0, args.pos.y + args.radius * 2.0), 18 | anchor = abs(0.5, 0.5), 19 | }, 20 | fill = args.color, 21 | innerRadius = args.radius - args.width, 22 | angles = { 0, args.progress * math.pi * 2.0 }, 23 | } 24 | end 25 | ) 26 | 27 | local clock = f.component { 28 | times = f.required, 29 | radius = f.required, 30 | pos = f.required, 31 | color = f.required, 32 | }(function(args) 33 | return f.region { 34 | props = { 35 | area = f.FILL, 36 | }, 37 | f.each("times", function(k, v) 38 | local radius = args.radius * 1.0 * v[2] 39 | 40 | return clockring { 41 | radius = radius, 42 | pos = args.pos, 43 | width = radius * 0.1, 44 | progress = v[1], 45 | color = args.color, 46 | } 47 | end, pairs(args.times)), 48 | } 49 | end) 50 | 51 | local function app(appstate) 52 | local clockcolor = 0xccccccff 53 | local w = f.window { 54 | title = "clock", 55 | resizable = true, 56 | clock { 57 | times = { 58 | { appstate.time_hour / 24.0, 0.7 }, 59 | { appstate.time_min / 60.0, 0.85 }, 60 | { appstate.time_sec / 60.0, 1.0 }, 61 | }, 62 | radius = 200, 63 | pos = f.abs(100, 100), 64 | color = clockcolor, 65 | }, 66 | } 67 | 68 | return appstate, w 69 | end 70 | 71 | return app, nil 72 | -------------------------------------------------------------------------------- /feather-ui/examples/basic-alc/basic.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use feather_ui::App; 5 | use feather_ui::lua::{AppState, LuaApp}; 6 | use feather_ui::mlua::Function; 7 | use feather_ui::mlua::prelude::*; 8 | use feather_ui::winit::event_loop; 9 | 10 | fn wrap_luafunc( 11 | f: Function, 12 | ) -> impl FnMut(feather_ui::DispatchPair, AppState) -> InputResult { 13 | move |pair, state| Ok(f.call((pair.0, state)).unwrap()) 14 | } 15 | 16 | fn main() { 17 | let lua = Lua::new(); 18 | let mut feather_interface = lua.create_table().unwrap(); 19 | feather_ui::lua::init_environment(&lua, &mut feather_interface).unwrap(); 20 | let alicorn = Box::new(alicorn::Alicorn::new(lua, &feather_interface).unwrap()); 21 | 22 | // Load the built-in GLSL prelude from alicorn 23 | alicorn.load_glsl_prelude().unwrap(); 24 | 25 | // Because of constraints on lifetimes, this needs to technically last forever. 26 | let alicorn = Box::leak(alicorn); 27 | { 28 | // This compiles and executes an alicorn program, which then calls back into the lua environment we have created in feather-ui/src/lua.rs 29 | let (window, init, onclick): (Function, Function, Function) = alicorn 30 | .execute(include_str!("layout.alc"), "layout.alc") 31 | .unwrap(); 32 | 33 | let onclick = Box::new(wrap_luafunc(onclick)); 34 | let outline = LuaApp { window, init }; 35 | let (mut app, event_loop, _, _): (App, event_loop::EventLoop<()>) = 36 | App::new(LuaValue::Integer(0), vec![onclick], outline, None, None).unwrap(); 37 | 38 | event_loop.run_app(&mut app).unwrap(); 39 | } 40 | //drop(unsafe { Box::from_raw(alicorn) }); 41 | } 42 | -------------------------------------------------------------------------------- /feather-ui/src/component/flexbox.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::layout::{Desc, Layout, flex}; 5 | use crate::persist::{FnPersist, VectorMap}; 6 | use crate::{SourceID, layout}; 7 | use derive_where::derive_where; 8 | use std::rc::Rc; 9 | use std::sync::Arc; 10 | 11 | use super::ChildOf; 12 | 13 | #[derive(feather_macro::StateMachineChild)] 14 | #[derive_where(Clone)] 15 | pub struct FlexBox { 16 | pub id: Arc, 17 | props: Rc, 18 | children: im::Vector>>>, 19 | } 20 | 21 | impl FlexBox { 22 | pub fn new( 23 | id: Arc, 24 | props: T, 25 | children: im::Vector>>>, 26 | ) -> Self { 27 | Self { 28 | id, 29 | props: props.into(), 30 | children, 31 | } 32 | } 33 | } 34 | 35 | impl super::Component for FlexBox { 36 | type Props = T; 37 | 38 | fn layout( 39 | &self, 40 | manager: &mut crate::StateManager, 41 | driver: &crate::graphics::Driver, 42 | window: &Arc, 43 | ) -> Box> { 44 | let mut map = VectorMap::new(crate::persist::Persist::new( 45 | |child: &Option>>| -> Option::Child>>> { 46 | Some(child.as_ref()?.layout(manager, driver,window)) 47 | }) 48 | ); 49 | 50 | let (_, children) = map.call(Default::default(), &self.children); 51 | Box::new(layout::Node:: { 52 | props: self.props.clone(), 53 | children, 54 | id: Arc::downgrade(&self.id), 55 | renderable: None, 56 | layer: None, 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /feather-ui/src/component/gridbox.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::layout::{Desc, Layout, grid}; 5 | use crate::persist::{FnPersist, VectorMap}; 6 | use crate::{SourceID, layout}; 7 | use derive_where::derive_where; 8 | use std::rc::Rc; 9 | use std::sync::Arc; 10 | 11 | use super::ChildOf; 12 | 13 | #[derive(feather_macro::StateMachineChild)] 14 | #[derive_where(Clone)] 15 | pub struct GridBox { 16 | pub id: Arc, 17 | props: Rc, 18 | children: im::Vector>>>, 19 | } 20 | 21 | impl GridBox { 22 | pub fn new( 23 | id: Arc, 24 | props: T, 25 | children: im::Vector>>>, 26 | ) -> Self { 27 | Self { 28 | id, 29 | props: props.into(), 30 | children, 31 | } 32 | } 33 | } 34 | 35 | impl super::Component for GridBox { 36 | type Props = T; 37 | 38 | fn layout( 39 | &self, 40 | manager: &mut crate::StateManager, 41 | driver: &crate::graphics::Driver, 42 | window: &Arc, 43 | ) -> Box> { 44 | let mut map = VectorMap::new(crate::persist::Persist::new( 45 | |child: &Option>>| -> Option::Child>>> { 46 | Some(child.as_ref()?.layout(manager, driver,window)) 47 | }) 48 | ); 49 | 50 | let (_, children) = map.call(Default::default(), &self.children); 51 | Box::new(layout::Node:: { 52 | props: self.props.clone(), 53 | children, 54 | id: Arc::downgrade(&self.id), 55 | renderable: None, 56 | layer: None, 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /feather-ui/src/component/listbox.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::layout::{Desc, Layout, list}; 5 | use crate::persist::{FnPersist, VectorMap}; 6 | use crate::{SourceID, layout}; 7 | use derive_where::derive_where; 8 | use std::rc::Rc; 9 | use std::sync::Arc; 10 | 11 | use super::ChildOf; 12 | 13 | #[derive(feather_macro::StateMachineChild)] 14 | #[derive_where(Clone)] 15 | pub struct ListBox { 16 | pub id: Arc, 17 | props: Rc, 18 | children: im::Vector>>>, 19 | } 20 | 21 | impl ListBox { 22 | pub fn new( 23 | id: Arc, 24 | props: T, 25 | children: im::Vector>>>, 26 | ) -> Self { 27 | Self { 28 | id, 29 | props: props.into(), 30 | children, 31 | } 32 | } 33 | } 34 | 35 | impl super::Component for ListBox { 36 | type Props = T; 37 | 38 | fn layout( 39 | &self, 40 | manager: &mut crate::StateManager, 41 | driver: &crate::graphics::Driver, 42 | window: &Arc, 43 | ) -> Box> { 44 | let mut map = VectorMap::new(crate::persist::Persist::new( 45 | |child: &Option>>| -> Option::Child>>> { 46 | Some(child.as_ref().unwrap().layout(manager, driver,window)) 47 | }) 48 | ); 49 | 50 | let (_, children) = map.call(Default::default(), &self.children); 51 | Box::new(layout::Node:: { 52 | props: self.props.clone(), 53 | children, 54 | id: Arc::downgrade(&self.id), 55 | renderable: None, 56 | layer: None, 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /feather-ui/src/render/domain/line.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::color::sRGB; 5 | use crate::render::compositor::{self, DataFlags}; 6 | use crate::{CrossReferenceDomain, SourceID}; 7 | 8 | use std::sync::Arc; 9 | 10 | pub struct Instance { 11 | pub domain: Arc, 12 | pub start: Arc, 13 | pub end: Arc, 14 | pub color: sRGB, 15 | } 16 | 17 | impl super::Renderable for Instance { 18 | fn render( 19 | &self, 20 | _: crate::PxRect, 21 | _: &crate::graphics::Driver, 22 | compositor: &mut compositor::CompositorView<'_>, 23 | ) -> Result<(), crate::Error> { 24 | let domain = self.domain.clone(); 25 | let start_id = self.start.clone(); 26 | let end_id = self.end.clone(); 27 | let color = self.color.as_32bit(); 28 | 29 | compositor.defer(move |_, data| { 30 | let start = domain.get_area(&start_id).unwrap_or_default(); 31 | let end = domain.get_area(&end_id).unwrap_or_default(); 32 | 33 | let p1 = (start.topleft() + start.bottomright().to_vector()) * 0.5; 34 | let p2 = (end.topleft() + end.bottomright().to_vector()) * 0.5; 35 | let p = p2 - p1; 36 | 37 | *data = compositor::Data { 38 | pos: (((p1 + p2.to_vector()) * 0.5) 39 | - (crate::PxVector::new(p.length() * 0.5, 0.0))) 40 | .to_array() 41 | .into(), 42 | dim: [p.length(), 1.0].into(), 43 | uv: [0.0, 0.0].into(), 44 | uvdim: [0.0, 0.0].into(), 45 | color: color.rgba, 46 | rotation: p.y.atan2(p.x) % std::f32::consts::TAU, 47 | flags: DataFlags::new().with_tex(u8::MAX).into(), 48 | ..Default::default() 49 | }; 50 | }); 51 | 52 | Ok(()) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /feather.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 12 | 19 | 23 | 27 | 28 | 35 | 39 | 43 | 44 | 45 | 48 | 55 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /feather-ui/src/layout/domain_write.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use super::base::{Empty, RLimits}; 5 | use super::{Concrete, Desc, Layout, Renderable, Staged}; 6 | use crate::{CrossReferenceDomain, SourceID, render, rtree}; 7 | use std::marker::PhantomData; 8 | use std::rc::Rc; 9 | use std::sync::Arc; 10 | 11 | // A DomainWrite layout spawns a renderable that writes it's area to the target 12 | // cross-reference domain 13 | pub trait Prop { 14 | fn domain(&self) -> Arc; 15 | } 16 | 17 | crate::gen_from_to_dyn!(Prop); 18 | 19 | impl Prop for Arc { 20 | fn domain(&self) -> Arc { 21 | self.clone() 22 | } 23 | } 24 | 25 | impl Empty for Arc {} 26 | impl RLimits for Arc {} 27 | impl super::fixed::Child for Arc {} 28 | 29 | impl Desc for dyn Prop { 30 | type Props = dyn Prop; 31 | type Child = dyn Empty; 32 | type Children = PhantomData>; 33 | 34 | fn stage<'a>( 35 | props: &Self::Props, 36 | mut outer_area: crate::PxRect, 37 | outer_limits: crate::PxLimits, 38 | _: &Self::Children, 39 | id: std::sync::Weak, 40 | renderable: Option>, 41 | window: &mut crate::component::window::WindowState, 42 | ) -> Box { 43 | outer_area = super::nuetralize_unsized(outer_area); 44 | outer_area = super::limit_area(outer_area, outer_limits); 45 | 46 | debug_assert!(outer_area.v.is_finite().all()); 47 | Box::new(Concrete { 48 | area: outer_area, 49 | renderable: Some(Rc::new(render::domain::Write { 50 | id: id.clone(), 51 | domain: props.domain().clone(), 52 | base: renderable, 53 | })), 54 | rtree: rtree::Node::new( 55 | outer_area.to_untyped(), 56 | None, 57 | Default::default(), 58 | id, 59 | window, 60 | ), 61 | children: Default::default(), 62 | layer: None, 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | permissions: 5 | contents: read 6 | 7 | name: CI 8 | 9 | on: 10 | push: 11 | branches: [ master ] 12 | pull_request: 13 | branches: [ master ] 14 | schedule: 15 | # run weekly 16 | - cron: '0 0 * * 0' 17 | 18 | env: 19 | CARGO_TERM_COLOR: always 20 | 21 | jobs: 22 | build: 23 | strategy: 24 | matrix: 25 | rust: 26 | - nightly 27 | - beta 28 | - stable 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v5 33 | 34 | - name: Install Rust 35 | run: rustup update ${{ matrix.rust }} --no-self-update && rustup default ${{ matrix.rust }} 36 | 37 | - name: Install Clang 38 | run: | 39 | export DEBIAN_FRONTEND=noninteractive 40 | sudo apt update 41 | sudo apt install -y clang-15 42 | sudo update-alternatives --install /usr/bin/c++ c++ /usr/bin/clang++-15 60 43 | sudo update-alternatives --install /usr/bin/cc cc /usr/bin/clang-15 60 44 | 45 | - name: Build 46 | run: cargo build --all 47 | 48 | - name: Build in release mode 49 | run: cargo build --all --release 50 | 51 | # We can't run any tests, because they would need a window manager and graphics to exist, which aren't available on standard CI 52 | 53 | fmt: 54 | name: formatting 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v5 58 | - uses: actions-rs/toolchain@v1 59 | with: 60 | toolchain: nightly 61 | override: true 62 | profile: minimal 63 | components: rustfmt 64 | - uses: actions-rs/cargo@v1 65 | with: 66 | command: fmt 67 | args: --all -- --check --unstable-features 68 | 69 | cargo-deny: 70 | runs-on: ubuntu-latest 71 | steps: 72 | - uses: actions/checkout@v5 73 | - uses: EmbarkStudios/cargo-deny-action@v2 74 | with: 75 | log-level: warn 76 | command: check 77 | arguments: --all-features 78 | 79 | # Check for typos in the repository based on a static dictionary 80 | typos: 81 | runs-on: ubuntu-latest 82 | steps: 83 | - uses: actions/checkout@v5 84 | 85 | # This is pinned to a specific version because the typos dictionary can 86 | # be updated between patch versions, and a new dictionary can find new 87 | # typos in the repo thus suddenly breaking CI unless we pin the version. 88 | - uses: crate-ci/typos@v1.36.2 89 | -------------------------------------------------------------------------------- /feather-ui/src/util.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use wgpu::CompilationMessageType; 5 | 6 | use crate::graphics::Driver; 7 | use crate::shaders; 8 | 9 | pub fn create_hotloader( 10 | path: &std::path::Path, 11 | label: &'static str, 12 | driver: std::sync::Weak, 13 | ) -> eyre::Result { 14 | use notify::Watcher; 15 | let mut prev = std::fs::read_to_string(path)?; 16 | let pathbuf = path.to_owned(); 17 | let mut watcher = notify::recommended_watcher(move |_| { 18 | if let Some(driver) = driver.upgrade() { 19 | let contents = std::fs::read_to_string(&pathbuf).unwrap(); 20 | if contents != prev { 21 | prev = contents; 22 | driver 23 | .device 24 | .push_error_scope(wgpu::ErrorFilter::Validation); 25 | let module = shaders::load_wgsl(&driver.device, label, &prev); 26 | let err = futures_lite::future::block_on(driver.device.pop_error_scope()); 27 | if let Some(e) = err { 28 | println!("{e}"); 29 | } else { 30 | let info = futures_lite::future::block_on(module.get_compilation_info()); 31 | 32 | let mut errored = false; 33 | for m in info.messages { 34 | println!("{m:?}"); 35 | errored = errored || m.message_type == CompilationMessageType::Error; 36 | } 37 | if !errored { 38 | driver.reload_pipeline::(module); 39 | } 40 | } 41 | } 42 | } 43 | })?; 44 | 45 | watcher.watch(path, notify::RecursiveMode::NonRecursive)?; 46 | 47 | Ok(watcher) 48 | } 49 | 50 | /// Allocates `&[T]` on stack space. 51 | pub(crate) fn alloca_array(n: usize, f: impl FnOnce(&mut [T]) -> R) -> R { 52 | use std::mem::{align_of, size_of}; 53 | 54 | alloca::with_alloca_zeroed( 55 | (n * size_of::()) + (align_of::() - 1), 56 | |memory| unsafe { 57 | let mut raw_memory = memory.as_mut_ptr(); 58 | if raw_memory as usize % align_of::() != 0 { 59 | raw_memory = 60 | raw_memory.add(align_of::() - raw_memory as usize % align_of::()); 61 | } 62 | 63 | f(std::slice::from_raw_parts_mut::( 64 | raw_memory.cast::(), 65 | n, 66 | )) 67 | }, 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /feather-ui/examples/basic.lua: -------------------------------------------------------------------------------- 1 | -- SPDX-License-Identifier: Apache-2.0 2 | -- SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | local f = require("feather") 5 | local abs, rel, px, NONE = f.abs, f.rel, f.px, f.NONE 6 | 7 | local function app(appstate) 8 | local w = f.window { 9 | title = "basic-lua", 10 | resizable = true, 11 | f.region { 12 | props = { 13 | area = abs(90, 90, 0, 200) + rel(0, 0, NONE, 0), -- The NONE in the rect is preserved and turns into a context-dependent value based on what it's assigned to. 14 | zindex = 0, 15 | }, 16 | f.button { 17 | onclick = handlers.onclick, -- This maps to the slot index of this rust function, which will be 0, or slot(APP_SOURCE_ID, 0) 18 | props = { 19 | area = abs(45, 45, 0, 0) + rel(0, 0, NONE, 1), -- If the NONE is in an area, it becomes UNSIZED 20 | }, 21 | f.shape.rect { 22 | props = { 23 | area = f.FILL, -- f.FILL is just rel(0, 0, 1, 1) 24 | }, 25 | corners = 10, 26 | fill = { 0.2, 0.7, 0.4, 1.0 }, 27 | outline = 0xFFFFFFFF, 28 | }, 29 | f.text { 30 | props = { 31 | area = abs(8, 0, 8, 0) + rel(0, 0.5, NONE, NONE), 32 | anchor = rel.y(0.5), 33 | }, 34 | text = "Clicks: " .. appstate.count, 35 | color = 0xFFFFFFFF, 36 | fontsize = 40, 37 | lineheight = 56, 38 | align = f.Align.Right, 39 | }, 40 | }, 41 | f.cond(appstate.count ~= 0) 42 | :Then(f.button { 43 | onclick = handlers.onclick, 44 | props = { 45 | area = abs(45, 245, 0, 0) + f.UNSIZED, -- f.UNSIZED is just rel(0, 0, NONE, NONE) 46 | minsize = abs(100, NONE), 47 | maxsize = abs(300, NONE), 48 | }, 49 | f.shape.rect { 50 | props = { 51 | area = f.FILL, 52 | }, 53 | corners = 10, -- shorthand for [10,10,10,10] 54 | fill = { 0.7, 0.2, 0.4, 1.0 }, -- colors can be arrays of floats 55 | outline = 0xFFFFFFFF, -- Or specified as an RGBA hex code 56 | }, 57 | f.text { 58 | props = { 59 | area = rel(0.5, 0, NONE, NONE), 60 | minsize = abs(NONE, 10), -- nil in a limit, however, becomes either NEG_INFINITY for minsize 61 | maxsize = abs(NONE, 200) + rel(1, NONE), -- or MAX_INFINITY for a maxsize 62 | anchor = rel.x(0.5), 63 | padding = abs(8), -- shorthand for abs(8,8,8,8) 64 | }, 65 | text = string.rep("█", appstate.count), 66 | color = 0xFFFFFFFF, 67 | fontsize = 40, 68 | lineheight = 56, 69 | wrap = f.Wrap.Any, 70 | align = f.Align.Left, 71 | }, 72 | }) 73 | :End(), 74 | f.shape.rect { 75 | props = { 76 | area = px(1, 1, 2, 2), 77 | }, 78 | fill = { 1, 1, 1, 1 }, 79 | }, 80 | }, 81 | } 82 | 83 | return appstate, w 84 | end 85 | 86 | return app, nil 87 | -------------------------------------------------------------------------------- /feather-ui/src/component/image.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::layout::{Layout, leaf}; 5 | use crate::render::atlas::Size; 6 | use crate::{DAbsPoint, SourceID, UNSIZED_AXIS}; 7 | use derive_where::derive_where; 8 | use std::rc::Rc; 9 | use std::sync::Arc; 10 | 11 | #[derive(feather_macro::StateMachineChild)] 12 | #[derive_where(Clone)] 13 | pub struct Image { 14 | pub id: Arc, 15 | pub props: Rc, 16 | pub resource: Box, 17 | pub size: DAbsPoint, 18 | pub dynamic: bool, 19 | } 20 | 21 | impl Image { 22 | pub fn new( 23 | id: Arc, 24 | props: T, 25 | resource: &dyn crate::resource::Location, 26 | size: DAbsPoint, 27 | dynamic: bool, 28 | ) -> Self { 29 | Self { 30 | id, 31 | props: props.into(), 32 | resource: dyn_clone::clone_box(resource), 33 | size, 34 | dynamic, 35 | } 36 | } 37 | } 38 | 39 | fn zero_float(f: f32) -> i32 { 40 | if f.is_finite() && f != UNSIZED_AXIS { 41 | f.ceil() as i32 42 | } else { 43 | 0 44 | } 45 | } 46 | 47 | impl super::Component for Image 48 | where 49 | for<'a> &'a T: Into<&'a (dyn leaf::Padded + 'static)>, 50 | { 51 | type Props = T; 52 | 53 | fn layout( 54 | &self, 55 | manager: &mut crate::StateManager, 56 | driver: &crate::graphics::Driver, 57 | window: &Arc, 58 | ) -> Box> { 59 | let dpi = manager 60 | .get::(window) 61 | .map(|x| x.state.dpi) 62 | .unwrap_or(crate::BASE_DPI); 63 | 64 | let size = self.size.resolve(dpi); 65 | 66 | // TODO: Layout cannot easily return an error because this messes up the 67 | // persistent functions 68 | let uvsize = driver 69 | .load_and_resize( 70 | self.resource.as_ref(), 71 | Size::new(zero_float(size.x), zero_float(size.y)), 72 | dpi.width, 73 | self.dynamic, 74 | ) 75 | .unwrap(); 76 | 77 | Box::new(leaf::Sized:: { 78 | props: self.props.clone(), 79 | id: Arc::downgrade(&self.id), 80 | size: uvsize.cast().cast_unit(), 81 | renderable: Some(Rc::new(crate::render::image::Instance { 82 | image: self.resource.clone(), 83 | padding: self.props.padding().as_perimeter(dpi), 84 | dpi: dpi.width, 85 | resize: self.dynamic, 86 | })), 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /feather-ui/examples/clock-lua.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use std::sync::atomic::{AtomicUsize, Ordering}; 5 | 6 | use feather_ui::lua::LuaApp; 7 | use mlua::{FromLua, Lua, UserData, UserDataFields}; 8 | 9 | const LAYOUT: &[u8] = include_bytes!("./clock.lua"); 10 | 11 | #[derive(Debug)] 12 | struct TimeState { 13 | count: AtomicUsize, 14 | } 15 | 16 | impl PartialEq for TimeState { 17 | fn eq(&self, other: &Self) -> bool { 18 | self.count.load(Ordering::Relaxed) == other.count.load(Ordering::Relaxed) 19 | } 20 | } 21 | 22 | impl Clone for TimeState { 23 | fn clone(&self) -> Self { 24 | Self { 25 | count: self.count.load(Ordering::Relaxed).into(), 26 | } 27 | } 28 | } 29 | 30 | impl UserData for TimeState { 31 | fn add_fields>(f: &mut F) { 32 | f.add_field_method_get("time_hour", |_, s| { 33 | s.count.fetch_add(1, Ordering::Relaxed); 34 | let day = std::time::SystemTime::now() 35 | .duration_since(std::time::UNIX_EPOCH) 36 | .unwrap() 37 | .as_secs() 38 | % (24 * 60 * 60); 39 | Ok(day / (60 * 60)) 40 | }); 41 | f.add_field_method_get("time_min", |_, s| { 42 | s.count.fetch_add(1, Ordering::Relaxed); 43 | let day = std::time::SystemTime::now() 44 | .duration_since(std::time::UNIX_EPOCH) 45 | .unwrap() 46 | .as_secs() 47 | % (24 * 60 * 60); 48 | Ok((day / 60) % 60) 49 | }); 50 | f.add_field_method_get("time_sec", |_, s| { 51 | s.count.fetch_add(1, Ordering::Relaxed); 52 | let day = std::time::SystemTime::now() 53 | .duration_since(std::time::UNIX_EPOCH) 54 | .unwrap() 55 | .as_secs() 56 | % (24 * 60 * 60); 57 | Ok(day % 60) 58 | }); 59 | } 60 | } 61 | 62 | impl FromLua for TimeState { 63 | #[inline] 64 | fn from_lua(value: ::mlua::Value, _: &::mlua::Lua) -> ::mlua::Result { 65 | match value { 66 | ::mlua::Value::UserData(ud) => Ok(ud.borrow::()?.clone()), 67 | _ => Err(::mlua::Error::FromLuaConversionError { 68 | from: value.type_name(), 69 | to: stringify!(TimeState).to_string(), 70 | message: None, 71 | }), 72 | } 73 | } 74 | } 75 | 76 | fn main() { 77 | let lua = Lua::new(); 78 | 79 | let (mut app, event_loop) = 80 | LuaApp::::new(&lua, TimeState { count: 0.into() }, Vec::new(), LAYOUT) 81 | .unwrap(); 82 | 83 | event_loop.run_app(&mut app).unwrap(); 84 | } 85 | -------------------------------------------------------------------------------- /feather-ui/examples/calculator-rs/build.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use std::path::PathBuf; 5 | 6 | fn get_cargo_target_dir() -> Result> { 7 | let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR")?); 8 | let profile = std::env::var("PROFILE")?; 9 | let mut target_dir = None; 10 | let mut sub_path = out_dir.as_path(); 11 | while let Some(parent) = sub_path.parent() { 12 | if parent.ends_with(&profile) { 13 | target_dir = Some(parent); 14 | break; 15 | } 16 | sub_path = parent; 17 | } 18 | let target_dir = target_dir.ok_or("not found")?; 19 | Ok(target_dir.to_path_buf()) 20 | } 21 | 22 | fn main() { 23 | uniffi::generate_scaffolding("src/calculator.udl").unwrap(); 24 | 25 | // Attempt to build C# example and copy it to target dir 26 | match std::process::Command::new("dotnet") 27 | .args(["build", "calculator-cs/calculator-cs.csproj"]) 28 | .spawn() 29 | .and_then(|mut c| c.wait()) 30 | .map(|e| e.success()) 31 | { 32 | Ok(true) => { 33 | if let Ok(s) = get_cargo_target_dir() { 34 | let curdir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); 35 | let bin = curdir.join("calculator-cs/bin"); 36 | 37 | let debug = bin.read_dir().unwrap().last().unwrap().unwrap(); 38 | let net8 = debug.path().read_dir().unwrap().last().unwrap().unwrap(); 39 | 40 | std::fs::copy( 41 | net8.path().join("calculator-cs.runtimeconfig.json"), 42 | s.join("calculator-cs.runtimeconfig.json"), 43 | ) 44 | .unwrap(); 45 | std::fs::copy( 46 | net8.path().join("calculator-cs.dll"), 47 | s.join("calculator-cs.dll"), 48 | ) 49 | .unwrap(); 50 | if std::fs::copy( 51 | net8.path().join("calculator-cs.exe"), 52 | s.join("calculator-cs.exe"), 53 | ) 54 | .is_err() 55 | { 56 | std::fs::copy(net8.path().join("calculator-cs"), s.join("calculator-cs")) 57 | .unwrap(); 58 | } 59 | } else { 60 | print!("Couldn't get TARGET_DIR for current crate, C# example not copied to output dir."); 61 | } 62 | } 63 | // We do not panic on error here so systems without dotnet can still build the rust example 64 | Ok(false) => print!("dotnet build failed, calculator-cs will not be available."), 65 | Err(e) => print!("Error running dotnet build, calculator-cs will not be available: {e}"), 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /feather-ui/src/component/region.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::color::sRGB32; 5 | use crate::component::ChildOf; 6 | use crate::layout::{Desc, Layout, fixed}; 7 | use crate::persist::{FnPersist, VectorMap}; 8 | use crate::{SourceID, layout}; 9 | use derive_where::derive_where; 10 | use std::rc::Rc; 11 | use std::sync::Arc; 12 | 13 | #[derive(feather_macro::StateMachineChild)] 14 | #[derive_where(Clone, Default)] 15 | pub struct Region { 16 | pub id: Arc, 17 | pub color: Option, 18 | pub rotation: Option, 19 | props: Rc, 20 | children: im::Vector>>>, 21 | } 22 | 23 | impl Region { 24 | pub fn new( 25 | id: Arc, 26 | props: T, 27 | children: im::Vector>>>, 28 | ) -> Self { 29 | Self { 30 | id, 31 | props: props.into(), 32 | children, 33 | ..Default::default() 34 | } 35 | } 36 | 37 | pub fn new_layer( 38 | id: Arc, 39 | props: T, 40 | color: sRGB32, 41 | rotation: f32, 42 | children: im::Vector>>>, 43 | ) -> Self { 44 | Self { 45 | id, 46 | props: props.into(), 47 | color: Some(color), 48 | rotation: Some(rotation), 49 | children, 50 | } 51 | } 52 | } 53 | 54 | impl super::Component for Region 55 | where 56 | for<'a> &'a T: Into<&'a (dyn fixed::Prop + 'static)>, 57 | { 58 | type Props = T; 59 | 60 | fn layout( 61 | &self, 62 | manager: &mut crate::StateManager, 63 | driver: &crate::graphics::Driver, 64 | window: &Arc, 65 | ) -> Box> { 66 | let mut map = VectorMap::new(crate::persist::Persist::new( 67 | |child: &Option>>| -> Option::Child>>> { 68 | Some(child.as_ref()?.layout(manager, driver, window)) 69 | })); 70 | 71 | let layer = if self.color.is_some() || self.rotation.is_some() { 72 | Some(( 73 | self.color.unwrap_or(sRGB32::white()), 74 | self.rotation.unwrap_or_default(), 75 | )) 76 | } else { 77 | None 78 | }; 79 | 80 | let (_, children) = map.call(Default::default(), &self.children); 81 | Box::new(layout::Node:: { 82 | props: self.props.clone(), 83 | children, 84 | id: Arc::downgrade(&self.id), 85 | renderable: None, 86 | layer, 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Feather UI 2 | 3 | 4 | [![Build Status](https://img.shields.io/github/actions/workflow/status/Fundament-Institute/feather-ui/ci.yml?branch=main&logo=github&label=CI)](https://github.com/Fundament-Institute/feather-ui/actions) 5 | [![Discord](https://img.shields.io/static/v1?label=Channel&message=%23feather&color=blue&logo=discord)](https://discord.gg/mU7deD8DmT) 6 | 7 | Feather is a reactive data-driven UI framework that only mutates application state in response to user inputs or events, using event streams and reactive properties, and represents application state using persistent data structures, which then efficiently render only the parts of the UI that changed using either a standard GPU compositor or custom shaders. 8 | 9 | ## Building 10 | 11 | Feather is a standard rust project, simply run `cargo build` on your platform of choice. A NixOS flake is included that provides a develop environment for nix developers who do not have rust installed system-wide. 12 | 13 | ## Running 14 | 15 | Examples can be found in [feather-ui/examples](feather-ui/examples), and can be run via `cargo run --example `. 16 | 17 | If you are on NixOS, use `nix run github:Fundament-Software/feathergui#` 18 | If you are not on nixos but have nix, use `nix run --impure github:nix-community/nixGL -- nix run github:fundament-software/feathergui#` 19 | 20 | The examples have currently only been tested on NixOS and Windows 11, but should work on most systems. 21 | 22 | ## Community 23 | 24 | We have a [discord server](https://discord.gg/mU7deD8DmT) where you can ask questions and talk with contributors. However, if you have found an issue, you should prioritize [opening an issue](https://github.com/Fundament-Institute/feather-ui/issues/new) on GitHub rather than using Discord. 25 | 26 | ## Funding 27 | 28 | This project is funded through [NGI Zero Core](https://nlnet.nl/core), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/FeatherUI). 29 | 30 | [NLnet foundation logo](https://nlnet.nl) 31 | [NGI Zero Logo](https://nlnet.nl/core) 32 | 33 | ### Donations 34 | 35 | Feather is developed by a non-profit, and we rely on community support to continue our work. Visit our [Open Collective](https://opencollective.com/feather-ui) if you are interested in donating towards a specific development goal, or just want to support us. We appreciate any and all help! 36 | 37 | ## License 38 | Copyright © 2025 Fundament Research Institute 39 | 40 | Distributed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). 41 | 42 | SPDX-License-Identifier: Apache-2.0 43 | -------------------------------------------------------------------------------- /feather-ui/src/component/button.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use super::mouse_area::MouseArea; 5 | 6 | use crate::component::{ChildOf, Desc}; 7 | use crate::layout::{Layout, fixed}; 8 | use crate::persist::{FnPersist, VectorMap}; 9 | use crate::{Component, DRect, Slot, SourceID, layout}; 10 | use derive_where::derive_where; 11 | use std::rc::Rc; 12 | use std::sync::Arc; 13 | 14 | // A button component that contains a mousearea alongside it's children 15 | #[derive_where(Clone)] 16 | pub struct Button { 17 | pub id: Arc, 18 | props: Rc, 19 | marea: MouseArea, 20 | children: im::Vector>>>, 21 | } 22 | 23 | impl Button { 24 | pub fn new( 25 | id: Arc, 26 | props: T, 27 | onclick: Slot, 28 | children: im::Vector>>>, 29 | ) -> Self { 30 | Self { 31 | id: id.clone(), 32 | props: props.into(), 33 | marea: MouseArea::new( 34 | id.child(crate::DataID::Named("__marea_internal__")), 35 | crate::FILL_DRECT, 36 | None, 37 | [Some(onclick), None, None, None, None, None], 38 | ), 39 | children, 40 | } 41 | } 42 | } 43 | 44 | impl crate::StateMachineChild for Button { 45 | fn id(&self) -> Arc { 46 | self.id.clone() 47 | } 48 | 49 | fn apply_children( 50 | &self, 51 | f: &mut dyn FnMut(&dyn crate::StateMachineChild) -> eyre::Result<()>, 52 | ) -> eyre::Result<()> { 53 | self.children 54 | .iter() 55 | .try_for_each(|x| f(x.as_ref().unwrap().as_ref()))?; 56 | f(&self.marea) 57 | } 58 | } 59 | 60 | impl Component for Button 61 | where 62 | for<'a> &'a T: Into<&'a (dyn fixed::Prop + 'static)>, 63 | { 64 | type Props = T; 65 | 66 | fn layout( 67 | &self, 68 | manager: &mut crate::StateManager, 69 | driver: &crate::graphics::Driver, 70 | window: &Arc, 71 | ) -> Box> { 72 | let mut map = VectorMap::new(crate::persist::Persist::new( 73 | |child: &Option>>| -> Option::Child>>> { 74 | Some(child.as_ref()?.layout(manager, driver, window)) 75 | }) 76 | ); 77 | 78 | let (_, mut children) = map.call(Default::default(), &self.children); 79 | children.push_back(Some(Box::new(self.marea.layout(manager, driver, window)))); 80 | 81 | Box::new(layout::Node:: { 82 | props: self.props.clone(), 83 | children, 84 | id: Arc::downgrade(&self.id), 85 | renderable: None, 86 | layer: None, 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /feather-ui/b-tree.alc: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | enum Option(T) 5 | Some(v : T) 6 | None 7 | 8 | enum Result(T, E) 9 | Ok(v : T) 10 | Err(e : E) 11 | 12 | enum Either(L, R) 13 | Left(l : L) 14 | Right(r : R) 15 | 16 | enum NodeValue(K : Ord, V) 17 | Child(node : BNode(K, V)) 18 | Value(value : V) 19 | 20 | struct BNode(K : Ord, V) 21 | size : uint 22 | keys : Array(K, size-1) 23 | children : Array(NodeValue(K, V), size) 24 | 25 | struct BTree(K, V, N : Int) 26 | root : BNode(K, V) 27 | size : uint 28 | 29 | def binary_search_inner('K : Ord, 'N : Int, src : Array(K, N), target : K, start : uint, end : uint) -> K 30 | if (start < end) 31 | then 32 | let center = (start + end) / 2 33 | return match compare(target, src[center]) 34 | LT 35 | binary_search_inner(src, target, start, center-1) 36 | EQ 37 | src[center] 38 | GE 39 | binary_search_inner(src, target, center+1, end) 40 | else 41 | return src[start] 42 | 43 | def binary_search('K : Ord, 'N : Int, src : Array(K, N), target : K) -> K 44 | return binary_search_inner(src, target, 0, N-1) 45 | 46 | def empty('K, 'V) 47 | return BNode(K, V){ 48 | size = 0 49 | keys = array-of() 50 | children = array-of() 51 | } 52 | 53 | 54 | module Internal ('K : Ord, 'V) 55 | let Node = BNode(K, V) 56 | 57 | def search(self : Node, key : K) -> Either(uint, uint) 58 | 59 | 60 | def lookup(self : Node, key : K) -> Option(V) 61 | if (self.keys.is_empty()) 62 | then 63 | return None 64 | else 65 | match search_value(key) 66 | Left(found) 67 | self.children( 68 | Right(missed) 69 | 70 | 71 | 72 | def insert(self : Node, key : K, value : V) -> Option(V) 73 | 74 | 75 | unlet Node 76 | 77 | module Methods ('K : Ord, 'V, 'N : Int) 78 | let Self = BTree(K, V, N) 79 | 80 | def new() 81 | return Self{ root = empty(), size = 0 } 82 | 83 | def is_empty(self : Self) -> bool 84 | return self.size == 0 85 | 86 | def len(self : Self) -> uint 87 | return self.size 88 | 89 | def clear(self : Self) 90 | self.size = 0 91 | self.root = empty() 92 | 93 | def get_max(self : Self) -> Option(tuple(K, V)) 94 | return Internal.max(self.root) 95 | 96 | def get_min(self : Self) -> Option(tuple(K, V)) 97 | return Internal.min(self.root) 98 | 99 | def get(key : K) -> Option(V) 100 | return Internal.lookup(self.root, key) 101 | 102 | def contains(key : K) -> bool 103 | return match get(key) 104 | Some(_) 105 | true 106 | None 107 | false 108 | 109 | def insert(key : K, value : V) -> Option(V) 110 | 111 | 112 | unlet Self 113 | 114 | 115 | open-module Methods 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | #[cfg(has_specialisation)] 132 | impl BTreeValue for (K, V) { 133 | default fn search_key(slice: &[Self], key: &BK) -> Result 134 | where 135 | BK: Ord + ?Sized, 136 | Self::Key: Borrow, 137 | { 138 | slice.binary_search_by(|value| Self::Key::borrow(&value.0).cmp(key)) 139 | } 140 | 141 | default fn search_value(slice: &[Self], key: &Self) -> Result { 142 | slice.binary_search_by(|value| value.0.cmp(&key.0)) 143 | } 144 | 145 | fn cmp_keys(&self, other: &BK) -> Ordering 146 | where 147 | BK: Ord + ?Sized, 148 | Self::Key: Borrow, 149 | { 150 | Self::Key::borrow(&self.0).cmp(other) 151 | } 152 | 153 | fn cmp_values(&self, other: &Self) -> Ordering { 154 | self.0.cmp(&other.0) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /feather-ui/src/layout/base.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::{DAbsRect, DPoint, DRect, ZERO_DRECT}; 5 | use std::rc::Rc; 6 | 7 | #[macro_export] 8 | macro_rules! gen_from_to_dyn { 9 | ($idx:ident) => { 10 | impl<'a, T: $idx + 'static> From<&'a T> for &'a (dyn $idx + 'static) { 11 | fn from(value: &'a T) -> Self { 12 | return value; 13 | } 14 | } 15 | }; 16 | } 17 | 18 | pub trait Empty {} 19 | 20 | impl Empty for () {} 21 | impl RLimits for () {} 22 | impl Margin for () {} 23 | impl Order for () {} 24 | impl crate::layout::fixed::Child for () {} 25 | impl crate::layout::list::Child for () {} 26 | 27 | impl Empty for Rc {} 28 | 29 | impl Empty for DRect {} 30 | 31 | gen_from_to_dyn!(Empty); 32 | 33 | impl crate::layout::Desc for dyn Empty { 34 | type Props = dyn Empty; 35 | type Child = dyn Empty; 36 | type Children = (); 37 | 38 | fn stage<'a>( 39 | _: &Self::Props, 40 | mut outer_area: crate::PxRect, 41 | outer_limits: crate::PxLimits, 42 | _: &Self::Children, 43 | id: std::sync::Weak, 44 | renderable: Option>, 45 | window: &mut crate::component::window::WindowState, 46 | ) -> Box { 47 | outer_area = super::nuetralize_unsized(outer_area); 48 | outer_area = super::limit_area(outer_area, outer_limits); 49 | 50 | Box::new(crate::layout::Concrete::new( 51 | renderable, 52 | outer_area, 53 | crate::rtree::Node::new( 54 | outer_area.to_untyped(), 55 | None, 56 | Default::default(), 57 | id, 58 | window, 59 | ), 60 | Default::default(), 61 | )) 62 | } 63 | } 64 | 65 | pub trait Obstacles { 66 | fn obstacles(&self) -> &[DAbsRect]; 67 | } 68 | 69 | pub trait ZIndex { 70 | fn zindex(&self) -> i32 { 71 | 0 72 | } 73 | } 74 | 75 | impl ZIndex for DRect {} 76 | 77 | // Padding is used so an element's actual area can be larger than the area it 78 | // draws children inside (like text). 79 | pub trait Padding { 80 | fn padding(&self) -> &DAbsRect { 81 | &crate::ZERO_DABSRECT 82 | } 83 | } 84 | 85 | impl Padding for DRect {} 86 | 87 | // Relative to parent's area, but only ever used to determine spacing between 88 | // child elements. 89 | pub trait Margin { 90 | fn margin(&self) -> &DRect { 91 | &ZERO_DRECT 92 | } 93 | } 94 | 95 | // Relative to child's assigned area (outer area) 96 | pub trait Area { 97 | fn area(&self) -> &DRect; 98 | } 99 | 100 | impl Area for DRect { 101 | fn area(&self) -> &DRect { 102 | self 103 | } 104 | } 105 | 106 | gen_from_to_dyn!(Area); 107 | 108 | // Relative to child's evaluated area (inner area) 109 | pub trait Anchor { 110 | fn anchor(&self) -> &DPoint { 111 | &crate::ZERO_DPOINT 112 | } 113 | } 114 | 115 | impl Anchor for DRect {} 116 | 117 | pub trait Limits { 118 | fn limits(&self) -> &crate::DLimits { 119 | &crate::DEFAULT_DLIMITS 120 | } 121 | } 122 | 123 | // Relative to parent's area 124 | pub trait RLimits { 125 | fn rlimits(&self) -> &crate::RelLimits { 126 | &crate::DEFAULT_RLIMITS 127 | } 128 | } 129 | 130 | pub trait Order { 131 | fn order(&self) -> i64 { 132 | 0 133 | } 134 | } 135 | 136 | pub trait Direction { 137 | fn direction(&self) -> crate::RowDirection { 138 | crate::RowDirection::LeftToRight 139 | } 140 | } 141 | 142 | impl Limits for DRect {} 143 | impl RLimits for DRect {} 144 | 145 | pub trait TextEdit { 146 | fn textedit(&self) -> &crate::text::EditView; 147 | } 148 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "advisory-db": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1754472784, 7 | "narHash": "sha256-b390kY06Sm+gzwGiaXrVzIg4mjxwt/oONlDu49260lM=", 8 | "owner": "rustsec", 9 | "repo": "advisory-db", 10 | "rev": "388a3128c3cda69c6f466de2015aadfae9f9bc75", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "rustsec", 15 | "repo": "advisory-db", 16 | "type": "github" 17 | } 18 | }, 19 | "crane": { 20 | "locked": { 21 | "lastModified": 1754269165, 22 | "narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=", 23 | "owner": "ipetkov", 24 | "repo": "crane", 25 | "rev": "444e81206df3f7d92780680e45858e31d2f07a08", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "ipetkov", 30 | "repo": "crane", 31 | "type": "github" 32 | } 33 | }, 34 | "flake-utils": { 35 | "inputs": { 36 | "systems": "systems" 37 | }, 38 | "locked": { 39 | "lastModified": 1731533236, 40 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 41 | "owner": "numtide", 42 | "repo": "flake-utils", 43 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "numtide", 48 | "repo": "flake-utils", 49 | "type": "github" 50 | } 51 | }, 52 | "nixpkgs": { 53 | "locked": { 54 | "lastModified": 1754689972, 55 | "narHash": "sha256-eogqv6FqZXHgqrbZzHnq43GalnRbLTkbBbFtEfm1RSc=", 56 | "owner": "NixOS", 57 | "repo": "nixpkgs", 58 | "rev": "fc756aa6f5d3e2e5666efcf865d190701fef150a", 59 | "type": "github" 60 | }, 61 | "original": { 62 | "owner": "NixOS", 63 | "ref": "nixos-25.05", 64 | "repo": "nixpkgs", 65 | "type": "github" 66 | } 67 | }, 68 | "nixpkgs_2": { 69 | "locked": { 70 | "lastModified": 1744536153, 71 | "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", 72 | "owner": "NixOS", 73 | "repo": "nixpkgs", 74 | "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", 75 | "type": "github" 76 | }, 77 | "original": { 78 | "owner": "NixOS", 79 | "ref": "nixpkgs-unstable", 80 | "repo": "nixpkgs", 81 | "type": "github" 82 | } 83 | }, 84 | "root": { 85 | "inputs": { 86 | "advisory-db": "advisory-db", 87 | "crane": "crane", 88 | "flake-utils": "flake-utils", 89 | "nixpkgs": "nixpkgs", 90 | "rust-overlay": "rust-overlay" 91 | } 92 | }, 93 | "rust-overlay": { 94 | "inputs": { 95 | "nixpkgs": "nixpkgs_2" 96 | }, 97 | "locked": { 98 | "lastModified": 1754707163, 99 | "narHash": "sha256-wgVgOsyJUDn2ZRpzu2gELKALoJXlBSoZJSln+Tlg5Pw=", 100 | "owner": "oxalica", 101 | "repo": "rust-overlay", 102 | "rev": "ac39ab4c8ed7cefe48d5ae5750f864422df58f01", 103 | "type": "github" 104 | }, 105 | "original": { 106 | "owner": "oxalica", 107 | "repo": "rust-overlay", 108 | "type": "github" 109 | } 110 | }, 111 | "systems": { 112 | "locked": { 113 | "lastModified": 1681028828, 114 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 115 | "owner": "nix-systems", 116 | "repo": "default", 117 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 118 | "type": "github" 119 | }, 120 | "original": { 121 | "owner": "nix-systems", 122 | "repo": "default", 123 | "type": "github" 124 | } 125 | } 126 | }, 127 | "root": "root", 128 | "version": 7 129 | } 130 | -------------------------------------------------------------------------------- /feather-ui/Cargo.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | [package] 5 | name = "feather-ui" 6 | version.workspace = true 7 | edition.workspace = true 8 | rust-version.workspace = true 9 | authors = ["Erik McClure "] 10 | description = """ 11 | Feather UI library 12 | """ 13 | homepage.workspace = true 14 | repository = "https://github.com/Fundament-Software/feathergui/tree/main/feather-ui" 15 | readme.workspace = true 16 | keywords = ["ui", "interface", "graphics", "gpu"] 17 | license.workspace = true 18 | 19 | [lib] 20 | path = "src/lib.rs" 21 | doctest = false 22 | 23 | [features] 24 | default = ["lua", "default-formats", "svg"] 25 | lua = ["dep:mlua"] 26 | svg = ["dep:resvg"] 27 | default-formats = [ 28 | "avif", 29 | "bmp", 30 | "dds", 31 | "exr", 32 | "ff", 33 | "gif", 34 | "hdr", 35 | "ico", 36 | "jpeg", 37 | "png", 38 | "pnm", 39 | "qoi", 40 | "tga", 41 | "tiff", 42 | "webp", 43 | ] 44 | rayon = ["image?/rayon", "fast_image_resize?/rayon", "jxl-oxide?/rayon"] 45 | avif = ["dep:image", "image/avif", "fast_image_resize/image"] 46 | bmp = ["dep:image", "image/bmp", "fast_image_resize/image"] 47 | dds = ["dep:image", "image/dds", "fast_image_resize/image"] 48 | exr = ["dep:image", "image/exr", "fast_image_resize/image"] 49 | ff = ["dep:image", "image/ff", "fast_image_resize/image"] 50 | gif = ["dep:image", "image/gif", "fast_image_resize/image"] 51 | hdr = ["dep:image", "image/hdr", "fast_image_resize/image"] 52 | ico = ["dep:image", "image/ico", "fast_image_resize/image"] 53 | jpeg = ["dep:load_image", "load_image/jpeg", "dep:fast_image_resize"] 54 | png = ["dep:load_image", "load_image/zlibrs", "dep:fast_image_resize"] 55 | pnm = ["dep:image", "image/pnm", "fast_image_resize/image"] 56 | qoi = ["dep:image", "image/qoi", "fast_image_resize/image"] 57 | tga = ["dep:image", "image/tga", "fast_image_resize/image"] 58 | tiff = ["dep:image", "image/tiff", "fast_image_resize/image"] 59 | webp = ["dep:image", "image/webp", "fast_image_resize/image"] 60 | jxl = ["dep:jxl-oxide", "lcms2/static", "fast_image_resize/image"] 61 | vello = ["dep:vello"] 62 | vello-svg = ["dep:vello_svg"] 63 | 64 | [dependencies] 65 | im.workspace = true 66 | wgpu.workspace = true 67 | winit.workspace = true 68 | eyre.workspace = true 69 | dyn-clone = "1.0" 70 | derive-where = "1.2.7" 71 | enum_variant_type = "0.3.1" 72 | smallvec = { version = "1.13", features = ["union", "const_generics"] } 73 | thiserror = "2.0" 74 | feather-macro.workspace = true 75 | derive_more = { version = "2.0.1", features = ["try_from", "display"] } 76 | wide = "0.7.32" 77 | alloca = "0.4.0" 78 | unicode-segmentation = "1.12.0" 79 | arboard = { version = "3.5.0", features = ["wayland-data-control"] } 80 | parking_lot = { version = "0.12.4", features = [ 81 | "hardware-lock-elision", 82 | "arc_lock", 83 | ] } 84 | static_assertions = "1.1.0" 85 | windows-sys = { version = "0.61.0", features = [ 86 | "Win32_UI_WindowsAndMessaging", 87 | ] } 88 | bytemuck = "1.23.0" 89 | cosmic-text.workspace = true 90 | guillotiere = "0.6.2" 91 | notify = "8.0.0" 92 | futures-lite = "2.6.0" 93 | num-traits = "0.2.19" 94 | swash = "0.2.5" 95 | bitfield-struct = "0.11.0" 96 | mlua = { version = "0.11", features = [ 97 | "luajit52", 98 | "vendored", 99 | "error-send", 100 | ], optional = true } 101 | image = { version = "0.25.8", optional = true, default-features = false } 102 | resvg = { version = "0.45.1", optional = true, default-features = false, features = [ 103 | "text", 104 | "system-fonts", 105 | "memmap-fonts", 106 | ] } 107 | vello = { version = "0.5.0", optional = true } 108 | vello_svg = { version = "0.7.1", optional = true } 109 | load_image = { version = "3.3.0", optional = true, default-features = false, features = [ 110 | "lcms2-static", 111 | ] } 112 | fast_image_resize = { version = "5.2.1", optional = true, features = ["image"] } 113 | jxl-oxide = { version = "0.12", optional = true, default-features = false, features = [ 114 | "lcms2", 115 | ] } 116 | lcms2 = { version = "6.1.0", optional = true } 117 | either = "1.15.0" 118 | 119 | [lints.clippy] 120 | too_many_arguments = { level = "allow" } 121 | -------------------------------------------------------------------------------- /feather-ui/src/component/paragraph.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::color::sRGB; 5 | use crate::component::ChildOf; 6 | use crate::component::text::Text; 7 | use crate::layout::{Desc, Layout, base, flex, leaf}; 8 | use crate::persist::{FnPersist, VectorMap}; 9 | use crate::{SourceID, UNSIZED_AXIS, gen_id, layout}; 10 | use core::f32; 11 | use derive_where::derive_where; 12 | use std::rc::Rc; 13 | use std::sync::Arc; 14 | 15 | #[derive(feather_macro::StateMachineChild)] 16 | #[derive_where(Clone)] 17 | pub struct Paragraph { 18 | pub id: Arc, 19 | props: Rc, 20 | children: im::Vector>>>, 21 | } 22 | 23 | #[derive(Clone, Copy, Default, PartialEq, PartialOrd)] 24 | struct MinimalFlexChild { 25 | grow: f32, 26 | } 27 | 28 | impl flex::Child for MinimalFlexChild { 29 | fn grow(&self) -> f32 { 30 | self.grow 31 | } 32 | 33 | fn shrink(&self) -> f32 { 34 | 0.0 35 | } 36 | 37 | fn basis(&self) -> crate::DValue { 38 | crate::DValue { 39 | dp: 0.0, 40 | px: 0.0, 41 | rel: UNSIZED_AXIS, 42 | } 43 | } 44 | } 45 | 46 | impl base::Area for MinimalFlexChild { 47 | fn area(&self) -> &crate::DRect { 48 | &crate::AUTO_DRECT 49 | } 50 | } 51 | 52 | impl base::Anchor for MinimalFlexChild {} 53 | impl base::Order for MinimalFlexChild {} 54 | impl base::Margin for MinimalFlexChild {} 55 | impl base::RLimits for MinimalFlexChild {} 56 | impl base::Limits for MinimalFlexChild {} 57 | impl base::Padding for MinimalFlexChild {} 58 | impl leaf::Prop for MinimalFlexChild {} 59 | impl leaf::Padded for MinimalFlexChild {} 60 | 61 | impl Paragraph { 62 | pub fn new(id: Arc, props: T) -> Self { 63 | Self { 64 | id, 65 | props: props.into(), 66 | children: im::Vector::new(), 67 | } 68 | } 69 | 70 | pub fn append(&mut self, child: Box>) { 71 | self.children.push_back(Some(child)); 72 | } 73 | 74 | pub fn prepend(&mut self, child: Box>) { 75 | self.children.push_front(Some(child)); 76 | } 77 | 78 | #[allow(clippy::too_many_arguments)] 79 | pub fn set_text( 80 | &mut self, 81 | text: &str, 82 | font_size: f32, 83 | line_height: f32, 84 | font: cosmic_text::FamilyOwned, 85 | color: sRGB, 86 | weight: cosmic_text::Weight, 87 | style: cosmic_text::Style, 88 | fullwidth: bool, 89 | ) { 90 | self.children.clear(); 91 | for (i, word) in text.split_ascii_whitespace().enumerate() { 92 | let text = Text::::new( 93 | gen_id!(gen_id!(self.id), i), 94 | MinimalFlexChild { 95 | grow: if fullwidth { 1.0 } else { 0.0 }, 96 | }, 97 | font_size, 98 | line_height, 99 | word.to_owned() + " ", 100 | font.clone(), 101 | color, 102 | weight, 103 | style, 104 | cosmic_text::Wrap::None, 105 | None, // paragraph does it's own alignment so we don't set any here 106 | ); 107 | self.children.push_back(Some(Box::new(text))); 108 | } 109 | } 110 | } 111 | 112 | impl super::Component for Paragraph { 113 | type Props = T; 114 | 115 | fn layout( 116 | &self, 117 | manager: &mut crate::StateManager, 118 | driver: &crate::graphics::Driver, 119 | window: &Arc, 120 | ) -> Box> { 121 | let mut map = VectorMap::new(crate::persist::Persist::new( 122 | |child: &Option>>| -> Option::Child>>> { 123 | Some(child.as_ref()?.layout(manager, driver, window)) 124 | }) 125 | ); 126 | 127 | let (_, children) = map.call(Default::default(), &self.children); 128 | Box::new(layout::Node:: { 129 | props: self.props.clone(), 130 | children, 131 | id: Arc::downgrade(&self.id), 132 | renderable: None, 133 | layer: None, 134 | }) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /feather-ui/examples/textbox-rs.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use feather_ui::color::sRGB; 5 | use feather_ui::layout::{fixed, leaf}; 6 | use feather_ui::text::{EditBuffer, EditView}; 7 | use feather_ui::{DAbsRect, ScopeID, gen_id}; 8 | 9 | use feather_ui::component::region::Region; 10 | use feather_ui::component::textbox; 11 | use feather_ui::component::textbox::TextBox; 12 | use feather_ui::component::window::Window; 13 | use feather_ui::layout::base; 14 | use feather_ui::persist::{FnPersist2, FnPersistStore}; 15 | use feather_ui::{AbsRect, App, DRect, FILL_DRECT, RelRect, SourceID, cosmic_text}; 16 | use std::sync::Arc; 17 | 18 | #[derive(PartialEq, Clone, Debug, Default)] 19 | struct TextState { 20 | text: EditView, 21 | } 22 | 23 | struct BasicApp {} 24 | 25 | #[derive(Default, Clone, feather_macro::Empty, feather_macro::Area)] 26 | struct MinimalArea { 27 | area: DRect, 28 | } 29 | 30 | impl base::ZIndex for MinimalArea {} 31 | impl base::Anchor for MinimalArea {} 32 | impl base::Limits for MinimalArea {} 33 | impl fixed::Prop for MinimalArea {} 34 | 35 | #[derive( 36 | Clone, 37 | feather_macro::Empty, 38 | feather_macro::Area, 39 | feather_macro::TextEdit, 40 | feather_macro::Padding, 41 | )] 42 | struct MinimalText { 43 | area: DRect, 44 | padding: DAbsRect, 45 | textedit: EditView, 46 | } 47 | impl base::Direction for MinimalText {} 48 | impl base::ZIndex for MinimalText {} 49 | impl base::Limits for MinimalText {} 50 | impl base::RLimits for MinimalText {} 51 | impl base::Anchor for MinimalText {} 52 | impl leaf::Padded for MinimalText {} 53 | impl leaf::Prop for MinimalText {} 54 | impl fixed::Child for MinimalText {} 55 | impl textbox::Prop for MinimalText {} 56 | 57 | impl FnPersistStore for BasicApp { 58 | type Store = (TextState, im::HashMap, Option>); 59 | } 60 | 61 | impl FnPersist2<&TextState, ScopeID<'_>, im::HashMap, Option>> for BasicApp { 62 | fn init(&self) -> Self::Store { 63 | ( 64 | TextState { 65 | ..Default::default() 66 | }, 67 | im::HashMap::new(), 68 | ) 69 | } 70 | fn call( 71 | &mut self, 72 | mut store: Self::Store, 73 | args: &TextState, 74 | mut scope: ScopeID<'_>, 75 | ) -> (Self::Store, im::HashMap, Option>) { 76 | if store.0 != *args { 77 | let textbox = TextBox::new( 78 | gen_id!(scope), 79 | MinimalText { 80 | area: FILL_DRECT, 81 | padding: AbsRect::splat(12.0).into(), 82 | textedit: args.text.clone(), /* Be careful to take the value from args, not 83 | * store.0, which is stale. */ 84 | }, 85 | 40.0, 86 | 56.0, 87 | cosmic_text::FamilyOwned::SansSerif, 88 | sRGB::white(), 89 | Default::default(), 90 | Default::default(), 91 | cosmic_text::Wrap::Word, 92 | Some(cosmic_text::Align::Right), 93 | ); 94 | 95 | let region = Region::new( 96 | gen_id!(scope), 97 | MinimalArea { 98 | area: AbsRect::new(90.0, 0.0, -90.0, -180.0) + RelRect::new(0.0, 0.0, 1.0, 1.0), 99 | }, 100 | feather_ui::children![fixed::Prop, textbox], 101 | ); 102 | let window = Window::new( 103 | gen_id!(scope), 104 | winit::window::Window::default_attributes() 105 | .with_title(env!("CARGO_CRATE_NAME")) 106 | .with_inner_size(winit::dpi::PhysicalSize::new(600, 400)) 107 | .with_resizable(true), 108 | Box::new(region), 109 | ); 110 | 111 | store.1 = im::HashMap::new(); 112 | store.1.insert(window.id.clone(), Some(window)); 113 | store.0 = args.clone(); 114 | } 115 | let windows = store.1.clone(); 116 | (store, windows) 117 | } 118 | } 119 | 120 | fn main() { 121 | let (mut app, event_loop, _, _) = App::::new( 122 | TextState { 123 | text: EditBuffer::new("new text", (0, 0)).into(), 124 | }, 125 | vec![], 126 | BasicApp {}, 127 | None, 128 | None, 129 | ) 130 | .unwrap(); 131 | 132 | event_loop.run_app(&mut app).unwrap(); 133 | } 134 | -------------------------------------------------------------------------------- /feather-ui/examples/calculator-rs/src/bin.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use calculator::{CalcOp, Calculator}; 5 | use std::ops::Deref; 6 | use std::sync::{Arc, RwLock}; 7 | 8 | #[derive(PartialEq, Clone, Debug, Default)] 9 | struct CalcState { 10 | last: f64, 11 | cur: Option, 12 | digits: Vec, 13 | decimals: Vec, 14 | decimal_mode: bool, 15 | op: CalcOp, 16 | } 17 | 18 | impl CalcState { 19 | fn update_cur(&mut self) { 20 | let mut cur = 0.0; 21 | let mut mul = 1.0; 22 | for v in self.digits.iter().rev() { 23 | cur += *v as f64 * mul; 24 | mul *= 10.0; 25 | } 26 | let mut mul = 0.1; 27 | for v in self.decimals.iter() { 28 | cur += *v as f64 * mul; 29 | mul /= 10.0; 30 | } 31 | self.cur = Some(cur); 32 | } 33 | 34 | pub fn add_digit(&mut self, digit: u8) { 35 | if digit == 0 && !self.digits.is_empty() && !self.decimal_mode { 36 | return; 37 | } 38 | if self.decimal_mode { 39 | self.decimals.push(digit); 40 | } else { 41 | self.digits.push(digit); 42 | } 43 | self.update_cur(); 44 | } 45 | pub fn backspace(&mut self) { 46 | if self.decimal_mode { 47 | self.decimals.pop(); 48 | } else { 49 | self.digits.pop(); 50 | } 51 | self.update_cur(); 52 | } 53 | 54 | pub fn apply_op(&mut self) { 55 | if let Some(cur) = self.cur { 56 | self.last = match self.op { 57 | CalcOp::None => cur, 58 | CalcOp::Add => self.last + cur, 59 | CalcOp::Sub => self.last - cur, 60 | CalcOp::Mul => self.last * cur, 61 | CalcOp::Div => self.last / cur, 62 | CalcOp::Mod => self.last % cur, 63 | CalcOp::Pow => self.last.powf(cur), 64 | _ => cur, // If this is an instant op, move cur to last 65 | }; 66 | } 67 | self.last = match self.op { 68 | CalcOp::Square => self.last * self.last, 69 | CalcOp::Sqrt => self.last.sqrt(), 70 | CalcOp::Inv => self.last.recip(), 71 | CalcOp::Negate => -self.last, 72 | CalcOp::Clear => { 73 | self.last = 0.0; 74 | self.cur = Some(0.0); 75 | self.op = CalcOp::None; 76 | self.decimal_mode = false; 77 | self.decimals.clear(); 78 | self.digits.clear(); 79 | return; 80 | } 81 | _ => self.last, 82 | }; 83 | self.cur = None; 84 | self.op = CalcOp::None; 85 | self.decimal_mode = false; 86 | self.decimals.clear(); 87 | self.digits.clear(); 88 | } 89 | pub fn set_op(&mut self, op: CalcOp) { 90 | match op { 91 | CalcOp::Square | CalcOp::Sqrt | CalcOp::Inv | CalcOp::Negate | CalcOp::Clear => { 92 | self.op = op; 93 | self.apply_op(); 94 | } 95 | _ => { 96 | self.apply_op(); 97 | self.op = op; 98 | } 99 | }; 100 | } 101 | } 102 | 103 | struct Calc(RwLock); 104 | 105 | impl Calculator for Calc { 106 | fn add_digit(&self, digit: u8) { 107 | self.0.write().unwrap().add_digit(digit) 108 | } 109 | fn backspace(&self) { 110 | self.0.write().unwrap().backspace() 111 | } 112 | fn apply_op(&self) { 113 | self.0.write().unwrap().apply_op() 114 | } 115 | fn set_op(&self, op: CalcOp) { 116 | self.0.write().unwrap().set_op(op) 117 | } 118 | fn get(&self) -> f64 { 119 | let state = self.0.read().unwrap(); 120 | state.cur.unwrap_or(state.last) 121 | } 122 | fn toggle_decimal(&self) { 123 | let prev = self.0.read().unwrap().decimal_mode; 124 | self.0.write().unwrap().decimal_mode = !prev; 125 | } 126 | 127 | fn copy(&self) -> Arc { 128 | Arc::new(Calc(RwLock::new(self.0.read().unwrap().clone()))) 129 | } 130 | 131 | fn eq(&self, rhs: Arc) -> bool { 132 | let rhs = >::as_ref(&rhs); 133 | let rhs = match rhs.downcast_ref::() { 134 | Some(rhs) => rhs, 135 | None => return false, 136 | }; 137 | let lhs = self.0.read().unwrap(); 138 | let rhs = rhs.0.read().unwrap(); 139 | lhs.deref() == rhs.deref() 140 | } 141 | } 142 | 143 | fn main() { 144 | calculator::register(Arc::new(Calc(RwLock::new(CalcState { 145 | last: 0.0, 146 | cur: None, 147 | digits: Vec::new(), 148 | decimals: Vec::new(), 149 | decimal_mode: false, 150 | op: CalcOp::None, 151 | })))); 152 | } 153 | -------------------------------------------------------------------------------- /feather-ui/src/layout/text.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use std::cell::RefCell; 5 | use std::rc::Rc; 6 | 7 | use derive_where::derive_where; 8 | 9 | use crate::{PxRect, SourceID, render, rtree}; 10 | 11 | use super::{Layout, check_unsized, leaf, limit_area}; 12 | 13 | #[derive_where(Clone)] 14 | pub struct Node { 15 | pub id: std::sync::Weak, 16 | pub props: Rc, 17 | pub buffer: Rc>, 18 | pub renderable: Rc, 19 | pub realign: bool, 20 | } 21 | 22 | impl Layout for Node { 23 | fn get_props(&self) -> &T { 24 | &self.props 25 | } 26 | fn stage<'a>( 27 | &self, 28 | outer_area: PxRect, 29 | outer_limits: crate::PxLimits, 30 | window: &mut crate::component::window::WindowState, 31 | ) -> Box { 32 | let mut limits = self.props.limits().resolve(window.dpi) + outer_limits; 33 | let myarea = self.props.area().resolve(window.dpi); 34 | let (unsized_x, unsized_y) = check_unsized(myarea); 35 | let padding = self.props.padding().as_perimeter(window.dpi); 36 | let allpadding = myarea.bottomright().abs().to_vector().to_size().cast_unit() 37 | + padding.topleft() 38 | + padding.bottomright(); 39 | let minmax = limits.v.as_array_mut(); 40 | if unsized_x { 41 | minmax[2] -= allpadding.width; 42 | minmax[0] -= allpadding.width; 43 | } 44 | if unsized_y { 45 | minmax[3] -= allpadding.height; 46 | minmax[1] -= allpadding.height; 47 | } 48 | 49 | let mut evaluated_area = limit_area( 50 | super::cap_unsized(myarea * crate::layout::nuetralize_unsized(outer_area)), 51 | limits, 52 | ); 53 | 54 | let (limitx, limity) = { 55 | let max = limits.max(); 56 | ( 57 | max.width.is_finite().then_some(max.width), 58 | max.height.is_finite().then_some(max.height), 59 | ) 60 | }; 61 | 62 | let mut text_buffer = self.buffer.borrow_mut(); 63 | let driver = window.driver.clone(); 64 | let dim = evaluated_area.dim() - padding.topleft() - padding.bottomright(); 65 | { 66 | let mut font_system = driver.font_system.write(); 67 | 68 | text_buffer.set_size( 69 | &mut font_system, 70 | if unsized_x { 71 | limitx 72 | } else { 73 | Some(dim.width.max(0.0)) 74 | }, 75 | if unsized_y { 76 | limity 77 | } else { 78 | Some(dim.height.max(0.0)) 79 | }, 80 | ); 81 | } 82 | 83 | // If we have indeterminate area, calculate the size 84 | if unsized_x || unsized_y { 85 | let mut h = 0.0; 86 | let mut w: f32 = 0.0; 87 | let mut realign = self.realign; 88 | for run in text_buffer.layout_runs() { 89 | w = w.max(run.line_w); 90 | // If a line is RTL and we're unsized, we ALWAYS have to re-evaluate it! 91 | realign = realign || run.rtl; 92 | h += run.line_height; 93 | } 94 | 95 | // Apply adjusted limits to inner size calculation 96 | w = w.max(limits.min().width).min(limits.max().width); 97 | h = h.max(limits.min().height).min(limits.max().height); 98 | let ltrb = evaluated_area.v.as_array_mut(); 99 | if unsized_x { 100 | ltrb[2] = ltrb[0] + w + allpadding.width; 101 | } 102 | if unsized_y { 103 | ltrb[3] = ltrb[1] + h + allpadding.height; 104 | } 105 | 106 | // If we are centered or right aligned, we have to set the size again now that 107 | // we know how big it really is. This is true even if all the text 108 | // was originally marked as RTL - the layout will still be wrong because 109 | // it didn't know how big the text would be. 110 | if realign { 111 | text_buffer.set_size(&mut driver.font_system.write(), Some(w), Some(h)) 112 | } 113 | }; 114 | 115 | evaluated_area = crate::layout::apply_anchor( 116 | evaluated_area, 117 | outer_area, 118 | self.props.anchor().resolve(window.dpi) * evaluated_area.dim(), 119 | ); 120 | 121 | super::assert_sized(evaluated_area); 122 | Box::new(crate::layout::Concrete::new( 123 | Some(self.renderable.clone()), 124 | evaluated_area, 125 | rtree::Node::new( 126 | evaluated_area.to_untyped(), 127 | None, 128 | Default::default(), 129 | self.id.clone(), 130 | window, 131 | ), 132 | Default::default(), 133 | )) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /FRI_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 15 | 17 | 21 | 25 | 26 | 28 | 32 | 36 | 37 | 46 | 48 | 52 | 56 | 57 | 65 | 74 | 75 | 78 | 82 | 86 | 88 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /feather-ui/examples/calculator-rs/calculator-cs/Program.cs: -------------------------------------------------------------------------------- 1 | 2 | using Microsoft.Win32; 3 | using System.Text.RegularExpressions; 4 | using uniffi.calc; 5 | 6 | // To build the necessary `calc.cs` file that this depends on, run this command from the parent directory: 7 | // 8 | // uniffi-bindgen-cs .\src\calculator.udl --config .\src\calculator.toml 9 | // 10 | // Then, copy calc.cs from src/calc.cs to this directory. 11 | 12 | public class Impl : uniffi.calc.Calculator 13 | { 14 | private double last; 15 | private double? cur; 16 | private List digits; 17 | private List decimals; 18 | private bool decimal_mode; 19 | private CalcOp cur_op; 20 | 21 | public Impl() 22 | { 23 | last = 0.0; 24 | cur = null; 25 | digits = new List(); 26 | decimals = new List(); 27 | decimal_mode = false; 28 | cur_op = CalcOp.None; 29 | } 30 | void update_cur() 31 | { 32 | var x = 0.0; 33 | var mul = 1.0; 34 | digits.Reverse(); 35 | foreach (var v in digits) { 36 | x += v * mul; 37 | mul *= 10; 38 | } 39 | digits.Reverse(); 40 | 41 | mul = 0.1; 42 | foreach (var v in decimals) 43 | { 44 | x += v * mul; 45 | mul /= 10; 46 | } 47 | cur = x; 48 | } 49 | 50 | void apply_op() 51 | { 52 | if(cur != null) { 53 | var x = cur ?? 0; 54 | switch(cur_op) { 55 | case CalcOp.Add: 56 | last = last + x; 57 | break; 58 | case CalcOp.Sub: 59 | last = last - x; 60 | break; 61 | case CalcOp.Mul: 62 | last = last * x; 63 | break; 64 | case CalcOp.Div: 65 | last = last / x; 66 | break; 67 | case CalcOp.Mod: 68 | last = last % x; 69 | break; 70 | case CalcOp.Pow: 71 | last = Math.Pow(last, x); 72 | break; 73 | default: 74 | break; 75 | } 76 | } 77 | 78 | switch(cur_op) 79 | { 80 | case CalcOp.Square: 81 | last = last * last; 82 | break; 83 | case CalcOp.Sqrt: 84 | last = Math.Sqrt(last); 85 | break; 86 | case CalcOp.Inv: 87 | last = Math.ReciprocalEstimate(last); 88 | break; 89 | case CalcOp.Negate: 90 | last = -last; 91 | break; 92 | case CalcOp.Clear: 93 | last = 0.0; 94 | break; 95 | } 96 | 97 | cur = null; 98 | cur_op = CalcOp.None; 99 | decimal_mode = false; 100 | decimals.Clear(); 101 | digits.Clear(); 102 | } 103 | 104 | void Calculator.AddDigit(byte digit) 105 | { 106 | if (@digit == 0 && digits.Count == 0 && !decimal_mode) 107 | { 108 | return; 109 | } 110 | if (decimal_mode) 111 | { 112 | decimals.Add(@digit); 113 | } 114 | else 115 | { 116 | digits.Add(@digit); 117 | } 118 | update_cur(); 119 | } 120 | 121 | void Calculator.ApplyOp() 122 | { 123 | apply_op(); 124 | } 125 | 126 | void Calculator.Backspace() 127 | { 128 | if (decimal_mode) 129 | { 130 | decimals.RemoveAt(decimals.Count - 1); 131 | } 132 | else 133 | { 134 | digits.RemoveAt(decimals.Count - 1); 135 | } 136 | update_cur(); 137 | } 138 | 139 | Calculator Calculator.Copy() 140 | { 141 | var self = new Impl(); 142 | 143 | self.last = last; 144 | self.cur = cur; 145 | self.digits = digits; 146 | self.decimals = decimals; 147 | self.decimal_mode = decimal_mode; 148 | self.cur_op = cur_op; 149 | 150 | return self; 151 | } 152 | 153 | bool Calculator.Eq(Calculator rhs) 154 | { 155 | var v = rhs as Impl; 156 | 157 | if(v == null) 158 | { 159 | return false; 160 | } 161 | 162 | return v.last == last && 163 | v.cur == cur && 164 | v.digits == digits && 165 | v.decimals == decimals && 166 | v.decimal_mode == decimal_mode && 167 | v.cur_op == cur_op; 168 | } 169 | 170 | double Calculator.Get() 171 | { 172 | return cur ?? last; 173 | } 174 | 175 | void Calculator.SetOp(CalcOp op) 176 | { 177 | switch(op) { 178 | case CalcOp.Square | CalcOp.Sqrt | CalcOp.Inv | CalcOp.Negate | CalcOp.Clear: 179 | cur_op = op; 180 | apply_op(); 181 | break; 182 | default: 183 | apply_op(); 184 | cur_op = op; 185 | break; 186 | } 187 | } 188 | 189 | void Calculator.ToggleDecimal() 190 | { 191 | decimal_mode = !decimal_mode; 192 | } 193 | } 194 | 195 | namespace calculator_cs 196 | { 197 | internal class Program 198 | { 199 | static void Main(string[] args) 200 | { 201 | uniffi.calc.CalcMethods.Register(new Impl()); 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /feather-ui/src/layout/leaf.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use super::base::Empty; 5 | use super::{Concrete, Desc, Layout, Renderable, Staged, base, map_unsized_area}; 6 | use crate::{DRect, PxDim, PxRect, SourceID, rtree}; 7 | use std::marker::PhantomData; 8 | use std::rc::Rc; 9 | 10 | pub trait Prop: base::Area + base::Limits + base::Anchor {} 11 | 12 | crate::gen_from_to_dyn!(Prop); 13 | 14 | impl Prop for DRect {} 15 | 16 | // Actual leaves do not require padding, but a lot of raw elements do (text, 17 | // shape, images, etc.) This inherits Prop to allow elements to "extract" the 18 | // padding for the rendering system for when it doesn't affect layouts. 19 | pub trait Padded: Prop + base::Padding {} 20 | 21 | crate::gen_from_to_dyn!(Padded); 22 | 23 | impl Padded for DRect {} 24 | 25 | impl Desc for dyn Prop { 26 | type Props = dyn Prop; 27 | type Child = dyn Empty; 28 | type Children = PhantomData>; 29 | 30 | fn stage<'a>( 31 | props: &Self::Props, 32 | outer_area: PxRect, 33 | outer_limits: crate::PxLimits, 34 | _: &Self::Children, 35 | id: std::sync::Weak, 36 | renderable: Option>, 37 | window: &mut crate::component::window::WindowState, 38 | ) -> Box { 39 | let limits = outer_limits + props.limits().resolve(window.dpi); 40 | let evaluated_area = super::limit_area( 41 | map_unsized_area(props.area().resolve(window.dpi), PxDim::zero()) 42 | * super::nuetralize_unsized(outer_area), 43 | limits, 44 | ); 45 | 46 | let anchor = props.anchor().resolve(window.dpi) * evaluated_area.dim(); 47 | let evaluated_area = evaluated_area - anchor; 48 | 49 | debug_assert!(evaluated_area.v.is_finite().all()); 50 | Box::new(Concrete { 51 | area: evaluated_area, 52 | renderable, 53 | rtree: rtree::Node::new( 54 | evaluated_area.to_untyped(), 55 | None, 56 | Default::default(), 57 | id, 58 | window, 59 | ), 60 | children: Default::default(), 61 | layer: None, 62 | }) 63 | } 64 | } 65 | 66 | /// A sized leaf is one with inherent size, like an image. This is used to 67 | /// preserve aspect ratio when encounting an unsized axis. This must be provided 68 | /// in pixels. 69 | 70 | #[derive_where::derive_where(Clone)] 71 | pub struct Sized { 72 | pub id: std::sync::Weak, 73 | pub props: Rc, 74 | pub size: crate::PxDim, 75 | pub renderable: Option>, 76 | } 77 | 78 | impl Layout for Sized { 79 | fn get_props(&self) -> &T { 80 | &self.props 81 | } 82 | fn stage<'a>( 83 | &self, 84 | outer_area: crate::PxRect, 85 | outer_limits: crate::PxLimits, 86 | window: &mut crate::component::window::WindowState, 87 | ) -> Box { 88 | let limits = outer_limits + self.props.limits().resolve(window.dpi); 89 | let padding = self.props.padding().as_perimeter(window.dpi); 90 | let area = self.props.area().resolve(window.dpi); 91 | let aspect_ratio = self.size.width / self.size.height; // Will be NAN if both are 0, which disables any attempt to preserve aspect ratio 92 | 93 | // The way we handle unsized here is different from how we normally handle it. 94 | // If both axes are unsized, we simply set the area to the internal 95 | // size. If only one axis is unsized, we stretch it to maintain an aspect 96 | // ratio relative to the size of the other axis. 97 | let (unsized_x, unsized_y) = super::check_unsized(area); 98 | let outer_area = super::nuetralize_unsized(outer_area); 99 | let mapped_area = match (unsized_x, unsized_y, aspect_ratio.is_finite()) { 100 | (true, false, false) => { 101 | let mut presize = map_unsized_area(area, PxDim::zero()) * outer_area; 102 | let adjust = presize.dim().height * aspect_ratio; 103 | let v = presize.v.as_array_mut(); 104 | v[2] += adjust; 105 | presize 106 | } 107 | (false, true, false) => { 108 | let mut presize = map_unsized_area(area, PxDim::zero()) * outer_area; 109 | // Be careful, the aspect ratio here is being divided instead of multiplied 110 | let adjust = presize.dim().width / aspect_ratio; 111 | let v = presize.v.as_array_mut(); 112 | v[3] += adjust; 113 | presize 114 | } 115 | _ => { 116 | map_unsized_area(area, self.size + padding.topleft() + padding.bottomright()) 117 | * outer_area 118 | } 119 | }; 120 | 121 | let evaluated_area = super::limit_area(mapped_area, limits); 122 | 123 | let anchor = self.props.anchor().resolve(window.dpi) * evaluated_area.dim(); 124 | let evaluated_area = evaluated_area - anchor; 125 | 126 | debug_assert!( 127 | evaluated_area.v.is_finite().all(), 128 | "non-finite evaluated area!" 129 | ); 130 | debug_assert!(evaluated_area.v.is_finite().all()); 131 | Box::new(Concrete { 132 | area: evaluated_area, 133 | renderable: self.renderable.clone(), 134 | rtree: rtree::Node::new( 135 | evaluated_area.to_untyped(), 136 | None, 137 | Default::default(), 138 | self.id.clone(), 139 | window, 140 | ), 141 | children: Default::default(), 142 | layer: None, 143 | }) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | { 5 | inputs = { 6 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; 7 | rust-overlay.url = "github:oxalica/rust-overlay"; 8 | flake-utils.url = "github:numtide/flake-utils"; 9 | 10 | crane.url = "github:ipetkov/crane"; 11 | advisory-db = { 12 | url = "github:rustsec/advisory-db"; 13 | flake = false; 14 | }; 15 | }; 16 | 17 | outputs = inputs@{ self, flake-utils, nixpkgs, rust-overlay, crane 18 | , advisory-db, ... }: 19 | flake-utils.lib.eachSystem [ flake-utils.lib.system.x86_64-linux ] (system: 20 | let 21 | overlays = [ (import rust-overlay) ]; 22 | pkgs = import nixpkgs { inherit system overlays; }; 23 | 24 | rust-custom-toolchain = (pkgs.rust-bin.stable.latest.default.override { 25 | extensions = [ 26 | "rust-src" 27 | "rustfmt" 28 | "llvm-tools-preview" 29 | "rust-analyzer-preview" 30 | ]; 31 | }); 32 | impureDrivers = [ 33 | "/run/opengl-driver" # impure deps on specific GPU, mesa, vulkan loader, radv, nvidia proprietary etc 34 | ]; 35 | gfxDeps = [ 36 | pkgs.xorg.libxcb 37 | pkgs.xorg.libX11 38 | pkgs.xorg.libXcursor 39 | pkgs.xorg.libXrandr 40 | pkgs.xorg.libXi 41 | pkgs.libxkbcommon 42 | pkgs.wayland 43 | pkgs.fontconfig # you probably need this? unless you're ignoring system font config entirely 44 | # pkgs.libGL/U # should be in /run/opengl-driver? 45 | pkgs.pkg-config # let things detect packages at build time 46 | # some toolkits use these for dialogs - probably not relevant for feather? 47 | # pkgs.kdialog 48 | # pkgs.yad 49 | pkgs.vulkan-loader 50 | #pkgs.libglvnd 51 | ]; 52 | craneLib = 53 | (inputs.crane.mkLib pkgs).overrideToolchain rust-custom-toolchain; 54 | commonArgs = { 55 | src = ./.; 56 | buildInputs = with pkgs; [ pkg-config openssl zlib ]; 57 | strictDeps = true; 58 | version = "0.1.0"; 59 | stdenv = pkgs: 60 | pkgs.stdenvAdapters.useMoldLinker pkgs.llvmPackages_15.stdenv; 61 | CARGO_BUILD_RUSTFLAGS = 62 | "-C linker=clang -C link-arg=-fuse-ld=${pkgs.mold}/bin/mold -C link-arg=-flto=thin"; 63 | }; 64 | 65 | in rec { 66 | devShells.default = 67 | (pkgs.mkShell.override { stdenv = pkgs.llvmPackages.stdenv; }) { 68 | buildInputs = with pkgs; 69 | [ openssl pkg-config dotnet-sdk ] ++ gfxDeps; 70 | 71 | nativeBuildInputs = with pkgs; [ 72 | # get current rust toolchain defaults (this includes clippy and rustfmt) 73 | rust-custom-toolchain 74 | 75 | cargo-edit 76 | ]; 77 | 78 | #LD_LIBRARY_PATH = pkgs.lib.strings.concatMapStringsSep ":" toString (with pkgs; [ xorg.libX11 xorg.libXcursor xorg.libXi (libxkbcommon + "/lib") (vulkan-loader + "/lib") libglvnd ]); 79 | LD_LIBRARY_PATH = 80 | pkgs.lib.makeLibraryPath (impureDrivers ++ gfxDeps); 81 | # fetch with cli instead of native 82 | CARGO_NET_GIT_FETCH_WITH_CLI = "true"; 83 | RUST_BACKTRACE = 1; 84 | RUSTFLAGS = 85 | "-C linker=clang -C link-arg=-fuse-ld=${pkgs.mold}/bin/mold -C link-arg=-flto=thin"; 86 | }; 87 | 88 | packages = let 89 | example = examplename: 90 | (craneLib.buildPackage ({ 91 | pname = examplename; 92 | version = "0.1.0"; 93 | src = ./.; 94 | 95 | cargoArtifacts = craneLib.buildDepsOnly 96 | (commonArgs // { pname = "workspacedeps"; }); 97 | 98 | nativeBuildInputs = [ pkgs.makeWrapper ]; 99 | 100 | cargoExtraArgs = "--example ${examplename}"; 101 | 102 | postInstall = '' 103 | wrapProgram $out/bin/${examplename} --prefix LD_LIBRARY_PATH : ${ 104 | pkgs.lib.makeLibraryPath (impureDrivers ++ gfxDeps) 105 | }:$LD_LIBRARY_PATH 106 | ''; 107 | 108 | } // commonArgs)); 109 | in { 110 | basic-rs = (example "basic-rs"); 111 | graph-rs = (example "graph-rs"); 112 | grid-rs = (example "grid-rs"); 113 | list-rs = (example "list-rs"); 114 | paragraph-rs = (example "paragraph-rs"); 115 | textbox-rs = (example "textbox-rs"); 116 | }; 117 | 118 | checks = let 119 | pname = "feather-checks"; 120 | cargoArtifacts = 121 | craneLib.buildDepsOnly (commonArgs // { inherit pname; }); 122 | build-tests = craneLib.buildPackage (commonArgs // { 123 | inherit cargoArtifacts pname; 124 | cargoTestExtraArgs = "--no-run"; 125 | }); 126 | in { 127 | inherit build-tests; 128 | 129 | # Run clippy (and deny all warnings) on the crate source, 130 | # again, reusing the dependency artifacts from above. 131 | # 132 | # Note that this is done as a separate derivation so that 133 | # we can block the CI if there are issues here, but not 134 | # prevent downstream consumers from building our crate by itself. 135 | feather-clippy = craneLib.cargoClippy (commonArgs // { 136 | inherit cargoArtifacts; 137 | pname = "${pname}-clippy"; 138 | cargoClippyExtraArgs = "-- --deny warnings"; 139 | }); 140 | 141 | # Check formatting 142 | feather-fmt = 143 | craneLib.cargoFmt (commonArgs // { pname = "${pname}-fmt"; }); 144 | 145 | # Audit dependencies 146 | feather-audit = craneLib.cargoAudit (commonArgs // { 147 | pname = "${pname}-audit"; 148 | advisory-db = inputs.advisory-db; 149 | cargoAuditExtraArgs = "--ignore RUSTSEC-2020-0071"; 150 | }); 151 | 152 | # We can't run tests during nix flake check because it might not have a graphical device. 153 | }; 154 | }); 155 | } 156 | -------------------------------------------------------------------------------- /feather-ui/src/shaders/compositor.wgsl: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | //#import "feather.wgsl" 5 | const UNITX = array(0.0, 1.0, 0.0, 1.0, 1.0, 0.0); 6 | const UNITY = array(0.0, 0.0, 1.0, 0.0, 1.0, 1.0); 7 | const IDENTITY_MAT4 = mat4x4f(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0); 8 | 9 | fn srgb_to_linear(c: f32) -> f32 { 10 | if c <= 0.04045 { 11 | return c / 12.92; 12 | } 13 | else { 14 | return pow((c + 0.055) / 1.055, 2.4); 15 | } 16 | } 17 | 18 | fn srgb_to_linear_vec4(c: vec4) -> vec4 { 19 | return vec4f(srgb_to_linear(c.x), srgb_to_linear(c.y), srgb_to_linear(c.z), c.w); 20 | } 21 | 22 | fn linear_to_srgb(c: f32) -> f32 { 23 | if c < 0.0031308 { 24 | return c * 12.92; 25 | } 26 | else { 27 | return 1.055 * pow(c, (1.0 / 2.4)) - (0.055); 28 | } 29 | } 30 | 31 | fn linear_to_srgb_vec4(c: vec4f) -> vec4f { 32 | return vec4f(linear_to_srgb(c.x), linear_to_srgb(c.y), linear_to_srgb(c.z), c.w); 33 | } 34 | 35 | fn u32_to_vec4(c: u32) -> vec4 { 36 | return vec4(f32((c & 0xff000000u) >> 24u) / 255.0, f32((c & 0x00ff0000u) >> 16u) / 255.0, f32((c & 0x0000ff00u) >> 8u) / 255.0, f32(c & 0x000000ffu) / 255.0); 37 | } 38 | 39 | // Rotates a point around the origin 40 | fn rotate(p: vec2f, r: f32) -> vec2f { 41 | let sr = sin(r); 42 | let cr = cos(r); 43 | return vec2f(p.x * cr - p.y * sr, p.y * cr + p.x * sr); 44 | } 45 | 46 | @group(0) @binding(0) 47 | var MVP: mat4x4f; 48 | @group(0) @binding(1) 49 | var buf: array; 50 | @group(0) @binding(2) 51 | var cliprects: array; 52 | @group(0) @binding(3) 53 | var sampling: sampler; 54 | @group(0) @binding(4) 55 | var atlas: texture_2d_array; 56 | @group(0) @binding(5) 57 | var layeratlas: texture_2d_array; 58 | 59 | struct Data { 60 | pos: vec2f, 61 | dim: vec2f, 62 | uv: vec2f, 63 | uvdim: vec2f, 64 | color: u32, 65 | rotation: f32, 66 | texclip: u32, 67 | } 68 | 69 | struct VertexOutput { 70 | @invariant @builtin(position) position: vec4, 71 | @location(0) uv: vec2f, 72 | @location(1) dist: vec2f, 73 | @location(2) @interpolate(flat) index: u32, 74 | @location(3) color: vec4f, 75 | } 76 | 77 | @vertex 78 | fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput { 79 | let vert = idx % 6; 80 | let index = idx / 6; 81 | var vpos = vec2(UNITX[vert], UNITY[vert]); 82 | let d = buf[index]; 83 | 84 | // Setting this flag *disables* inflation, so we invert it by comparing to 0 85 | let inflate = (d.texclip & 0x80000000) == 0; 86 | let layer = (d.texclip & 0x40000000) != 0; 87 | 88 | var inflate_dim = d.dim; 89 | var inflate_pos = d.pos; 90 | var inflate_uv = d.uv; 91 | var inflate_uvdim = d.uvdim; 92 | 93 | if (inflate) { 94 | // To emulate conservative rasterization, we must inflate the quad by 0.5 pixels outwards. This 95 | // is done by increasing the total dimension size by 1, then subtracing 0.5 from the position. 96 | inflate_dim += vec2f(1); 97 | inflate_pos -= vec2f(0.5); 98 | 99 | // We must also compensate the UV coordinates, but this is trickier because they could already be 100 | // scaled differently. We acquire the size of a UV pixel by dividing the UV dimensions by the true 101 | // dimensions. Thus, if we have a 2x2 UV lookup scaled to a 4x4 square, one scaled UV pixel is 0.5 102 | let uv_pixel = d.uvdim / d.dim; 103 | inflate_uvdim += vec2f(uv_pixel); 104 | inflate_uv -= vec2f(uv_pixel * 0.5); 105 | } 106 | 107 | var pos = vpos; 108 | pos *= inflate_dim; 109 | if d.rotation != 0.0f { 110 | // For complicated reasons, we have to rotate around the center of whatever we're rendering. This 111 | // allows lines to precisely position themselves, and also conveniently works with inflation enabled. 112 | let half = inflate_dim * vec2f(0.5); 113 | pos = rotate(pos - half, d.rotation) + half; 114 | } 115 | pos += inflate_pos; 116 | 117 | let out_pos = MVP * vec4(pos.x, pos.y, 1f, 1f); 118 | 119 | // When porting this to older shader versions, you can pass in the texture extent via a uniform instead, 120 | // but since we can access the texture from the vertex shader here, we just get the dimensions explicitly. 121 | var extent: vec2; 122 | if layer { 123 | extent = textureDimensions(layeratlas); 124 | } 125 | else { 126 | extent = textureDimensions(atlas); 127 | } 128 | 129 | var source = IDENTITY_MAT4; 130 | let uv = inflate_uv / vec2f(extent); 131 | let uvdim = inflate_uvdim / vec2f(extent); 132 | 133 | var out_uv = vpos * uvdim; 134 | // If the rotation is negative it's applied to the UV rectangle as well 135 | if d.rotation < 0.0f { 136 | let half = uvdim * vec2f(0.5); 137 | out_uv = rotate(out_uv - half, d.rotation) + half; 138 | } 139 | out_uv += uv; 140 | 141 | let color = srgb_to_linear_vec4(u32_to_vec4(d.color)); 142 | let dist = (vpos - vec2f(0.5f)) * inflate_dim; 143 | 144 | return VertexOutput(out_pos, out_uv.xy, dist, index, color); 145 | } 146 | 147 | @fragment 148 | fn fs_main(input: VertexOutput) -> @location(0) vec4f { 149 | let d = buf[input.index]; 150 | let clip = d.texclip & 0x0000FFFF; 151 | let tex = (d.texclip & 0x00FF0000) >> 16; 152 | let inflate = (d.texclip & 0x80000000) == 0; 153 | let layer = (d.texclip & 0x40000000) != 0; 154 | 155 | if clip > 0 { 156 | let r = cliprects[clip]; 157 | if !(input.uv.x >= r.x && input.uv.y >= r.y && input.uv.x < r.z && input.uv.y < r.z) { 158 | discard; 159 | } 160 | } 161 | 162 | var color = vec4f(input.color.rgb * input.color.a, input.color.a); 163 | var uv = input.uv; 164 | 165 | if (inflate) { 166 | var extent: vec2; 167 | if layer { 168 | extent = textureDimensions(layeratlas); 169 | } 170 | else { 171 | extent = textureDimensions(atlas); 172 | } 173 | 174 | // A pixel-perfect texture lookup at pixel 0,0 actually samples at 0.5,0.5, at the center of the 175 | // texel. Hence, if we simply clamp from 0,0 to height,width, this doesn't prevent bleedover when 176 | // we get a misaligned pixel that tries to sample the texel at 0,0, which will bleed over into the 177 | // texels next to it. As a result, we must clamp from 0.5,0.5 to width - 0.5, height - 0.5 178 | let uvmin = (d.uv + vec2f(0.5)) / vec2f(extent); 179 | let uvmax = (d.uv + d.uvdim - vec2f(0.5)) / vec2f(extent); 180 | uv = clamp(input.uv, uvmin, uvmax); 181 | 182 | // We get the pixel distance from the center of our quad, which we then use to do a precise alpha 183 | // dropoff, which recreates anti-aliasing. 184 | let dist = 1.0 - (abs(input.dist) - (d.dim * 0.5) + 0.5); 185 | color *= clamp(min(dist.x, dist.y), 0.0, 1.0); 186 | } 187 | 188 | if tex == 0xFF { 189 | return color; 190 | } 191 | 192 | if layer { 193 | return textureSample(layeratlas, sampling, uv, tex) * color; 194 | } 195 | 196 | return textureSample(atlas, sampling, uv, tex) * color; 197 | } -------------------------------------------------------------------------------- /feather-ui/examples/paragraph-rs.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use feather_ui::color::sRGB; 5 | use feather_ui::layout::{fixed, flex, leaf}; 6 | use feather_ui::{DAbsRect, DValue, ScopeID, gen_id}; 7 | 8 | use feather_ui::component::paragraph::Paragraph; 9 | use feather_ui::component::region::Region; 10 | use feather_ui::component::shape::{Shape, ShapeKind}; 11 | use feather_ui::component::window::Window; 12 | use feather_ui::layout::base; 13 | use feather_ui::persist::{FnPersist2, FnPersistStore}; 14 | use feather_ui::{AbsRect, App, DRect, FILL_DRECT, RelRect, SourceID, cosmic_text}; 15 | use std::f32; 16 | use std::sync::Arc; 17 | 18 | #[derive(PartialEq, Clone, Debug)] 19 | struct Blocker { 20 | area: AbsRect, 21 | } 22 | 23 | struct BasicApp {} 24 | 25 | #[derive(Default, Clone, feather_macro::Area)] 26 | struct MinimalFlexChild { 27 | area: DRect, 28 | } 29 | 30 | impl flex::Child for MinimalFlexChild { 31 | fn grow(&self) -> f32 { 32 | 0.0 33 | } 34 | 35 | fn shrink(&self) -> f32 { 36 | 1.0 37 | } 38 | 39 | fn basis(&self) -> DValue { 40 | 100.0.into() 41 | } 42 | } 43 | 44 | impl base::Order for MinimalFlexChild {} 45 | impl base::Anchor for MinimalFlexChild {} 46 | impl base::Padding for MinimalFlexChild {} 47 | impl base::Margin for MinimalFlexChild {} 48 | impl base::Limits for MinimalFlexChild {} 49 | impl base::RLimits for MinimalFlexChild {} 50 | impl leaf::Prop for MinimalFlexChild {} 51 | impl leaf::Padded for MinimalFlexChild {} 52 | 53 | #[derive(Default, Clone, feather_macro::Empty, feather_macro::Area)] 54 | struct MinimalArea { 55 | area: DRect, 56 | } 57 | 58 | impl base::ZIndex for MinimalArea {} 59 | impl base::Anchor for MinimalArea {} 60 | impl base::Limits for MinimalArea {} 61 | impl fixed::Prop for MinimalArea {} 62 | 63 | #[derive(Default, Clone, feather_macro::Empty, feather_macro::Area)] 64 | struct MinimalFlex { 65 | obstacles: Vec, 66 | area: DRect, 67 | } 68 | impl base::Direction for MinimalFlex {} 69 | impl base::ZIndex for MinimalFlex {} 70 | impl base::Limits for MinimalFlex {} 71 | impl base::RLimits for MinimalFlex {} 72 | impl fixed::Child for MinimalFlex {} 73 | 74 | impl base::Obstacles for MinimalFlex { 75 | fn obstacles(&self) -> &[DAbsRect] { 76 | &self.obstacles 77 | } 78 | } 79 | 80 | impl flex::Prop for MinimalFlex { 81 | fn wrap(&self) -> bool { 82 | true 83 | } 84 | 85 | fn justify(&self) -> flex::FlexJustify { 86 | flex::FlexJustify::Start 87 | } 88 | 89 | fn align(&self) -> flex::FlexJustify { 90 | flex::FlexJustify::Start 91 | } 92 | } 93 | 94 | impl FnPersistStore for BasicApp { 95 | type Store = (Blocker, im::HashMap, Option>); 96 | } 97 | impl FnPersist2<&Blocker, ScopeID<'_>, im::HashMap, Option>> for BasicApp { 98 | fn init(&self) -> Self::Store { 99 | ( 100 | Blocker { 101 | area: AbsRect::new(f32::NAN, f32::NAN, f32::NAN, f32::NAN), 102 | }, 103 | im::HashMap::new(), 104 | ) 105 | } 106 | fn call( 107 | &mut self, 108 | mut store: Self::Store, 109 | args: &Blocker, 110 | mut scope: ScopeID<'_>, 111 | ) -> (Self::Store, im::HashMap, Option>) { 112 | if store.0 != *args { 113 | let flex = { 114 | let rect = Shape::::new( 115 | gen_id!(scope), 116 | MinimalFlexChild { 117 | area: AbsRect::new(0.0, 0.0, 40.0, 40.0).into(), 118 | }, 119 | 0.0, 120 | 0.0, 121 | wide::f32x4::splat(10.0), 122 | sRGB::new(0.2, 0.7, 0.4, 1.0), 123 | sRGB::transparent(), 124 | feather_ui::DAbsPoint::zero(), 125 | ); 126 | 127 | let mut p = Paragraph::new( 128 | gen_id!(scope), 129 | MinimalFlex { 130 | area: FILL_DRECT, 131 | obstacles: vec![AbsRect::new(200.0, 30.0, 300.0, 150.0).into()], 132 | }, 133 | ); 134 | 135 | let text = "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?"; 136 | p.set_text( 137 | text, 138 | 40.0, 139 | 56.0, 140 | cosmic_text::FamilyOwned::SansSerif, 141 | sRGB::white(), 142 | Default::default(), 143 | Default::default(), 144 | true, 145 | ); 146 | p.prepend(Box::new(rect.clone())); 147 | p.append(Box::new(rect.clone())); 148 | p.append(Box::new(rect.clone())); 149 | 150 | p 151 | }; 152 | 153 | let region = Region::new( 154 | gen_id!(scope), 155 | MinimalArea { 156 | area: AbsRect::new(90.0, 90.0, -90.0, -90.0) + RelRect::new(0.0, 0.0, 1.0, 1.0), 157 | }, 158 | feather_ui::children![fixed::Prop, flex], 159 | ); 160 | 161 | let window = Window::new( 162 | gen_id!(scope), 163 | winit::window::Window::default_attributes() 164 | .with_title(env!("CARGO_CRATE_NAME")) 165 | .with_resizable(true), 166 | Box::new(region), 167 | ); 168 | 169 | store.1 = im::HashMap::new(); 170 | store.1.insert(window.id.clone(), Some(window)); 171 | store.0 = args.clone(); 172 | } 173 | let windows = store.1.clone(); 174 | (store, windows) 175 | } 176 | } 177 | 178 | fn main() { 179 | let (mut app, event_loop, _, _) = App::::new( 180 | Blocker { 181 | area: AbsRect::new(-1.0, -1.0, -1.0, -1.0), 182 | }, 183 | vec![], 184 | BasicApp {}, 185 | None, 186 | None, 187 | ) 188 | .unwrap(); 189 | 190 | event_loop.run_app(&mut app).unwrap(); 191 | } 192 | -------------------------------------------------------------------------------- /feather-ui/src/component/text.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::color::sRGB; 5 | use crate::component::{EventRouter, StateMachine}; 6 | use crate::graphics::point_to_pixel; 7 | use crate::layout::{self, Layout, leaf}; 8 | use crate::{SourceID, graphics}; 9 | use cosmic_text::{LineIter, Metrics}; 10 | use derive_where::derive_where; 11 | use std::cell::RefCell; 12 | use std::convert::Infallible; 13 | use std::rc::Rc; 14 | use std::sync::Arc; 15 | 16 | #[derive(Clone)] 17 | pub struct TextState { 18 | buffer: Rc>, 19 | text: String, 20 | align: Option, 21 | } 22 | 23 | impl EventRouter for TextState { 24 | type Input = Infallible; 25 | type Output = Infallible; 26 | } 27 | 28 | impl PartialEq for TextState { 29 | fn eq(&self, other: &Self) -> bool { 30 | Rc::ptr_eq(&self.buffer, &other.buffer) 31 | && self.text == other.text 32 | && self.align == other.align 33 | } 34 | } 35 | 36 | #[derive_where(Clone)] 37 | pub struct Text { 38 | pub id: Arc, 39 | pub props: Rc, 40 | pub font_size: f32, 41 | pub line_height: f32, 42 | pub text: String, 43 | pub font: cosmic_text::FamilyOwned, 44 | pub color: sRGB, 45 | pub weight: cosmic_text::Weight, 46 | pub style: cosmic_text::Style, 47 | pub wrap: cosmic_text::Wrap, 48 | pub align: Option, /* Alignment overrides whether text is LTR or RTL so 49 | * we usually only want to set it if we're centering 50 | * text */ 51 | } 52 | 53 | impl Text { 54 | pub fn new( 55 | id: Arc, 56 | props: T, 57 | font_size: f32, 58 | line_height: f32, 59 | text: String, 60 | font: cosmic_text::FamilyOwned, 61 | color: sRGB, 62 | weight: cosmic_text::Weight, 63 | style: cosmic_text::Style, 64 | wrap: cosmic_text::Wrap, 65 | align: Option, 66 | ) -> Self { 67 | Self { 68 | id, 69 | props: props.into(), 70 | font_size, 71 | line_height, 72 | text, 73 | font, 74 | color, 75 | weight, 76 | style, 77 | wrap, 78 | align, 79 | } 80 | } 81 | } 82 | 83 | impl crate::StateMachineChild for Text { 84 | fn id(&self) -> Arc { 85 | self.id.clone() 86 | } 87 | 88 | fn init( 89 | &self, 90 | _: &std::sync::Weak, 91 | ) -> Result, crate::Error> { 92 | let statemachine: StateMachine = StateMachine { 93 | state: TextState { 94 | buffer: Rc::new(RefCell::new(cosmic_text::Buffer::new_empty(Metrics::new( 95 | point_to_pixel(self.font_size, 1.0), 96 | point_to_pixel(self.line_height, 1.0), 97 | )))), 98 | text: String::new(), 99 | align: None, 100 | }, 101 | input_mask: 0, 102 | output: [], 103 | changed: true, 104 | }; 105 | Ok(Box::new(statemachine)) 106 | } 107 | } 108 | 109 | impl Default for Text { 110 | fn default() -> Self { 111 | Self { 112 | id: Default::default(), 113 | props: Default::default(), 114 | font_size: Default::default(), 115 | line_height: Default::default(), 116 | text: Default::default(), 117 | font: cosmic_text::FamilyOwned::SansSerif, 118 | color: sRGB::new(1.0, 1.0, 1.0, 1.0), 119 | weight: Default::default(), 120 | style: Default::default(), 121 | wrap: cosmic_text::Wrap::None, 122 | align: None, 123 | } 124 | } 125 | } 126 | 127 | fn buffer_eq(s: &str, b: &cosmic_text::Buffer) -> bool { 128 | let mut ranges = LineIter::new(s); 129 | let mut lines = b.lines.iter(); 130 | loop { 131 | match (lines.next(), ranges.next()) { 132 | (Some(line), Some((r, _))) => { 133 | if &s[r] != line.text() { 134 | return false; 135 | } 136 | } 137 | (None, None) => return true, 138 | _ => return false, 139 | } 140 | } 141 | } 142 | 143 | impl super::Component for Text 144 | where 145 | for<'a> &'a T: Into<&'a (dyn leaf::Padded + 'static)>, 146 | { 147 | type Props = T; 148 | 149 | fn layout( 150 | &self, 151 | manager: &mut crate::StateManager, 152 | driver: &graphics::Driver, 153 | window: &Arc, 154 | ) -> Box> { 155 | let dpi = manager 156 | .get::(window) 157 | .map(|x| x.state.dpi) 158 | .unwrap_or(crate::BASE_DPI); 159 | let mut font_system = driver.font_system.write(); 160 | 161 | let metrics = cosmic_text::Metrics::new( 162 | point_to_pixel(self.font_size, dpi.width), 163 | point_to_pixel(self.line_height, dpi.height), 164 | ); 165 | 166 | let textstate = manager 167 | .get_mut::>(&self.id) 168 | .unwrap(); 169 | let textstate = &mut textstate.state; 170 | textstate 171 | .buffer 172 | .borrow_mut() 173 | .set_metrics(&mut font_system, metrics); 174 | textstate 175 | .buffer 176 | .borrow_mut() 177 | .set_wrap(&mut font_system, self.wrap); 178 | 179 | if self.align != textstate.align || !buffer_eq(&self.text, &textstate.buffer.borrow()) { 180 | textstate.buffer.borrow_mut().set_text( 181 | &mut font_system, 182 | &self.text, 183 | &cosmic_text::Attrs::new() 184 | .family(self.font.as_family()) 185 | .color(self.color.into()) 186 | .weight(self.weight) 187 | .style(self.style), 188 | cosmic_text::Shaping::Advanced, 189 | self.align, 190 | ); 191 | 192 | textstate.text = self.text.clone(); 193 | textstate.align = self.align; 194 | } 195 | 196 | let render = Rc::new(crate::render::text::Instance { 197 | text_buffer: textstate.buffer.clone(), 198 | padding: self.props.padding().as_perimeter(dpi).into(), 199 | }); 200 | 201 | Box::new(layout::text::Node:: { 202 | props: self.props.clone(), 203 | id: Arc::downgrade(&self.id), 204 | buffer: textstate.buffer.clone(), 205 | renderable: render.clone(), 206 | realign: self.align.is_some_and(|x| x != cosmic_text::Align::Left), 207 | }) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /feather-ui/src/input.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use enum_variant_type::EnumVariantType; 5 | use feather_macro::Dispatch; 6 | use guillotiere::euclid::default::Rotation3D; 7 | use guillotiere::euclid::{Point3D, Vector3D}; 8 | use winit::dpi::PhysicalPosition; 9 | use winit::event::{DeviceId, TouchPhase}; 10 | 11 | use crate::{Pixel, PxPoint, PxVector, RelVector}; 12 | 13 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 14 | #[repr(u8)] 15 | pub enum TouchState { 16 | Start = 0, 17 | Move = 1, 18 | End = 2, 19 | } 20 | 21 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 22 | #[repr(u8)] 23 | pub enum MouseState { 24 | Down = 0, 25 | Up = 1, 26 | DblClick = 2, 27 | } 28 | 29 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 30 | #[repr(u16)] 31 | pub enum MouseButton { 32 | Left = (1 << 0), 33 | Right = (1 << 1), 34 | Middle = (1 << 2), 35 | Back = (1 << 3), 36 | Forward = (1 << 4), 37 | X1 = (1 << 5), 38 | X2 = (1 << 6), 39 | X3 = (1 << 7), 40 | X4 = (1 << 8), 41 | X5 = (1 << 9), 42 | X6 = (1 << 10), 43 | X7 = (1 << 11), 44 | X8 = (1 << 12), 45 | X9 = (1 << 13), 46 | X10 = (1 << 14), 47 | X11 = (1 << 15), 48 | } 49 | 50 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 51 | #[repr(u8)] 52 | pub enum ModifierKeys { 53 | Shift = 1, 54 | Control = 2, 55 | Alt = 4, 56 | Super = 8, 57 | Capslock = 16, 58 | Numlock = 32, 59 | Held = 64, 60 | } 61 | 62 | #[derive(Debug, Dispatch, EnumVariantType, Clone)] 63 | #[evt(derive(Clone), module = "raw_event")] 64 | pub enum RawEvent { 65 | Drag, // TBD, must be included here so RawEvent matches RawEventKind 66 | Drop { 67 | device_id: DeviceId, 68 | pos: PhysicalPosition, 69 | }, 70 | Focus { 71 | acquired: bool, 72 | window: std::sync::Arc, // Allows setting IME mode for textboxes 73 | }, 74 | JoyAxis { 75 | device_id: DeviceId, 76 | value: f64, 77 | axis: u32, 78 | }, 79 | JoyButton { 80 | device_id: DeviceId, 81 | down: bool, 82 | button: u32, 83 | }, 84 | JoyOrientation { 85 | // 32 bytes 86 | device_id: DeviceId, 87 | velocity: Vector3D, 88 | rotation: Rotation3D, 89 | }, 90 | Key { 91 | // 48 bytes 92 | device_id: DeviceId, 93 | physical_key: winit::keyboard::PhysicalKey, 94 | location: winit::keyboard::KeyLocation, 95 | down: bool, 96 | logical_key: winit::keyboard::Key, 97 | modifiers: u8, 98 | }, 99 | Mouse { 100 | // 24 bytes 101 | device_id: DeviceId, 102 | state: MouseState, 103 | pos: PxPoint, 104 | button: MouseButton, 105 | all_buttons: u16, 106 | modifiers: u8, 107 | }, 108 | MouseOn { 109 | device_id: DeviceId, 110 | pos: PxPoint, 111 | modifiers: u8, 112 | all_buttons: u16, 113 | }, 114 | MouseMove { 115 | device_id: DeviceId, 116 | pos: PxPoint, 117 | modifiers: u8, 118 | all_buttons: u16, 119 | }, 120 | MouseOff { 121 | device_id: DeviceId, 122 | modifiers: u8, 123 | all_buttons: u16, 124 | }, 125 | MouseScroll { 126 | device_id: DeviceId, 127 | state: TouchState, 128 | pos: PxPoint, 129 | delta: Result, 130 | }, 131 | Touch { 132 | // 48 bytes 133 | device_id: DeviceId, 134 | index: u32, 135 | state: TouchState, 136 | pos: Point3D, 137 | angle: Rotation3D, 138 | pressure: f64, 139 | }, 140 | } 141 | 142 | static_assertions::const_assert!(size_of::() == 48); 143 | 144 | impl RawEvent { 145 | pub fn kind(&self) -> RawEventKind { 146 | self.into() 147 | } 148 | } 149 | 150 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 151 | #[repr(u64)] 152 | pub enum RawEventKind { 153 | Drag = (1 << 0), /* This must start from 1 and perfectly match RawEvent to ensure the 154 | * dispatch works correctly */ 155 | Drop = (1 << 1), 156 | Focus = (1 << 2), 157 | JoyAxis = (1 << 3), 158 | JoyButton = (1 << 4), 159 | JoyOrientation = (1 << 5), 160 | Key = (1 << 6), 161 | Mouse = (1 << 7), 162 | MouseOn = (1 << 8), 163 | MouseMove = (1 << 9), 164 | MouseOff = (1 << 10), 165 | MouseScroll = (1 << 11), 166 | Touch = (1 << 12), 167 | } 168 | 169 | impl From<&RawEvent> for RawEventKind { 170 | fn from(value: &RawEvent) -> Self { 171 | match value { 172 | RawEvent::Drag => RawEventKind::Drag, 173 | RawEvent::Drop { .. } => RawEventKind::Drop, 174 | RawEvent::Focus { .. } => RawEventKind::Focus, 175 | RawEvent::JoyAxis { .. } => RawEventKind::JoyAxis, 176 | RawEvent::JoyButton { .. } => RawEventKind::JoyButton, 177 | RawEvent::JoyOrientation { .. } => RawEventKind::JoyOrientation, 178 | RawEvent::Key { .. } => RawEventKind::Key, 179 | RawEvent::Mouse { .. } => RawEventKind::Mouse, 180 | RawEvent::MouseOn { .. } => RawEventKind::MouseOn, 181 | RawEvent::MouseMove { .. } => RawEventKind::MouseMove, 182 | RawEvent::MouseOff { .. } => RawEventKind::MouseOff, 183 | RawEvent::MouseScroll { .. } => RawEventKind::MouseScroll, 184 | RawEvent::Touch { .. } => RawEventKind::Touch, 185 | } 186 | } 187 | } 188 | 189 | impl From for TouchState { 190 | fn from(value: TouchPhase) -> Self { 191 | match value { 192 | TouchPhase::Started => TouchState::Start, 193 | TouchPhase::Moved => TouchState::Move, 194 | TouchPhase::Ended => TouchState::End, 195 | TouchPhase::Cancelled => TouchState::End, 196 | } 197 | } 198 | } 199 | 200 | impl From for MouseButton { 201 | fn from(value: winit::event::MouseButton) -> Self { 202 | use winit::event; 203 | match value { 204 | event::MouseButton::Left => MouseButton::Left, 205 | event::MouseButton::Right => MouseButton::Right, 206 | event::MouseButton::Middle => MouseButton::Middle, 207 | event::MouseButton::Back => MouseButton::Back, 208 | event::MouseButton::Forward => MouseButton::Forward, 209 | event::MouseButton::Other(5) => MouseButton::X1, 210 | event::MouseButton::Other(6) => MouseButton::X2, 211 | event::MouseButton::Other(7) => MouseButton::X3, 212 | event::MouseButton::Other(8) => MouseButton::X4, 213 | event::MouseButton::Other(9) => MouseButton::X5, 214 | event::MouseButton::Other(10) => MouseButton::X6, 215 | event::MouseButton::Other(11) => MouseButton::X7, 216 | event::MouseButton::Other(12) => MouseButton::X8, 217 | event::MouseButton::Other(13) => MouseButton::X9, 218 | event::MouseButton::Other(14) => MouseButton::X10, 219 | event::MouseButton::Other(15) => MouseButton::X11, 220 | event::MouseButton::Other(_) => panic!("Mouse button out of range"), 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /feather-ui/src/component/shape.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use crate::color::sRGB; 5 | use crate::layout::{Layout, leaf}; 6 | use crate::{DAbsPoint, SourceID}; 7 | use std::rc::Rc; 8 | use std::sync::Arc; 9 | 10 | #[repr(u8)] 11 | pub enum ShapeKind { 12 | RoundRect, 13 | Triangle, 14 | Circle, 15 | Arc, 16 | } 17 | 18 | pub struct Shape { 19 | id: std::sync::Arc, 20 | props: Rc, 21 | border: f32, 22 | blur: f32, 23 | size: crate::DAbsPoint, 24 | corners: [f32; 4], 25 | fill: sRGB, 26 | outline: sRGB, 27 | } 28 | 29 | pub fn round_rect( 30 | id: std::sync::Arc, 31 | props: T, 32 | border: f32, 33 | blur: f32, 34 | corners: wide::f32x4, 35 | fill: sRGB, 36 | outline: sRGB, 37 | size: DAbsPoint, 38 | ) -> Shape { 39 | Shape { 40 | id, 41 | props: props.into(), 42 | border, 43 | blur, 44 | corners: corners.to_array(), 45 | fill, 46 | outline, 47 | size, 48 | } 49 | } 50 | 51 | pub fn triangle( 52 | id: std::sync::Arc, 53 | props: T, 54 | border: f32, 55 | blur: f32, 56 | corners: [f32; 3], 57 | offset: f32, 58 | fill: sRGB, 59 | outline: sRGB, 60 | size: DAbsPoint, 61 | ) -> Shape { 62 | Shape { 63 | id, 64 | props: props.into(), 65 | border, 66 | blur, 67 | corners: [corners[0], corners[1], corners[2], offset], 68 | fill, 69 | outline, 70 | size, 71 | } 72 | } 73 | 74 | pub fn circle( 75 | id: std::sync::Arc, 76 | props: T, 77 | border: f32, 78 | blur: f32, 79 | radii: [f32; 2], 80 | fill: sRGB, 81 | outline: sRGB, 82 | size: DAbsPoint, 83 | ) -> Shape { 84 | Shape { 85 | id, 86 | props: props.into(), 87 | border, 88 | blur, 89 | corners: [radii[0], radii[1], 0.0, 0.0], 90 | fill, 91 | outline, 92 | size, 93 | } 94 | } 95 | 96 | pub fn arcs( 97 | id: std::sync::Arc, 98 | props: T, 99 | border: f32, 100 | blur: f32, 101 | inner_radius: f32, 102 | arcs: [f32; 2], 103 | fill: sRGB, 104 | outline: sRGB, 105 | size: DAbsPoint, 106 | ) -> Shape { 107 | Shape { 108 | id, 109 | props: props.into(), 110 | border, 111 | blur, 112 | corners: [arcs[0] + arcs[1] * 0.5, arcs[1] * 0.5, inner_radius, 0.0], 113 | fill, 114 | outline, 115 | size, 116 | } 117 | } 118 | 119 | impl Clone for Shape { 120 | fn clone(&self) -> Self { 121 | Self { 122 | id: self.id.duplicate(), 123 | props: self.props.clone(), 124 | border: self.border, 125 | blur: self.blur, 126 | corners: self.corners, 127 | fill: self.fill, 128 | outline: self.outline, 129 | size: self.size, 130 | } 131 | } 132 | } 133 | 134 | impl Shape { 135 | pub fn new( 136 | id: std::sync::Arc, 137 | props: T, 138 | border: f32, 139 | blur: f32, 140 | corners: wide::f32x4, 141 | fill: sRGB, 142 | outline: sRGB, 143 | size: DAbsPoint, 144 | ) -> Self { 145 | Self { 146 | id, 147 | props: props.into(), 148 | border, 149 | blur, 150 | corners: corners.to_array(), 151 | fill, 152 | outline, 153 | size, 154 | } 155 | } 156 | } 157 | 158 | impl Shape { 159 | pub fn new( 160 | id: std::sync::Arc, 161 | props: T, 162 | border: f32, 163 | blur: f32, 164 | corners: [f32; 3], 165 | offset: f32, 166 | fill: sRGB, 167 | outline: sRGB, 168 | size: DAbsPoint, 169 | ) -> Self { 170 | Self { 171 | id, 172 | props: props.into(), 173 | border, 174 | blur, 175 | corners: [corners[0], corners[1], corners[2], offset], 176 | fill, 177 | outline, 178 | size, 179 | } 180 | } 181 | } 182 | 183 | impl Shape { 184 | pub fn new( 185 | id: std::sync::Arc, 186 | props: T, 187 | border: f32, 188 | blur: f32, 189 | radii: [f32; 2], 190 | fill: sRGB, 191 | outline: sRGB, 192 | size: DAbsPoint, 193 | ) -> Self { 194 | Self { 195 | id, 196 | props: props.into(), 197 | border, 198 | blur, 199 | corners: [radii[0], radii[1], 0.0, 0.0], 200 | fill, 201 | outline, 202 | size, 203 | } 204 | } 205 | } 206 | 207 | impl Shape { 208 | pub fn new( 209 | id: std::sync::Arc, 210 | props: T, 211 | border: f32, 212 | blur: f32, 213 | inner_radius: f32, 214 | arcs: [f32; 2], 215 | fill: sRGB, 216 | outline: sRGB, 217 | size: DAbsPoint, 218 | ) -> Self { 219 | Self { 220 | id, 221 | props: props.into(), 222 | border, 223 | blur, 224 | corners: [arcs[0] + arcs[1] * 0.5, arcs[1] * 0.5, inner_radius, 0.0], 225 | fill, 226 | outline, 227 | size, 228 | } 229 | } 230 | } 231 | 232 | impl crate::StateMachineChild for Shape { 233 | fn id(&self) -> std::sync::Arc { 234 | self.id.clone() 235 | } 236 | } 237 | 238 | impl super::Component for Shape 239 | where 240 | for<'a> &'a T: Into<&'a (dyn leaf::Padded + 'static)>, 241 | { 242 | type Props = T; 243 | 244 | fn layout( 245 | &self, 246 | manager: &mut crate::StateManager, 247 | _: &crate::graphics::Driver, 248 | window: &Arc, 249 | ) -> Box> { 250 | let dpi = manager 251 | .get::(window) 252 | .map(|x| x.state.dpi) 253 | .unwrap_or(crate::BASE_DPI); 254 | 255 | let mut corners = self.corners; 256 | if KIND == ShapeKind::RoundRect as u8 { 257 | corners[0] *= dpi.width; 258 | corners[1] *= dpi.height; 259 | corners[2] *= dpi.width; 260 | corners[3] *= dpi.height; 261 | } 262 | 263 | Box::new(leaf::Sized:: { 264 | props: self.props.clone(), 265 | id: Arc::downgrade(&self.id), 266 | size: self.size.resolve(dpi).to_vector().to_size().cast_unit(), 267 | renderable: Some(Rc::new(crate::render::shape::Instance:: { 268 | padding: self.props.padding().as_perimeter(dpi), 269 | border: self.border, 270 | blur: self.blur, 271 | fill: self.fill, 272 | outline: self.outline, 273 | corners, 274 | id: self.id.clone(), 275 | })), 276 | }) 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /feather-ui/examples/basic-rs.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use bytemuck::Zeroable; 5 | use feather_macro::*; 6 | use feather_ui::color::{sRGB, sRGB32}; 7 | use feather_ui::component::button::Button; 8 | use feather_ui::component::region::Region; 9 | use feather_ui::component::text::Text; 10 | use feather_ui::component::window::Window; 11 | use feather_ui::component::{mouse_area, shape}; 12 | use feather_ui::layout::{fixed, leaf}; 13 | use feather_ui::persist::{FnPersist2, FnPersistStore}; 14 | use feather_ui::{ 15 | AbsRect, App, DAbsPoint, DAbsRect, DPoint, DRect, PxRect, RelRect, ScopeID, Slot, SourceID, 16 | UNSIZED_AXIS, gen_id, im, winit, 17 | }; 18 | use std::rc::Rc; 19 | use std::sync::Arc; 20 | 21 | #[derive(PartialEq, Clone, Debug)] 22 | struct CounterState { 23 | count: i32, 24 | } 25 | 26 | #[derive(Default, Empty, Area, Anchor, ZIndex, Limits, RLimits, Padding)] 27 | struct FixedData { 28 | area: DRect, 29 | anchor: DPoint, 30 | limits: feather_ui::DLimits, 31 | rlimits: feather_ui::RelLimits, 32 | padding: DAbsRect, 33 | zindex: i32, 34 | } 35 | 36 | impl fixed::Prop for FixedData {} 37 | impl fixed::Child for FixedData {} 38 | impl leaf::Prop for FixedData {} 39 | impl leaf::Padded for FixedData {} 40 | 41 | struct BasicApp {} 42 | 43 | impl FnPersistStore for BasicApp { 44 | type Store = (CounterState, im::HashMap, Option>); 45 | } 46 | 47 | impl FnPersist2<&CounterState, ScopeID<'_>, im::HashMap, Option>> 48 | for BasicApp 49 | { 50 | fn init(&self) -> Self::Store { 51 | (CounterState { count: -1 }, im::HashMap::new()) 52 | } 53 | fn call( 54 | &mut self, 55 | mut store: Self::Store, 56 | app: &CounterState, 57 | mut id: ScopeID<'_>, 58 | ) -> (Self::Store, im::HashMap, Option>) { 59 | if store.0 != *app { 60 | let button = { 61 | let text = Text:: { 62 | id: gen_id!(id), 63 | props: Rc::new(FixedData { 64 | area: AbsRect::new(8.0, 0.0, 8.0, 0.0) 65 | + RelRect::new(0.0, 0.5, UNSIZED_AXIS, UNSIZED_AXIS), 66 | anchor: feather_ui::RelPoint::new(0.0, 0.5).into(), 67 | ..Default::default() 68 | }), 69 | color: sRGB::new(1.0, 1.0, 0.0, 1.0), 70 | text: format!("Clicks: {}", app.count), 71 | font_size: 40.0, 72 | line_height: 56.0, 73 | align: Some(cosmic_text::Align::Center), 74 | ..Default::default() 75 | }; 76 | 77 | let rect = shape::round_rect::( 78 | gen_id!(id), 79 | feather_ui::FILL_DRECT, 80 | 0.0, 81 | 0.0, 82 | wide::f32x4::splat(10.0), 83 | sRGB::new(0.2, 0.7, 0.4, 1.0), 84 | sRGB::transparent(), 85 | DAbsPoint::zero(), 86 | ); 87 | 88 | Button::::new( 89 | gen_id!(id), 90 | FixedData { 91 | area: AbsRect::new(45.0, 45.0, 0.0, 0.0) 92 | + RelRect::new(0.0, 0.0, UNSIZED_AXIS, 1.0), 93 | 94 | ..Default::default() 95 | }, 96 | Slot(feather_ui::APP_SOURCE_ID.into(), 0), 97 | feather_ui::children![fixed::Prop, rect, text], 98 | ) 99 | }; 100 | 101 | let block = { 102 | let text = Text:: { 103 | id: gen_id!(id), 104 | props: Rc::new(FixedData { 105 | area: RelRect::new(0.5, 0.0, UNSIZED_AXIS, UNSIZED_AXIS).into(), 106 | limits: feather_ui::AbsLimits::new(.., 10.0..200.0).into(), 107 | rlimits: feather_ui::RelLimits::new(..1.0, ..), 108 | anchor: feather_ui::RelPoint::new(0.5, 0.0).into(), 109 | padding: AbsRect::new(8.0, 8.0, 8.0, 8.0).into(), 110 | ..Default::default() 111 | }), 112 | text: (0..app.count).map(|_| "█").collect::(), 113 | font_size: 40.0, 114 | line_height: 56.0, 115 | wrap: feather_ui::cosmic_text::Wrap::WordOrGlyph, 116 | align: Some(cosmic_text::Align::Center), 117 | ..Default::default() 118 | }; 119 | 120 | let rect = shape::round_rect::( 121 | gen_id!(id), 122 | feather_ui::FILL_DRECT, 123 | 0.0, 124 | 0.0, 125 | wide::f32x4::splat(10.0), 126 | sRGB::new(0.7, 0.2, 0.4, 1.0), 127 | sRGB::transparent(), 128 | DAbsPoint::zero(), 129 | ); 130 | 131 | Region::::new_layer( 132 | gen_id!(id), 133 | FixedData { 134 | area: AbsRect::new(45.0, 245.0, 0.0, 0.0) 135 | + RelRect::new(0.0, 0.0, UNSIZED_AXIS, UNSIZED_AXIS), 136 | limits: feather_ui::AbsLimits::new(100.0..300.0, ..).into(), 137 | ..Default::default() 138 | }, 139 | sRGB32::from_alpha(128), 140 | 0.0, 141 | feather_ui::children![fixed::Prop, rect, text], 142 | ) 143 | }; 144 | 145 | let pixel = shape::round_rect::( 146 | gen_id!(id), 147 | PxRect::new(1.0, 1.0, 2.0, 2.0).into(), 148 | 0.0, 149 | 0.0, 150 | wide::f32x4::zeroed(), 151 | sRGB::new(1.0, 1.0, 1.0, 1.0), 152 | sRGB::transparent(), 153 | DAbsPoint::zero(), 154 | ); 155 | 156 | let region = Region::new( 157 | gen_id!(id), 158 | FixedData { 159 | area: AbsRect::new(90.0, 90.0, 0.0, 200.0) 160 | + RelRect::new(0.0, 0.0, UNSIZED_AXIS, 0.0), 161 | zindex: 0, 162 | ..Default::default() 163 | }, 164 | feather_ui::children![fixed::Prop, button, block, pixel], 165 | ); 166 | let window = Window::new( 167 | gen_id!(id), 168 | winit::window::Window::default_attributes() 169 | .with_title(env!("CARGO_CRATE_NAME")) 170 | .with_resizable(true), 171 | Box::new(region), 172 | ); 173 | 174 | store.1 = im::HashMap::new(); 175 | store.1.insert(window.id.clone(), Some(window)); 176 | store.0 = app.clone(); 177 | } 178 | let windows = store.1.clone(); 179 | (store, windows) 180 | } 181 | } 182 | 183 | use feather_ui::WrapEventEx; 184 | 185 | fn main() { 186 | let onclick = Box::new( 187 | |_: mouse_area::MouseAreaEvent, 188 | mut appdata: feather_ui::AccessCell| 189 | -> feather_ui::InputResult<()> { 190 | { 191 | appdata.count += 1; 192 | feather_ui::InputResult::Consume(()) 193 | } 194 | } 195 | .wrap(), 196 | ); 197 | 198 | let (mut app, event_loop, _, _) = App::::new( 199 | CounterState { count: 0 }, 200 | vec![onclick], 201 | BasicApp {}, 202 | None, 203 | None, 204 | ) 205 | .unwrap(); 206 | 207 | event_loop.run_app(&mut app).unwrap(); 208 | } 209 | -------------------------------------------------------------------------------- /feather-ui/src/shaders/shape.wgsl: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | //#import "feather.wgsl" 5 | const UNITX = array(0.0, 1.0, 0.0, 1.0, 1.0, 0.0); 6 | const UNITY = array(0.0, 0.0, 1.0, 0.0, 1.0, 1.0); 7 | 8 | fn linearstep(low: f32, high: f32, x: f32) -> f32 { 9 | return clamp((x - low) / (high - low), 0.0f, 1.0f); 10 | } 11 | 12 | fn u32_to_vec4(c: u32) -> vec4 { 13 | return vec4(f32((c & 0xff000000u) >> 24u) / 255.0, f32((c & 0x00ff0000u) >> 16u) / 255.0, f32((c & 0x0000ff00u) >> 8u) / 255.0, f32(c & 0x000000ffu) / 255.0); 14 | } 15 | 16 | fn srgb_to_linear(c: f32) -> f32 { 17 | if c <= 0.04045 { 18 | return c / 12.92; 19 | } 20 | else { 21 | return pow((c + 0.055) / 1.055, 2.4); 22 | } 23 | } 24 | 25 | fn srgb_to_linear_vec4(c: vec4) -> vec4 { 26 | return vec4f(srgb_to_linear(c.x), srgb_to_linear(c.y), srgb_to_linear(c.z), c.w); 27 | } 28 | 29 | @group(0) @binding(0) 30 | var MVP: mat4x4f; 31 | @group(0) @binding(1) 32 | var buf: array; 33 | @group(0) @binding(2) 34 | var extent: u32; 35 | 36 | struct Data { 37 | corners: vec4f, 38 | pos: vec2f, 39 | dim: vec2f, 40 | border: f32, 41 | blur: f32, 42 | fill: u32, 43 | outline: u32, 44 | } 45 | 46 | struct VertexOutput { 47 | @invariant @builtin(position) position: vec4, 48 | @location(0) uv: vec2f, 49 | @location(1) @interpolate(flat) index: u32, 50 | } 51 | 52 | @vertex 53 | fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput { 54 | let vert = idx % 6; 55 | let index = idx / 6; 56 | var vpos = vec2(UNITX[vert], UNITY[vert]); 57 | let d = buf[index]; 58 | 59 | var mv: mat4x4f; 60 | mv[0] = vec4f(d.dim.x, 0f, 0f, 0f); 61 | mv[1] = vec4f(0f, d.dim.y, 0f, 0f); 62 | mv[2] = vec4f(0f, 0f, 1f, 0f); 63 | mv[3] = vec4f(d.pos.x + d.dim.x * 0.5f, d.pos.y + d.dim.y * 0.5f, 0f, 1f); 64 | //let outpos = vec4f(d.pos.x, d.pos.y, d.dim.x, d.dim.y); 65 | let outpos = MVP * mv * vec4(vpos.x - 0.5f, vpos.y - 0.5f, 1f, 1f); 66 | 67 | return VertexOutput(outpos, vpos.xy, index); 68 | } 69 | 70 | fn rectangle_sdf(samplePosition: vec2f, halfSize: vec2f, edges: vec4f) -> f32 { 71 | var edge: f32 = 20.0f; 72 | if (samplePosition.x > 0.0f) { 73 | edge = select(edges.z, edges.y, samplePosition.y < 0.0f); 74 | } 75 | else { 76 | edge = select(edges.w, edges.x, samplePosition.y < 0.0f); 77 | } 78 | 79 | let componentWiseEdgeDistance = abs(samplePosition) - halfSize + vec2f(edge); 80 | let outsideDistance = length(max(componentWiseEdgeDistance, vec2f(0.0f))); 81 | let insideDistance = min(max(componentWiseEdgeDistance.x, componentWiseEdgeDistance.y), 0.0f); 82 | return outsideDistance + insideDistance - edge; 83 | } 84 | 85 | @fragment 86 | fn rectangle(input: VertexOutput) -> @location(0) vec4f { 87 | let d = buf[input.index]; 88 | // Ideally we would get DPI for both height and width, but for now we just assume DPI isn't weird 89 | let w = fwidth(d.dim.x * input.uv.x) * 0.5f * (1.0f + d.blur); 90 | let uv = (input.uv * d.dim) - (d.dim * 0.5f); 91 | 92 | let dist = rectangle_sdf(uv, d.dim * 0.5f, d.corners); 93 | let alpha = linearstep(w, - w, dist); 94 | let s = linearstep(w, - w, dist + d.border); 95 | let fill = srgb_to_linear_vec4(u32_to_vec4(d.fill)); 96 | let outline = srgb_to_linear_vec4(u32_to_vec4(d.outline)); 97 | 98 | return (vec4f(fill.rgb, 1f) * fill.a * s) + (vec4f(outline.rgb, 1f) * outline.a * clamp(alpha - s, 0.0f, 1.0f)); 99 | } 100 | 101 | const PI = 3.14159265359; 102 | 103 | @fragment 104 | fn circle(input: VertexOutput) -> @location(0) vec4f { 105 | let d = buf[input.index]; 106 | let l = (d.dim.x + d.dim.y) * 0.5; 107 | let uv = (input.uv * 2.0) - 1.0; 108 | let w1 = (1.0 + d.blur) * fwidth(input.uv.x); 109 | 110 | let border = (d.border / l) * 2.0; 111 | // double because UV is in range [-1,1], not [0,1] 112 | let t = 0.50 - (d.corners.x / l); 113 | // We have to compensate for needing to do smoothstep starting from 0, which combined with abs() 114 | // acts as a ceil() function, creating one extra half pixel. 115 | let r = 1.0 - t - w1; 116 | 117 | // SDF for circle 118 | let inner = (d.corners.y / l) * 2.0; 119 | let d0 = abs(length(uv) - r + (border * 0.5) - (inner * 0.5)) - t + (border * 0.5) + (inner * 0.5); 120 | let d1 = abs(length(uv) - r) - t; 121 | let s = pow(linearstep(w1 * 2.0, 0.0, d0), 2.2); 122 | let alpha = pow(linearstep(w1 * 2.0, 0.0, d1), 2.2); 123 | let fill = srgb_to_linear_vec4(u32_to_vec4(d.fill)); 124 | let outline = srgb_to_linear_vec4(u32_to_vec4(d.outline)); 125 | 126 | // Output to screen 127 | return (vec4f(fill.rgb, 1) * fill.a * s) + (vec4f(outline.rgb, 1) * outline.a * clamp(alpha - s, 0.0, 1.0)); 128 | } 129 | 130 | fn linetopoint(p1: vec2f, p2: vec2f, p: vec2f) -> f32 { 131 | let n = p2 - p1; 132 | let v = vec2f(n.y, - n.x); 133 | return dot(normalize(v), p1 - p); 134 | } 135 | 136 | @fragment 137 | fn triangle(input: VertexOutput) -> @location(0) vec4f { 138 | let d = buf[input.index]; 139 | let p = input.uv * d.dim + vec2f(- 0.5, 0.5); 140 | let c = d.corners; 141 | let p2 = vec2f(c.w * d.dim.x, 0.0); 142 | let r1 = linetopoint(p2, vec2f(0.0, d.dim.y), p); 143 | let r2 = - linetopoint(p2, d.dim, p); 144 | var r = max(r1, r2); 145 | r = max(r, p.y - d.dim.y); 146 | 147 | // Ideally we would get DPI for both height and width, but for now we just assume DPI isn't weird 148 | let w = fwidth(p.x) * (1.0 + d.blur); 149 | let s = 1.0 - linearstep(1.0 - d.border - w * 2.0, 1.0 - d.border - w, r); 150 | let alpha = linearstep(1.0 - w, 1.0 - w * 2.0, r); 151 | let fill = srgb_to_linear_vec4(u32_to_vec4(d.fill)); 152 | let outline = srgb_to_linear_vec4(u32_to_vec4(d.outline)); 153 | 154 | return (vec4(fill.rgb, 1.0) * fill.a * s) + (vec4(outline.rgb, 1.0) * outline.a * clamp(alpha - s, 0.0, 1.0)); 155 | } 156 | 157 | fn rotate(p: vec2f, a: f32) -> vec2f { 158 | return vec2f(p.x * cos(a) + p.y * sin(a), p.x * sin(a) - p.y * cos(a)); 159 | } 160 | 161 | @fragment 162 | fn arcs(input: VertexOutput) -> @location(0) vec4f { 163 | let data = buf[input.index]; 164 | let l = (data.dim.x + data.dim.y) * .5; 165 | let uv = (input.uv * 2.) - 1.; 166 | let width = fwidth(input.uv.x); 167 | let w1 = (1. + data.blur) * width; 168 | 169 | let border = (data.border / l) * 2.; 170 | // double because UV is in range [-1,1], not [0,1] 171 | let t = .50 - (data.corners.z / l) + w1 * 1.5; 172 | // We have to compensate for needing to do smoothstep starting from 0, which combined with abs() 173 | // acts as a ceil() function, creating one extra half pixel. 174 | let r = 1. - t + w1; 175 | 176 | // SDF for circle 177 | let d0 = abs(length(uv) - r) - t + border; 178 | let d1 = abs(length(uv) - r) - t; 179 | 180 | // SDF for lines that make up arc 181 | let omega1 = rotate(uv, data.corners.x - data.corners.y); 182 | let omega2 = rotate(uv, data.corners.x + data.corners.y); 183 | var d = 0.0; 184 | 185 | // TODO: This cannot deal with non-integer circle radii, but it might be generalizable to those cases. 186 | if (abs(- omega1.y) + abs(omega2.y) < width) { 187 | d = ((data.corners.y / PI) - 0.5) * 2.0 * width; 188 | } 189 | else if (data.corners.y > PI * 0.5) { 190 | d = max(- omega1.y, omega2.y); 191 | } 192 | else { 193 | d = min(- omega1.y, omega2.y); 194 | } 195 | 196 | // Compensate for blur so the circle is still full or empty at 2pi and 0. 197 | d += (clamp(data.corners.y / PI, 0.0, 1.0) - 0.5) * 2.0 * (data.blur * width) + border; 198 | 199 | let d2 = d - border + w1; 200 | let d3 = min(d, omega1.x + data.corners.y) + w1; 201 | 202 | // Merge results of both SDFs 203 | let s = linearstep(- w1, w1, min(- d0, d2) - w1); 204 | let alpha = linearstep(- w1, w1, min(- d1, d3) - w1); 205 | let fill = srgb_to_linear_vec4(u32_to_vec4(data.fill)); 206 | let outline = srgb_to_linear_vec4(u32_to_vec4(data.outline)); 207 | 208 | // Output to screen 209 | return vec4(fill.rgb, 1) * fill.a * s + vec4(outline.rgb, 1) * outline.a * clamp(alpha - s, 0.0, 1.0); 210 | } 211 | 212 | -------------------------------------------------------------------------------- /feather-ui/src/layout/fixed.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use super::{ 5 | Concrete, Desc, Layout, Renderable, Staged, base, check_unsized, check_unsized_abs, 6 | map_unsized_area, 7 | }; 8 | use crate::{PxDim, PxRect, rtree}; 9 | use std::rc::Rc; 10 | 11 | pub trait Prop: base::Area + base::Anchor + base::Limits + base::ZIndex {} 12 | 13 | crate::gen_from_to_dyn!(Prop); 14 | 15 | pub trait Child: base::RLimits {} 16 | 17 | crate::gen_from_to_dyn!(Child); 18 | 19 | impl Prop for crate::DRect {} 20 | impl Child for crate::DRect {} 21 | 22 | impl Desc for dyn Prop { 23 | type Props = dyn Prop; 24 | type Child = dyn Child; 25 | type Children = im::Vector>>>; 26 | 27 | fn stage<'a>( 28 | props: &Self::Props, 29 | outer_area: PxRect, 30 | outer_limits: crate::PxLimits, 31 | children: &Self::Children, 32 | id: std::sync::Weak, 33 | renderable: Option>, 34 | window: &mut crate::component::window::WindowState, 35 | ) -> Box { 36 | // If we have an unsized outer_area, any sized object with relative dimensions 37 | // must evaluate to 0 (or to the minimum limited size). An 38 | // unsized object can never have relative dimensions, as that creates a logic 39 | // loop - instead it can only have a single relative anchor. 40 | // If both axes are sized, then all limits are applied as if outer_area was 41 | // unsized, and children calculations are skipped. 42 | // 43 | // If we have an unsized outer_area and an unsized myarea.rel, then limits are 44 | // applied as if outer_area was unsized, and furthermore, 45 | // they are reduced by myarea.abs.bottomright(), because that will be added on 46 | // to the total area later, which will still be subject to size 47 | // limits, so we must anticipate this when calculating how much size the 48 | // children will have available to them. This forces limits to be 49 | // true infinite numbers, so we can subtract finite amounts and still have 50 | // infinity. We can't use infinity anywhere else, because infinity times 51 | // zero is NaN, so we cap certain calculations at f32::MAX 52 | // 53 | // If outer_area is sized and myarea.rel is zero or nonzero, all limits are 54 | // applied normally and child calculations are skipped. If outer_area is 55 | // sized and myarea.rel is unsized, limits are applied normally, but are once 56 | // again reduced by myarea.abs.bottomright() to account for how the area 57 | // calculations will interact with the limits later on. 58 | 59 | let limits = outer_limits + props.limits().resolve(window.dpi); 60 | let myarea = props.area().resolve(window.dpi); 61 | let (unsized_x, unsized_y) = check_unsized(myarea); 62 | 63 | // Check if any axis is unsized in a way that requires us to calculate baseline 64 | // child sizes 65 | let evaluated_area = if unsized_x || unsized_y { 66 | // When an axis is unsized, we don't apply any limits to it, so we don't have to 67 | // worry about cases where the full evaluated area would invalidate 68 | // the limit. 69 | let inner_dim = super::limit_dim(super::eval_dim(myarea, outer_area.dim()), limits); 70 | let inner_area = PxRect::from(inner_dim); 71 | // The area we pass to children must be independent of our own area, so it 72 | // starts at 0,0 73 | let mut bottomright = PxDim::zero(); 74 | 75 | for child in children.iter() { 76 | let child_props = child.as_ref().unwrap().get_props(); 77 | let child_limit = super::apply_limit(inner_dim, limits, *child_props.rlimits()); 78 | 79 | let stage = child 80 | .as_ref() 81 | .unwrap() 82 | .stage(inner_area, child_limit, window); 83 | bottomright = bottomright.max(stage.get_area().bottomright().to_vector().to_size()); 84 | } 85 | 86 | let area = map_unsized_area(myarea, bottomright); 87 | 88 | // No need to cap this because unsized axis have now been resolved 89 | super::limit_area(area * crate::layout::nuetralize_unsized(outer_area), limits) 90 | } else { 91 | // If outer_area is unsized here, we nuetralize it when evaluating the relative 92 | // coordinates. 93 | super::limit_area( 94 | myarea * crate::layout::nuetralize_unsized(outer_area), 95 | limits, 96 | ) 97 | }; 98 | 99 | let mut staging: im::Vector>> = im::Vector::new(); 100 | let mut nodes: im::Vector>> = im::Vector::new(); 101 | 102 | // If our parent just wants a size estimate, no need to layout children or 103 | // render anything 104 | let (unsized_x, unsized_y) = check_unsized_abs(outer_area.bottomright()); 105 | if unsized_x || unsized_y { 106 | return Box::new(Concrete::new( 107 | None, 108 | evaluated_area, 109 | rtree::Node::new( 110 | evaluated_area.to_untyped(), 111 | Some(props.zindex()), 112 | nodes, 113 | id, 114 | window, 115 | ), 116 | staging, 117 | )); 118 | } 119 | 120 | // We had to evaluate the full area first because our final area calculation can 121 | // change the dimensions in unsized cases. Thus, we calculate the final 122 | // inner_area for the children from this evaluated area. 123 | let evaluated_dim = evaluated_area.dim(); 124 | 125 | let inner_area = PxRect::from(evaluated_dim); 126 | 127 | for child in children.iter() { 128 | let child_props = child.as_ref().unwrap().get_props(); 129 | let child_limit = *child_props.rlimits() * evaluated_dim; 130 | 131 | let stage = child 132 | .as_ref() 133 | .unwrap() 134 | .stage(inner_area, child_limit, window); 135 | if let Some(node) = stage.get_rtree().upgrade() { 136 | nodes.push_back(Some(node)); 137 | } 138 | staging.push_back(Some(stage)); 139 | } 140 | 141 | // TODO: It isn't clear if the simple layout should attempt to handle children 142 | // changing their estimated sizes after the initial estimate. If we were 143 | // to handle this, we would need to recalculate the unsized 144 | // axis with the new child results here, and repeat until it stops changing (we 145 | // find the fixed point). Because the performance implications are 146 | // unclear, this might need to be relagated to a special layout. 147 | 148 | // Calculate the anchor using the final evaluated dimensions, after all unsized 149 | // axis and limits are calculated. However, we can only apply the anchor 150 | // if the parent isn't unsized on that axis. 151 | let mut anchor = props.anchor().resolve(window.dpi) * evaluated_dim; 152 | let (unsized_outer_x, unsized_outer_y) = 153 | crate::layout::check_unsized_abs(outer_area.bottomright()); 154 | if unsized_outer_x { 155 | anchor.x = 0.0; 156 | } 157 | if unsized_outer_y { 158 | anchor.y = 0.0; 159 | } 160 | let evaluated_area = evaluated_area - anchor; 161 | 162 | Box::new(Concrete::new( 163 | renderable, 164 | evaluated_area, 165 | rtree::Node::new( 166 | evaluated_area.to_untyped(), 167 | Some(props.zindex()), 168 | nodes, 169 | id, 170 | window, 171 | ), 172 | staging, 173 | )) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /feather-ui/examples/image-rs.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use feather_macro::*; 5 | use feather_ui::color::sRGB; 6 | use feather_ui::component::image::Image; 7 | use feather_ui::component::region::Region; 8 | use feather_ui::component::shape::{Shape, ShapeKind}; 9 | use feather_ui::component::window::Window; 10 | use feather_ui::layout::{fixed, leaf}; 11 | use feather_ui::persist::{FnPersist2, FnPersistStore}; 12 | use feather_ui::{ 13 | AbsPoint, AbsRect, App, DAbsRect, DPoint, DRect, PxRect, RelRect, ScopeID, SourceID, 14 | UNSIZED_AXIS, gen_id, im, winit, 15 | }; 16 | use std::path::PathBuf; 17 | use std::sync::Arc; 18 | 19 | #[derive(Default, Empty, Area, Anchor, ZIndex, Limits, RLimits, Padding)] 20 | struct FixedData { 21 | area: DRect, 22 | anchor: DPoint, 23 | limits: feather_ui::DLimits, 24 | rlimits: feather_ui::RelLimits, 25 | padding: DAbsRect, 26 | zindex: i32, 27 | } 28 | 29 | impl fixed::Prop for FixedData {} 30 | impl fixed::Child for FixedData {} 31 | impl leaf::Prop for FixedData {} 32 | impl leaf::Padded for FixedData {} 33 | 34 | struct BasicApp {} 35 | 36 | impl FnPersistStore for BasicApp { 37 | type Store = im::HashMap, Option>; 38 | } 39 | 40 | impl FnPersist2<&i32, ScopeID<'_>, im::HashMap, Option>> for BasicApp { 41 | fn init(&self) -> Self::Store { 42 | im::HashMap::new() 43 | } 44 | 45 | fn call( 46 | &mut self, 47 | _: Self::Store, 48 | _: &i32, 49 | mut scope: ScopeID<'_>, 50 | ) -> (Self::Store, im::HashMap, Option>) { 51 | let pixel = Shape::::new( 52 | scope.create(), 53 | PxRect::new(1.0, 1.0, 2.0, 2.0).into(), 54 | 0.0, 55 | 0.0, 56 | wide::f32x4::splat(0.0), 57 | sRGB::new(1.0, 1.0, 1.0, 1.0), 58 | sRGB::transparent(), 59 | feather_ui::DAbsPoint::zero(), 60 | ); 61 | 62 | let mut children: im::Vector>>> = 63 | im::Vector::new(); 64 | children.push_back(Some(Box::new(pixel))); 65 | 66 | let mut genimage = |pos: AbsPoint, 67 | w: Option, 68 | h: Option, 69 | res: &dyn feather_ui::resource::Location, 70 | size: Option| { 71 | Image::::new( 72 | scope.create(), 73 | AbsRect::new( 74 | pos.x, 75 | pos.y, 76 | w.map(|x| x + pos.x).unwrap_or_default(), 77 | h.map(|y| y + pos.y).unwrap_or_default(), 78 | ) + RelRect::new( 79 | 0.0, 80 | 0.0, 81 | if w.is_none() { UNSIZED_AXIS } else { 0.0 }, 82 | if h.is_none() { UNSIZED_AXIS } else { 0.0 }, 83 | ), 84 | res, 85 | size.unwrap_or_default().into(), 86 | false, 87 | ) 88 | }; 89 | 90 | #[cfg(feature = "png")] 91 | { 92 | let testimage = PathBuf::from("./premul_test.png"); 93 | 94 | children.push_back(Some(Box::new(genimage( 95 | AbsPoint::new(0.0, 0.0), 96 | Some(100.0), 97 | Some(100.0), 98 | &testimage, 99 | None, 100 | )))); 101 | 102 | children.push_back(Some(Box::new(genimage( 103 | AbsPoint::new(100.0, 0.0), 104 | None, 105 | Some(100.0), 106 | &testimage, 107 | None, 108 | )))); 109 | 110 | children.push_back(Some(Box::new(genimage( 111 | AbsPoint::new(0.0, 100.0), 112 | None, 113 | None, 114 | &testimage, 115 | Some(AbsPoint::new(100.0, 100.0)), 116 | )))); 117 | 118 | children.push_back(Some(Box::new(genimage( 119 | AbsPoint::new(100.0, 100.0), 120 | None, 121 | None, 122 | &testimage, 123 | None, 124 | )))); 125 | } 126 | 127 | #[cfg(feature = "svg")] 128 | { 129 | let testsvg = PathBuf::from("./FRI_logo.svg"); 130 | 131 | children.push_back(Some(Box::new(genimage( 132 | AbsPoint::new(200.0, 0.0), 133 | Some(100.0), 134 | Some(100.0), 135 | &testsvg, 136 | None, 137 | )))); 138 | 139 | children.push_back(Some(Box::new(genimage( 140 | AbsPoint::new(300.0, 0.0), 141 | None, 142 | Some(100.0), 143 | &testsvg, 144 | None, 145 | )))); 146 | 147 | children.push_back(Some(Box::new(genimage( 148 | AbsPoint::new(200.0, 100.0), 149 | None, 150 | None, 151 | &testsvg, 152 | Some(AbsPoint::new(100.0, 100.0)), 153 | )))); 154 | 155 | children.push_back(Some(Box::new(genimage( 156 | AbsPoint::new(300.0, 100.0), 157 | None, 158 | None, 159 | &testsvg, 160 | None, 161 | )))); 162 | } 163 | 164 | #[cfg(feature = "png")] 165 | { 166 | let testimage = PathBuf::from("./test_color.png"); 167 | 168 | children.push_back(Some(Box::new(genimage( 169 | AbsPoint::new(0.0, 200.0), 170 | Some(100.0), 171 | Some(100.0), 172 | &testimage, 173 | None, 174 | )))); 175 | 176 | children.push_back(Some(Box::new(genimage( 177 | AbsPoint::new(100.0, 200.0), 178 | Some(100.0), 179 | None, 180 | &testimage, 181 | None, 182 | )))); 183 | 184 | children.push_back(Some(Box::new(genimage( 185 | AbsPoint::new(0.0, 300.0), 186 | None, 187 | None, 188 | &testimage, 189 | Some(AbsPoint::new(100.0, 100.0)), 190 | )))); 191 | 192 | children.push_back(Some(Box::new(genimage( 193 | AbsPoint::new(100.0, 300.0), 194 | None, 195 | None, 196 | &testimage, 197 | None, 198 | )))); 199 | } 200 | 201 | #[cfg(feature = "jxl")] 202 | { 203 | let testimage = PathBuf::from("./dice.jxl"); 204 | 205 | children.push_back(Some(Box::new(genimage( 206 | AbsPoint::new(200.0, 200.0), 207 | Some(100.0), 208 | Some(100.0), 209 | &testimage, 210 | None, 211 | )))); 212 | 213 | children.push_back(Some(Box::new(genimage( 214 | AbsPoint::new(300.0, 200.0), 215 | Some(100.0), 216 | None, 217 | &testimage, 218 | None, 219 | )))); 220 | 221 | children.push_back(Some(Box::new(genimage( 222 | AbsPoint::new(200.0, 300.0), 223 | None, 224 | None, 225 | &testimage, 226 | Some(AbsPoint::new(100.0, 100.0)), 227 | )))); 228 | 229 | children.push_back(Some(Box::new(genimage( 230 | AbsPoint::new(300.0, 300.0), 231 | None, 232 | None, 233 | &testimage, 234 | None, 235 | )))); 236 | } 237 | 238 | let region = Region::new( 239 | gen_id!(scope), 240 | FixedData { 241 | area: AbsRect::new(10.0, 10.0, -10.0, -10.0) + RelRect::new(0.0, 0.0, 1.0, 1.0), 242 | 243 | zindex: 0, 244 | ..Default::default() 245 | }, 246 | children, 247 | ); 248 | 249 | #[cfg(feature = "svg")] 250 | let icon = Some( 251 | feather_ui::resource::load_icon(&std::path::PathBuf::from("./FRI_logo.svg")).unwrap(), 252 | ); 253 | #[cfg(not(feature = "svg"))] 254 | let icon = None; 255 | 256 | let window = Window::new( 257 | gen_id!(scope), 258 | winit::window::Window::default_attributes() 259 | .with_title(env!("CARGO_CRATE_NAME")) 260 | .with_resizable(true) 261 | .with_window_icon(icon), 262 | Box::new(region), 263 | ); 264 | 265 | let mut store = im::HashMap::new(); 266 | store.insert(window.id.clone(), Some(window)); 267 | let windows = store.clone(); 268 | (store, windows) 269 | } 270 | } 271 | 272 | fn main() { 273 | let (mut app, event_loop, _, _) = 274 | App::::new(0, Vec::new(), BasicApp {}, None, None).unwrap(); 275 | 276 | event_loop.run_app(&mut app).unwrap(); 277 | } 278 | -------------------------------------------------------------------------------- /feather-ui/examples/grid-rs.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use core::f32; 5 | use feather_macro::*; 6 | use feather_ui::color::sRGB; 7 | use feather_ui::component::button::Button; 8 | use feather_ui::component::gridbox::GridBox; 9 | use feather_ui::component::region::Region; 10 | use feather_ui::component::shape::{Shape, ShapeKind}; 11 | use feather_ui::component::text::Text; 12 | use feather_ui::component::window::Window; 13 | use feather_ui::component::{ChildOf, mouse_area}; 14 | use feather_ui::layout::{base, fixed, grid, leaf}; 15 | use feather_ui::persist::{FnPersist2, FnPersistStore}; 16 | use feather_ui::{ 17 | AbsPoint, AbsRect, App, DAbsPoint, DAbsRect, DRect, DValue, FILL_DRECT, InputResult, RelRect, 18 | ScopeID, Slot, SourceID, UNSIZED_AXIS, 19 | }; 20 | use std::sync::Arc; 21 | 22 | #[derive(PartialEq, Clone, Debug)] 23 | struct CounterState { 24 | count: usize, 25 | } 26 | 27 | #[derive(Default, Empty, Area, Anchor, ZIndex)] 28 | struct FixedData { 29 | area: DRect, 30 | anchor: feather_ui::DPoint, 31 | zindex: i32, 32 | } 33 | 34 | impl base::Padding for FixedData {} 35 | impl base::Limits for FixedData {} 36 | impl base::RLimits for FixedData {} 37 | impl fixed::Prop for FixedData {} 38 | impl fixed::Child for FixedData {} 39 | impl leaf::Prop for FixedData {} 40 | impl leaf::Padded for FixedData {} 41 | 42 | #[derive(Default, Empty, Area, Direction, RLimits, Padding)] 43 | struct GridData { 44 | area: DRect, 45 | direction: feather_ui::RowDirection, 46 | rlimits: feather_ui::RelLimits, 47 | rows: Vec, 48 | columns: Vec, 49 | spacing: feather_ui::DPoint, 50 | padding: DAbsRect, 51 | } 52 | 53 | impl base::Anchor for GridData {} 54 | impl base::Limits for GridData {} 55 | impl fixed::Child for GridData {} 56 | 57 | impl grid::Prop for GridData { 58 | fn rows(&self) -> &[DValue] { 59 | &self.rows 60 | } 61 | 62 | fn columns(&self) -> &[DValue] { 63 | &self.columns 64 | } 65 | 66 | fn spacing(&self) -> feather_ui::DPoint { 67 | self.spacing 68 | } 69 | } 70 | 71 | #[derive(Default, Empty, Area)] 72 | struct GridChild { 73 | area: DRect, 74 | x: usize, 75 | y: usize, 76 | } 77 | 78 | impl base::Padding for GridChild {} 79 | impl base::Anchor for GridChild {} 80 | impl base::Limits for GridChild {} 81 | impl base::Margin for GridChild {} 82 | impl base::RLimits for GridChild {} 83 | impl base::Order for GridChild {} 84 | impl leaf::Prop for GridChild {} 85 | impl leaf::Padded for GridChild {} 86 | 87 | impl grid::Child for GridChild { 88 | fn coord(&self) -> (usize, usize) { 89 | (self.y, self.x) 90 | } 91 | 92 | fn span(&self) -> (usize, usize) { 93 | (1, 1) 94 | } 95 | } 96 | 97 | struct BasicApp {} 98 | 99 | impl FnPersistStore for BasicApp { 100 | type Store = (CounterState, im::HashMap, Option>); 101 | } 102 | 103 | impl FnPersist2<&CounterState, ScopeID<'_>, im::HashMap, Option>> 104 | for BasicApp 105 | { 106 | fn init(&self) -> Self::Store { 107 | (CounterState { count: 99999999 }, im::HashMap::new()) 108 | } 109 | fn call( 110 | &mut self, 111 | mut store: Self::Store, 112 | args: &CounterState, 113 | mut scope: ScopeID<'_>, 114 | ) -> (Self::Store, im::HashMap, Option>) { 115 | if store.0 != *args { 116 | let button = { 117 | let text = { 118 | Text:: { 119 | id: scope.create(), 120 | props: FixedData { 121 | area: AbsRect::new(10.0, 15.0, 10.0, 15.0) 122 | + RelRect::new(0.0, 0.0, UNSIZED_AXIS, UNSIZED_AXIS), 123 | anchor: feather_ui::RelPoint::zero().into(), 124 | ..Default::default() 125 | } 126 | .into(), 127 | text: format!("Boxes: {}", args.count), 128 | font_size: 40.0, 129 | line_height: 56.0, 130 | ..Default::default() 131 | } 132 | }; 133 | 134 | let rect = Shape::::new( 135 | scope.create(), 136 | feather_ui::FILL_DRECT, 137 | 0.0, 138 | 0.0, 139 | wide::f32x4::splat(10.0), 140 | sRGB::new(0.2, 0.7, 0.4, 1.0), 141 | sRGB::transparent(), 142 | DAbsPoint::zero(), 143 | ); 144 | 145 | Button::::new( 146 | scope.create(), 147 | FixedData { 148 | area: AbsRect::new(0.0, 20.0, 0.0, 0.0) 149 | + RelRect::new(0.5, 0.0, UNSIZED_AXIS, UNSIZED_AXIS), 150 | anchor: feather_ui::RelPoint::new(0.5, 0.0).into(), 151 | zindex: 0, 152 | }, 153 | Slot(feather_ui::APP_SOURCE_ID.into(), 0), 154 | feather_ui::children![fixed::Prop, rect, text], 155 | ) 156 | }; 157 | 158 | const NUM_COLUMNS: usize = 5; 159 | let rectgrid = { 160 | let mut children: im::Vector>>> = 161 | im::Vector::new(); 162 | { 163 | for (i, id) in scope.iter(0..args.count) { 164 | children.push_back(Some(Box::new(Shape::< 165 | GridChild, 166 | { ShapeKind::RoundRect as u8 }, 167 | >::new( 168 | id, 169 | GridChild { 170 | area: FILL_DRECT, 171 | x: i % NUM_COLUMNS, 172 | y: i / NUM_COLUMNS, 173 | }, 174 | 0.0, 175 | 0.0, 176 | wide::f32x4::splat(4.0), 177 | sRGB::new( 178 | (0.1 * i as f32) % 1.0, 179 | (0.65 * i as f32) % 1.0, 180 | (0.2 * i as f32) % 1.0, 181 | 1.0, 182 | ), 183 | sRGB::transparent(), 184 | DAbsPoint::zero(), 185 | )))); 186 | } 187 | } 188 | 189 | GridBox::::new( 190 | scope.create(), 191 | GridData { 192 | area: AbsRect::new(0.0, 200.0, 0.0, 0.0) 193 | + RelRect::new(0.0, 0.0, UNSIZED_AXIS, 1.0), 194 | 195 | rlimits: feather_ui::RelLimits::new(0.0..1.0, 0.0..), 196 | direction: feather_ui::RowDirection::LeftToRight, 197 | rows: [40.0, 20.0, 40.0, 20.0, 40.0, 20.0, 10.0] 198 | .map(DValue::from) 199 | .to_vec(), 200 | columns: [80.0, 40.0, 80.0, 40.0, 80.0].map(DValue::from).to_vec(), 201 | spacing: AbsPoint::new(4.0, 4.0).into(), 202 | padding: AbsRect::new(8.0, 8.0, 8.0, 8.0).into(), 203 | }, 204 | children, 205 | ) 206 | }; 207 | 208 | let region = Region::new( 209 | scope.create(), 210 | FixedData { 211 | area: FILL_DRECT, 212 | zindex: 0, 213 | ..Default::default() 214 | }, 215 | feather_ui::children![fixed::Prop, button, rectgrid], 216 | ); 217 | let window = Window::new( 218 | scope.create(), 219 | winit::window::Window::default_attributes() 220 | .with_title(env!("CARGO_CRATE_NAME")) 221 | .with_resizable(true), 222 | Box::new(region), 223 | ); 224 | 225 | store.1 = im::HashMap::new(); 226 | store.1.insert(window.id.clone(), Some(window)); 227 | store.0 = args.clone(); 228 | } 229 | let windows = store.1.clone(); 230 | (store, windows) 231 | } 232 | } 233 | 234 | use feather_ui::WrapEventEx; 235 | 236 | fn main() { 237 | let onclick = Box::new( 238 | |_: mouse_area::MouseAreaEvent, 239 | mut appdata: feather_ui::AccessCell| 240 | -> InputResult<()> { 241 | { 242 | appdata.count += 1; 243 | InputResult::Consume(()) 244 | } 245 | } 246 | .wrap(), 247 | ); 248 | 249 | let (mut app, event_loop, _, _) = App::::new( 250 | CounterState { count: 0 }, 251 | vec![onclick], 252 | BasicApp {}, 253 | None, 254 | None, 255 | ) 256 | .unwrap(); 257 | 258 | event_loop.run_app(&mut app).unwrap(); 259 | } 260 | -------------------------------------------------------------------------------- /feather-ui/src/layout/grid.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2025 Fundament Research Institute 3 | 4 | use super::{ 5 | Concrete, Desc, Layout, Renderable, Staged, base, check_unsized, map_unsized_area, 6 | nuetralize_unsized, 7 | }; 8 | use crate::{DPoint, DValue, PxDim, PxRect, RowDirection, SourceID, UNSIZED_AXIS, rtree}; 9 | use std::rc::Rc; 10 | 11 | // TODO: use sparse vectors here? Does that even make sense if rows require a 12 | // default size of some kind? 13 | pub trait Prop: base::Area + base::Limits + base::Anchor + base::Padding + base::Direction { 14 | fn rows(&self) -> &[DValue]; 15 | fn columns(&self) -> &[DValue]; 16 | fn spacing(&self) -> DPoint; // Spacing is specified as (row, column) 17 | } 18 | 19 | crate::gen_from_to_dyn!(Prop); 20 | 21 | pub trait Child: base::RLimits { 22 | /// (Column, Row) coordinate of the item 23 | fn coord(&self) -> (usize, usize); 24 | /// (Column, Row) span of the item, lets items span across multiple rows or 25 | /// columns. Minimum is (1,1), and the layout won't save you if you tell 26 | /// it to overlap items. 27 | fn span(&self) -> (usize, usize); 28 | } 29 | 30 | crate::gen_from_to_dyn!(Child); 31 | 32 | fn swap_coord((x, y): (usize, usize), (w, h): (usize, usize), dir: RowDirection) -> (usize, usize) { 33 | match dir { 34 | RowDirection::LeftToRight => (x, y), 35 | RowDirection::RightToLeft => (w - 1 - x, y), 36 | RowDirection::BottomToTop => (x, h - 1 - y), 37 | RowDirection::TopToBottom => (w - 1 - x, h - 1 - y), /* TODO: This is confusing, but it's 38 | * not clear how to handle this 39 | * without being verbose or 40 | * confusing. */ 41 | } 42 | } 43 | 44 | impl Desc for dyn Prop { 45 | type Props = dyn Prop; 46 | type Child = dyn Child; 47 | type Children = im::Vector>>>; 48 | 49 | fn stage<'a>( 50 | props: &Self::Props, 51 | outer_area: crate::PxRect, 52 | outer_limits: crate::PxLimits, 53 | children: &Self::Children, 54 | id: std::sync::Weak, 55 | renderable: Option>, 56 | window: &mut crate::component::window::WindowState, 57 | ) -> Box { 58 | let mut limits = outer_limits + props.limits().resolve(window.dpi); 59 | let myarea = props.area().resolve(window.dpi); 60 | let (unsized_x, unsized_y) = check_unsized(myarea); 61 | let padding = props.padding().as_perimeter(window.dpi); 62 | let allpadding = padding.topleft() + padding.bottomright(); 63 | let minmax = limits.v.as_array_mut(); 64 | if unsized_x { 65 | minmax[2] -= allpadding.width; 66 | minmax[0] -= allpadding.width; 67 | } 68 | if unsized_y { 69 | minmax[3] -= allpadding.height; 70 | minmax[1] -= allpadding.height; 71 | } 72 | 73 | let outer_safe = nuetralize_unsized(outer_area); 74 | let inner_dim = super::limit_dim(super::eval_dim(myarea, outer_area.dim()), limits) 75 | - padding.topleft() 76 | - padding.bottomright(); 77 | 78 | //let (outer_column, outer_row) = ; 79 | 80 | let spacing = props.spacing().resolve(window.dpi) * outer_safe.dim(); 81 | let nrows = props.rows().len(); 82 | let ncolumns = props.columns().len(); 83 | 84 | let mut staging: im::Vector>> = im::Vector::new(); 85 | let mut nodes: im::Vector>> = im::Vector::new(); 86 | 87 | let evaluated_area = 88 | crate::util::alloca_array::((nrows + ncolumns) * 2, |x| { 89 | let (resolved, sizes) = x.split_at_mut(nrows + ncolumns); 90 | { 91 | let (rows, columns) = resolved.split_at_mut(nrows); 92 | 93 | // Fill our max calculation rows with NANs (this ensures max()/min() behave 94 | // properly) 95 | sizes.fill(f32::NAN); 96 | 97 | let (maxrows, maxcolumns) = sizes.split_at_mut(nrows); 98 | 99 | // First we precalculate all row/column sizes that we can (if an outer axis is 100 | // unsized, relative sizes are set to 0) 101 | for (i, row) in props.rows().iter().enumerate() { 102 | rows[i] = row.resolve(window.dpi.height).resolve(inner_dim.height); 103 | } 104 | for (i, column) in props.columns().iter().enumerate() { 105 | columns[i] = column.resolve(window.dpi.width).resolve(inner_dim.width); 106 | } 107 | 108 | // Then we go through all child elements so we can precalculate the maximum area 109 | // of all rows and columns 110 | for child in children.iter() { 111 | let child_props = child.as_ref().unwrap().get_props(); 112 | let child_limit = 113 | super::apply_limit(inner_dim, limits, *child_props.rlimits()); 114 | let (column, row) = 115 | swap_coord(child_props.coord(), (ncolumns, nrows), props.direction()); 116 | 117 | if rows[row] == UNSIZED_AXIS || columns[column] == UNSIZED_AXIS { 118 | let (w, h) = (columns[column], rows[row]); 119 | let child_area = PxRect::new(0.0, 0.0, w, h); 120 | 121 | let stage = 122 | child 123 | .as_ref() 124 | .unwrap() 125 | .stage(child_area, child_limit, window); 126 | let area = stage.get_area(); 127 | maxrows[row] = maxrows[row].max(area.dim().height); 128 | maxcolumns[column] = maxcolumns[column].max(area.dim().width); 129 | } 130 | } 131 | } 132 | 133 | // Copy back our resolved row or column to any unsized ones 134 | for (i, size) in sizes.iter().enumerate() { 135 | if resolved[i] == UNSIZED_AXIS { 136 | resolved[i] = if size.is_nan() { 0.0 } else { *size }; 137 | } 138 | } 139 | let (rows, columns) = resolved.split_at_mut(nrows); 140 | let (x_used, y_used) = ( 141 | columns.iter().fold(0.0, |x, y| x + y) 142 | + (spacing.y * ncolumns.saturating_sub(1) as f32), 143 | rows.iter().fold(0.0, |x, y| x + y) 144 | + (spacing.x * nrows.saturating_sub(1) as f32), 145 | ); 146 | let area = map_unsized_area(myarea, PxDim::new(x_used, y_used)); 147 | 148 | // Calculate the offset to each row or column, without overwriting the size we 149 | // stored in resolved 150 | let (row_offsets, column_offsets) = sizes.split_at_mut(nrows); 151 | let mut offset = 0.0; 152 | 153 | for (i, row) in rows.iter().enumerate() { 154 | row_offsets[i] = offset; 155 | offset += row + spacing.x; 156 | } 157 | 158 | offset = 0.0; 159 | for (i, column) in columns.iter().enumerate() { 160 | column_offsets[i] = offset; 161 | offset += column + spacing.y; 162 | } 163 | 164 | for child in children.iter() { 165 | let child_props = child.as_ref().unwrap().get_props(); 166 | let child_limit = super::apply_limit(inner_dim, limits, *child_props.rlimits()); 167 | let (column, row) = 168 | swap_coord(child_props.coord(), (ncolumns, nrows), props.direction()); 169 | 170 | let (x, y) = (column_offsets[column], row_offsets[row]); 171 | let (w, h) = (columns[column], rows[row]); 172 | let child_area = PxRect::new(x, y, x + w, y + h); 173 | 174 | let stage = child 175 | .as_ref() 176 | .unwrap() 177 | .stage(child_area, child_limit, window); 178 | if let Some(node) = stage.get_rtree().upgrade() { 179 | nodes.push_back(Some(node)); 180 | } 181 | staging.push_back(Some(stage)); 182 | } 183 | 184 | // No need to cap this because unsized axis have now been resolved 185 | let evaluated_area = super::limit_area(area * outer_safe, limits) + padding; 186 | 187 | let anchor = props.anchor().resolve(window.dpi) * evaluated_area.dim(); 188 | evaluated_area - anchor 189 | }); 190 | 191 | debug_assert!(evaluated_area.v.is_finite().all()); 192 | Box::new(Concrete { 193 | area: evaluated_area, 194 | renderable, 195 | rtree: rtree::Node::new(evaluated_area.to_untyped(), None, nodes, id, window), 196 | children: staging, 197 | layer: None, 198 | }) 199 | } 200 | } 201 | --------------------------------------------------------------------------------