├── .github └── workflows │ ├── publish-zi-term.yml │ ├── publish-zi.yml │ └── rust.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── examples ├── Cargo.toml ├── counter.rs ├── lib.rs ├── mandelbrot.rs ├── splash.rs └── todo.rs ├── logo ├── logo.png └── logo.xcf ├── screenshots ├── counter.png ├── mandelbrot.png ├── splash.png └── todo.png ├── scripts ├── build └── check ├── zi-term ├── Cargo.toml ├── README.md └── src │ ├── error.rs │ ├── lib.rs │ ├── painter.rs │ └── utils.rs └── zi ├── Cargo.toml ├── README.md └── src ├── app.rs ├── component ├── bindings.rs ├── layout.rs ├── mod.rs └── template.rs ├── components ├── border.rs ├── input.rs ├── mod.rs ├── select.rs └── text.rs ├── lib.rs ├── terminal ├── canvas.rs ├── input.rs └── mod.rs └── text ├── cursor.rs ├── mod.rs ├── rope.rs └── string.rs /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "zi", 4 | "zi-term", 5 | "examples", 6 | ] 7 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/lib.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/mandelbrot.rs: -------------------------------------------------------------------------------- 1 | use num_complex::Complex; 2 | use rayon::{iter::ParallelExtend, prelude::*}; 3 | use zi::{prelude::*, terminal::SquarePixelGrid}; 4 | use zi_term::Result; 5 | 6 | type Position = euclid::default::Point2D; 7 | 8 | #[derive(Clone, Debug, Default, PartialEq)] 9 | struct Properties { 10 | position: Position, 11 | scale: f64, 12 | } 13 | 14 | #[derive(Debug)] 15 | struct Mandelbrot { 16 | properties: Properties, 17 | frame: Rect, 18 | fractal: Vec<(usize, usize, f64)>, 19 | min: f64, 20 | max: f64, 21 | } 22 | 23 | impl Mandelbrot { 24 | fn compute_fractal(&mut self, size: Size) { 25 | let Self { 26 | properties: Properties { position, scale }, 27 | .. 28 | } = *self; 29 | 30 | let width = size.width as f64; 31 | let height = size.height as f64; 32 | 33 | self.fractal.clear(); 34 | self.fractal 35 | .par_extend((0..size.width).into_par_iter().flat_map(|x| { 36 | (0..size.height).into_par_iter().map(move |y| { 37 | let xf = (x as f64 - width / 2.0) * scale + position.x; 38 | let yf = (y as f64 - height / 2.0) * scale + position.y; 39 | let c = Complex::new(xf, yf); 40 | let mut z = Complex::new(0.0, 0.0); 41 | let target = 4.0; 42 | let mut num_steps = 0; 43 | for _ in 0..1000 { 44 | num_steps += 1; 45 | z = z * z + c; 46 | if z.norm_sqr() > target { 47 | break; 48 | } 49 | } 50 | let conv = (num_steps as f64 / 1000.0).max(0.0).min(1.0); 51 | // let conv2 = 1.0 - (z.norm_sqr() / target).max(0.0).min(1.0); 52 | // let conv = conv1 * conv2; 53 | // let xx = (conv * 255.0).floor() as u8; 54 | // let g = colorous::CUBEHELIX.eval_continuous(1.0 - conv); 55 | // Colour::rgb(g.r, g.g, g.b) 56 | 57 | (x, y, conv) 58 | }) 59 | })); 60 | self.min = self 61 | .fractal 62 | .par_iter() 63 | .cloned() 64 | .reduce(|| (0, 0, 1.0), |x, y| (0, 0, x.2.min(y.2))) 65 | .2; 66 | self.max = self 67 | .fractal 68 | .par_iter() 69 | .cloned() 70 | .reduce(|| (0, 0, 0.0), |x, y| (0, 0, x.2.max(y.2))) 71 | .2; 72 | } 73 | } 74 | impl Component for Mandelbrot { 75 | type Message = (); 76 | type Properties = Properties; 77 | 78 | fn create(properties: Self::Properties, frame: Rect, _link: ComponentLink) -> Self { 79 | let mut component = Self { 80 | properties, 81 | frame, 82 | fractal: Vec::new(), 83 | min: 0.0, 84 | max: 0.0, 85 | }; 86 | component.compute_fractal(Size::new(frame.size.width, 2 * frame.size.height)); 87 | component 88 | } 89 | 90 | fn change(&mut self, properties: Self::Properties) -> ShouldRender { 91 | if self.properties != properties { 92 | self.properties = properties; 93 | self.compute_fractal(Size::new(self.frame.size.width, 2 * self.frame.size.height)); 94 | ShouldRender::Yes 95 | } else { 96 | ShouldRender::No 97 | } 98 | } 99 | 100 | fn resize(&mut self, frame: Rect) -> ShouldRender { 101 | self.frame = frame; 102 | self.compute_fractal(Size::new(self.frame.size.width, 2 * self.frame.size.height)); 103 | ShouldRender::Yes 104 | } 105 | 106 | #[inline] 107 | fn view(&self) -> Layout { 108 | // eprintln!("Range: {} -> {}", self.min, self.max); 109 | let mut grid = SquarePixelGrid::from_available(self.frame.size); 110 | for (x, y, conv) in self.fractal.iter() { 111 | // let g = colorous::CUBEHELIX.eval_continuous(1.0 - conv); 112 | let g = colorous::CUBEHELIX 113 | .eval_continuous(1.0 - (conv - self.min) / (self.max - self.min)); 114 | grid.draw(zi::Position::new(*x, *y), Colour::rgb(g.r, g.g, g.b)); 115 | } 116 | grid.into_canvas().into() 117 | } 118 | } 119 | 120 | enum Message { 121 | MoveUp, 122 | MoveRight, 123 | MoveDown, 124 | MoveLeft, 125 | ZoomIn, 126 | ZoomOut, 127 | } 128 | 129 | #[derive(Debug)] 130 | struct Viewer { 131 | position: Position, 132 | scale: f64, 133 | link: ComponentLink, 134 | } 135 | 136 | impl Component for Viewer { 137 | type Message = Message; 138 | type Properties = (); 139 | 140 | fn create(_properties: Self::Properties, _frame: Rect, link: ComponentLink) -> Self { 141 | Self { 142 | position: Position::new(-1.0, -1.0), 143 | scale: 0.01, 144 | link, 145 | } 146 | } 147 | 148 | fn update(&mut self, message: Self::Message) -> ShouldRender { 149 | let step = self.scale * 2.0; 150 | match message { 151 | Message::MoveUp => self.position.y -= step, 152 | Message::MoveDown => self.position.y += step, 153 | Message::MoveLeft => self.position.x -= step, 154 | Message::MoveRight => self.position.x += step, 155 | Message::ZoomIn => self.scale /= 1.05, 156 | Message::ZoomOut => self.scale *= 1.05, 157 | } 158 | ShouldRender::Yes 159 | } 160 | 161 | fn change(&mut self, _properties: Self::Properties) -> ShouldRender { 162 | ShouldRender::Yes 163 | } 164 | 165 | fn view(&self) -> Layout { 166 | Mandelbrot::with(Properties { 167 | position: self.position, 168 | scale: self.scale, 169 | }) 170 | } 171 | 172 | fn bindings(&self, bindings: &mut Bindings) { 173 | // If we already initialised the bindings, nothing to do -- they never 174 | // change in this example 175 | if !bindings.is_empty() { 176 | return; 177 | } 178 | // Set focus to `true` in order to react to key presses 179 | bindings.set_focus(true); 180 | 181 | // Panning 182 | bindings.add("move-up", [Key::Char('w')], || Message::MoveUp); 183 | bindings.add("move-right", [Key::Char('d')], || Message::MoveRight); 184 | bindings.add("move-down", [Key::Char('s')], || Message::MoveDown); 185 | bindings.add("move-left", [Key::Char('a')], || Message::MoveLeft); 186 | 187 | // Zoom 188 | bindings.add("zoom-in", [Key::Char('=')], || Message::ZoomIn); 189 | bindings.add("zoom-out", [Key::Char('-')], || Message::ZoomOut); 190 | 191 | // Exit 192 | bindings.add("exit", [Key::Ctrl('x'), Key::Ctrl('c')], |this: &Self| { 193 | this.link.exit() 194 | }); 195 | } 196 | } 197 | 198 | fn main() -> Result<()> { 199 | env_logger::init(); 200 | zi_term::incremental()?.run_event_loop(Viewer::with(())) 201 | } 202 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/todo.rs: -------------------------------------------------------------------------------- 1 | use ropey::Rope; 2 | use std::{cmp, rc::Rc}; 3 | use unicode_width::UnicodeWidthStr; 4 | 5 | use zi::{ 6 | components::{ 7 | input::{Cursor, Input, InputChange, InputProperties, InputStyle}, 8 | select::{Select, SelectProperties}, 9 | text::{Text, TextAlign, TextProperties}, 10 | }, 11 | prelude::*, 12 | Callback, 13 | }; 14 | use zi_term::Result; 15 | 16 | #[derive(Clone, Debug, PartialEq, Eq)] 17 | pub struct CheckboxProperties { 18 | pub style: Style, 19 | pub checked: bool, 20 | } 21 | 22 | /// A simple component displaying a checkbox 23 | #[derive(Debug)] 24 | pub struct Checkbox { 25 | properties: CheckboxProperties, 26 | frame: Rect, 27 | } 28 | 29 | impl Component for Checkbox { 30 | type Message = (); 31 | type Properties = CheckboxProperties; 32 | 33 | fn create(properties: Self::Properties, frame: Rect, _link: ComponentLink) -> Self { 34 | Self { properties, frame } 35 | } 36 | 37 | fn change(&mut self, properties: Self::Properties) -> ShouldRender { 38 | if self.properties != properties { 39 | self.properties = properties; 40 | ShouldRender::Yes 41 | } else { 42 | ShouldRender::No 43 | } 44 | } 45 | 46 | fn resize(&mut self, frame: Rect) -> ShouldRender { 47 | self.frame = frame; 48 | ShouldRender::Yes 49 | } 50 | 51 | fn view(&self) -> Layout { 52 | let mut canvas = Canvas::new(self.frame.size); 53 | canvas.clear(self.properties.style); 54 | canvas.draw_str( 55 | 0, 56 | 0, 57 | self.properties.style, 58 | if self.properties.checked { 59 | CHECKED 60 | } else { 61 | UNCHECKED 62 | }, 63 | ); 64 | canvas.into() 65 | } 66 | } 67 | 68 | // The missing space from `CHECKED` is because of a terminal rendering bug (?) 69 | // when using unicode combining characters for strikethrough styling. 70 | const UNCHECKED: &str = " [ ] "; 71 | const CHECKED: &str = " [x] "; 72 | 73 | #[derive(Clone, PartialEq)] 74 | struct TodoProperties { 75 | content: String, 76 | checked: bool, 77 | content_style: Style, 78 | cursor_style: Style, 79 | editing: bool, 80 | on_change: Callback, 81 | } 82 | 83 | enum TodoMessage { 84 | SetCursor(Cursor), 85 | } 86 | 87 | struct Todo { 88 | properties: TodoProperties, 89 | link: ComponentLink, 90 | cursor: Cursor, 91 | handle_input_change: Callback, 92 | } 93 | 94 | impl Component for Todo { 95 | type Message = TodoMessage; 96 | type Properties = TodoProperties; 97 | 98 | fn create(properties: Self::Properties, _frame: Rect, link: ComponentLink) -> Self { 99 | let handle_input_change = { 100 | let link = link.clone(); 101 | let on_change = properties.on_change.clone(); 102 | (move |InputChange { cursor, content }| { 103 | link.send(TodoMessage::SetCursor(cursor)); 104 | if let Some(content) = content { 105 | on_change.emit(content) 106 | } 107 | }) 108 | .into() 109 | }; 110 | 111 | Self { 112 | properties, 113 | link, 114 | cursor: Cursor::new(), 115 | handle_input_change, 116 | } 117 | } 118 | 119 | fn change(&mut self, properties: Self::Properties) -> ShouldRender { 120 | if self.properties != properties { 121 | self.properties = properties; 122 | self.handle_input_change = { 123 | let link = self.link.clone(); 124 | let on_change = self.properties.on_change.clone(); 125 | (move |InputChange { cursor, content }| { 126 | link.send(TodoMessage::SetCursor(cursor)); 127 | if let Some(content) = content { 128 | on_change.emit(content) 129 | } 130 | }) 131 | .into() 132 | }; 133 | ShouldRender::Yes 134 | } else { 135 | ShouldRender::No 136 | } 137 | } 138 | 139 | fn update(&mut self, message: Self::Message) -> ShouldRender { 140 | match message { 141 | Self::Message::SetCursor(cursor) => self.cursor = cursor, 142 | } 143 | ShouldRender::Yes 144 | } 145 | 146 | fn view(&self) -> Layout { 147 | let Self::Properties { 148 | ref content, 149 | checked, 150 | content_style, 151 | cursor_style, 152 | editing, 153 | .. 154 | } = self.properties; 155 | let todo_component = if editing { 156 | let style = InputStyle { 157 | content: content_style, 158 | cursor: cursor_style, 159 | }; 160 | let cursor = self.cursor.clone(); 161 | Input::with(InputProperties { 162 | style, 163 | content: Rope::from_str(content), 164 | cursor, 165 | on_change: self.handle_input_change.clone().into(), 166 | focused: true, 167 | }) 168 | } else { 169 | Text::with( 170 | TextProperties::new() 171 | .style(content_style) 172 | .content(if checked { 173 | unicode_strikethrough(content) 174 | } else { 175 | content.clone() 176 | }), 177 | ) 178 | }; 179 | 180 | let checkbox_width = UnicodeWidthStr::width(if checked { CHECKED } else { UNCHECKED }); 181 | Layout::row([ 182 | Item::fixed(checkbox_width)(Checkbox::with(CheckboxProperties { 183 | style: content_style, 184 | checked, 185 | })), 186 | Item::auto(todo_component), 187 | ]) 188 | } 189 | } 190 | 191 | fn unicode_strikethrough(content: &str) -> String { 192 | let content = content.trim_end(); 193 | if content.is_empty() { 194 | return "\n".into(); 195 | } 196 | 197 | let mut styled_content = String::new(); 198 | for character in content.chars() { 199 | styled_content.push('\u{0336}'); 200 | styled_content.push(character); 201 | } 202 | styled_content.push('\n'); 203 | styled_content 204 | } 205 | 206 | #[derive(Clone, Debug)] 207 | struct Theme { 208 | checked: Style, 209 | unchecked: Style, 210 | focused: Style, 211 | cursor: Style, 212 | } 213 | 214 | impl Default for Theme { 215 | fn default() -> Self { 216 | const DARK0_SOFT: Colour = Colour::rgb(50, 48, 47); 217 | const LIGHT2: Colour = Colour::rgb(213, 196, 161); 218 | const GRAY_245: Colour = Colour::rgb(146, 131, 116); 219 | const BRIGHT_BLUE: Colour = Colour::rgb(131, 165, 152); 220 | 221 | Self { 222 | unchecked: Style::normal(DARK0_SOFT, LIGHT2), 223 | checked: Style::normal(DARK0_SOFT, GRAY_245), 224 | focused: Style::normal(BRIGHT_BLUE, DARK0_SOFT), 225 | cursor: Style::normal(BRIGHT_BLUE, DARK0_SOFT), 226 | } 227 | } 228 | } 229 | 230 | #[derive(Clone, Debug)] 231 | struct TodoItem { 232 | id: usize, 233 | checked: bool, 234 | content: String, 235 | } 236 | 237 | #[derive(Clone, Debug, PartialEq, Eq)] 238 | enum Message { 239 | AddItem, 240 | ChangeContent((usize, Rope)), 241 | DeleteDone, 242 | DeleteItem, 243 | Edit, 244 | FocusItem(usize), 245 | MoveItemDown, 246 | MoveItemUp, 247 | ToggleDone, 248 | } 249 | 250 | struct TodoMvc { 251 | link: ComponentLink, 252 | theme: Theme, 253 | todos: Rc>, 254 | next_id: usize, 255 | focus_index: usize, 256 | editing: bool, 257 | } 258 | 259 | impl TodoMvc { 260 | fn insert_todo(&mut self, index: usize, checked: bool, content: String) { 261 | Rc::make_mut(&mut self.todos).insert( 262 | index, 263 | TodoItem { 264 | id: self.next_id, 265 | checked, 266 | content, 267 | }, 268 | ); 269 | self.next_id += 1; 270 | } 271 | } 272 | 273 | impl Component for TodoMvc { 274 | type Message = Message; 275 | type Properties = (); 276 | 277 | fn create(_properties: (), _frame: Rect, link: ComponentLink) -> Self { 278 | Self { 279 | link, 280 | theme: Default::default(), 281 | todos: (0..10) 282 | .map(|index| TodoItem { 283 | id: index, 284 | checked: false, 285 | content: "All work and no play makes Jack a dull boy.\n".into(), 286 | }) 287 | .collect::>() 288 | .into(), 289 | next_id: 100, 290 | focus_index: 0, 291 | editing: true, 292 | } 293 | } 294 | 295 | fn update(&mut self, message: Self::Message) -> ShouldRender { 296 | match message { 297 | Message::Edit => { 298 | self.editing = false; 299 | } 300 | Message::AddItem if !self.editing => { 301 | self.editing = true; 302 | if self.todos.is_empty() { 303 | self.insert_todo(0, false, "\n".into()); 304 | } 305 | } 306 | Message::AddItem if self.editing => { 307 | self.insert_todo( 308 | cmp::min(self.focus_index + 1, self.todos.len()), 309 | false, 310 | "\n".into(), 311 | ); 312 | self.focus_index = 313 | cmp::min(self.focus_index + 1, self.todos.len().saturating_sub(1)); 314 | } 315 | Message::FocusItem(index) => { 316 | self.editing = false; 317 | self.focus_index = index; 318 | } 319 | Message::MoveItemUp => { 320 | self.editing = false; 321 | let current_index = self.focus_index; 322 | let new_index = self.focus_index.saturating_sub(1); 323 | if current_index != new_index { 324 | Rc::make_mut(&mut self.todos).swap(current_index, new_index); 325 | self.focus_index = new_index; 326 | } 327 | } 328 | Message::MoveItemDown => { 329 | self.editing = false; 330 | let current_index = self.focus_index; 331 | let new_index = cmp::min(self.focus_index + 1, self.todos.len().saturating_sub(1)); 332 | if current_index != new_index { 333 | Rc::make_mut(&mut self.todos).swap(current_index, new_index); 334 | self.focus_index = new_index; 335 | } 336 | } 337 | Message::DeleteItem if !self.todos.is_empty() => { 338 | Rc::make_mut(&mut self.todos).remove(self.focus_index); 339 | self.focus_index = cmp::min(self.focus_index, self.todos.len().saturating_sub(1)); 340 | } 341 | Message::DeleteDone if !self.todos.is_empty() => { 342 | self.focus_index = 0; 343 | Rc::make_mut(&mut self.todos).retain(|item| !item.checked); 344 | } 345 | Message::ToggleDone if !self.todos.is_empty() => { 346 | let checked = &mut Rc::make_mut(&mut self.todos)[self.focus_index].checked; 347 | *checked = !*checked; 348 | self.focus_index = 349 | cmp::min(self.focus_index + 1, self.todos.len().saturating_sub(1)); 350 | } 351 | Message::ChangeContent(content) => { 352 | Rc::make_mut(&mut self.todos)[content.0].content = content.1.into(); 353 | } 354 | _ => { 355 | return ShouldRender::No; 356 | } 357 | } 358 | ShouldRender::Yes 359 | } 360 | 361 | fn view(&self) -> Layout { 362 | let Self { 363 | ref link, 364 | ref theme, 365 | ref todos, 366 | editing, 367 | focus_index, 368 | .. 369 | } = *self; 370 | let num_left = todos.iter().filter(|item| !item.checked).count(); 371 | 372 | // Title component 373 | let title = Item::fixed(LOGO.lines().count() + 1)(Text::with_key( 374 | "title", 375 | TextProperties::new() 376 | .content(LOGO) 377 | .style(theme.checked) 378 | .align(TextAlign::Centre), 379 | )); 380 | 381 | // The list of todo items 382 | let todo_items = Item::auto(Select::with_key( 383 | "select", 384 | SelectProperties { 385 | background: theme.unchecked, 386 | direction: FlexDirection::Column, 387 | num_items: todos.len(), 388 | selected: focus_index, 389 | item_at: { 390 | let todos = todos.clone(); 391 | let link = self.link.clone(); 392 | let theme = theme.clone(); 393 | (move |index| { 394 | let item: &TodoItem = &todos[index]; 395 | let link = link.clone(); 396 | Item::fixed(1)(Todo::with_key( 397 | item.id, 398 | TodoProperties { 399 | content_style: if focus_index == index && !editing { 400 | theme.focused 401 | } else if item.checked { 402 | theme.checked 403 | } else { 404 | theme.unchecked 405 | }, 406 | cursor_style: theme.cursor, 407 | checked: item.checked, 408 | content: item.content.clone(), 409 | editing: index == focus_index && editing, 410 | on_change: (move |content| { 411 | link.send(Message::ChangeContent((index, content))) 412 | }) 413 | .into(), 414 | }, 415 | )) 416 | }) 417 | .into() 418 | }, 419 | item_size: 1, 420 | focused: true, 421 | on_change: Some(link.callback(Message::FocusItem)), 422 | }, 423 | )); 424 | 425 | // Status bar at the bottom counting how many items have been ticked off 426 | let status_bar = Item::fixed(1)(Text::with_key( 427 | "status-bar", 428 | TextProperties::new() 429 | .content(format!( 430 | "Item {} of {} ({} remaining, {} done)", 431 | focus_index + 1, 432 | todos.len(), 433 | num_left, 434 | todos.len() - num_left 435 | )) 436 | .style(theme.checked), 437 | )); 438 | 439 | Layout::column([title, todo_items, status_bar]) 440 | } 441 | 442 | fn bindings(&self, bindings: &mut Bindings) { 443 | if !bindings.is_empty() { 444 | return; 445 | } 446 | bindings.set_focus(true); 447 | 448 | bindings 449 | .command("edit", || Message::Edit) 450 | .with([Key::Esc]) 451 | .with([Key::Alt('\u{1b}')]); 452 | bindings.add("add-item", [Key::Char('\n')], || Message::AddItem); 453 | bindings.add("move-item-up", [Key::Alt('p')], || Message::MoveItemUp); 454 | bindings.add("move-item-down", [Key::Alt('n')], || Message::MoveItemDown); 455 | bindings.add("delete-item", [Key::Ctrl('k')], || Message::DeleteItem); 456 | bindings.add( 457 | "delete-done-items", 458 | [Key::Ctrl('x'), Key::Ctrl('k')], 459 | || Message::DeleteDone, 460 | ); 461 | bindings.add("toggle-done", [Key::Char('\t')], || Message::ToggleDone); 462 | bindings.add("exit", [Key::Ctrl('x'), Key::Ctrl('c')], |this: &Self| { 463 | this.link.exit() 464 | }); 465 | } 466 | } 467 | 468 | const LOGO: &str = r#" 469 | ████████╗ ██████╗ ██████╗ ██████╗ ███████╗ 470 | ╚══██╔══╝██╔═══██╗██╔══██╗██╔═══██╗██╔════╝ 471 | ██║ ██║ ██║██║ ██║██║ ██║███████╗ 472 | ██║ ██║ ██║██║ ██║██║ ██║╚════██║ 473 | ██║ ╚██████╔╝██████╔╝╚██████╔╝███████║ 474 | ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ 475 | 476 | RET: new item TAB: toggle done C-k: delete item 477 | C-p, Up: cursor up C-n, Down: cursor down C-x C-k: delete done 478 | A-p: move item up A-n: move item down C-x C-c: exit 479 | 480 | "#; 481 | 482 | fn main() -> Result<()> { 483 | env_logger::init(); 484 | zi_term::incremental()?.run_event_loop(TodoMvc::with(())) 485 | } 486 | -------------------------------------------------------------------------------- /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/3f21ced0c49e94ae058dabfaba52582723c31f2a/logo/logo.png -------------------------------------------------------------------------------- /logo/logo.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/3f21ced0c49e94ae058dabfaba52582723c31f2a/logo/logo.xcf -------------------------------------------------------------------------------- /screenshots/counter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/3f21ced0c49e94ae058dabfaba52582723c31f2a/screenshots/counter.png -------------------------------------------------------------------------------- /screenshots/mandelbrot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/3f21ced0c49e94ae058dabfaba52582723c31f2a/screenshots/mandelbrot.png -------------------------------------------------------------------------------- /screenshots/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/3f21ced0c49e94ae058dabfaba52582723c31f2a/screenshots/splash.png -------------------------------------------------------------------------------- /screenshots/todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcobzarenco/zi/3f21ced0c49e94ae058dabfaba52582723c31f2a/screenshots/todo.png -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | cargo check --all-targets 6 | cargo build --all-targets 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/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 | -------------------------------------------------------------------------------- /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-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-term/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A terminal backend implementation for [Zi](https://docs.rs/zi) using 2 | //! [crossterm](https://docs.rs/crossterm) 3 | mod error; 4 | mod painter; 5 | mod utils; 6 | 7 | pub use self::error::{Error, Result}; 8 | 9 | use crossterm::{self, queue, QueueableCommand}; 10 | use futures::stream::{Stream, StreamExt}; 11 | use std::{ 12 | io::{self, BufWriter, Stdout, Write}, 13 | pin::Pin, 14 | time::{Duration, Instant}, 15 | }; 16 | use tokio::{ 17 | self, 18 | runtime::{Builder as RuntimeBuilder, Runtime}, 19 | sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, 20 | }; 21 | 22 | use self::{ 23 | painter::{FullPainter, IncrementalPainter, PaintOperation, Painter}, 24 | utils::MeteredWriter, 25 | }; 26 | use zi::{ 27 | app::{App, ComponentMessage, MessageSender}, 28 | terminal::{Canvas, Colour, Key, Size, Style}, 29 | Layout, 30 | }; 31 | 32 | /// Creates a new backend with an incremental painter. It only draws those 33 | /// parts of the terminal that have changed since last drawn. 34 | /// 35 | /// ```no_run 36 | /// # use zi::prelude::*; 37 | /// # use zi::components::text::{Text, TextProperties}; 38 | /// fn main() -> zi_term::Result<()> { 39 | /// zi_term::incremental()? 40 | /// .run_event_loop(Text::with(TextProperties::new().content("Hello, world!"))) 41 | /// } 42 | /// ``` 43 | pub fn incremental() -> Result> { 44 | Crossterm::::new() 45 | } 46 | 47 | /// Creates a new backend with a full painter. It redraws the whole canvas on 48 | /// every canvas. 49 | /// 50 | /// ```no_run 51 | /// # use zi::prelude::*; 52 | /// # use zi::components::text::{Text, TextProperties}; 53 | /// fn main() -> zi_term::Result<()> { 54 | /// zi_term::full()? 55 | /// .run_event_loop(Text::with(TextProperties::new().content("Hello, world!"))) 56 | /// } 57 | /// ``` 58 | pub fn full() -> Result> { 59 | Crossterm::::new() 60 | } 61 | 62 | /// A terminal backend implementation for [Zi](https://docs.rs/zi) using 63 | /// [crossterm](https://docs.rs/crossterm) 64 | /// 65 | /// ```no_run 66 | /// # use zi::prelude::*; 67 | /// # use zi::components::text::{Text, TextProperties}; 68 | /// fn main() -> zi_term::Result<()> { 69 | /// zi_term::incremental()? 70 | /// .run_event_loop(Text::with(TextProperties::new().content("Hello, world!"))) 71 | /// } 72 | /// ``` 73 | pub struct Crossterm { 74 | target: MeteredWriter>, 75 | painter: PainterT, 76 | events: Option, 77 | link: LinkChannel, 78 | } 79 | 80 | impl Crossterm { 81 | /// Create a new backend instance. 82 | /// 83 | /// This method initialises the underlying tty device, enables raw mode, 84 | /// hides the cursor and enters alternative screen mode. Additionally, an 85 | /// async event stream with input events from stdin is started. 86 | pub fn new() -> Result { 87 | let mut backend = Self { 88 | target: MeteredWriter::new(BufWriter::with_capacity(1 << 20, io::stdout())), 89 | painter: PainterT::create( 90 | crossterm::terminal::size() 91 | .map(|(width, height)| Size::new(width as usize, height as usize))?, 92 | ), 93 | events: Some(new_event_stream()), 94 | link: LinkChannel::new(), 95 | }; 96 | initialise_tty::(&mut backend.target)?; 97 | Ok(backend) 98 | } 99 | 100 | /// Starts the event loop. This is the main entry point of a Zi application. 101 | /// It draws and presents the components to the backend, handles user input 102 | /// and delivers messages to components. This method returns either when 103 | /// prompted using the [`exit`](struct.ComponentLink.html#method.exit) 104 | /// method on [`ComponentLink`](struct.ComponentLink.html) or on error. 105 | /// 106 | /// ```no_run 107 | /// # use zi::prelude::*; 108 | /// # use zi::components::text::{Text, TextProperties}; 109 | /// fn main() -> zi_term::Result<()> { 110 | /// zi_term::incremental()? 111 | /// .run_event_loop(Text::with(TextProperties::new().content("Hello, world!"))) 112 | /// } 113 | /// ``` 114 | pub fn run_event_loop(&mut self, layout: Layout) -> Result<()> { 115 | let mut tokio_runtime = RuntimeBuilder::new_current_thread().enable_all().build()?; 116 | let mut app = App::new( 117 | UnboundedMessageSender(self.link.sender.clone()), 118 | self.size()?, 119 | layout, 120 | ); 121 | 122 | while !app.poll_state().exit() { 123 | let canvas = app.draw(); 124 | 125 | let last_drawn = Instant::now(); 126 | let num_bytes_presented = self.present(canvas)?; 127 | let presented_time = last_drawn.elapsed(); 128 | 129 | log::debug!( 130 | "Frame: pres {:.1}ms diff {}b", 131 | presented_time.as_secs_f64() * 1000.0, 132 | num_bytes_presented, 133 | ); 134 | 135 | self.poll_events_batch(&mut tokio_runtime, &mut app, last_drawn)?; 136 | } 137 | 138 | Ok(()) 139 | } 140 | 141 | /// Suspends the event stream. 142 | /// 143 | /// This is used when running something that needs exclusive access to the underlying 144 | /// terminal (i.e. to stdin and stdout). For example spawning an external editor to collect 145 | /// or display text. The `resume` function is called upon returning to the application. 146 | #[inline] 147 | pub fn suspend(&mut self) -> Result<()> { 148 | self.events = None; 149 | Ok(()) 150 | } 151 | 152 | /// Recreates the event stream and reinitialises the underlying terminal. 153 | /// 154 | /// This function is used to return execution to the application after running something 155 | /// that needs exclusive access to the underlying backend. It will only be called after a 156 | /// call to `suspend`. 157 | /// 158 | /// In addition to restarting the event stream, this function should perform any other 159 | /// required initialisation of the backend. For ANSI terminals, this typically hides the 160 | /// cursor and saves the current screen content (i.e. "alternative screen mode") in order 161 | /// to restore the previous terminal content on exit. 162 | #[inline] 163 | pub fn resume(&mut self) -> Result<()> { 164 | self.painter = PainterT::create(self.size()?); 165 | self.events = Some(new_event_stream()); 166 | initialise_tty::(&mut self.target) 167 | } 168 | 169 | /// Poll as many events as we can respecting REDRAW_LATENCY and REDRAW_LATENCY_SUSTAINED_IO 170 | #[inline] 171 | fn poll_events_batch( 172 | &mut self, 173 | runtime: &mut Runtime, 174 | app: &mut App, 175 | last_drawn: Instant, 176 | ) -> Result<()> { 177 | let Self { 178 | ref mut link, 179 | ref mut events, 180 | .. 181 | } = *self; 182 | let mut force_redraw = false; 183 | let mut first_event_time: Option = None; 184 | 185 | while !force_redraw && !app.poll_state().exit() { 186 | let timeout_duration = { 187 | let since_last_drawn = last_drawn.elapsed(); 188 | if app.poll_state().dirty() && since_last_drawn >= REDRAW_LATENCY { 189 | Duration::from_millis(0) 190 | } else if app.poll_state().dirty() { 191 | REDRAW_LATENCY - since_last_drawn 192 | } else { 193 | Duration::from_millis(if app.is_tickable() { 60 } else { 60_000 }) 194 | } 195 | }; 196 | (runtime.block_on(async { 197 | tokio::select! { 198 | link_message = link.receiver.recv() => { 199 | app.handle_message( 200 | link_message.expect("at least one sender exists"), 201 | ); 202 | Ok(()) 203 | } 204 | input_event = events.as_mut().expect("backend events are suspended").next() => { 205 | match input_event.expect( 206 | "at least one sender exists", 207 | )? { 208 | FilteredEvent::Input(input_event) => app.handle_input(input_event), 209 | FilteredEvent::Resize(size) => app.handle_resize(size), 210 | }; 211 | force_redraw = app.poll_state().dirty() 212 | && (first_event_time.get_or_insert_with(Instant::now).elapsed() 213 | >= SUSTAINED_IO_REDRAW_LATENCY 214 | || app.poll_state().resized()); 215 | Ok(()) 216 | } 217 | _ = tokio::time::sleep(timeout_duration) => { 218 | app.tick(); 219 | force_redraw = true; 220 | Ok(()) 221 | } 222 | } 223 | }) as Result<()>)?; 224 | } 225 | 226 | Ok(()) 227 | } 228 | 229 | /// Returns the size of the underlying terminal. 230 | #[inline] 231 | fn size(&self) -> Result { 232 | Ok(crossterm::terminal::size() 233 | .map(|(width, height)| Size::new(width as usize, height as usize))?) 234 | } 235 | 236 | /// Draws the [`Canvas`](../terminal/struct.Canvas.html) to the terminal. 237 | #[inline] 238 | fn present(&mut self, canvas: &Canvas) -> Result { 239 | let Self { 240 | ref mut target, 241 | ref mut painter, 242 | .. 243 | } = *self; 244 | let initial_num_bytes_written = target.num_bytes_written(); 245 | painter.paint(canvas, |operation| { 246 | match operation { 247 | PaintOperation::WriteContent(grapheme) => { 248 | queue!(target, crossterm::style::Print(grapheme))? 249 | } 250 | PaintOperation::SetStyle(style) => queue_set_style(target, style)?, 251 | PaintOperation::MoveTo(position) => queue!( 252 | target, 253 | crossterm::cursor::MoveTo(position.x as u16, position.y as u16) 254 | )?, // Go to the begining of line (`MoveTo` uses 0-based indexing) 255 | } 256 | Ok(()) 257 | })?; 258 | target.flush()?; 259 | Ok(target.num_bytes_written() - initial_num_bytes_written) 260 | } 261 | } 262 | 263 | impl Drop for Crossterm { 264 | fn drop(&mut self) { 265 | queue!( 266 | self.target, 267 | crossterm::style::ResetColor, 268 | crossterm::terminal::Clear(crossterm::terminal::ClearType::All), 269 | crossterm::cursor::Show, 270 | crossterm::terminal::LeaveAlternateScreen 271 | ) 272 | .expect("Failed to clear screen when closing `crossterm` backend"); 273 | crossterm::terminal::disable_raw_mode() 274 | .expect("Failed to disable raw mode when closing `crossterm` backend"); 275 | self.target 276 | .flush() 277 | .expect("Failed to flush when closing `crossterm` backend"); 278 | } 279 | } 280 | 281 | const REDRAW_LATENCY: Duration = Duration::from_millis(10); 282 | const SUSTAINED_IO_REDRAW_LATENCY: Duration = Duration::from_millis(100); 283 | 284 | struct LinkChannel { 285 | sender: UnboundedSender, 286 | receiver: UnboundedReceiver, 287 | } 288 | 289 | impl LinkChannel { 290 | fn new() -> Self { 291 | let (sender, receiver) = mpsc::unbounded_channel(); 292 | Self { sender, receiver } 293 | } 294 | } 295 | 296 | #[derive(Debug, Clone)] 297 | struct UnboundedMessageSender(UnboundedSender); 298 | 299 | impl MessageSender for UnboundedMessageSender { 300 | fn send(&self, message: ComponentMessage) { 301 | self.0 302 | .send(message) 303 | .map_err(|_| ()) // tokio's SendError doesn't implement Debug 304 | .expect("App receiver needs to outlive senders for inter-component messages"); 305 | } 306 | 307 | fn clone_box(&self) -> Box { 308 | Box::new(self.clone()) 309 | } 310 | } 311 | 312 | #[inline] 313 | fn initialise_tty(target: &mut TargetT) -> Result<()> { 314 | target 315 | .queue(crossterm::terminal::EnterAlternateScreen)? 316 | .queue(crossterm::cursor::Hide)?; 317 | crossterm::terminal::enable_raw_mode()?; 318 | queue_set_style(target, &PainterT::INITIAL_STYLE)?; 319 | target.flush()?; 320 | Ok(()) 321 | } 322 | 323 | #[inline] 324 | fn queue_set_style(target: &mut impl Write, style: &Style) -> Result<()> { 325 | use crossterm::style::{ 326 | Attribute, Color, SetAttribute, SetBackgroundColor, SetForegroundColor, 327 | }; 328 | 329 | // Bold 330 | if style.bold { 331 | queue!(target, SetAttribute(Attribute::Bold))?; 332 | } else { 333 | // Using Reset is not ideal as it resets all style attributes. The correct thing to do 334 | // would be to use `NoBold`, but it seems this is not reliably supported (at least it 335 | // didn't work for me in tmux, although it does in alacritty). 336 | // Also see https://github.com/crossterm-rs/crossterm/issues/294 337 | queue!(target, SetAttribute(Attribute::Reset))?; 338 | } 339 | 340 | // Underline 341 | if style.underline { 342 | queue!(target, SetAttribute(Attribute::Underlined))?; 343 | } else { 344 | queue!(target, SetAttribute(Attribute::NoUnderline))?; 345 | } 346 | 347 | // Background 348 | { 349 | let Colour { red, green, blue } = style.background; 350 | queue!( 351 | target, 352 | SetBackgroundColor(Color::Rgb { 353 | r: red, 354 | g: green, 355 | b: blue 356 | }) 357 | )?; 358 | } 359 | 360 | // Foreground 361 | { 362 | let Colour { red, green, blue } = style.foreground; 363 | queue!( 364 | target, 365 | SetForegroundColor(Color::Rgb { 366 | r: red, 367 | g: green, 368 | b: blue 369 | }) 370 | )?; 371 | } 372 | 373 | Ok(()) 374 | } 375 | 376 | enum FilteredEvent { 377 | Input(zi::terminal::Event), 378 | Resize(Size), 379 | } 380 | 381 | type EventStream = Pin> + Send + 'static>>; 382 | 383 | #[inline] 384 | fn new_event_stream() -> EventStream { 385 | Box::pin( 386 | crossterm::event::EventStream::new() 387 | .filter_map(|event| async move { 388 | match event { 389 | Ok(crossterm::event::Event::Key(key_event)) => Some(Ok(FilteredEvent::Input( 390 | zi::terminal::Event::KeyPress(map_key(key_event)), 391 | ))), 392 | Ok(crossterm::event::Event::Resize(width, height)) => Some(Ok( 393 | FilteredEvent::Resize(Size::new(width as usize, height as usize)), 394 | )), 395 | Ok(_) => None, 396 | Err(error) => Some(Err(error.into())), 397 | } 398 | }) 399 | .fuse(), 400 | ) 401 | } 402 | 403 | #[inline] 404 | fn map_key(key: crossterm::event::KeyEvent) -> Key { 405 | use crossterm::event::{KeyCode, KeyModifiers}; 406 | match key.code { 407 | KeyCode::Backspace => Key::Backspace, 408 | KeyCode::Left => Key::Left, 409 | KeyCode::Right => Key::Right, 410 | KeyCode::Up => Key::Up, 411 | KeyCode::Down => Key::Down, 412 | KeyCode::Home => Key::Home, 413 | KeyCode::End => Key::End, 414 | KeyCode::PageUp => Key::PageUp, 415 | KeyCode::PageDown => Key::PageDown, 416 | KeyCode::BackTab => Key::BackTab, 417 | KeyCode::Delete => Key::Delete, 418 | KeyCode::Insert => Key::Insert, 419 | KeyCode::F(u8) => Key::F(u8), 420 | KeyCode::Null => Key::Null, 421 | KeyCode::Esc => Key::Esc, 422 | KeyCode::Char(char) if key.modifiers.contains(KeyModifiers::CONTROL) => Key::Ctrl(char), 423 | KeyCode::Char(char) if key.modifiers.contains(KeyModifiers::ALT) => Key::Alt(char), 424 | KeyCode::Char(char) => Key::Char(char), 425 | KeyCode::Enter => Key::Char('\n'), 426 | KeyCode::Tab => Key::Char('\t'), 427 | } 428 | } 429 | -------------------------------------------------------------------------------- /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-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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /zi/src/component/bindings.rs: -------------------------------------------------------------------------------- 1 | use smallvec::{smallvec, SmallVec}; 2 | use std::{ 3 | any::{Any, TypeId}, 4 | borrow::Cow, 5 | collections::hash_map::HashMap, 6 | fmt, 7 | marker::PhantomData, 8 | }; 9 | 10 | use super::{Component, DynamicMessage}; 11 | use crate::terminal::Key; 12 | 13 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] 14 | pub struct CommandId(usize); 15 | 16 | #[derive(Clone, Debug, PartialEq)] 17 | pub enum NamedBindingQuery { 18 | Match(Cow<'static, str>), 19 | PrefixOf(SmallVec<[Cow<'static, str>; 4]>), 20 | } 21 | 22 | impl NamedBindingQuery { 23 | pub fn new(keymap: &Keymap, query: &BindingQuery) -> Self { 24 | match query { 25 | BindingQuery::Match(command_id) => Self::Match(keymap.names[command_id.0].clone()), 26 | BindingQuery::PrefixOf(commands) => Self::PrefixOf( 27 | commands 28 | .iter() 29 | .map(|command_id| keymap.names[command_id.0].clone()) 30 | .collect(), 31 | ), 32 | } 33 | } 34 | } 35 | 36 | #[derive(Clone, Debug, PartialEq)] 37 | pub enum BindingQuery { 38 | Match(CommandId), 39 | PrefixOf(SmallVec<[CommandId; 4]>), 40 | } 41 | 42 | impl BindingQuery { 43 | pub fn matches(&self) -> Option { 44 | match self { 45 | Self::Match(command_id) => Some(*command_id), 46 | _ => None, 47 | } 48 | } 49 | 50 | pub fn prefix_of(&self) -> Option<&[CommandId]> { 51 | match self { 52 | Self::PrefixOf(commands) => Some(commands), 53 | _ => None, 54 | } 55 | } 56 | } 57 | 58 | #[derive(Debug, Default)] 59 | pub struct Keymap { 60 | names: Vec>, 61 | keymap: HashMap, 62 | } 63 | 64 | impl Keymap { 65 | pub fn new() -> Self { 66 | Self::default() 67 | } 68 | 69 | pub fn name(&self, command_id: &CommandId) -> &str { 70 | &self.names[command_id.0] 71 | } 72 | 73 | pub fn is_empty(&self) -> bool { 74 | self.keymap.is_empty() 75 | } 76 | 77 | pub fn add( 78 | &mut self, 79 | name: impl Into>, 80 | pattern: impl Into, 81 | ) -> CommandId { 82 | let command_id = self.add_command(name).0; 83 | self.bind_command(command_id, pattern); 84 | command_id 85 | } 86 | 87 | pub fn add_command(&mut self, name: impl Into>) -> (CommandId, bool) { 88 | let name = name.into(); 89 | let (command_id, is_new_command) = self 90 | .names 91 | .iter() 92 | .enumerate() 93 | .find(|(_index, existing)| **existing == name) 94 | .map(|(index, _)| (CommandId(index), false)) 95 | .unwrap_or_else(|| (CommandId(self.names.len()), true)); 96 | if is_new_command { 97 | self.names.push(name); 98 | } 99 | (command_id, is_new_command) 100 | } 101 | 102 | pub fn bind_command(&mut self, command_id: CommandId, pattern: impl Into) { 103 | let name = &self.names[command_id.0]; 104 | let pattern = pattern.into(); 105 | 106 | // Add `BindingQuery::PrefixOf` entries for all prefixes of the key sequence 107 | if let Some(keys) = pattern.keys() { 108 | for prefix_len in 0..keys.len() { 109 | let prefix = KeyPattern::Keys(keys.iter().copied().take(prefix_len).collect()); 110 | self.keymap 111 | .entry(prefix.clone()) 112 | .and_modify(|entry| match entry { 113 | BindingQuery::Match(other_command_id) => panic_on_overlapping_key_bindings( 114 | &pattern, 115 | name, 116 | &prefix, 117 | &self.names[other_command_id.0], 118 | ), 119 | BindingQuery::PrefixOf(prefix_of) => { 120 | prefix_of.push(command_id); 121 | } 122 | }) 123 | .or_insert_with(|| BindingQuery::PrefixOf(smallvec![command_id])); 124 | } 125 | } 126 | 127 | // Add a `BindingQuery::Match` for the full key sequence 128 | self.keymap 129 | .entry(pattern.clone()) 130 | .and_modify(|entry| match entry { 131 | BindingQuery::Match(other_command_id) => panic_on_overlapping_key_bindings( 132 | &pattern, 133 | name, 134 | &pattern, 135 | &self.names[other_command_id.0], 136 | ), 137 | BindingQuery::PrefixOf(prefix_of) => panic_on_overlapping_key_bindings( 138 | &pattern, 139 | name, 140 | &pattern, 141 | &self.names[prefix_of[0].0], 142 | ), 143 | }) 144 | .or_insert_with(|| BindingQuery::Match(command_id)); 145 | } 146 | 147 | pub fn check_sequence(&self, keys: &[Key]) -> Option<&BindingQuery> { 148 | let pattern: KeyPattern = keys.iter().copied().into(); 149 | self.keymap 150 | .get(&pattern) 151 | .or_else(|| match keys { 152 | &[Key::Char(_)] => self.keymap.get(&KeyPattern::AnyCharacter), 153 | _ => None, 154 | }) 155 | .or_else(|| match keys { 156 | &[_, key] | &[key] => self.keymap.get(&KeyPattern::EndsWith([key])), 157 | _ => None, 158 | }) 159 | } 160 | } 161 | 162 | #[allow(clippy::type_complexity)] 163 | struct DynamicCommandFn(Box Option>); 164 | 165 | impl fmt::Debug for DynamicCommandFn { 166 | fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 167 | write!(formatter, "CommandFn@{:?})", &self.0 as *const _) 168 | } 169 | } 170 | 171 | #[derive(Debug)] 172 | pub(crate) struct DynamicBindings { 173 | keymap: Keymap, 174 | commands: Vec, 175 | focused: bool, 176 | notify: bool, 177 | type_id: TypeId, 178 | } 179 | 180 | impl DynamicBindings { 181 | pub fn new() -> Self { 182 | Self { 183 | keymap: Keymap::new(), 184 | commands: Vec::new(), 185 | focused: false, 186 | notify: false, 187 | type_id: TypeId::of::(), 188 | } 189 | } 190 | 191 | #[inline] 192 | pub fn keymap(&self) -> &Keymap { 193 | &self.keymap 194 | } 195 | 196 | #[inline] 197 | pub fn set_focus(&mut self, focused: bool) { 198 | self.focused = focused; 199 | } 200 | 201 | #[inline] 202 | pub fn focused(&self) -> bool { 203 | self.focused 204 | } 205 | 206 | #[inline] 207 | pub fn set_notify(&mut self, notify: bool) { 208 | self.notify = notify; 209 | } 210 | 211 | #[inline] 212 | pub fn notify(&self) -> bool { 213 | self.notify 214 | } 215 | 216 | pub fn add( 217 | &mut self, 218 | name: impl Into>, 219 | keys: impl Into, 220 | command_fn: impl CommandFn + 'static, 221 | ) -> CommandId { 222 | let command_id = self.add_command(name, command_fn); 223 | self.bind_command(command_id, keys); 224 | command_id 225 | } 226 | 227 | pub fn add_command( 228 | &mut self, 229 | name: impl Into>, 230 | command_fn: impl CommandFn + 'static, 231 | ) -> CommandId { 232 | assert_eq!(self.type_id, TypeId::of::()); 233 | 234 | let (command_id, is_new_command) = self.keymap.add_command(name); 235 | let dyn_command_fn = DynamicCommandFn(Box::new(move |erased: &dyn Any, keys: &[Key]| { 236 | let component = erased 237 | .downcast_ref() 238 | .expect("Incorrect `Component` type when downcasting"); 239 | command_fn 240 | .call(component, keys) 241 | .map(|message| DynamicMessage(Box::new(message))) 242 | })); 243 | if is_new_command { 244 | self.commands.push(dyn_command_fn); 245 | } else { 246 | self.commands[command_id.0] = dyn_command_fn; 247 | } 248 | 249 | command_id 250 | } 251 | 252 | pub fn bind_command(&mut self, command_id: CommandId, keys: impl Into) { 253 | self.keymap.bind_command(command_id, keys); 254 | } 255 | 256 | pub fn execute_command( 257 | &self, 258 | component: &ComponentT, 259 | id: CommandId, 260 | keys: &[Key], 261 | ) -> Option { 262 | assert_eq!(self.type_id, TypeId::of::()); 263 | 264 | (self.commands[id.0].0)(component, keys) 265 | } 266 | 267 | pub fn typed( 268 | &mut self, 269 | callback: impl FnOnce(&mut Bindings), 270 | ) { 271 | assert_eq!(self.type_id, TypeId::of::()); 272 | 273 | let mut bindings = Self::new::(); 274 | std::mem::swap(self, &mut bindings); 275 | let mut typed = Bindings::::new(bindings); 276 | callback(&mut typed); 277 | std::mem::swap(self, &mut typed.bindings); 278 | } 279 | } 280 | 281 | #[derive(Debug)] 282 | pub struct Bindings { 283 | bindings: DynamicBindings, 284 | _component: PhantomData ComponentT>, 285 | } 286 | 287 | impl Bindings { 288 | fn new(bindings: DynamicBindings) -> Self { 289 | Self { 290 | bindings, 291 | _component: PhantomData, 292 | } 293 | } 294 | 295 | #[inline] 296 | pub fn is_empty(&self) -> bool { 297 | self.bindings.keymap.is_empty() 298 | } 299 | 300 | #[inline] 301 | pub fn set_focus(&mut self, focused: bool) { 302 | self.bindings.set_focus(focused) 303 | } 304 | 305 | #[inline] 306 | pub fn focused(&self) -> bool { 307 | self.bindings.focused() 308 | } 309 | 310 | #[inline] 311 | pub fn set_notify(&mut self, notify: bool) { 312 | self.bindings.set_notify(notify) 313 | } 314 | 315 | #[inline] 316 | pub fn notify(&self) -> bool { 317 | self.bindings.notify() 318 | } 319 | 320 | #[inline] 321 | pub fn add( 322 | &mut self, 323 | name: impl Into>, 324 | keys: impl Into, 325 | command_fn: impl CommandFn + 'static, 326 | ) { 327 | self.bindings.add(name, keys, command_fn); 328 | } 329 | 330 | #[inline] 331 | pub fn command( 332 | &mut self, 333 | name: impl Into>, 334 | command_fn: impl CommandFn + 'static, 335 | ) -> BindingBuilder { 336 | let command_id = self.bindings.add_command(name, command_fn); 337 | BindingBuilder { 338 | wrapped: self, 339 | command_id, 340 | } 341 | } 342 | } 343 | 344 | #[derive(Debug)] 345 | pub struct BindingBuilder<'a, ComponentT> { 346 | wrapped: &'a mut Bindings, 347 | command_id: CommandId, 348 | } 349 | 350 | impl BindingBuilder<'_, ComponentT> { 351 | pub fn with(self, keys: impl Into) -> Self { 352 | self.wrapped.bindings.bind_command(self.command_id, keys); 353 | self 354 | } 355 | } 356 | 357 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 358 | pub enum KeyPattern { 359 | AnyCharacter, 360 | EndsWith([Key; 1]), 361 | Keys(SmallVec<[Key; 8]>), 362 | } 363 | 364 | impl KeyPattern { 365 | fn keys(&self) -> Option<&[Key]> { 366 | match self { 367 | Self::AnyCharacter => None, 368 | Self::EndsWith(key) => Some(key.as_slice()), 369 | Self::Keys(keys) => Some(keys.as_slice()), 370 | } 371 | } 372 | } 373 | 374 | impl> From for KeyPattern { 375 | fn from(keys: IterT) -> Self { 376 | Self::Keys(keys.into_iter().collect()) 377 | } 378 | } 379 | 380 | impl std::fmt::Display for KeyPattern { 381 | fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> { 382 | match self { 383 | Self::AnyCharacter => { 384 | write!(formatter, "Char(*)") 385 | } 386 | Self::Keys(keys) => KeySequenceSlice(keys.as_slice()).fmt(formatter), 387 | Self::EndsWith(keys) => KeySequenceSlice(keys.as_slice()).fmt(formatter), 388 | } 389 | } 390 | } 391 | 392 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 393 | pub struct AnyCharacter; 394 | 395 | impl From for KeyPattern { 396 | fn from(_: AnyCharacter) -> Self { 397 | Self::AnyCharacter 398 | } 399 | } 400 | 401 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 402 | pub struct EndsWith(pub Key); 403 | 404 | impl From for KeyPattern { 405 | fn from(ends_with: EndsWith) -> Self { 406 | Self::EndsWith([ends_with.0]) 407 | } 408 | } 409 | 410 | pub trait CommandFn { 411 | fn call(&self, component: &ComponentT, keys: &[Key]) -> Option; 412 | } 413 | 414 | // Specializations for callbacks that take either a component or slice with keys 415 | // and return an option 416 | impl CommandFn for FnT 417 | where 418 | ComponentT: Component, 419 | FnT: Fn(&ComponentT, &[Key]) -> Option + 'static, 420 | { 421 | fn call(&self, component: &ComponentT, keys: &[Key]) -> Option { 422 | (self)(component, keys) 423 | } 424 | } 425 | 426 | impl CommandFn for FnT 427 | where 428 | ComponentT: Component, 429 | FnT: Fn(&ComponentT) -> Option + 'static, 430 | { 431 | #[inline] 432 | fn call(&self, component: &ComponentT, _keys: &[Key]) -> Option { 433 | (self)(component) 434 | } 435 | } 436 | 437 | impl CommandFn for FnT 438 | where 439 | ComponentT: Component, 440 | FnT: Fn(&[Key]) -> Option + 'static, 441 | { 442 | #[inline] 443 | fn call(&self, _component: &ComponentT, keys: &[Key]) -> Option { 444 | (self)(keys) 445 | } 446 | } 447 | 448 | // Specializations for callbacks that take a component and optionally a slice with keys 449 | impl CommandFn for FnT 450 | where 451 | ComponentT: Component, 452 | FnT: Fn(&ComponentT, &[Key]) + 'static, 453 | { 454 | #[inline] 455 | fn call(&self, component: &ComponentT, keys: &[Key]) -> Option { 456 | (self)(component, keys); 457 | None 458 | } 459 | } 460 | 461 | impl CommandFn for FnT 462 | where 463 | ComponentT: Component, 464 | FnT: Fn(&ComponentT) + 'static, 465 | { 466 | #[inline] 467 | fn call(&self, component: &ComponentT, _keys: &[Key]) -> Option { 468 | (self)(component); 469 | None 470 | } 471 | } 472 | 473 | // Specialization for callbacks that take no parameters and return a message 474 | impl CommandFn for FnT 475 | where 476 | ComponentT: Component, 477 | FnT: Fn() -> ComponentT::Message + 'static, 478 | { 479 | #[inline] 480 | fn call(&self, _component: &ComponentT, _keys: &[Key]) -> Option { 481 | Some((self)()) 482 | } 483 | } 484 | 485 | #[derive(Debug, Clone, PartialEq, Eq)] 486 | pub struct KeySequenceSlice<'a>(&'a [Key]); 487 | 488 | impl<'a> From<&'a [Key]> for KeySequenceSlice<'a> { 489 | fn from(keys: &'a [Key]) -> Self { 490 | Self(keys) 491 | } 492 | } 493 | 494 | impl<'a> std::fmt::Display for KeySequenceSlice<'a> { 495 | fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> { 496 | for (index, key) in self.0.iter().enumerate() { 497 | match key { 498 | Key::Char(' ') => write!(formatter, "SPC")?, 499 | Key::Char('\n') => write!(formatter, "RET")?, 500 | Key::Char('\t') => write!(formatter, "TAB")?, 501 | Key::Char(char) => write!(formatter, "{}", char)?, 502 | Key::Ctrl(char) => write!(formatter, "C-{}", char)?, 503 | Key::Alt(char) => write!(formatter, "A-{}", char)?, 504 | Key::F(number) => write!(formatter, "F{}", number)?, 505 | Key::Esc => write!(formatter, "ESC")?, 506 | key => write!(formatter, "{:?}", key)?, 507 | } 508 | if index < self.0.len().saturating_sub(1) { 509 | write!(formatter, " ")?; 510 | } 511 | } 512 | Ok(()) 513 | } 514 | } 515 | 516 | fn panic_on_overlapping_key_bindings( 517 | new_pattern: &KeyPattern, 518 | new_name: &str, 519 | existing_pattern: &KeyPattern, 520 | existing_name: &str, 521 | ) -> ! { 522 | panic!( 523 | "Binding `{}` for `{}` is ambiguous as it overlaps with binding `{}` for command `{}`", 524 | new_pattern, new_name, existing_pattern, existing_name, 525 | ); 526 | } 527 | 528 | #[cfg(test)] 529 | mod tests { 530 | use super::*; 531 | use crate::prelude::*; 532 | use smallvec::smallvec; 533 | use std::{cell::RefCell, rc::Rc}; 534 | 535 | struct Empty; 536 | 537 | impl Component for Empty { 538 | type Message = (); 539 | type Properties = (); 540 | 541 | fn create(_: Self::Properties, _: Rect, _: ComponentLink) -> Self { 542 | Self 543 | } 544 | 545 | fn view(&self) -> Layout { 546 | Canvas::new(Size::new(10, 10)).into() 547 | } 548 | } 549 | 550 | #[test] 551 | fn keymap_alternative_binding_for_same_command() { 552 | let mut keymap = Keymap::new(); 553 | let right_id = keymap.add("right", [Key::Right]); 554 | let left_id = keymap.add("left", [Key::Left]); 555 | assert_ne!(left_id, right_id); 556 | let alternate_left_id = keymap.add("left", [Key::Ctrl('b')]); 557 | assert_eq!(left_id, alternate_left_id); 558 | } 559 | 560 | #[test] 561 | fn controller_one_command_end_to_end() { 562 | let called = Rc::new(RefCell::new(false)); 563 | 564 | // Create a controller with one registered command 565 | let mut controller = DynamicBindings::new::(); 566 | let test_command_id = controller.add("test-command", [Key::Ctrl('x'), Key::Ctrl('f')], { 567 | let called = Rc::clone(&called); 568 | move |_: &Empty| { 569 | *called.borrow_mut() = true; 570 | None 571 | } 572 | }); 573 | 574 | // Check no key sequence is a prefix of test-command 575 | assert_eq!( 576 | controller.keymap().check_sequence(&[]), 577 | Some(&BindingQuery::PrefixOf(smallvec![test_command_id])) 578 | ); 579 | // Check C-x is a prefix of test-command 580 | assert_eq!( 581 | controller.keymap().check_sequence(&[Key::Ctrl('x')]), 582 | Some(&BindingQuery::PrefixOf(smallvec![test_command_id])) 583 | ); 584 | // Check C-x C-f is a match for test-command 585 | assert_eq!( 586 | controller 587 | .keymap() 588 | .check_sequence(&[Key::Ctrl('x'), Key::Ctrl('f')]), 589 | Some(&BindingQuery::Match(test_command_id)) 590 | ); 591 | 592 | // Check C-f doesn't match any command 593 | assert_eq!(controller.keymap().check_sequence(&[Key::Ctrl('f')]), None); 594 | // Check C-x C-x doesn't match any command 595 | assert_eq!( 596 | controller 597 | .keymap() 598 | .check_sequence(&[Key::Ctrl('x'), Key::Ctrl('x')]), 599 | None 600 | ); 601 | 602 | controller.execute_command(&Empty, test_command_id, &[]); 603 | assert!(*called.borrow(), "set-controller wasn't called"); 604 | } 605 | } 606 | -------------------------------------------------------------------------------- /zi/src/component/layout.rs: -------------------------------------------------------------------------------- 1 | //! The `Layout` type and flexbox-like utilities for laying out components. 2 | 3 | use smallvec::SmallVec; 4 | use std::{ 5 | cmp, 6 | collections::hash_map::DefaultHasher, 7 | hash::{Hash, Hasher}, 8 | }; 9 | 10 | use super::{ 11 | template::{ComponentDef, DynamicTemplate}, 12 | Component, 13 | }; 14 | use crate::terminal::{Canvas, Position, Rect, Size}; 15 | 16 | pub trait ComponentExt: Component { 17 | /// Creates a component definition from its `Properties`. 18 | fn with(properties: Self::Properties) -> Layout { 19 | Layout(LayoutNode::Component(DynamicTemplate(Box::new( 20 | ComponentDef::::new(None, properties), 21 | )))) 22 | } 23 | 24 | /// Creates a component definition from its `Properties`, using a custom 25 | /// identity specified by a key (in addition to the component's ancestors). 26 | /// 27 | /// Useful to avoid rerendering components of the same type in a container 28 | /// when changing the number of items in the container. 29 | fn with_key(key: impl Into, properties: Self::Properties) -> Layout { 30 | Layout(LayoutNode::Component(DynamicTemplate(Box::new( 31 | ComponentDef::::new(Some(key.into()), properties), 32 | )))) 33 | } 34 | 35 | fn item_with(flex: FlexBasis, properties: Self::Properties) -> Item { 36 | Item { 37 | flex, 38 | node: Layout(LayoutNode::Component(DynamicTemplate(Box::new( 39 | ComponentDef::::new(None, properties), 40 | )))), 41 | } 42 | } 43 | 44 | fn item_with_key( 45 | flex: FlexBasis, 46 | key: impl Into, 47 | properties: Self::Properties, 48 | ) -> Item { 49 | Item { 50 | flex, 51 | node: Layout(LayoutNode::Component(DynamicTemplate(Box::new( 52 | ComponentDef::::new(Some(key.into()), properties), 53 | )))), 54 | } 55 | } 56 | } 57 | 58 | impl ComponentExt for T {} 59 | 60 | /// Wrapper type for user defined component identity. 61 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] 62 | pub struct ComponentKey(usize); 63 | 64 | impl From for ComponentKey { 65 | fn from(key: usize) -> Self { 66 | Self(key) 67 | } 68 | } 69 | 70 | impl From<&str> for ComponentKey { 71 | fn from(key: &str) -> Self { 72 | let mut hasher = DefaultHasher::new(); 73 | key.hash(&mut hasher); 74 | Self(hasher.finish() as usize) 75 | } 76 | } 77 | 78 | /// Represents a layout tree which is the main building block of a UI in Zi. 79 | /// 80 | /// Each node in the layout tree is one 81 | /// 1. A component, any type implementing [`Component`](./Component). 82 | /// 2. A flex container that groups multiple `Layout`s, represented by 83 | /// [`Container`](./Container). 84 | /// 3. A canvas which corresponds to the raw content in a region, represented 85 | /// by [`Canvas`](./Canvas). 86 | pub struct Layout(pub(crate) LayoutNode); 87 | 88 | impl Layout { 89 | /// Creates a new flex container with a specified direction and containing 90 | /// the provided items. 91 | /// 92 | /// This is a utility function that builds a container and converts it to a `Layout`. 93 | /// It is equivalent to calling `Container::new(direction, items).into()`. 94 | #[inline] 95 | pub fn container(direction: FlexDirection, items: impl IntoIterator) -> Self { 96 | Container::new(direction, items).into() 97 | } 98 | 99 | /// Creates a container with column (vertical) layout. 100 | /// 101 | /// Child components are laid out from top to bottom. Pass in the children as an 102 | /// something that can be converted to an iterator of items, e.g. an array of 103 | /// items. 104 | /// 105 | /// This is a utility function that builds a container and converts it to a `Layout`. 106 | /// It is equivalent to calling `Container::column(items).into()`. 107 | #[inline] 108 | pub fn column(items: impl IntoIterator) -> Self { 109 | Container::column(items).into() 110 | } 111 | 112 | /// Creates a container with reversed column (vertical) layout. 113 | /// 114 | /// Child components are laid out from bottom to top. Pass in the children as an 115 | /// something that can be converted to an iterator of items, e.g. an array of 116 | /// items. 117 | /// 118 | /// This is a utility function that builds a container and converts it to a `Layout`. 119 | /// It is equivalent to calling `Container::column_reverse(items).into()`. 120 | #[inline] 121 | pub fn column_reverse(items: impl IntoIterator) -> Self { 122 | Container::column_reverse(items).into() 123 | } 124 | 125 | /// Creates a container with row (horizontal) layout. 126 | /// 127 | /// Child components are laid out from left to right. Pass in the children as an 128 | /// something that can be converted to an iterator of items, e.g. an array of 129 | /// items. 130 | /// 131 | /// This is a utility function that builds a container and converts it to a `Layout`. 132 | /// It is equivalent to calling `Container::row(items).into()`. 133 | #[inline] 134 | pub fn row(items: impl IntoIterator) -> Self { 135 | Container::row(items).into() 136 | } 137 | 138 | /// Creates a container with reversed row (horizontal) layout. 139 | /// 140 | /// Child components are laid out from right to left. Pass in the children as an 141 | /// something that can be converted to an iterator of items, e.g. an array of 142 | /// items. 143 | /// 144 | /// This is a utility function that builds a container and converts it to a `Layout`. 145 | /// It is equivalent to calling `Container::row_reverse(items).into()`. 146 | #[inline] 147 | pub fn row_reverse(items: impl IntoIterator) -> Self { 148 | Container::row_reverse(items).into() 149 | } 150 | } 151 | 152 | pub(crate) enum LayoutNode { 153 | Container(Box), 154 | Component(DynamicTemplate), 155 | Canvas(Canvas), 156 | } 157 | 158 | impl LayoutNode { 159 | pub(crate) fn crawl( 160 | &mut self, 161 | frame: Rect, 162 | position_hash: u64, 163 | view_fn: &mut impl FnMut(LaidComponent), 164 | draw_fn: &mut impl FnMut(LaidCanvas), 165 | ) { 166 | let mut hasher = DefaultHasher::new(); 167 | hasher.write_u64(position_hash); 168 | match self { 169 | Self::Container(container) => { 170 | hasher.write_u64(Self::CONTAINER_HASH); 171 | if container.direction.is_reversed() { 172 | let frames: SmallVec<[_; ITEMS_INLINE_SIZE]> = 173 | splits_iter(frame, container.direction, container.children.iter().rev()) 174 | .collect(); 175 | for (child, frame) in container.children.iter_mut().rev().zip(frames) { 176 | // hasher.write_u64(Self::CONTAINER_ITEM_HASH); 177 | child.node.0.crawl(frame, hasher.finish(), view_fn, draw_fn); 178 | } 179 | } else { 180 | let frames: SmallVec<[_; ITEMS_INLINE_SIZE]> = 181 | splits_iter(frame, container.direction, container.children.iter()) 182 | .collect(); 183 | for (child, frame) in container.children.iter_mut().zip(frames) { 184 | // hasher.write_u64(Self::CONTAINER_ITEM_HASH); 185 | child.node.0.crawl(frame, hasher.finish(), view_fn, draw_fn); 186 | } 187 | } 188 | } 189 | Self::Component(template) => { 190 | template.component_type_id().hash(&mut hasher); 191 | if let Some(key) = template.key() { 192 | key.hash(&mut hasher); 193 | } 194 | view_fn(LaidComponent { 195 | frame, 196 | position_hash: hasher.finish(), 197 | template, 198 | }); 199 | } 200 | Self::Canvas(canvas) => { 201 | draw_fn(LaidCanvas { frame, canvas }); 202 | } 203 | }; 204 | } 205 | 206 | // Some random number to initialise the hash (0 would also do, but hopefully 207 | // this is less pathological if a simpler hash function is used for 208 | // `DefaultHasher`). 209 | const CONTAINER_HASH: u64 = 0x5aa2d5349a05cde8; 210 | } 211 | 212 | impl From for Layout { 213 | fn from(canvas: Canvas) -> Self { 214 | Self(LayoutNode::Canvas(canvas)) 215 | } 216 | } 217 | 218 | const ITEMS_INLINE_SIZE: usize = 4; 219 | type Items = SmallVec<[Item; ITEMS_INLINE_SIZE]>; 220 | 221 | /// A flex container with a specified direction and items. 222 | pub struct Container { 223 | children: Items, 224 | direction: FlexDirection, 225 | } 226 | 227 | impl Container { 228 | /// Creates a new flex container with a specified direction and containing 229 | /// the provided items. 230 | /// 231 | /// # Example 232 | /// 233 | /// ```rust 234 | /// # use zi::prelude::*; 235 | /// # use zi::components::text::{Text, TextProperties}; 236 | /// # fn main() { 237 | /// let container = Container::new( 238 | /// FlexDirection::Column, 239 | /// [ 240 | /// Item::auto(Text::with(TextProperties::new().content("Item 1"))), 241 | /// Item::auto(Text::with(TextProperties::new().content("Item 2"))), 242 | /// ], 243 | /// ); 244 | /// # } 245 | /// ``` 246 | #[inline] 247 | pub fn new(direction: FlexDirection, items: impl IntoIterator) -> Self { 248 | Self { 249 | children: items.into_iter().collect(), 250 | direction, 251 | } 252 | } 253 | 254 | /// Creates a new empty flex container with a specified direction. 255 | #[inline] 256 | pub fn empty(direction: FlexDirection) -> Self { 257 | Self { 258 | children: SmallVec::new(), 259 | direction, 260 | } 261 | } 262 | 263 | /// Adds an item to the end of the container. 264 | /// 265 | /// # Example 266 | /// 267 | /// ```rust 268 | /// # use zi::prelude::*; 269 | /// # use zi::components::text::{Text, TextProperties}; 270 | /// # fn main() { 271 | /// let mut container = Container::empty(FlexDirection::Row); 272 | /// container 273 | /// .push(Item::auto(Text::with(TextProperties::new().content("Item 1")))) 274 | /// .push(Item::auto(Text::with(TextProperties::new().content("Item 2")))); 275 | /// # } 276 | /// ``` 277 | #[inline] 278 | pub fn push(&mut self, item: Item) -> &mut Self { 279 | self.children.push(item); 280 | self 281 | } 282 | 283 | /// Creates a container with column (vertical) layout. 284 | /// 285 | /// Child components are laid out from top to bottom. Pass in the children as an 286 | /// something that can be converted to an iterator of items, e.g. an array of 287 | /// items. 288 | /// 289 | /// This is a utility function and it is equivalent to calling 290 | /// `Container::new(FlexDirection::Column, items)`. 291 | #[inline] 292 | pub fn column(items: impl IntoIterator) -> Self { 293 | Self::new(FlexDirection::Column, items) 294 | } 295 | 296 | /// Creates a container with reversed column (vertical) layout. 297 | /// 298 | /// Child components are laid out from bottom to top. Pass in the children as an 299 | /// something that can be converted to an iterator of items, e.g. an array of 300 | /// items. 301 | #[inline] 302 | pub fn column_reverse(items: impl IntoIterator) -> Self { 303 | Self::new(FlexDirection::ColumnReverse, items) 304 | } 305 | 306 | /// Creates a container with row (horizontal) layout. 307 | /// 308 | /// Child components are laid out from left to right. Pass in the children as an 309 | /// something that can be converted to an iterator of items, e.g. an array of 310 | /// items. 311 | #[inline] 312 | pub fn row(items: impl IntoIterator) -> Self { 313 | Self::new(FlexDirection::Row, items) 314 | } 315 | 316 | /// Creates a container with reversed row (horizontal) layout. 317 | /// 318 | /// Child components are laid out from right to left. Pass in the children as an 319 | /// something that can be converted to an iterator of items, e.g. an array of 320 | /// items. 321 | #[inline] 322 | pub fn row_reverse(items: impl IntoIterator) -> Self { 323 | Self::new(FlexDirection::RowReverse, items) 324 | } 325 | } 326 | 327 | impl From for Layout { 328 | fn from(container: Container) -> Self { 329 | Layout(LayoutNode::Container(Box::new(container))) 330 | } 331 | } 332 | 333 | /// Represents a flex item, a layout tree nested inside a container. 334 | /// 335 | /// An `Item` consists of a `Layout` and an associated `FlexBasis`. The latter 336 | /// specifies how much space the layout should take along the main axis of the 337 | /// container. 338 | pub struct Item { 339 | node: Layout, 340 | flex: FlexBasis, 341 | } 342 | 343 | impl Item { 344 | /// Creates an item that will share the available space equally with other 345 | /// sibling items with `FlexBasis::auto`. 346 | #[inline] 347 | pub fn auto(layout: impl Into) -> Item { 348 | Item { 349 | node: layout.into(), 350 | flex: FlexBasis::Auto, 351 | } 352 | } 353 | 354 | /// Creates an item that will have a fixed size. 355 | #[inline] 356 | pub fn fixed(size: usize) -> impl FnOnce(LayoutT) -> Item 357 | where 358 | LayoutT: Into, 359 | { 360 | move |layout| Item { 361 | node: layout.into(), 362 | flex: FlexBasis::Fixed(size), 363 | } 364 | } 365 | } 366 | 367 | /// Enum to control the size of an item inside a container. 368 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 369 | pub enum FlexBasis { 370 | Auto, 371 | Fixed(usize), 372 | } 373 | 374 | /// Enum to control how items are placed in a container. It defines the main 375 | /// axis and the direction (normal or reversed). 376 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 377 | pub enum FlexDirection { 378 | Column, 379 | ColumnReverse, 380 | Row, 381 | RowReverse, 382 | } 383 | 384 | impl FlexDirection { 385 | #[inline] 386 | pub fn is_reversed(&self) -> bool { 387 | match self { 388 | FlexDirection::Column | FlexDirection::Row => false, 389 | FlexDirection::ColumnReverse | FlexDirection::RowReverse => true, 390 | } 391 | } 392 | 393 | #[inline] 394 | pub(crate) fn dimension(self, size: Size) -> usize { 395 | match self { 396 | FlexDirection::Row => size.width, 397 | FlexDirection::RowReverse => size.width, 398 | FlexDirection::Column => size.height, 399 | FlexDirection::ColumnReverse => size.height, 400 | } 401 | } 402 | } 403 | 404 | pub(crate) struct LaidComponent<'a> { 405 | pub frame: Rect, 406 | pub position_hash: u64, 407 | pub template: &'a mut DynamicTemplate, 408 | } 409 | 410 | pub(crate) struct LaidCanvas<'a> { 411 | pub frame: Rect, 412 | pub canvas: &'a Canvas, 413 | } 414 | 415 | #[inline] 416 | fn splits_iter<'a>( 417 | frame: Rect, 418 | direction: FlexDirection, 419 | children: impl Iterator + Clone + 'a, 420 | ) -> impl Iterator + 'a { 421 | let total_size = direction.dimension(frame.size); 422 | 423 | // Compute how much space is available for stretched components 424 | let (stretched_budget, num_stretched_children, total_fixed_size) = { 425 | let mut stretched_budget = total_size; 426 | let mut num_stretched_children = 0; 427 | let mut total_fixed_size = 0; 428 | for child in children.clone() { 429 | match child.flex { 430 | FlexBasis::Auto => { 431 | num_stretched_children += 1; 432 | } 433 | FlexBasis::Fixed(size) => { 434 | stretched_budget = stretched_budget.saturating_sub(size); 435 | total_fixed_size += size; 436 | } 437 | } 438 | } 439 | (stretched_budget, num_stretched_children, total_fixed_size) 440 | }; 441 | 442 | // Divvy up the space equaly between stretched components. 443 | let stretched_size = if num_stretched_children > 0 { 444 | stretched_budget / num_stretched_children 445 | } else { 446 | 0 447 | }; 448 | let mut remainder = 449 | total_size.saturating_sub(num_stretched_children * stretched_size + total_fixed_size); 450 | let mut remaining_size = total_size; 451 | 452 | children 453 | .map(move |child| match child.flex { 454 | FlexBasis::Auto => { 455 | let offset = total_size - remaining_size; 456 | let size = if remainder > 0 { 457 | remainder -= 1; 458 | stretched_size + 1 459 | } else { 460 | stretched_size 461 | }; 462 | remaining_size -= size; 463 | (offset, size) 464 | } 465 | FlexBasis::Fixed(size) => { 466 | let offset = total_size - remaining_size; 467 | let size = cmp::min(remaining_size, size); 468 | remaining_size -= size; 469 | (offset, size) 470 | } 471 | }) 472 | .map(move |(offset, size)| match direction { 473 | FlexDirection::Row | FlexDirection::RowReverse => Rect::new( 474 | Position::new(frame.origin.x + offset, frame.origin.y), 475 | Size::new(size, frame.size.height), 476 | ), 477 | FlexDirection::Column | FlexDirection::ColumnReverse => Rect::new( 478 | Position::new(frame.origin.x, frame.origin.y + offset), 479 | Size::new(frame.size.width, size), 480 | ), 481 | }) 482 | } 483 | -------------------------------------------------------------------------------- /zi/src/component/mod.rs: -------------------------------------------------------------------------------- 1 | //! Defines the `Component` trait and related types. 2 | pub mod bindings; 3 | pub mod layout; 4 | pub(crate) mod template; 5 | 6 | pub use self::layout::{ComponentExt, Layout}; 7 | 8 | use std::{ 9 | any::{self, TypeId}, 10 | fmt, 11 | marker::PhantomData, 12 | rc::Rc, 13 | }; 14 | 15 | use self::{ 16 | bindings::{Bindings, NamedBindingQuery}, 17 | template::{ComponentId, DynamicMessage}, 18 | }; 19 | use crate::{ 20 | app::{ComponentMessage, MessageSender}, 21 | terminal::{Key, Rect}, 22 | }; 23 | 24 | /// Components are the building blocks of the UI in Zi. 25 | /// 26 | /// The trait describes stateful components and their lifecycle. This is the 27 | /// main trait that users of the library will implement to describe their UI. 28 | /// All components are owned directly by an [`App`](../struct.App.html) which 29 | /// manages their lifecycle. An `App` instance will create new components, 30 | /// update them in reaction to user input or to messages from other components 31 | /// and eventually drop them when a component gone off screen. 32 | /// 33 | /// Anyone familiar with Yew, Elm or React + Redux should be familiar with all 34 | /// the high-level concepts. Moreover, the names of some types and functions are 35 | /// the same as in `Yew`. 36 | /// 37 | /// A component has to describe how: 38 | /// - how to create a fresh instance from `Component::Properties` received from their parent (`create` fn) 39 | /// - how to render it (`view` fn) 40 | /// - how to update its inter 41 | /// 42 | pub trait Component: Sized + 'static { 43 | /// Messages are used to make components dynamic and interactive. For simple 44 | /// components, this will be `()`. Complex ones will typically use 45 | /// an enum to declare multiple Message types. 46 | type Message: Send + 'static; 47 | 48 | /// Properties are the inputs to a Component. 49 | type Properties; 50 | 51 | /// Components are created with three pieces of data: 52 | /// - their Properties 53 | /// - the current position and size on the screen 54 | /// - a `ComponentLink` which can be used to send messages and create callbacks for triggering updates 55 | /// 56 | /// Conceptually, there's an "update" method for each one of these: 57 | /// - `change` when the Properties change 58 | /// - `resize` when their current position and size on the screen changes 59 | /// - `update` when the a message was sent to the component 60 | fn create(properties: Self::Properties, frame: Rect, link: ComponentLink) -> Self; 61 | 62 | /// Returns the current visual layout of the component. 63 | fn view(&self) -> Layout; 64 | 65 | /// When the parent of a Component is re-rendered, it will either be re-created or 66 | /// receive new properties in the `change` lifecycle method. Component's can choose 67 | /// to re-render if the new properties are different than the previously 68 | /// received properties. 69 | /// 70 | /// Root components don't have a parent and subsequently, their `change` 71 | /// method will never be called. Components which don't have properties 72 | /// should always return false. 73 | fn change(&mut self, _properties: Self::Properties) -> ShouldRender { 74 | ShouldRender::No 75 | } 76 | 77 | /// This method is called when a component's position and size on the screen changes. 78 | fn resize(&mut self, _frame: Rect) -> ShouldRender { 79 | ShouldRender::No 80 | } 81 | 82 | /// Components handle messages in their `update` method and commonly use this method 83 | /// to update their state and (optionally) re-render themselves. 84 | fn update(&mut self, _message: Self::Message) -> ShouldRender { 85 | ShouldRender::No 86 | } 87 | 88 | /// Updates the key bindings of the component. 89 | /// 90 | /// This method will be called after the component lifecycle methods. It is 91 | /// used to specify how to react in response to keyboard events, typically 92 | /// by sending a message. 93 | fn bindings(&self, _bindings: &mut Bindings) {} 94 | 95 | fn notify_binding_queries(&self, _queries: &[Option], _keys: &[Key]) {} 96 | 97 | fn tick(&self) -> Option { 98 | None 99 | } 100 | } 101 | 102 | /// Callback wrapper. Useful for passing callbacks in child components 103 | /// `Properties`. An `Rc` wrapper is used to make it cloneable. 104 | pub struct Callback(pub Rc OutputT>); 105 | 106 | impl Clone for Callback { 107 | fn clone(&self) -> Self { 108 | Self(self.0.clone()) 109 | } 110 | } 111 | 112 | impl fmt::Debug for Callback { 113 | fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 114 | write!( 115 | formatter, 116 | "Callback({} -> {} @ {:?})", 117 | any::type_name::(), 118 | any::type_name::(), 119 | Rc::as_ptr(&self.0) 120 | ) 121 | } 122 | } 123 | 124 | impl PartialEq for Callback { 125 | fn eq(&self, other: &Self) -> bool { 126 | // `Callback` is a fat pointer: vtable address + data address. We're 127 | // only comparing the pointers to the data portion for equality. 128 | // 129 | // This could fail if some of your objects can have the same address but 130 | // different concrete types, for example if one is stored in a field of 131 | // another, or if they are different zero-sized types. 132 | // 133 | // Comparing vtable addresses doesn't work either as "vtable addresses 134 | // are not guaranteed to be unique and could vary between different code 135 | // generation units. Furthermore vtables for different types could have 136 | // the same address after being merged together". 137 | // 138 | // References 139 | // - https://rust-lang.github.io/rust-clippy/master/index.html#vtable_address_comparisons 140 | // - https://users.rust-lang.org/t/rc-dyn-trait-ptr-equality 141 | std::ptr::eq( 142 | self.0.as_ref() as *const _ as *const (), 143 | other.0.as_ref() as *const _ as *const (), 144 | ) 145 | } 146 | } 147 | 148 | impl Callback { 149 | pub fn emit(&self, value: InputT) -> OutputT { 150 | (self.0)(value) 151 | } 152 | } 153 | 154 | impl From for Callback 155 | where 156 | FnT: Fn(InputT) -> OutputT + 'static, 157 | { 158 | fn from(function: FnT) -> Self { 159 | Self(Rc::new(function)) 160 | } 161 | } 162 | 163 | /// A context for sending messages to a component or the runtime. 164 | /// 165 | /// It can be used in a multi-threaded environment (implements `Sync` and 166 | /// `Send`). Additionally, it can send messages to the runtime, in particular 167 | /// it's used to gracefully stop a running [`App`](struct.App.html). 168 | #[derive(Debug)] 169 | pub struct ComponentLink { 170 | sender: Box, 171 | component_id: ComponentId, 172 | _component: PhantomData ComponentT>, 173 | } 174 | 175 | impl ComponentLink { 176 | /// Sends a message to the component. 177 | pub fn send(&self, message: ComponentT::Message) { 178 | self.sender.send(ComponentMessage(LinkMessage::Component( 179 | self.component_id, 180 | DynamicMessage(Box::new(message)), 181 | ))); 182 | } 183 | 184 | /// Creates a `Callback` which will send a message to the linked component's 185 | /// update method when invoked. 186 | pub fn callback( 187 | &self, 188 | callback: impl Fn(InputT) -> ComponentT::Message + 'static, 189 | ) -> Callback { 190 | let link = self.clone(); 191 | Callback(Rc::new(move |input| link.send(callback(input)))) 192 | } 193 | 194 | /// Sends a message to the `App` runtime requesting it to stop executing. 195 | /// 196 | /// This method only sends a message and returns immediately, the app will 197 | /// stop asynchronously and may deliver other pending messages before 198 | /// exiting. 199 | pub fn exit(&self) { 200 | self.sender.send(ComponentMessage(LinkMessage::Exit)); 201 | } 202 | 203 | pub(crate) fn new(sender: Box, component_id: ComponentId) -> Self { 204 | assert_eq!(TypeId::of::(), component_id.type_id()); 205 | Self { 206 | sender, 207 | component_id, 208 | _component: PhantomData, 209 | } 210 | } 211 | } 212 | 213 | impl Clone for ComponentLink { 214 | fn clone(&self) -> Self { 215 | Self { 216 | sender: self.sender.clone_box(), 217 | component_id: self.component_id, 218 | _component: PhantomData, 219 | } 220 | } 221 | } 222 | 223 | impl PartialEq for ComponentLink { 224 | fn eq(&self, other: &Self) -> bool { 225 | self.component_id == other.component_id 226 | } 227 | } 228 | 229 | /// Type to indicate whether a component should be rendered again. 230 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 231 | pub enum ShouldRender { 232 | Yes, 233 | No, 234 | } 235 | 236 | impl From for bool { 237 | fn from(should_render: ShouldRender) -> Self { 238 | matches!(should_render, ShouldRender::Yes) 239 | } 240 | } 241 | 242 | impl From for ShouldRender { 243 | fn from(should_render: bool) -> Self { 244 | if should_render { 245 | ShouldRender::Yes 246 | } else { 247 | ShouldRender::No 248 | } 249 | } 250 | } 251 | 252 | impl std::ops::BitOr for ShouldRender { 253 | type Output = Self; 254 | 255 | fn bitor(self, other: Self) -> Self { 256 | ((self == ShouldRender::Yes) || (other == ShouldRender::Yes)).into() 257 | } 258 | } 259 | 260 | impl std::ops::BitAnd for ShouldRender { 261 | type Output = Self; 262 | 263 | fn bitand(self, other: Self) -> Self { 264 | ((self == ShouldRender::Yes) && (other == ShouldRender::Yes)).into() 265 | } 266 | } 267 | 268 | pub(crate) enum LinkMessage { 269 | Component(ComponentId, DynamicMessage), 270 | Exit, 271 | } 272 | 273 | impl std::fmt::Debug for LinkMessage { 274 | fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 275 | write!(formatter, "LinkMessage::")?; 276 | match self { 277 | Self::Component(id, message) => write!( 278 | formatter, 279 | "Component({:?}, DynamicMessage(...) @ {:?})", 280 | id, &*message.0 as *const _ 281 | ), 282 | Self::Exit => write!(formatter, "Exit"), 283 | } 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /zi/src/component/template.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | any::{Any, TypeId}, 3 | hash::{Hash, Hasher}, 4 | ops::{Deref, DerefMut}, 5 | }; 6 | 7 | use super::{ 8 | bindings::{CommandId, DynamicBindings, NamedBindingQuery}, 9 | layout::{ComponentKey, Layout}, 10 | Component, ComponentLink, MessageSender, ShouldRender, 11 | }; 12 | use crate::terminal::{Key, Rect}; 13 | 14 | #[derive(Clone, Copy, Debug)] 15 | pub(crate) struct ComponentId { 16 | type_id: TypeId, 17 | id: u64, 18 | 19 | // The `type_name` field is used only for debugging -- in particular 20 | // note that it's not a valid unique id for a type. See 21 | // https://doc.rust-lang.org/std/any/fn.type_name.html 22 | type_name: &'static str, 23 | } 24 | 25 | // `PartialEq` is impl'ed manually as `type_name` is only used for 26 | // debugging and is ignored when testing for equality. 27 | impl PartialEq for ComponentId { 28 | fn eq(&self, other: &Self) -> bool { 29 | self.type_id == other.type_id && self.id == other.id 30 | } 31 | } 32 | 33 | impl Eq for ComponentId {} 34 | 35 | impl Hash for ComponentId { 36 | fn hash(&self, hasher: &mut HasherT) { 37 | self.type_id.hash(hasher); 38 | self.id.hash(hasher); 39 | } 40 | } 41 | 42 | impl ComponentId { 43 | #[inline] 44 | pub(crate) fn new(id: u64) -> Self { 45 | Self { 46 | type_id: TypeId::of::(), 47 | type_name: std::any::type_name::(), 48 | id, 49 | } 50 | } 51 | 52 | #[inline] 53 | pub(crate) fn type_id(&self) -> TypeId { 54 | self.type_id 55 | } 56 | 57 | pub(crate) fn type_name(&self) -> &'static str { 58 | self.type_name 59 | } 60 | } 61 | 62 | impl std::fmt::Display for ComponentId { 63 | fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 64 | write!(formatter, "{} / {:x}", self.type_name(), self.id >> 32) 65 | } 66 | } 67 | 68 | pub(crate) struct DynamicMessage(pub(crate) Box); 69 | pub(crate) struct DynamicProperties(Box); 70 | // pub(crate) struct DynamicBindings(pub(crate) Box); 71 | pub(crate) struct DynamicTemplate(pub(crate) Box); 72 | 73 | impl Deref for DynamicTemplate { 74 | type Target = dyn Template; 75 | 76 | fn deref(&self) -> &Self::Target { 77 | self.0.deref() 78 | } 79 | } 80 | 81 | impl DerefMut for DynamicTemplate { 82 | fn deref_mut(&mut self) -> &mut ::Target { 83 | self.0.deref_mut() 84 | } 85 | } 86 | 87 | pub(crate) trait Renderable { 88 | fn change(&mut self, properties: DynamicProperties) -> ShouldRender; 89 | 90 | fn resize(&mut self, frame: Rect) -> ShouldRender; 91 | 92 | fn update(&mut self, message: DynamicMessage) -> ShouldRender; 93 | 94 | fn view(&self) -> Layout; 95 | 96 | fn bindings(&self, bindings: &mut DynamicBindings); 97 | 98 | fn notify_binding_queries(&self, bindings: &[Option], keys: &[Key]); 99 | 100 | fn run_command( 101 | &self, 102 | bindings: &DynamicBindings, 103 | command_id: CommandId, 104 | pressed: &[Key], 105 | ) -> Option; 106 | 107 | fn tick(&self) -> Option; 108 | } 109 | 110 | impl Renderable for ComponentT { 111 | #[inline] 112 | fn update(&mut self, message: DynamicMessage) -> ShouldRender { 113 | ::update( 114 | self, 115 | *message 116 | .0 117 | .downcast() 118 | .expect("Incorrect `Message` type when downcasting"), 119 | ) 120 | } 121 | 122 | #[inline] 123 | fn change(&mut self, properties: DynamicProperties) -> ShouldRender { 124 | ::change( 125 | self, 126 | *properties 127 | .0 128 | .downcast() 129 | .expect("Incorrect `Properties` type when downcasting"), 130 | ) 131 | } 132 | 133 | #[inline] 134 | fn resize(&mut self, frame: Rect) -> ShouldRender { 135 | ::resize(self, frame) 136 | } 137 | 138 | #[inline] 139 | fn view(&self) -> Layout { 140 | ::view(self) 141 | } 142 | 143 | #[inline] 144 | fn bindings(&self, bindings: &mut DynamicBindings) { 145 | bindings.typed(|bindings| ::bindings(self, bindings)); 146 | } 147 | 148 | fn notify_binding_queries(&self, bindings: &[Option], keys: &[Key]) { 149 | ::notify_binding_queries(self, bindings, keys); 150 | } 151 | 152 | #[inline] 153 | fn run_command( 154 | &self, 155 | bindings: &DynamicBindings, 156 | command_id: CommandId, 157 | keys: &[Key], 158 | ) -> Option { 159 | bindings.execute_command(self, command_id, keys) 160 | } 161 | 162 | #[inline] 163 | fn tick(&self) -> Option { 164 | ::tick(self).map(|message| DynamicMessage(Box::new(message))) 165 | } 166 | } 167 | 168 | pub(crate) trait Template { 169 | fn key(&self) -> Option; 170 | 171 | fn component_type_id(&self) -> TypeId; 172 | 173 | fn generate_id(&self, id: u64) -> ComponentId; 174 | 175 | fn create( 176 | &mut self, 177 | id: ComponentId, 178 | frame: Rect, 179 | sender: Box, 180 | ) -> (Box, DynamicBindings); 181 | 182 | fn dynamic_properties(&mut self) -> DynamicProperties; 183 | } 184 | 185 | pub(crate) struct ComponentDef { 186 | pub key: Option, 187 | pub properties: Option, 188 | } 189 | 190 | impl ComponentDef { 191 | pub(crate) fn new(key: Option, properties: ComponentT::Properties) -> Self { 192 | Self { 193 | key, 194 | properties: properties.into(), 195 | } 196 | } 197 | 198 | fn properties_unwrap(&mut self) -> ComponentT::Properties { 199 | let mut properties = None; 200 | std::mem::swap(&mut properties, &mut self.properties); 201 | properties.expect("Already called a method that used the `Properties` value") 202 | } 203 | } 204 | 205 | impl Template for ComponentDef { 206 | #[inline] 207 | fn key(&self) -> Option { 208 | self.key 209 | } 210 | 211 | #[inline] 212 | fn component_type_id(&self) -> TypeId { 213 | TypeId::of::() 214 | } 215 | 216 | #[inline] 217 | fn generate_id(&self, position_hash: u64) -> ComponentId { 218 | ComponentId::new::(position_hash) 219 | } 220 | 221 | #[inline] 222 | fn create( 223 | &mut self, 224 | component_id: ComponentId, 225 | frame: Rect, 226 | sender: Box, 227 | ) -> (Box, DynamicBindings) { 228 | let link = ComponentLink::new(sender, component_id); 229 | ( 230 | Box::new(ComponentT::create(self.properties_unwrap(), frame, link)), 231 | DynamicBindings::new::(), 232 | ) 233 | } 234 | 235 | #[inline] 236 | fn dynamic_properties(&mut self) -> DynamicProperties { 237 | DynamicProperties(Box::new(self.properties_unwrap())) 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /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