├── examples ├── lib.rs ├── Cargo.toml ├── counter.rs ├── splash.rs ├── mandelbrot.rs └── todo.rs ├── .gitignore ├── logo ├── logo.png └── logo.xcf ├── screenshots ├── todo.png ├── counter.png ├── splash.png └── mandelbrot.png ├── Cargo.toml ├── scripts ├── build └── check ├── zi-term ├── README.md ├── src │ ├── error.rs │ ├── utils.rs │ ├── painter.rs │ └── lib.rs └── Cargo.toml ├── zi ├── src │ ├── components │ │ ├── mod.rs │ │ ├── text.rs │ │ ├── select.rs │ │ ├── border.rs │ │ └── input.rs │ ├── terminal │ │ ├── mod.rs │ │ ├── input.rs │ │ └── canvas.rs │ ├── text │ │ ├── mod.rs │ │ ├── string.rs │ │ ├── rope.rs │ │ └── cursor.rs │ ├── lib.rs │ └── component │ │ ├── template.rs │ │ ├── mod.rs │ │ ├── layout.rs │ │ └── bindings.rs ├── Cargo.toml └── README.md ├── .github └── workflows │ ├── rust.yml │ ├── publish-zi.yml │ └── publish-zi-term.yml ├── LICENSE-MIT ├── README.md ├── CHANGELOG.md └── LICENSE-APACHE /examples/lib.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/HEAD/logo/logo.png -------------------------------------------------------------------------------- /logo/logo.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/HEAD/logo/logo.xcf -------------------------------------------------------------------------------- /screenshots/todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/HEAD/screenshots/todo.png -------------------------------------------------------------------------------- /screenshots/counter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/HEAD/screenshots/counter.png -------------------------------------------------------------------------------- /screenshots/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/HEAD/screenshots/splash.png -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "zi", 4 | "zi-term", 5 | "examples", 6 | ] 7 | -------------------------------------------------------------------------------- /screenshots/mandelbrot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/HEAD/screenshots/mandelbrot.png -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | cargo check --all-targets 6 | cargo build --all-targets 7 | -------------------------------------------------------------------------------- /zi-term/README.md: -------------------------------------------------------------------------------- 1 | `zi-crossterm` is a terminal backend for [`zi`](https://github.com/mcobzarenco/zi) using [`crossterm`](https://github.com/crossterm-rs/crossterm) 2 | -------------------------------------------------------------------------------- /zi/src/components/mod.rs: -------------------------------------------------------------------------------- 1 | //! A collection of reusable components useful as building blocks. 2 | 3 | pub mod border; 4 | pub mod input; 5 | pub mod select; 6 | pub mod text; 7 | -------------------------------------------------------------------------------- /scripts/check: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | cargo fmt -- --check 6 | cargo clippy --offline --all-targets -- -D warnings 7 | cargo test --offline --all-targets 8 | cargo test --offline --doc 9 | -------------------------------------------------------------------------------- /zi-term/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use thiserror::Error; 3 | 4 | /// Alias for `Result` with a backend error. 5 | pub type Result = std::result::Result; 6 | 7 | /// Error type for 8 | #[derive(Debug, Error)] 9 | pub enum Error { 10 | /// Error originating from [crossterm](https://docs.rs/crossterm) 11 | #[error(transparent)] 12 | Crossterm(#[from] crossterm::ErrorKind), 13 | 14 | /// IO error 15 | #[error(transparent)] 16 | Io(io::Error), 17 | } 18 | -------------------------------------------------------------------------------- /zi/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zi" 3 | version = "0.3.2" 4 | authors = ["Marius Cobzarenco "] 5 | description = "A declarative library for building monospace user interfaces" 6 | readme = "README.md" 7 | homepage = "https://github.com/mcobzarenco/zi" 8 | license = "MIT OR Apache-2.0" 9 | edition = "2021" 10 | rust-version = "1.56" 11 | 12 | [dependencies] 13 | euclid = "0.22.7" 14 | log = "0.4.16" 15 | ropey = "1.4.1" 16 | smallstr = "0.3.0" 17 | smallvec = "1.8.0" 18 | unicode-segmentation = "1.9.0" 19 | unicode-width = "0.1.9" 20 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Install latest stable Rust 18 | uses: actions-rs/toolchain@v1 19 | with: 20 | profile: minimal 21 | toolchain: stable 22 | override: true 23 | components: rustfmt, clippy 24 | - uses: actions/checkout@v2 25 | - name: Build 26 | run: ./scripts/build 27 | - name: Run checks 28 | run: ./scripts/check 29 | -------------------------------------------------------------------------------- /zi-term/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zi-term" 3 | version = "0.3.2" 4 | authors = ["Marius Cobzarenco "] 5 | description = "A terminal backend for zi using crossterm" 6 | readme = "README.md" 7 | homepage = "https://github.com/mcobzarenco/zi" 8 | license = "MIT OR Apache-2.0" 9 | edition = "2021" 10 | rust-version = "1.56" 11 | 12 | [dependencies] 13 | crossterm = { version = "0.23.2", features = ["event-stream"] } 14 | futures = "0.3.21" 15 | log = "0.4.16" 16 | thiserror = "1.0.30" 17 | tokio = { version = "1.17.0", features = ["io-util", "macros", "rt", "sync", "time"] } 18 | 19 | zi = { version = "0.3.2", path = "../zi" } 20 | -------------------------------------------------------------------------------- /examples/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zi-examples" 3 | version = "0.2.0" 4 | authors = ["Marius Cobzarenco "] 5 | description = "Counter example for zi" 6 | homepage = "https://github.com/mcobzarenco/zi" 7 | license = "MIT OR Apache-2.0" 8 | edition = "2021" 9 | rust-version = "1.56" 10 | 11 | [dependencies] 12 | colorous = "1.0.5" 13 | criterion = { version = "0.3.4", features = ["html_reports"] } 14 | env_logger = "0.8.4" 15 | euclid = "0.22.6" 16 | num-complex = "0.4.0" 17 | rayon = "1.5.1" 18 | ropey = "1.3.1" 19 | unicode-width = "0.1.8" 20 | 21 | zi = { path = "../zi" } 22 | zi-term = { path = "../zi-term" } 23 | 24 | [lib] 25 | name = "zi_examples_lib" 26 | path = "lib.rs" 27 | 28 | [[example]] 29 | name = "counter" 30 | path = "counter.rs" 31 | 32 | [[example]] 33 | name = "mandelbrot" 34 | path = "mandelbrot.rs" 35 | 36 | [[example]] 37 | name = "splash" 38 | path = "splash.rs" 39 | 40 | [[example]] 41 | name = "todo" 42 | path = "todo.rs" 43 | -------------------------------------------------------------------------------- /zi-term/src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write}; 2 | 3 | pub(crate) struct MeteredWriter { 4 | writer: WriterT, 5 | num_bytes_written: usize, 6 | } 7 | 8 | impl MeteredWriter { 9 | pub(crate) fn new(writer: WriterT) -> Self { 10 | Self { 11 | writer, 12 | num_bytes_written: 0, 13 | } 14 | } 15 | 16 | pub(crate) fn num_bytes_written(&self) -> usize { 17 | self.num_bytes_written 18 | } 19 | } 20 | 21 | impl Write for MeteredWriter { 22 | #[inline] 23 | fn write(&mut self, buffer: &[u8]) -> io::Result { 24 | let write_result = self.writer.write(buffer); 25 | if let Ok(num_bytes_written) = write_result.as_ref() { 26 | self.num_bytes_written += num_bytes_written; 27 | } 28 | write_result 29 | } 30 | 31 | #[inline] 32 | fn flush(&mut self) -> io::Result<()> { 33 | self.writer.flush() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/publish-zi.yml: -------------------------------------------------------------------------------- 1 | name: Publish zi 2 | 3 | on: 4 | push: 5 | tags: zi-v* 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | publish-zi: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Install latest stable Rust 15 | uses: actions-rs/toolchain@v1 16 | with: 17 | profile: minimal 18 | toolchain: stable 19 | override: true 20 | components: rustfmt, clippy 21 | - uses: actions/checkout@v2 22 | - uses: actions/cache@v2 23 | with: 24 | path: | 25 | ~/.cargo/registry 26 | ~/.cargo/git 27 | target 28 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 29 | - name: Build 30 | run: ./scripts/build 31 | - name: Run checks 32 | run: ./scripts/check 33 | - name: Publish zi crate 34 | run: | 35 | cd zi 36 | cargo login ${{ secrets.CRATES_IO_TOKEN }} 37 | cargo publish 38 | -------------------------------------------------------------------------------- /zi/src/terminal/mod.rs: -------------------------------------------------------------------------------- 1 | //! An abstract specification of a lightweight terminal. 2 | //! 3 | //! All components in Zi ultimately draw to a `Canvas`. Typically this is done 4 | //! via their child components and their descendants. At the bottom of the 5 | //! component hierarchy, low level components would draw directly on a canvas. 6 | 7 | pub use canvas::{ 8 | Background, Canvas, Colour, Foreground, GraphemeCluster, SquarePixelGrid, Style, Textel, 9 | }; 10 | pub use input::{Event, Key}; 11 | 12 | /// A 2D rectangle with usize coordinates. Re-exported from 13 | /// [euclid](https://docs.rs/euclid). 14 | pub type Rect = euclid::default::Rect; 15 | 16 | /// A 2D position with usize coordinates. Re-exported from 17 | /// [euclid](https://docs.rs/euclid). 18 | pub type Position = euclid::default::Point2D; 19 | 20 | /// A 2D size with usize width and height. Re-exported from 21 | /// [euclid](https://docs.rs/euclid). 22 | pub type Size = euclid::default::Size2D; 23 | 24 | pub(crate) mod canvas; 25 | pub(crate) mod input; 26 | -------------------------------------------------------------------------------- /.github/workflows/publish-zi-term.yml: -------------------------------------------------------------------------------- 1 | name: Publish zi-term 2 | 3 | on: 4 | push: 5 | tags: zi-term-v* 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | publish-zi-term: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Install latest stable Rust 15 | uses: actions-rs/toolchain@v1 16 | with: 17 | profile: minimal 18 | toolchain: stable 19 | override: true 20 | components: rustfmt, clippy 21 | - uses: actions/checkout@v2 22 | - uses: actions/cache@v2 23 | with: 24 | path: | 25 | ~/.cargo/registry 26 | ~/.cargo/git 27 | target 28 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 29 | - name: Build 30 | run: ./scripts/build 31 | - name: Run checks 32 | run: ./scripts/check 33 | - name: Publish zi-term crate 34 | run: | 35 | cd zi-term 36 | cargo login ${{ secrets.CRATES_IO_TOKEN }} 37 | cargo publish 38 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /zi/src/terminal/input.rs: -------------------------------------------------------------------------------- 1 | /// Input event 2 | #[derive(Debug)] 3 | pub enum Event { 4 | KeyPress(Key), 5 | } 6 | 7 | /// Keyboard input. It aims to match what a terminal supports. 8 | #[derive(Debug, Clone, Copy, PartialOrd, PartialEq, Eq, Hash)] 9 | pub enum Key { 10 | /// Backspace. 11 | Backspace, 12 | /// Left arrow. 13 | Left, 14 | /// Right arrow. 15 | Right, 16 | /// Up arrow. 17 | Up, 18 | /// Down arrow. 19 | Down, 20 | /// Home key. 21 | Home, 22 | /// End key. 23 | End, 24 | /// Page Up key. 25 | PageUp, 26 | /// Page Down key. 27 | PageDown, 28 | /// Backward Tab key. 29 | BackTab, 30 | /// Delete key. 31 | Delete, 32 | /// Insert key. 33 | Insert, 34 | /// Function keys. 35 | /// 36 | /// Only function keys 1 through 12 are supported. 37 | F(u8), 38 | /// Normal character. 39 | Char(char), 40 | /// Alt modified character. 41 | Alt(char), 42 | /// Ctrl modified character. 43 | /// 44 | /// Note that certain keys may not be modifiable with `ctrl`, due to limitations of terminals. 45 | Ctrl(char), 46 | /// Null byte. 47 | Null, 48 | /// Esc key. 49 | Esc, 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Zi logo 3 |

4 | 5 |

6 | Modern terminal user interfaces in Rust. 7 |

8 | 9 |

10 | 11 | Build Status 12 | 13 | 14 | Documentation 15 | 16 | 17 | Crates.io 18 | 19 |

20 | 21 | Zi is a Rust library for building modern terminal user interfaces in an incremental, declarative fashion. 22 | 23 | # Screenshots 24 | ![Counter Example Screenshot](/screenshots/counter.png?raw=true "Counters") 25 | ![Mandelbrot Example Screenshot](/screenshots/mandelbrot.png?raw=true "Mandelbrot") 26 | ![Splash Example Screenshot](/screenshots/splash.png?raw=true "Splash") 27 | ![Todo Example Screenshot](/screenshots/todo.png?raw=true "Todo") 28 | 29 | # License 30 | 31 | This project is licensed under either of 32 | 33 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or 34 | http://www.apache.org/licenses/LICENSE-2.0) 35 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or 36 | http://opensource.org/licenses/MIT) 37 | 38 | at your option. 39 | 40 | ### Contribution 41 | 42 | Unless you explicitly state otherwise, any contribution intentionally submitted 43 | for inclusion by you, as defined in the Apache-2.0 license, shall be dual 44 | licensed as above, without any additional terms or conditions. 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | # v0.3.1 4 | - Re-export unicode_width and unicode_segmentation dependencies 5 | - Implement `BitOr` for `ShouldRender` 6 | 7 | # v0.3.0 8 | ## Breaking 9 | 10 | - Replaced input handling with a new declarative system for specifying key 11 | bindings and how to react in response to input events. There's a new 12 | `Component` lifecycle method `bindings()` which replaces the old `input_binding` 13 | and `has_focus` methods. The newly introduced `Bindings` type allows 14 | registering handlers which will run in response to key patterns. 15 | - Enable support for animated components in zi-term (crossterm backend) 16 | - A new experimental notification api for binding queries 17 | - Fix trying to draw while the app is exiting 18 | - Upgrade all dependencies of zi and zi-term to latest available 19 | 20 | 21 | # v0.2.0 22 | ## Breaking 23 | 24 | - Simplifies the public layout API functions and dealing with array of 25 | components (latter thanks to const generics). In particular: 26 | - The free functions `row`, `row_reverse`, `column`, `column_reverse`, 27 | `container` and their iterator versions have been replaced with more 28 | flexible methods on the `Layout`, `Container` and `Item` structs. 29 | - `layout::component` and `layout::component_with_*` have also been removed 30 | in favour of utility methods on the extension trait `CompoentExt`. This is 31 | automatically implemented by all components. 32 | - Moves the responsibility of running the event loop from the `App` struct and 33 | into the backend. This inversion of control help reduce sys dependencies in 34 | `zi::App` (moves tokio dependency to the `crossterm` backend which was moved to 35 | a separate crate, yay). This change allows for different implementations of 36 | the event loop (e.g. using winit which will come in handy for a new 37 | experimental wgpu backend). 38 | -------------------------------------------------------------------------------- /zi/src/text/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cursor; 2 | pub mod rope; 3 | // pub mod string; 4 | 5 | pub use cursor::Cursor; 6 | 7 | use std::ops::{Add, RangeBounds, Sub}; 8 | 9 | pub trait TextStorage<'a> { 10 | type Slice: TextStorage<'a>; 11 | type GraphemeIterator: Iterator; 12 | 13 | fn len_bytes(&self) -> ByteIndex; 14 | fn len_chars(&self) -> CharIndex; 15 | fn len_lines(&self) -> LineIndex; 16 | fn len_graphemes(&self) -> usize; 17 | 18 | fn char_to_line(&self, char_index: CharIndex) -> LineIndex; 19 | fn line_to_char(&self, line_index: LineIndex) -> CharIndex; 20 | 21 | fn char(&self, char_index: CharIndex) -> char; 22 | fn graphemes(&'a self) -> Self::GraphemeIterator; 23 | fn line(&'a self, line_index: LineIndex) -> Self::Slice; 24 | fn slice(&'a self, range: impl RangeBounds) -> Self::Slice; 25 | 26 | fn prev_grapheme_boundary(&self, char_index: CharIndex) -> CharIndex; 27 | fn next_grapheme_boundary(&self, char_index: CharIndex) -> CharIndex; 28 | } 29 | 30 | pub trait TextStorageMut<'a>: TextStorage<'a> { 31 | fn insert_char(&mut self, char_index: CharIndex, character: char); 32 | fn remove(&mut self, range: impl RangeBounds); 33 | } 34 | 35 | #[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Eq, Ord)] 36 | pub struct CharIndex(pub usize); 37 | 38 | impl From for CharIndex { 39 | fn from(index: usize) -> Self { 40 | Self(index) 41 | } 42 | } 43 | 44 | impl From for usize { 45 | fn from(value: CharIndex) -> Self { 46 | value.0 47 | } 48 | } 49 | 50 | impl CharIndex { 51 | fn saturating_sub(self, other: Self) -> Self { 52 | Self(usize::saturating_sub(self.0, other.0)) 53 | } 54 | } 55 | 56 | impl Add for CharIndex { 57 | type Output = Self; 58 | 59 | fn add(self, other: Self) -> Self { 60 | Self(self.0 + other.0) 61 | } 62 | } 63 | 64 | impl Sub for CharIndex { 65 | type Output = Self; 66 | 67 | fn sub(self, other: Self) -> Self { 68 | Self(self.0 - other.0) 69 | } 70 | } 71 | 72 | #[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Eq, Ord)] 73 | pub struct ByteIndex(pub usize); 74 | 75 | impl From for ByteIndex { 76 | fn from(value: usize) -> Self { 77 | Self(value) 78 | } 79 | } 80 | 81 | impl From for usize { 82 | fn from(value: ByteIndex) -> Self { 83 | value.0 84 | } 85 | } 86 | 87 | #[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Eq, Ord)] 88 | pub struct LineIndex(pub usize); 89 | 90 | impl From for LineIndex { 91 | fn from(value: usize) -> Self { 92 | Self(value) 93 | } 94 | } 95 | 96 | impl From for usize { 97 | fn from(value: LineIndex) -> Self { 98 | value.0 99 | } 100 | } 101 | 102 | impl LineIndex { 103 | fn saturating_sub(self, other: Self) -> Self { 104 | Self(usize::saturating_sub(self.0, other.0)) 105 | } 106 | } 107 | 108 | impl Add for LineIndex { 109 | type Output = Self; 110 | 111 | fn add(self, other: Self) -> Self { 112 | Self(self.0 + other.0) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /examples/counter.rs: -------------------------------------------------------------------------------- 1 | use zi::{ 2 | components::{ 3 | border::{Border, BorderProperties}, 4 | text::{Text, TextAlign, TextProperties}, 5 | }, 6 | prelude::*, 7 | }; 8 | use zi_term::Result; 9 | 10 | // Message type handled by the `Counter` component. 11 | #[derive(Clone, Copy)] 12 | enum Message { 13 | Increment, 14 | Decrement, 15 | } 16 | 17 | // Properties or the `Counter` component, in this case the initial value. 18 | struct Properties { 19 | initial_count: usize, 20 | } 21 | 22 | // The `Counter` component. 23 | struct Counter { 24 | // The state of the component -- the current value of the counter. 25 | count: usize, 26 | 27 | // A `ComponentLink` allows us to send messages to the component in reaction 28 | // to user input as well as to gracefully exit. 29 | link: ComponentLink, 30 | } 31 | 32 | // Components implement the `Component` trait and are the building blocks of the 33 | // UI in Zi. The trait describes stateful components and their lifecycle. 34 | impl Component for Counter { 35 | // Messages are used to make components dynamic and interactive. For simple 36 | // or pure components, this will be `()`. Complex, stateful ones will 37 | // typically use an enum to declare multiple Message types. In this case, we 38 | // will emit two kinds of message (`Increment` or `Decrement`) in reaction 39 | // to user input. 40 | type Message = Message; 41 | 42 | // Properties are the inputs to a Component passed in by their parent. 43 | type Properties = Properties; 44 | 45 | // Creates ("mounts") a new `Counter` component. 46 | fn create(properties: Self::Properties, _frame: Rect, link: ComponentLink) -> Self { 47 | Self { 48 | count: properties.initial_count, 49 | link, 50 | } 51 | } 52 | 53 | // Returns the current visual layout of the component. 54 | fn view(&self) -> Layout { 55 | let count = self.count; 56 | let text = move || { 57 | Text::with( 58 | TextProperties::new() 59 | .align(TextAlign::Centre) 60 | .style(STYLE) 61 | .content(format!( 62 | "\nCounter: {:>3} [+ to increment | - to decrement | C-c to exit]", 63 | count 64 | )), 65 | ) 66 | }; 67 | Border::with(BorderProperties::new(text).style(STYLE)) 68 | } 69 | 70 | // Components handle messages in their `update` method and commonly use this 71 | // method to update their state and (optionally) re-render themselves. 72 | fn update(&mut self, message: Self::Message) -> ShouldRender { 73 | let new_count = match message { 74 | Message::Increment => self.count.saturating_add(1), 75 | Message::Decrement => self.count.saturating_sub(1), 76 | }; 77 | if new_count != self.count { 78 | self.count = new_count; 79 | ShouldRender::Yes 80 | } else { 81 | ShouldRender::No 82 | } 83 | } 84 | 85 | // Updates the key bindings of the component. 86 | // 87 | // This method will be called after the component lifecycle methods. It is 88 | // used to specify how to react in response to keyboard events, typically 89 | // by sending a message. 90 | fn bindings(&self, bindings: &mut Bindings) { 91 | // If we already initialised the bindings, nothing to do -- they never 92 | // change in this example 93 | if !bindings.is_empty() { 94 | return; 95 | } 96 | 97 | // Set focus to `true` in order to react to key presses 98 | bindings.set_focus(true); 99 | 100 | // Increment, when pressing + or = 101 | bindings 102 | .command("increment", || Message::Increment) 103 | .with([Key::Char('+')]) 104 | .with([Key::Char('=')]); 105 | 106 | // Decrement, when pressing - 107 | bindings.add("decrement", [Key::Char('-')], || Message::Decrement); 108 | 109 | // Exit, when pressing Esc or Ctrl-c 110 | bindings 111 | .command("exit", |this: &Self| this.link.exit()) 112 | .with([Key::Ctrl('c')]) 113 | .with([Key::Esc]); 114 | } 115 | } 116 | 117 | const BACKGROUND: Colour = Colour::rgb(50, 48, 47); 118 | const FOREGROUND: Colour = Colour::rgb(213, 196, 161); 119 | const STYLE: Style = Style::bold(BACKGROUND, FOREGROUND); 120 | 121 | fn main() -> Result<()> { 122 | env_logger::init(); 123 | let counter = Counter::with(Properties { initial_count: 0 }); 124 | zi_term::incremental()?.run_event_loop(counter) 125 | } 126 | -------------------------------------------------------------------------------- /zi/src/text/string.rs: -------------------------------------------------------------------------------- 1 | use std::ops::{Bound, RangeBounds}; 2 | use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete, Graphemes, UnicodeSegmentation}; 3 | 4 | use super::{ByteIndex, CharIndex, LineIndex, TextStorage, TextStorageMut}; 5 | 6 | impl<'a> TextStorage<'a> for String { 7 | type Slice = &'a str; 8 | type GraphemeIterator = Graphemes<'a>; 9 | 10 | fn len_bytes(&self) -> ByteIndex { 11 | String::len(self).into() 12 | } 13 | 14 | fn len_chars(&self) -> CharIndex { 15 | self.chars().count().into() 16 | } 17 | 18 | fn len_lines(&self) -> LineIndex { 19 | 1.into() 20 | } 21 | 22 | fn len_graphemes(&self) -> usize { 23 | self.chars().count() 24 | } 25 | 26 | fn char_to_line(&self, char_index: CharIndex) -> LineIndex { 27 | 0.into() 28 | } 29 | 30 | fn line_to_char(&self, line_index: LineIndex) -> CharIndex { 31 | 0.into() 32 | } 33 | 34 | fn char(&self, char_index: CharIndex) -> char { 35 | // self.char[char_index.0] 36 | ' ' 37 | } 38 | 39 | fn graphemes(&'a self) -> Self::GraphemeIterator { 40 | UnicodeSegmentation::graphemes(self.as_str(), true) 41 | } 42 | 43 | fn line(&'a self, line_index: LineIndex) -> Self::Slice { 44 | self.line(line_index.into()) 45 | } 46 | 47 | fn slice(&'a self, range: impl RangeBounds) -> Self::Slice { 48 | let slice = String::as_str(self); 49 | match (range.start_bound(), range.end_bound()) { 50 | (Bound::Unbounded, Bound::Unbounded) => &slice[..], 51 | (Bound::Unbounded, Bound::Excluded(&end)) => &slice[..end], 52 | (Bound::Unbounded, Bound::Included(&end)) => &slice[..=end], 53 | 54 | (Bound::Included(&start), Bound::Unbounded) => &slice[start..], 55 | (Bound::Included(&start), Bound::Excluded(&end)) => &slice[start..end], 56 | (Bound::Included(&start), Bound::Included(&end)) => &slice[start..=end], 57 | 58 | (start, end) => panic!("Unsupported range type {:?} {:?}", start, end), 59 | } 60 | } 61 | 62 | fn prev_grapheme_boundary(&self, char_index: CharIndex) -> CharIndex { 63 | 0.into() 64 | // prev_grapheme_boundary(&self.slice(..), char_index) 65 | } 66 | 67 | fn next_grapheme_boundary(&self, char_index: CharIndex) -> CharIndex { 68 | 1.into() 69 | // next_grapheme_boundary(&self.slice(..), char_index) 70 | } 71 | } 72 | 73 | impl<'a> TextStorage<'a> for &'a str { 74 | type Slice = &'a str; 75 | type GraphemeIterator = Graphemes<'a>; 76 | 77 | fn len_bytes(&self) -> ByteIndex { 78 | str::len(self).into() 79 | } 80 | 81 | fn len_chars(&self) -> CharIndex { 82 | str::chars(self).count().into() 83 | } 84 | 85 | fn len_lines(&self) -> LineIndex { 86 | self.len_lines().into() 87 | } 88 | 89 | fn len_graphemes(&self) -> usize { 90 | self.chars().count() 91 | } 92 | 93 | fn char_to_line(&self, char_index: CharIndex) -> LineIndex { 94 | 0.into() 95 | } 96 | 97 | fn line_to_char(&self, line_index: LineIndex) -> CharIndex { 98 | 0.into() 99 | } 100 | 101 | fn char(&self, char_index: CharIndex) -> char { 102 | ' ' 103 | } 104 | 105 | fn graphemes(&'a self) -> Self::GraphemeIterator { 106 | UnicodeSegmentation::graphemes(*self, true) 107 | } 108 | 109 | fn line(&'a self, line_index: LineIndex) -> Self::Slice { 110 | self.line(line_index.into()) 111 | } 112 | 113 | fn slice(&'a self, range: impl RangeBounds) -> Self::Slice { 114 | let slice = self; 115 | match (range.start_bound(), range.end_bound()) { 116 | (Bound::Unbounded, Bound::Unbounded) => &slice[..], 117 | (Bound::Unbounded, Bound::Excluded(&end)) => &slice[..end], 118 | (Bound::Unbounded, Bound::Included(&end)) => &slice[..=end], 119 | 120 | (Bound::Included(&start), Bound::Unbounded) => &slice[start..], 121 | (Bound::Included(&start), Bound::Excluded(&end)) => &slice[start..end], 122 | (Bound::Included(&start), Bound::Included(&end)) => &slice[start..=end], 123 | 124 | (start, end) => panic!("Unsupported range type {:?} {:?}", start, end), 125 | } 126 | } 127 | 128 | fn prev_grapheme_boundary(&self, char_index: CharIndex) -> CharIndex { 129 | 0.into() 130 | // prev_grapheme_boundary(&self.slice(..), char_index) 131 | } 132 | 133 | fn next_grapheme_boundary(&self, char_index: CharIndex) -> CharIndex { 134 | 1.into() 135 | // next_grapheme_boundary(&self.slice(..), char_index) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /zi/src/components/text.rs: -------------------------------------------------------------------------------- 1 | use unicode_width::UnicodeWidthStr; 2 | 3 | use crate::{layout::Layout, Canvas, Component, ComponentLink, Rect, ShouldRender, Size, Style}; 4 | 5 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 6 | pub enum TextAlign { 7 | Left, 8 | Centre, 9 | Right, 10 | } 11 | 12 | impl Default for TextAlign { 13 | fn default() -> Self { 14 | Self::Left 15 | } 16 | } 17 | 18 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 19 | pub enum TextWrap { 20 | None, 21 | Word, 22 | } 23 | 24 | impl Default for TextWrap { 25 | fn default() -> Self { 26 | Self::None 27 | } 28 | } 29 | 30 | #[derive(Clone, Debug, Default, PartialEq, Eq)] 31 | pub struct TextProperties { 32 | pub style: Style, 33 | pub content: String, 34 | pub align: TextAlign, 35 | pub wrap: TextWrap, 36 | } 37 | 38 | impl TextProperties { 39 | pub fn new() -> Self { 40 | Self::default() 41 | } 42 | 43 | pub fn style(mut self, style: Style) -> Self { 44 | self.style = style; 45 | self 46 | } 47 | 48 | pub fn content(mut self, content: impl Into) -> Self { 49 | self.content = content.into(); 50 | self 51 | } 52 | 53 | pub fn align(mut self, align: TextAlign) -> Self { 54 | self.align = align; 55 | self 56 | } 57 | 58 | pub fn wrap(mut self, wrap: TextWrap) -> Self { 59 | self.wrap = wrap; 60 | self 61 | } 62 | } 63 | 64 | #[derive(Debug)] 65 | pub struct Text { 66 | frame: Rect, 67 | properties: ::Properties, 68 | } 69 | 70 | impl Component for Text { 71 | type Message = (); 72 | type Properties = TextProperties; 73 | 74 | fn create(properties: Self::Properties, frame: Rect, _link: ComponentLink) -> Self { 75 | Self { frame, properties } 76 | } 77 | 78 | fn change(&mut self, properties: Self::Properties) -> ShouldRender { 79 | if self.properties != properties { 80 | self.properties = properties; 81 | ShouldRender::Yes 82 | } else { 83 | ShouldRender::No 84 | } 85 | } 86 | 87 | fn resize(&mut self, frame: Rect) -> ShouldRender { 88 | if self.frame != frame { 89 | self.frame = frame; 90 | ShouldRender::Yes 91 | } else { 92 | ShouldRender::No 93 | } 94 | } 95 | 96 | fn view(&self) -> Layout { 97 | let Self { 98 | frame, 99 | properties: 100 | Self::Properties { 101 | ref content, 102 | align, 103 | style, 104 | wrap, 105 | }, 106 | } = *self; 107 | 108 | let mut canvas = Canvas::new(frame.size); 109 | canvas.clear(style); 110 | 111 | let content_size = text_block_size(content); 112 | let position_x = match align { 113 | TextAlign::Left => 0, 114 | TextAlign::Centre => (frame.size.width / 2).saturating_sub(content_size.width / 2), 115 | TextAlign::Right => frame.size.width.saturating_sub(content_size.width), 116 | }; 117 | 118 | let mut position_y = 0; 119 | for line in content.lines() { 120 | match wrap { 121 | TextWrap::None => { 122 | canvas.draw_str(position_x, position_y, style, line); 123 | } 124 | TextWrap::Word => { 125 | let mut cursor_x = position_x; 126 | for word in line.split_whitespace() { 127 | let word_width = UnicodeWidthStr::width(word); 128 | if cursor_x > position_x { 129 | if cursor_x >= frame.size.width 130 | || word_width > frame.size.width.saturating_sub(cursor_x + 1) 131 | { 132 | position_y += 1; 133 | cursor_x = position_x 134 | } else { 135 | canvas.draw_str(cursor_x, position_y, style, " "); 136 | cursor_x += 1; 137 | } 138 | } 139 | canvas.draw_str(cursor_x, position_y, style, word); 140 | cursor_x += word_width; 141 | } 142 | } 143 | } 144 | position_y += 1; 145 | } 146 | 147 | canvas.into() 148 | } 149 | } 150 | 151 | fn text_block_size(text: &str) -> Size { 152 | let width = text.lines().map(UnicodeWidthStr::width).max().unwrap_or(0); 153 | let height = text.lines().count(); 154 | Size::new(width, height) 155 | } 156 | -------------------------------------------------------------------------------- /zi-term/src/painter.rs: -------------------------------------------------------------------------------- 1 | //! Module with utilities to convert a `Canvas` to a set of abstract paint operations. 2 | use zi::{ 3 | terminal::{Canvas, Position, Size, Style, Textel}, 4 | unicode_width::UnicodeWidthStr, 5 | }; 6 | 7 | use super::Result; 8 | 9 | pub trait Painter { 10 | const INITIAL_POSITION: Position; 11 | const INITIAL_STYLE: Style; 12 | 13 | fn create(size: Size) -> Self; 14 | 15 | fn paint<'a>( 16 | &mut self, 17 | target: &'a Canvas, 18 | paint: impl FnMut(PaintOperation<'a>) -> Result<()>, 19 | ) -> Result<()>; 20 | } 21 | 22 | pub enum PaintOperation<'a> { 23 | WriteContent(&'a str), 24 | SetStyle(&'a Style), 25 | MoveTo(Position), 26 | } 27 | 28 | pub struct IncrementalPainter { 29 | screen: Canvas, 30 | current_position: Position, 31 | current_style: Style, 32 | } 33 | 34 | impl Painter for IncrementalPainter { 35 | const INITIAL_POSITION: Position = Position::new(0, 0); 36 | const INITIAL_STYLE: Style = Style::default(); 37 | 38 | fn create(size: Size) -> Self { 39 | Self { 40 | screen: Canvas::new(size), 41 | current_position: Self::INITIAL_POSITION, 42 | current_style: Self::INITIAL_STYLE, 43 | } 44 | } 45 | 46 | #[inline] 47 | fn paint<'a>( 48 | &mut self, 49 | target: &'a Canvas, 50 | mut paint: impl FnMut(PaintOperation<'a>) -> Result<()>, 51 | ) -> Result<()> { 52 | let Self { 53 | ref mut screen, 54 | ref mut current_position, 55 | ref mut current_style, 56 | } = *self; 57 | let size = target.size(); 58 | let force_redraw = size != screen.size(); 59 | if force_redraw { 60 | screen.resize(size); 61 | } 62 | 63 | screen 64 | .buffer_mut() 65 | .iter_mut() 66 | .zip(target.buffer()) 67 | .enumerate() 68 | .try_for_each(|(index, (current, new))| -> Result<()> { 69 | if force_redraw { 70 | *current = None; 71 | } 72 | 73 | if *current == *new { 74 | return Ok(()); 75 | } 76 | 77 | if let Some(new) = new { 78 | let position = Position::new(index % size.width, index / size.width); 79 | if position != *current_position { 80 | // eprintln!("MoveTo({})", position); 81 | paint(PaintOperation::MoveTo(position))?; 82 | *current_position = position; 83 | } 84 | 85 | if new.style != *current_style { 86 | // eprintln!("Style({:?})", new.style); 87 | paint(PaintOperation::SetStyle(&new.style))?; 88 | *current_style = new.style; 89 | } 90 | 91 | let content_width = UnicodeWidthStr::width(&new.grapheme[..]); 92 | // eprintln!("Content({:?}) {}", new.grapheme, content_width); 93 | paint(PaintOperation::WriteContent(&new.grapheme))?; 94 | current_position.x = (index + content_width) % size.width; 95 | current_position.y = (index + content_width) / size.width; 96 | } 97 | *current = new.clone(); 98 | 99 | Ok(()) 100 | }) 101 | } 102 | } 103 | 104 | pub struct FullPainter { 105 | current_style: Style, 106 | } 107 | 108 | impl Painter for FullPainter { 109 | const INITIAL_POSITION: Position = Position::new(0, 0); 110 | const INITIAL_STYLE: Style = Style::default(); 111 | 112 | fn create(_size: Size) -> Self { 113 | Self { 114 | current_style: Self::INITIAL_STYLE, 115 | } 116 | } 117 | 118 | #[inline] 119 | fn paint<'a>( 120 | &mut self, 121 | target: &'a Canvas, 122 | mut paint: impl FnMut(PaintOperation<'a>) -> Result<()>, 123 | ) -> Result<()> { 124 | let Self { 125 | ref mut current_style, 126 | } = *self; 127 | let size = target.size(); 128 | target 129 | .buffer() 130 | .chunks(size.width) 131 | .enumerate() 132 | .try_for_each(|(y, line)| -> Result<()> { 133 | paint(PaintOperation::MoveTo(Position::new(0, y)))?; 134 | line.iter().try_for_each(|textel| -> Result<()> { 135 | if let Some(Textel { 136 | ref style, 137 | ref grapheme, 138 | }) = textel 139 | { 140 | if *style != *current_style { 141 | paint(PaintOperation::SetStyle(style))?; 142 | *current_style = *style; 143 | } 144 | paint(PaintOperation::WriteContent(grapheme))?; 145 | } 146 | Ok(()) 147 | }) 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /zi/src/components/select.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp, iter}; 2 | 3 | use super::text::{Text, TextProperties}; 4 | use crate::{ 5 | Bindings, Callback, Component, ComponentExt, ComponentLink, FlexDirection, Item, Key, Layout, 6 | Rect, ShouldRender, Style, 7 | }; 8 | 9 | #[derive(Clone, PartialEq)] 10 | pub struct SelectProperties { 11 | pub background: Style, 12 | pub direction: FlexDirection, 13 | pub focused: bool, 14 | pub item_at: Callback, 15 | pub num_items: usize, 16 | pub item_size: usize, 17 | pub selected: usize, 18 | pub on_change: Option>, 19 | } 20 | 21 | #[derive(Clone, Debug, PartialEq, Eq)] 22 | pub enum Message { 23 | NextItem, 24 | PreviousItem, 25 | FirstItem, 26 | LastItem, 27 | NextPage, 28 | PreviousPage, 29 | } 30 | 31 | pub struct Select { 32 | properties: SelectProperties, 33 | frame: Rect, 34 | offset: usize, 35 | } 36 | 37 | impl Select { 38 | fn ensure_selected_item_in_view(&mut self) { 39 | let selected = self.properties.selected; 40 | let num_visible_items = self.frame.size.height / self.properties.item_size; 41 | 42 | // Compute offset 43 | self.offset = cmp::min(self.offset, selected); 44 | if selected - self.offset >= num_visible_items.saturating_sub(1) { 45 | self.offset = selected + 1 - num_visible_items; 46 | } else if selected < self.offset { 47 | self.offset = selected; 48 | } 49 | } 50 | } 51 | 52 | impl Component for Select { 53 | type Message = Message; 54 | type Properties = SelectProperties; 55 | 56 | fn create(properties: Self::Properties, frame: Rect, _link: ComponentLink) -> Self { 57 | let mut select = Self { 58 | properties, 59 | frame, 60 | offset: 0, 61 | }; 62 | select.ensure_selected_item_in_view(); 63 | select 64 | } 65 | 66 | fn change(&mut self, properties: Self::Properties) -> ShouldRender { 67 | if self.properties != properties { 68 | self.properties = properties; 69 | self.ensure_selected_item_in_view(); 70 | ShouldRender::Yes 71 | } else { 72 | ShouldRender::No 73 | } 74 | } 75 | 76 | fn resize(&mut self, frame: Rect) -> ShouldRender { 77 | self.frame = frame; 78 | self.ensure_selected_item_in_view(); 79 | ShouldRender::Yes 80 | } 81 | 82 | fn update(&mut self, message: Self::Message) -> ShouldRender { 83 | let current_selected = self.properties.selected; 84 | let new_selected = match (message, self.is_reversed()) { 85 | (Message::NextItem, false) | (Message::PreviousItem, true) => cmp::min( 86 | current_selected + 1, 87 | self.properties.num_items.saturating_sub(1), 88 | ), 89 | (Message::PreviousItem, false) | (Message::NextItem, true) => { 90 | current_selected.saturating_sub(1) 91 | } 92 | (Message::FirstItem, false) | (Message::LastItem, true) => 0, 93 | (Message::LastItem, false) | (Message::FirstItem, true) => { 94 | self.properties.num_items.saturating_sub(1) 95 | } 96 | (Message::NextPage, false) | (Message::PreviousPage, true) => cmp::min( 97 | current_selected + self.frame.size.height, 98 | self.properties.num_items.saturating_sub(1), 99 | ), 100 | (Message::PreviousPage, false) | (Message::NextPage, true) => { 101 | current_selected.saturating_sub(self.frame.size.height) 102 | } 103 | }; 104 | if current_selected != new_selected { 105 | if let Some(on_change) = self.properties.on_change.as_mut() { 106 | on_change.emit(new_selected) 107 | } 108 | } 109 | ShouldRender::No 110 | } 111 | 112 | fn view(&self) -> Layout { 113 | let num_visible_items = cmp::min( 114 | self.properties.num_items.saturating_sub(self.offset), 115 | self.frame.size.height / self.properties.item_size, 116 | ); 117 | let items = (self.offset..) 118 | .take(num_visible_items) 119 | .map(|index| self.properties.item_at.emit(index)); 120 | 121 | if self.properties.item_size * num_visible_items < self.frame.size.height { 122 | // "Filler" component for the unused space 123 | let spacer = iter::once(Item::auto(Text::with( 124 | TextProperties::new().style(self.properties.background), 125 | ))); 126 | Layout::container(self.properties.direction, items.chain(spacer)) 127 | } else { 128 | Layout::container(self.properties.direction, items) 129 | } 130 | } 131 | 132 | fn bindings(&self, bindings: &mut Bindings) { 133 | bindings.set_focus(self.properties.focused); 134 | 135 | if !bindings.is_empty() { 136 | return; 137 | } 138 | bindings.add("next-item", [Key::Ctrl('n')], || Message::NextItem); 139 | bindings.add("next-item", [Key::Down], || Message::NextItem); 140 | bindings.add("previous-item", [Key::Ctrl('p')], || Message::PreviousItem); 141 | bindings.add("previous-item", [Key::Up], || Message::PreviousItem); 142 | bindings.add("first-item", [Key::Alt('<')], || Message::FirstItem); 143 | bindings.add("last-item", [Key::Alt('>')], || Message::LastItem); 144 | bindings.add("next-page", [Key::Ctrl('v')], || Message::NextPage); 145 | bindings.add("next-page", [Key::PageDown], || Message::NextPage); 146 | bindings.add("previous-page", [Key::Alt('v')], || Message::PreviousPage); 147 | bindings.add("previous-page", [Key::PageUp], || Message::PreviousPage); 148 | } 149 | } 150 | 151 | impl Select { 152 | fn is_reversed(&self) -> bool { 153 | self.properties.direction.is_reversed() 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /zi/README.md: -------------------------------------------------------------------------------- 1 | Zi is a library for building modern terminal user interfaces. 2 | 3 | A user interface in Zi is built as a tree of stateful components. Components 4 | let you split the UI into independent, reusable pieces, and think about each 5 | piece in isolation. 6 | 7 | The [`App`](app/struct.App.html) runtime keeps track of components as they are 8 | mounted, updated and eventually removed and only calls `view()` on those UI 9 | components that have changed and have to be re-rendered. Lower level and 10 | independent of the components, the terminal backend will incrementally 11 | redraw only those parts of the screen that have changed. 12 | 13 | 14 | # A Basic Example 15 | 16 | The following is a complete example of a Zi application which implements a 17 | counter. It should provide a good sample of the different 18 | [`Component`](trait.Component.html) methods and how they fit together. 19 | 20 | A slightly more complex version which includes styling can be found at 21 | `examples/counter.rs`. 22 | 23 | ![zi-counter-example](https://user-images.githubusercontent.com/797170/137802270-0a4a50af-1fd5-473f-a52c-9d3a107809d0.gif) 24 | 25 | Anyone familiar with Yew, Elm or React + Redux should be familiar with all 26 | the high-level concepts. Moreover, the names of some types and functions are 27 | the same as in `Yew`. 28 | 29 | ```rust 30 | use zi::{ 31 | components::{ 32 | text::{Text, TextAlign, TextProperties}, 33 | }, 34 | prelude::*, 35 | }; 36 | use zi_term::Result; 37 | 38 | 39 | // Message type handled by the `Counter` component. 40 | enum Message { 41 | Increment, 42 | Decrement, 43 | } 44 | 45 | // Properties of the `Counter` component. in this case the initial value. 46 | struct Properties { 47 | initial_count: usize, 48 | } 49 | 50 | // The `Counter` component. 51 | struct Counter { 52 | // The state of the component -- the current value of the counter. 53 | count: usize, 54 | 55 | // A `ComponentLink` allows us to send messages to the component in reaction 56 | // to user input as well as to gracefully exit. 57 | link: ComponentLink, 58 | } 59 | 60 | // Components implement the `Component` trait and are the building blocks of the 61 | // UI in Zi. The trait describes stateful components and their lifecycle. 62 | impl Component for Counter { 63 | // Messages are used to make components dynamic and interactive. For simple 64 | // or pure components, this will be `()`. Complex, stateful components will 65 | // typically use an enum to declare multiple Message types. 66 | // 67 | // In this case, we will emit two kinds of message (`Increment` or 68 | // `Decrement`) in reaction to user input. 69 | type Message = Message; 70 | 71 | // Properties are the inputs to a Component passed in by their parent. 72 | type Properties = Properties; 73 | 74 | // Creates ("mounts") a new `Counter` component. 75 | fn create( 76 | properties: Self::Properties, 77 | _frame: Rect, 78 | link: ComponentLink, 79 | ) -> Self { 80 | Self { count: properties.initial_count, link } 81 | } 82 | 83 | // Returns the current visual layout of the component. 84 | // - The `Border` component wraps a component and draws a border around it. 85 | // - The `Text` component displays some text. 86 | fn view(&self) -> Layout { 87 | Text::with( 88 | TextProperties::new() 89 | .align(TextAlign::Centre) 90 | .content(format!("Counter: {}", self.count)), 91 | ) 92 | } 93 | 94 | // Components handle messages in their `update` method and commonly use this 95 | // method to update their state and (optionally) re-render themselves. 96 | fn update(&mut self, message: Self::Message) -> ShouldRender { 97 | self.count = match message { 98 | Message::Increment => self.count.saturating_add(1), 99 | Message::Decrement => self.count.saturating_sub(1), 100 | }; 101 | ShouldRender::Yes 102 | } 103 | 104 | // Updates the key bindings of the component. 105 | // 106 | // This method will be called after the component lifecycle methods. It is 107 | // used to specify how to react in response to keyboard events, typically 108 | // by sending a message. 109 | fn bindings(&self, bindings: &mut Bindings) { 110 | // If we already initialised the bindings, nothing to do -- they never 111 | // change in this example 112 | if !bindings.is_empty() { 113 | return; 114 | } 115 | // Set focus to `true` in order to react to key presses 116 | bindings.set_focus(true); 117 | 118 | // Increment, when pressing + or = 119 | bindings 120 | .command("increment", || Message::Increment) 121 | .with([Key::Char('+')]) 122 | .with([Key::Char('=')]); 123 | 124 | // Decrement, when pressing - 125 | bindings.add("decrement", [Key::Char('-')], || Message::Decrement); 126 | 127 | // Exit, when pressing Esc or Ctrl-c 128 | bindings 129 | .command("exit", |this: &Self| this.link.exit()) 130 | .with([Key::Ctrl('c')]) 131 | .with([Key::Esc]); 132 | } 133 | } 134 | 135 | fn main() -> zi_term::Result<()> { 136 | let counter = Counter::with(Properties { initial_count: 0 }); 137 | zi_term::incremental()?.run_event_loop(counter) 138 | } 139 | ``` 140 | 141 | More examples can be found in the `examples` directory of the git 142 | repository. 143 | 144 | 145 | # License 146 | 147 | This project is licensed under either of 148 | 149 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or 150 | http://www.apache.org/licenses/LICENSE-2.0) 151 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or 152 | http://opensource.org/licenses/MIT) 153 | 154 | at your option. 155 | 156 | ### Contribution 157 | 158 | Unless you explicitly state otherwise, any contribution intentionally submitted 159 | for inclusion by you, as defined in the Apache-2.0 license, shall be dual 160 | licensed as above, without any additional terms or conditions. 161 | -------------------------------------------------------------------------------- /examples/splash.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | use unicode_width::UnicodeWidthStr; 3 | use zi::{ 4 | components::border::{Border, BorderProperties}, 5 | prelude::*, 6 | }; 7 | use zi_term::Result; 8 | 9 | #[derive(Clone, Debug, PartialEq, Eq)] 10 | struct Theme { 11 | logo: Style, 12 | tagline: Style, 13 | credits: Style, 14 | } 15 | 16 | impl Default for Theme { 17 | fn default() -> Self { 18 | const DARK0_SOFT: Colour = Colour::rgb(50, 48, 47); 19 | const LIGHT2: Colour = Colour::rgb(213, 196, 161); 20 | const GRAY_245: Colour = Colour::rgb(146, 131, 116); 21 | const BRIGHT_BLUE: Colour = Colour::rgb(131, 165, 152); 22 | 23 | Self { 24 | logo: Style::normal(DARK0_SOFT, LIGHT2), 25 | tagline: Style::normal(DARK0_SOFT, BRIGHT_BLUE), 26 | credits: Style::normal(DARK0_SOFT, GRAY_245), 27 | } 28 | } 29 | } 30 | 31 | #[derive(Clone, Debug, Default, PartialEq, Eq)] 32 | struct SplashProperties { 33 | theme: Theme, 34 | logo: String, 35 | tagline: String, 36 | credits: String, 37 | offset: usize, 38 | } 39 | 40 | #[derive(Debug)] 41 | struct Splash { 42 | properties: SplashProperties, 43 | frame: Rect, 44 | } 45 | 46 | impl Component for Splash { 47 | type Message = usize; 48 | type Properties = SplashProperties; 49 | 50 | fn create(properties: Self::Properties, frame: Rect, _link: ComponentLink) -> Self { 51 | Self { properties, frame } 52 | } 53 | 54 | fn change(&mut self, properties: Self::Properties) -> ShouldRender { 55 | if self.properties != properties { 56 | self.properties = properties; 57 | ShouldRender::Yes 58 | } else { 59 | ShouldRender::No 60 | } 61 | } 62 | 63 | fn resize(&mut self, frame: Rect) -> ShouldRender { 64 | self.frame = frame; 65 | ShouldRender::Yes 66 | } 67 | 68 | #[inline] 69 | fn view(&self) -> Layout { 70 | let logo_size = text_block_size(&self.properties.logo); 71 | let tagline_size = text_block_size(&self.properties.tagline); 72 | let credits_size = text_block_size(&self.properties.credits); 73 | 74 | let theme = Theme::default(); 75 | let mut canvas = Canvas::new(self.frame.size); 76 | canvas.clear(theme.logo); 77 | 78 | // Draw logo 79 | let middle_x = (self.frame.size.width / 2).saturating_sub(logo_size.width / 2); 80 | let mut middle_y = cmp::min(8, self.frame.size.height.saturating_sub(logo_size.height)) 81 | + self.properties.offset; 82 | for line in self.properties.logo.lines() { 83 | canvas.draw_str(middle_x, middle_y, theme.logo, line); 84 | middle_y += 1; 85 | } 86 | 87 | // Draw tagline 88 | middle_y += 2; 89 | let middle_x = (self.frame.size.width / 2).saturating_sub(tagline_size.width / 2); 90 | for line in self.properties.tagline.lines() { 91 | canvas.draw_str(middle_x, middle_y, theme.tagline, line); 92 | middle_y += 1; 93 | } 94 | 95 | // Draw credits 96 | middle_y += 1; 97 | let middle_x = (self.frame.size.width / 2).saturating_sub(credits_size.width / 2); 98 | for line in self.properties.credits.lines() { 99 | canvas.draw_str(middle_x, middle_y, theme.credits, line); 100 | middle_y += 1; 101 | } 102 | 103 | canvas.into() 104 | } 105 | } 106 | 107 | #[derive(Debug)] 108 | struct SplashScreen { 109 | theme: Theme, 110 | link: ComponentLink, 111 | } 112 | 113 | impl Component for SplashScreen { 114 | type Message = usize; 115 | type Properties = (); 116 | 117 | fn create(_properties: Self::Properties, _frame: Rect, link: ComponentLink) -> Self { 118 | Self { 119 | theme: Default::default(), 120 | link, 121 | } 122 | } 123 | 124 | fn view(&self) -> Layout { 125 | // Instantiate our "splash screen" component 126 | let theme = self.theme.clone(); 127 | let splash = move || { 128 | Splash::with(SplashProperties { 129 | theme: theme.clone(), 130 | logo: SPLASH_LOGO.into(), 131 | tagline: SPLASH_TAGLINE.into(), 132 | credits: SPLASH_CREDITS.into(), 133 | offset: 0, 134 | }) 135 | }; 136 | 137 | // Adding a border 138 | Border::with(BorderProperties::new(splash).style(self.theme.credits)) 139 | } 140 | 141 | fn bindings(&self, bindings: &mut Bindings) { 142 | // If we already initialised the bindings, nothing to do -- they never 143 | // change in this example 144 | if !bindings.is_empty() { 145 | return; 146 | } 147 | // Set focus to `true` in order to react to key presses 148 | bindings.set_focus(true); 149 | 150 | // Only one binding, for exiting 151 | bindings.add("exit", [Key::Ctrl('x'), Key::Ctrl('c')], |this: &Self| { 152 | this.link.exit() 153 | }); 154 | } 155 | } 156 | 157 | fn text_block_size(text: &str) -> Size { 158 | let width = text.lines().map(UnicodeWidthStr::width).max().unwrap_or(0); 159 | let height = text.lines().count(); 160 | Size::new(width, height) 161 | } 162 | 163 | fn main() -> Result<()> { 164 | env_logger::init(); 165 | zi_term::incremental()?.run_event_loop(SplashScreen::with(())) 166 | } 167 | 168 | const SPLASH_LOGO: &str = r#" 169 | ▄████████ ▄███████▄ ▄█ ▄████████ ▄████████ ▄█ █▄ 170 | ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ 171 | ███ █▀ ███ ███ ███ ███ ███ ███ █▀ ███ ███ 172 | ███ ███ ███ ███ ███ ███ ███ ▄███▄▄▄▄███▄▄ 173 | ▀███████████ ▀█████████▀ ███ ▀███████████ ▀███████████ ▀▀███▀▀▀▀███▀ 174 | ███ ███ ███ ███ ███ ███ ███ ███ 175 | ▄█ ███ ███ ███▌ ▄ ███ ███ ▄█ ███ ███ ███ 176 | ▄████████▀ ▄████▀ █████▄▄██ ███ █▀ ▄████████▀ ███ █▀ 177 | "#; 178 | const SPLASH_TAGLINE: &str = "a splash screen for the terminal"; 179 | const SPLASH_CREDITS: &str = "C-x C-c to quit"; 180 | -------------------------------------------------------------------------------- /zi/src/components/border.rs: -------------------------------------------------------------------------------- 1 | use std::iter; 2 | 3 | use crate::{ 4 | Callback, Canvas, Component, ComponentLink, Item, Layout, Rect, ShouldRender, Size, Style, 5 | }; 6 | 7 | pub struct BorderProperties { 8 | pub component: Callback<(), Layout>, 9 | pub style: Style, 10 | pub stroke: BorderStroke, 11 | pub title: Option<(String, Style)>, 12 | } 13 | 14 | impl BorderProperties { 15 | pub fn new(component: impl Fn() -> Layout + 'static) -> Self { 16 | Self { 17 | component: (move |_| component()).into(), 18 | style: Style::default(), 19 | stroke: BorderStroke::default(), 20 | title: None, 21 | } 22 | } 23 | 24 | pub fn style(mut self, style: impl Into