├── .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 |
3 |
4 |
5 |
6 | Modern terminal user interfaces in Rust.
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | Zi is a Rust library for building modern terminal user interfaces in an incremental, declarative fashion.
22 |
23 | # Screenshots
24 | 
25 | 
26 | 
27 | 
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 | 
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