├── examples ├── images │ ├── demo.gif │ ├── demo.png │ ├── input.gif │ ├── palette.gif │ ├── threads.gif │ ├── unicode.png │ ├── checkbox.gif │ ├── fps_counter.gif │ ├── event_handler.png │ ├── unicode.tape │ ├── fps_counter.tape │ ├── event_handler.tape │ ├── threads.tape │ ├── palette.tape │ ├── checkbox.tape │ ├── input.tape │ └── demo.tape ├── build.zig.zon ├── src │ ├── event_handler.zig │ ├── input.zig │ ├── palette.zig │ ├── fps_counter.zig │ ├── threads.zig │ ├── checkbox.zig │ ├── unicode.zig │ └── demo.zig ├── build.zig └── README.md ├── src ├── render.zig ├── widgets │ ├── Padding.zig │ ├── FocusHandler.zig │ ├── Constraints.zig │ ├── LayoutProperties.zig │ ├── border.zig │ ├── Spacer.zig │ ├── Themed.zig │ ├── callbacks.zig │ ├── Button.zig │ ├── Checkbox.zig │ ├── Label.zig │ ├── CheckboxGroup.zig │ ├── Block.zig │ ├── Input.zig │ └── Widget.zig ├── display.zig ├── render │ ├── Cell.zig │ └── Frame.zig ├── backends.zig ├── events.zig ├── Vec2.zig ├── internal.zig ├── widgets.zig ├── display │ ├── Theme.zig │ ├── Style.zig │ └── colors.zig ├── Rect.zig ├── backends │ ├── Testing.zig │ ├── tty.zig │ ├── Backend.zig │ ├── Crossterm.zig │ └── Ncurses.zig ├── text_clustering.zig └── tuile.zig ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── other.md │ ├── feature-request.md │ └── bug-report.md ├── workflows │ ├── build-and-test-master.yml │ ├── build-and-test-stable.yml │ └── build-and-test.yml └── PULL_REQUEST_TEMPLATE.md ├── LICENSE ├── docs └── Backends.md └── README.md /examples/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akarpovskii/tuile/HEAD/examples/images/demo.gif -------------------------------------------------------------------------------- /examples/images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akarpovskii/tuile/HEAD/examples/images/demo.png -------------------------------------------------------------------------------- /examples/images/input.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akarpovskii/tuile/HEAD/examples/images/input.gif -------------------------------------------------------------------------------- /examples/images/palette.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akarpovskii/tuile/HEAD/examples/images/palette.gif -------------------------------------------------------------------------------- /examples/images/threads.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akarpovskii/tuile/HEAD/examples/images/threads.gif -------------------------------------------------------------------------------- /examples/images/unicode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akarpovskii/tuile/HEAD/examples/images/unicode.png -------------------------------------------------------------------------------- /examples/images/checkbox.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akarpovskii/tuile/HEAD/examples/images/checkbox.gif -------------------------------------------------------------------------------- /examples/images/fps_counter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akarpovskii/tuile/HEAD/examples/images/fps_counter.gif -------------------------------------------------------------------------------- /src/render.zig: -------------------------------------------------------------------------------- 1 | pub const Frame = @import("render/Frame.zig"); 2 | pub const Cell = @import("render/Cell.zig"); 3 | -------------------------------------------------------------------------------- /src/widgets/Padding.zig: -------------------------------------------------------------------------------- 1 | top: u32 = 0, 2 | 3 | bottom: u32 = 0, 4 | 5 | left: u32 = 0, 6 | 7 | right: u32 = 0, 8 | -------------------------------------------------------------------------------- /examples/images/event_handler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akarpovskii/tuile/HEAD/examples/images/event_handler.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | zig-cache/ 2 | .zig-cache/ 3 | zig-out/ 4 | build/ 5 | build-*/ 6 | docgen_tmp/ 7 | *.log 8 | .DS_Store 9 | target/ -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Question/other \U00002753" 3 | about: Anything else 4 | labels: other 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /src/display.zig: -------------------------------------------------------------------------------- 1 | pub const Style = @import("display/Style.zig"); 2 | pub const span = @import("display/span.zig"); 3 | pub usingnamespace span; 4 | pub const Theme = @import("display/Theme.zig"); 5 | pub const colors = @import("display/colors.zig"); 6 | pub usingnamespace colors; 7 | -------------------------------------------------------------------------------- /examples/build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | .name = "tuile-examples", 3 | .version = "0.0.0", 4 | .minimum_zig_version = "0.12.0", 5 | 6 | .dependencies = .{ 7 | .tuile = .{ .path = "../" }, 8 | }, 9 | 10 | .paths = .{ 11 | "build.zig", 12 | "build.zig.zon", 13 | "src", 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request \U0001F680" 3 | about: Suggest an idea 4 | labels: enhancement 5 | 6 | --- 7 | 8 | ## Summary 9 | Brief explanation of the feature. 10 | 11 | ### Basic example 12 | Include a basic example or links here. 13 | 14 | ### Motivation 15 | Why are we doing this? What use cases does it support? What is the expected outcome? -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report \U0001F41E" 3 | about: Create a bug report 4 | labels: bug 5 | 6 | --- 7 | 8 | ### Steps to reproduce and observed behavior 9 | What exactly can someone else do, in order to observe the problem that you observed? 10 | 11 | ### Expected behavior 12 | What did you expected to happen? 13 | 14 | ### Environment 15 | - OS: [e.g. Arch Linux] 16 | - Other details that you think may affect. 17 | 18 | ### Additional context 19 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/workflows/build-and-test-master.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test - Zig master 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | # Cancels pending runs when a PR gets updated. 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}-${{ github.actor }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build-and-test-master: 16 | uses: ./.github/workflows/build-and-test.yml 17 | with: 18 | zig-version: master 19 | 20 | build-and-test-master-success: 21 | needs: [build-and-test-master] 22 | runs-on: [ubuntu-latest] 23 | steps: 24 | - name: Success 25 | run: echo 'Success' 26 | -------------------------------------------------------------------------------- /src/render/Cell.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const display = @import("../display.zig"); 3 | const Cell = @This(); 4 | 5 | /// Doesn't own the memory 6 | symbol: ?[]const u8 = null, 7 | 8 | fg: display.Color, 9 | 10 | bg: display.Color, 11 | 12 | effect: display.Style.Effect = .{}, 13 | 14 | pub fn setStyle(self: *Cell, style: display.Style) void { 15 | if (style.fg) |fg| { 16 | self.fg = fg; 17 | } 18 | if (style.bg) |bg| { 19 | self.bg = bg; 20 | } 21 | if (style.add_effect) |effect| { 22 | self.effect = self.effect.add(effect); 23 | } 24 | if (style.sub_effect) |effect| { 25 | self.effect = self.effect.sub(effect); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/backends.zig: -------------------------------------------------------------------------------- 1 | pub const Backend = @import("backends/Backend.zig"); 2 | pub const Testing = @import("backends/Testing.zig"); 3 | 4 | const build_options = @import("build_options"); 5 | const backend = build_options.backend; 6 | 7 | pub const Ncurses = if (backend == .ncurses) 8 | @import("backends/Ncurses.zig") 9 | else 10 | undefined; 11 | 12 | pub const Crossterm = if (backend == .crossterm) 13 | @import("backends/Crossterm.zig") 14 | else 15 | undefined; 16 | 17 | pub fn createBackend() !Backend { 18 | if (comptime backend == .ncurses) { 19 | return (try Ncurses.create()).backend(); 20 | } else if (comptime backend == .crossterm) { 21 | return (try Crossterm.create()).backend(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test-stable.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test - Zig 0.12.0 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | workflow_dispatch: 10 | 11 | concurrency: 12 | # Cancels pending runs when a PR gets updated. 13 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}-${{ github.actor }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | build-and-test-stable: 18 | uses: ./.github/workflows/build-and-test.yml 19 | with: 20 | zig-version: 0.12.0 21 | 22 | build-and-test-stable-success: 23 | needs: [build-and-test-stable] 24 | runs-on: [ubuntu-latest] 25 | steps: 26 | - name: Success 27 | run: echo 'Success' 28 | -------------------------------------------------------------------------------- /examples/images/unicode.tape: -------------------------------------------------------------------------------- 1 | Set Width 1080 2 | Set Height 1200 3 | 4 | Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#f9feff", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } 5 | 6 | Set Padding 40 7 | Set MarginFill "#F9FEFF" 8 | 9 | Hide 10 | Type "zig build unicode" 11 | Enter 12 | Sleep 1s 13 | Show 14 | 15 | Screenshot unicode.png 16 | 17 | Sleep 1s 18 | 19 | Hide 20 | Ctrl+C 21 | -------------------------------------------------------------------------------- /examples/images/fps_counter.tape: -------------------------------------------------------------------------------- 1 | Output fps_counter.gif 2 | 3 | Set Width 700 4 | Set Height 320 5 | 6 | Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#f9feff", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } 7 | 8 | Set Padding 40 9 | Set MarginFill "#F9FEFF" 10 | 11 | Hide 12 | Type "zig build fps_counter" 13 | Enter 14 | Sleep 1s 15 | Show 16 | 17 | Sleep 10s 18 | 19 | Hide 20 | Ctrl+C 21 | -------------------------------------------------------------------------------- /src/events.zig: -------------------------------------------------------------------------------- 1 | pub const Key = enum { 2 | Enter, 3 | Escape, 4 | Backspace, 5 | Tab, 6 | Left, 7 | Right, 8 | Up, 9 | Down, 10 | Insert, 11 | Delete, 12 | Home, 13 | End, 14 | PageUp, 15 | PageDown, 16 | F0, 17 | F1, 18 | F2, 19 | F3, 20 | F4, 21 | F5, 22 | F6, 23 | F7, 24 | F8, 25 | F9, 26 | F10, 27 | F11, 28 | F12, 29 | Resize, 30 | }; 31 | 32 | pub const FocusDirection = enum { front, back }; 33 | 34 | pub const Event = union(enum) { 35 | char: u21, 36 | key: Key, 37 | ctrl_char: u21, 38 | shift_key: Key, 39 | 40 | focus_in: FocusDirection, 41 | focus_out, 42 | }; 43 | 44 | pub const EventResult = enum { 45 | ignored, 46 | consumed, 47 | }; 48 | -------------------------------------------------------------------------------- /examples/images/event_handler.tape: -------------------------------------------------------------------------------- 1 | Set Width 700 2 | Set Height 320 3 | 4 | Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#f9feff", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } 5 | 6 | Set Padding 40 7 | Set MarginFill "#F9FEFF" 8 | 9 | Hide 10 | Type "zig build event_handler" 11 | Enter 12 | Sleep 1s 13 | Show 14 | 15 | Screenshot event_handler.png 16 | 17 | Sleep 1s 18 | 19 | Type 'q' 20 | 21 | Hide 22 | Type 'clear' 23 | Enter 24 | Show 25 | 26 | Sleep 1s 27 | Hide 28 | -------------------------------------------------------------------------------- /examples/images/threads.tape: -------------------------------------------------------------------------------- 1 | Output threads.gif 2 | 3 | Set Width 700 4 | Set Height 320 5 | 6 | Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#f9feff", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } 7 | 8 | Set Padding 40 9 | Set MarginFill "#F9FEFF" 10 | 11 | Hide 12 | Type "zig build threads" 13 | Enter 14 | 15 | Sleep 3s 16 | 17 | Tab@500ms 18 | Space@500ms 19 | Show 20 | 21 | Sleep 2s 22 | Tab@500ms 23 | Space@500ms 24 | 25 | Sleep 2s 26 | Tab@500ms 27 | Space 28 | 29 | Hide 30 | Ctrl+C 31 | -------------------------------------------------------------------------------- /src/widgets/FocusHandler.zig: -------------------------------------------------------------------------------- 1 | const Rect = @import("../Rect.zig"); 2 | const events = @import("../events.zig"); 3 | const Frame = @import("../render/Frame.zig"); 4 | const display = @import("../display.zig"); 5 | 6 | const FocusHandler = @This(); 7 | 8 | focused: bool = false, 9 | 10 | pub fn handleEvent(self: *FocusHandler, event: events.Event) events.EventResult { 11 | switch (event) { 12 | .focus_in => { 13 | self.focused = true; 14 | return .consumed; 15 | }, 16 | .focus_out => { 17 | self.focused = false; 18 | return .consumed; 19 | }, 20 | else => { 21 | return .ignored; 22 | }, 23 | } 24 | } 25 | 26 | pub fn render(self: *FocusHandler, area: Rect, frame: Frame, theme: display.Theme) void { 27 | if (self.focused) { 28 | frame.setStyle(area, .{ .bg = theme.focused }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/src/event_handler.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const tuile = @import("tuile"); 3 | 4 | pub fn stopOnQ(ptr: ?*anyopaque, event: tuile.events.Event) !tuile.events.EventResult { 5 | var tui: *tuile.Tuile = @ptrCast(@alignCast(ptr)); 6 | switch (event) { 7 | .char => |char| if (char == 'q') { 8 | tui.stop(); 9 | return .consumed; 10 | }, 11 | else => {}, 12 | } 13 | return .ignored; 14 | } 15 | 16 | pub fn main() !void { 17 | var tui = try tuile.Tuile.init(.{}); 18 | defer tui.deinit(); 19 | 20 | const layout = tuile.vertical( 21 | .{ .layout = .{ .flex = 1 } }, 22 | .{tuile.block( 23 | .{ .layout = .{ .flex = 1 } }, 24 | tuile.label(.{ .text = "Press q to exit" }), 25 | )}, 26 | ); 27 | 28 | try tui.add(layout); 29 | try tui.addEventHandler(.{ 30 | .handler = stopOnQ, 31 | .payload = &tui, 32 | }); 33 | 34 | try tui.run(); 35 | } 36 | -------------------------------------------------------------------------------- /examples/images/palette.tape: -------------------------------------------------------------------------------- 1 | Output palette.gif 2 | 3 | Set Width 1400 4 | Set Height 600 5 | 6 | Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#f9feff", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } 7 | 8 | Set Padding 40 9 | Set MarginFill "#F9FEFF" 10 | 11 | Hide 12 | Type "zig build palette" 13 | Enter 14 | Sleep 1s 15 | Show 16 | 17 | Sleep 1s 18 | 19 | Tab@500ms 20 | Sleep 1s 21 | Type@200ms '31' 22 | Sleep 1s 23 | Backspace@200ms 3 24 | Sleep 1s 25 | Type@200ms '113' 26 | Sleep 1s 27 | Backspace@200ms 3 28 | Sleep 1s 29 | Type@200ms '237' 30 | Sleep 1s 31 | Backspace@200ms 3 32 | 33 | Sleep 2s 34 | 35 | Hide 36 | Ctrl+C 37 | -------------------------------------------------------------------------------- /examples/images/checkbox.tape: -------------------------------------------------------------------------------- 1 | Output checkbox.gif 2 | 3 | Set Width 700 4 | Set Height 320 5 | 6 | Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#f9feff", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } 7 | 8 | Set Padding 40 9 | Set MarginFill "#F9FEFF" 10 | 11 | # Setup 12 | Hide 13 | Type "zig build checkbox" 14 | Enter 15 | Sleep 1s 16 | Show 17 | 18 | Sleep 1s 19 | Tab@500ms 20 | Space@500ms 21 | Tab@500ms 22 | Space@500ms 23 | Tab@500ms 24 | Space@500ms 25 | 26 | Tab@500ms 27 | Space@500ms 28 | Tab@500ms 29 | Space@500ms 30 | Tab@500ms 31 | Space@500ms 32 | Shift+Tab 33 | Sleep 500ms 34 | Space@500ms 35 | 36 | Sleep 500ms 37 | 38 | Hide 39 | Ctrl+C 40 | -------------------------------------------------------------------------------- /src/widgets/Constraints.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Vec2 = @import("../Vec2.zig"); 3 | const LayoutProperties = @import("LayoutProperties.zig"); 4 | 5 | const Constraints = @This(); 6 | 7 | min_width: u32 = 0, 8 | 9 | min_height: u32 = 0, 10 | 11 | max_width: u32 = std.math.maxInt(u32), 12 | 13 | max_height: u32 = std.math.maxInt(u32), 14 | 15 | pub fn apply(self: Constraints, size: Vec2) Vec2 { 16 | return .{ 17 | .x = self.clampWidth(size.x), 18 | .y = self.clampHeight(size.y), 19 | }; 20 | } 21 | 22 | pub fn clampWidth(self: Constraints, value: u32) u32 { 23 | return std.math.clamp(value, self.min_width, self.max_width); 24 | } 25 | 26 | pub fn clampHeight(self: Constraints, value: u32) u32 { 27 | return std.math.clamp(value, self.min_height, self.max_height); 28 | } 29 | 30 | pub fn fromProps(props: LayoutProperties) Constraints { 31 | return .{ 32 | .min_width = props.min_width, 33 | .min_height = props.min_height, 34 | .max_width = props.max_width, 35 | .max_height = props.max_height, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Andrei Karpovskii 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/Vec2.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | const Vec2 = @This(); 4 | 5 | x: u32, 6 | y: u32, 7 | 8 | pub fn zero() Vec2 { 9 | return .{ .x = 0, .y = 0 }; 10 | } 11 | 12 | pub fn add(a: Vec2, b: Vec2) Vec2 { 13 | return .{ 14 | .x = a.x + b.x, 15 | .y = a.y + b.y, 16 | }; 17 | } 18 | 19 | pub fn addEq(self: *Vec2, b: Vec2) void { 20 | self.*.x += b.x; 21 | self.*.y += b.y; 22 | } 23 | 24 | pub fn sub(a: Vec2, b: Vec2) Vec2 { 25 | return .{ 26 | .x = a.x - b.x, 27 | .y = a.y - b.y, 28 | }; 29 | } 30 | 31 | pub fn subEq(self: *Vec2, b: Vec2) void { 32 | self.*.x -= b.x; 33 | self.*.y -= b.y; 34 | } 35 | 36 | pub fn mul(a: Vec2, k: u32) Vec2 { 37 | return .{ 38 | .x = a.x * k, 39 | .y = a.y * k, 40 | }; 41 | } 42 | 43 | pub fn mulEq(self: *Vec2, k: u32) void { 44 | self.*.x *= k; 45 | self.*.y *= k; 46 | } 47 | 48 | pub fn divFloor(self: Vec2, denominator: u32) Vec2 { 49 | return .{ 50 | .x = @divFloor(self.x, denominator), 51 | .y = @divFloor(self.y, denominator), 52 | }; 53 | } 54 | 55 | pub fn transpose(self: Vec2) Vec2 { 56 | return .{ .x = self.y, .y = self.x }; 57 | } 58 | -------------------------------------------------------------------------------- /src/internal.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const builtin = @import("builtin"); 3 | const root = @import("root"); 4 | const grapheme = @import("grapheme"); 5 | const DisplayWidth = @import("DisplayWidth"); 6 | const text_clustering = @import("text_clustering.zig"); 7 | 8 | const default_allocator = blk: { 9 | if (@hasDecl(root, "tuile_allocator")) { 10 | break :blk root.tuile_allocator; 11 | } else if (builtin.is_test) { 12 | break :blk std.testing.allocator; 13 | } else if (builtin.link_libc) { 14 | break :blk std.heap.c_allocator; 15 | } else { 16 | break :blk std.heap.page_allocator; 17 | } 18 | }; 19 | 20 | pub const allocator = default_allocator; 21 | 22 | pub var gd: grapheme.GraphemeData = undefined; 23 | pub var dwd: DisplayWidth.DisplayWidthData = undefined; 24 | pub var text_clustering_type: text_clustering.ClusteringType = undefined; 25 | 26 | pub fn init(clustering: text_clustering.ClusteringType) !void { 27 | gd = try grapheme.GraphemeData.init(allocator); 28 | dwd = try DisplayWidth.DisplayWidthData.init(allocator); 29 | text_clustering_type = clustering; 30 | } 31 | 32 | pub fn deinit() void { 33 | dwd.deinit(); 34 | gd.deinit(); 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | zig-version: 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | build-and-test: 12 | strategy: 13 | matrix: 14 | platform: [ubuntu-latest, macos-latest, windows-latest] 15 | backend: [ncurses, crossterm] 16 | prebuilt: [true, false] 17 | exclude: 18 | - platform: windows-latest 19 | backend: ncurses 20 | - backend: ncurses 21 | prebuilt: true 22 | 23 | runs-on: ${{ matrix.platform }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: goto-bus-stop/setup-zig@2a9625d550eefc3a9b1a43d342ad655f563f8241 28 | with: 29 | version: ${{ inputs.zig-version }} 30 | 31 | - name: Zig version 32 | run: zig version 33 | 34 | - name: Build all examples 35 | run: | 36 | cd examples 37 | zig build -Dbackend=${{ matrix.backend }} -Dprebuilt=${{ matrix.prebuilt }} --summary all 38 | cd .. 39 | 40 | - name: Run all tests 41 | run: zig build test -Dbackend=${{ matrix.backend }} -Dprebuilt=${{ matrix.prebuilt }} --summary all 42 | -------------------------------------------------------------------------------- /examples/images/input.tape: -------------------------------------------------------------------------------- 1 | Output input.gif 2 | 3 | Set Width 700 4 | Set Height 320 5 | 6 | Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#f9feff", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } 7 | 8 | Set Padding 40 9 | Set MarginFill "#F9FEFF" 10 | 11 | Hide 12 | Type "zig build input" 13 | Enter 14 | Sleep 1s 15 | Show 16 | 17 | Sleep 1s 18 | 19 | Type@50ms 'Lorem ipsum dolor sit amet,' 20 | Tab@500ms 21 | Space@500ms 22 | Shift+Tab 23 | Sleep 500ms 24 | Backspace@10ms 27 25 | 26 | Type@50ms 'consectetur adipiscing elit.' 27 | Tab@500ms 28 | Space@500ms 29 | Shift+Tab 30 | Sleep 500ms 31 | Backspace@10ms 28 32 | 33 | Type@50ms 'Cras varius nunc urna,' 34 | Tab@500ms 35 | Space@500ms 36 | Shift+Tab 37 | Sleep 500ms 38 | Backspace@10ms 22 39 | 40 | Type@50ms 'auctor luctus tortor maximus vitae.' 41 | Tab@500ms 42 | Space@500ms 43 | Sleep 2s 44 | 45 | Hide 46 | Ctrl+C 47 | -------------------------------------------------------------------------------- /examples/images/demo.tape: -------------------------------------------------------------------------------- 1 | Output demo.gif 2 | 3 | Set Width 1200 4 | Set Height 1000 5 | 6 | Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#f9feff", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } 7 | 8 | Set Padding 40 9 | Set MarginFill "#F9FEFF" 10 | 11 | # Setup 12 | Hide 13 | Type "zig build demo" 14 | Enter 15 | Sleep 1s 16 | Show 17 | 18 | # Themes 19 | 20 | Sleep 1s 21 | Tab@1s 2 22 | Space@1s 23 | Tab@1s 24 | Space@1s 25 | 26 | # Borders 27 | 28 | Tab@1s 29 | Space@1s 30 | Tab@1s 31 | Space@1s 32 | Tab@1s 33 | Space@1s 34 | Tab@1s 35 | Space@1s 36 | 37 | Space@1s 38 | Shift+Tab 39 | Space@1s 40 | Shift+Tab 41 | Space@1s 42 | Shift+Tab 43 | Space@1s 44 | 45 | Tab@1s 3 46 | Tab@1s 47 | 48 | Space@1s 49 | Tab@1s 50 | Space@1s 51 | Tab@1s 52 | Space@1s 53 | Tab@1s 54 | Space@1s 55 | 56 | Tab@1s 57 | Type@200ms 'Hello World!' 58 | Sleep 1s 59 | Tab@1s 60 | Space@1s 61 | 62 | Screenshot demo.png 63 | 64 | Sleep 2s 65 | 66 | Hide 67 | Ctrl+C 68 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Type 2 | What kind of change does this PR introduce? 3 | ``` 4 | [ ] Bugfix 5 | [ ] Feature 6 | [ ] Code style update (formatting, local variables) 7 | [ ] Refactoring (no functional changes, no api changes) 8 | [ ] Build related changes 9 | [ ] CI related changes 10 | [ ] Documentation content changes 11 | [ ] Tests 12 | [ ] Other 13 | ``` 14 | 15 | ## Description 16 | 17 | 18 | ## Related issue 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ## How Has This Been Tested? 27 | 28 | 29 | 30 | ## Checklist 31 | **(For new features)** 32 | * [ ] Feature is documented 33 | * [ ] Test cases added 34 | * [ ] Usage examples provided 35 | 36 | **(For bugfixes)** 37 | * [ ] Test cases added 38 | 39 | ## Screenshots (if appropriate): -------------------------------------------------------------------------------- /src/widgets/LayoutProperties.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | min_width: u32 = 0, 4 | 5 | min_height: u32 = 0, 6 | 7 | max_width: u32 = std.math.maxInt(u32), 8 | 9 | max_height: u32 = std.math.maxInt(u32), 10 | 11 | flex: u32 = 0, 12 | 13 | alignment: Align = Align.center(), 14 | 15 | pub const Align = struct { 16 | h: HAlign, 17 | v: VAlign, 18 | 19 | pub fn topLeft() Align { 20 | return .{ .h = .left, .v = .top }; 21 | } 22 | 23 | pub fn topCenter() Align { 24 | return .{ .h = .center, .v = .top }; 25 | } 26 | 27 | pub fn topRight() Align { 28 | return .{ .h = .right, .v = .top }; 29 | } 30 | 31 | pub fn centerLeft() Align { 32 | return .{ .h = .left, .v = .center }; 33 | } 34 | 35 | pub fn center() Align { 36 | return .{ .h = .center, .v = .center }; 37 | } 38 | 39 | pub fn centerRight() Align { 40 | return .{ .h = .right, .v = .center }; 41 | } 42 | 43 | pub fn bottomLeft() Align { 44 | return .{ .h = .left, .v = .bottom }; 45 | } 46 | 47 | pub fn bottomCenter() Align { 48 | return .{ .h = .center, .v = .bottom }; 49 | } 50 | 51 | pub fn bottomRight() Align { 52 | return .{ .h = .right, .v = .bottom }; 53 | } 54 | }; 55 | 56 | pub const HAlign = enum { 57 | left, 58 | center, 59 | right, 60 | }; 61 | 62 | pub const VAlign = enum { 63 | top, 64 | center, 65 | bottom, 66 | }; 67 | -------------------------------------------------------------------------------- /examples/build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn build(b: *std.Build) void { 4 | const target = b.standardTargetOptions(.{}); 5 | const optimize = b.standardOptimizeOption(.{}); 6 | 7 | const user_options = @import("tuile").Options.init(b); 8 | 9 | const tuile = b.dependency("tuile", .{ 10 | .target = target, 11 | .optimize = optimize, 12 | .backend = user_options.backend, 13 | .prebuilt = user_options.prebuilt, 14 | }); 15 | 16 | const executables: []const []const u8 = &.{ 17 | "demo", 18 | "input", 19 | "threads", 20 | "checkbox", 21 | "fps_counter", 22 | "event_handler", 23 | "palette", 24 | "unicode", 25 | }; 26 | inline for (executables) |name| { 27 | const exe = b.addExecutable(.{ 28 | .name = name, 29 | .root_source_file = b.path("src/" ++ name ++ ".zig"), 30 | .target = target, 31 | .optimize = optimize, 32 | }); 33 | 34 | exe.root_module.addImport("tuile", tuile.module("tuile")); 35 | b.installArtifact(exe); 36 | 37 | const run_cmd = b.addRunArtifact(exe); 38 | run_cmd.step.dependOn(b.getInstallStep()); 39 | 40 | if (b.args) |args| { 41 | run_cmd.addArgs(args); 42 | } 43 | 44 | const run_step = b.step(name, "Run " ++ name ++ " example"); 45 | run_step.dependOn(&run_cmd.step); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/widgets.zig: -------------------------------------------------------------------------------- 1 | pub const Block = @import("widgets/Block.zig"); 2 | pub const border = @import("widgets/border.zig"); 3 | pub usingnamespace border; 4 | pub const Button = @import("widgets/Button.zig"); 5 | pub const callbacks = @import("widgets/callbacks.zig"); 6 | pub const Callback = callbacks.Callback; 7 | pub const Checkbox = @import("widgets/Checkbox.zig"); 8 | pub const CheckboxGroup = @import("widgets/CheckboxGroup.zig"); 9 | pub const Constraints = @import("widgets/Constraints.zig"); 10 | pub const FocusHandler = @import("widgets/FocusHandler.zig"); 11 | pub const Input = @import("widgets/Input.zig"); 12 | pub const Label = @import("widgets/Label.zig"); 13 | pub const LayoutProperties = @import("widgets/LayoutProperties.zig"); 14 | pub const Align = LayoutProperties.Align; 15 | pub const HAlign = LayoutProperties.HAlign; 16 | pub const VAlign = LayoutProperties.VAlign; 17 | pub const Padding = @import("widgets/Padding.zig"); 18 | pub const Spacer = @import("widgets/Spacer.zig"); 19 | pub const StackLayout = @import("widgets/StackLayout.zig"); 20 | pub const Themed = @import("widgets/Themed.zig"); 21 | pub const Widget = @import("widgets/Widget.zig"); 22 | 23 | pub const block = Block.create; 24 | pub const button = Button.create; 25 | pub const checkbox = Checkbox.create; 26 | pub const checkbox_group = CheckboxGroup.create; 27 | pub const input = Input.create; 28 | pub const label = Label.create; 29 | pub const spacer = Spacer.create; 30 | pub const stack_layout = StackLayout.create; 31 | pub fn horizontal(config: StackLayout.Config, children: anytype) !*StackLayout { 32 | var cfg = config; 33 | cfg.orientation = .horizontal; 34 | return StackLayout.create(cfg, children); 35 | } 36 | pub fn vertical(config: StackLayout.Config, children: anytype) !*StackLayout { 37 | var cfg = config; 38 | cfg.orientation = .vertical; 39 | return StackLayout.create(cfg, children); 40 | } 41 | pub const themed = Themed.create; 42 | -------------------------------------------------------------------------------- /examples/src/input.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const tuile = @import("tuile"); 3 | 4 | const ListApp = struct { 5 | input: ?[]const u8 = null, 6 | 7 | tui: *tuile.Tuile, 8 | 9 | pub fn onPress(opt_self: ?*ListApp) void { 10 | const self = opt_self.?; 11 | if (self.input) |input| { 12 | if (input.len > 0) { 13 | const list = self.tui.findByIdTyped(tuile.StackLayout, "list-id") orelse unreachable; 14 | list.addChild(tuile.label(.{ .text = input })) catch unreachable; 15 | } 16 | } 17 | } 18 | 19 | pub fn inputChanged(opt_self: ?*ListApp, value: []const u8) void { 20 | const self = opt_self.?; 21 | self.input = value; 22 | } 23 | }; 24 | 25 | pub fn main() !void { 26 | var tui = try tuile.Tuile.init(.{}); 27 | defer tui.deinit(); 28 | 29 | var list_app = ListApp{ .tui = &tui }; 30 | 31 | const layout = tuile.vertical( 32 | .{ .layout = .{ .flex = 1 } }, 33 | .{ 34 | tuile.block( 35 | .{ .border = tuile.border.Border.all(), .layout = .{ .flex = 1 } }, 36 | tuile.vertical( 37 | .{ .id = "list-id" }, 38 | .{}, 39 | ), 40 | ), 41 | 42 | tuile.horizontal( 43 | .{}, 44 | .{ 45 | tuile.input(.{ 46 | .layout = .{ .flex = 1 }, 47 | .on_value_changed = .{ 48 | .cb = @ptrCast(&ListApp.inputChanged), 49 | .payload = &list_app, 50 | }, 51 | }), 52 | tuile.button(.{ 53 | .text = "Submit", 54 | .on_press = .{ 55 | .cb = @ptrCast(&ListApp.onPress), 56 | .payload = &list_app, 57 | }, 58 | }), 59 | }, 60 | ), 61 | }, 62 | ); 63 | 64 | try tui.add(layout); 65 | 66 | try tui.run(); 67 | } 68 | -------------------------------------------------------------------------------- /src/display/Theme.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const colors = @import("colors.zig"); 3 | const Color = colors.Color; 4 | const color = colors.color; 5 | 6 | const Theme = @This(); 7 | 8 | /// Used by widgets to print main text and foreground 9 | text_primary: Color, 10 | 11 | /// Used by widgets to print alternative or not unimportant text 12 | text_secondary: Color, 13 | 14 | /// The primary background of the application 15 | background_primary: Color, 16 | 17 | /// Widgets may use this color to stand out from the main background 18 | background_secondary: Color, 19 | 20 | /// Used by all interactive components 21 | interactive: Color, 22 | 23 | /// Color of the focused interactive element 24 | focused: Color, 25 | 26 | /// Borders of the Block widget 27 | borders: Color, 28 | 29 | /// A solid color that widgets may use to highlight something. 30 | /// For example, Input uses it for the color of the cursor. 31 | solid: Color, 32 | 33 | pub fn amber() Theme { 34 | return Theme{ 35 | .text_primary = color("#4F3422"), 36 | .text_secondary = color("#AB6400"), 37 | .background_primary = color("#FEFDFB"), 38 | .background_secondary = color("#FEFBE9"), 39 | .interactive = color("#FFF7C2"), 40 | .focused = color("#FBE577"), 41 | .borders = color("#E9C162"), 42 | .solid = color("#E2A336"), 43 | }; 44 | } 45 | 46 | pub fn lime() Theme { 47 | return Theme{ 48 | .text_primary = color("#37401C"), 49 | .text_secondary = color("#5C7C2F"), 50 | .background_primary = color("#FCFDFA"), 51 | .background_secondary = color("#D7FFD7"), 52 | .interactive = color("#D7FFAF"), 53 | .focused = color("#D3E7A6"), 54 | .borders = color("#ABC978"), 55 | .solid = color("#8DB654"), 56 | }; 57 | } 58 | 59 | pub fn sky() Theme { 60 | return Theme{ 61 | .text_primary = color("#1D3E56"), 62 | .text_secondary = color("#00749E"), 63 | .background_primary = color("#F9FEFF"), 64 | .background_secondary = color("#D7FFFF"), 65 | .interactive = color("#AFFFFF"), 66 | .focused = color("#BEE7F5"), 67 | .borders = color("#8DCAE3"), 68 | .solid = color("#60B3D7"), 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/Rect.zig: -------------------------------------------------------------------------------- 1 | const Vec2 = @import("Vec2.zig"); 2 | const LayoutProperties = @import("widgets/LayoutProperties.zig"); 3 | const Align = LayoutProperties.Align; 4 | const HAlign = LayoutProperties.HAlign; 5 | const VAlign = LayoutProperties.VAlign; 6 | 7 | const Rect = @This(); 8 | 9 | min: Vec2, 10 | 11 | max: Vec2, 12 | 13 | pub fn intersect(self: Rect, other: Rect) Rect { 14 | return Rect{ 15 | .min = .{ 16 | .x = @max(self.min.x, other.min.x), 17 | .y = @max(self.min.y, other.min.y), 18 | }, 19 | .max = .{ 20 | .x = @min(self.max.x, other.max.x), 21 | .y = @min(self.max.y, other.max.y), 22 | }, 23 | }; 24 | } 25 | 26 | pub fn width(self: Rect) u32 { 27 | return self.max.x - self.min.x; 28 | } 29 | 30 | pub fn height(self: Rect) u32 { 31 | return self.max.y - self.min.y; 32 | } 33 | 34 | pub fn diag(self: Rect) Vec2 { 35 | return self.max.sub(self.min); 36 | } 37 | 38 | /// Other area must fit inside this area, otherwise the result will be clamped 39 | pub fn alignH(self: Rect, alignment: HAlign, other: Rect) Rect { 40 | var min = Vec2{ 41 | .x = self.min.x, 42 | .y = other.min.y, 43 | }; 44 | switch (alignment) { 45 | .left => {}, 46 | .center => min.x += (self.width() -| other.width()) / 2, 47 | .right => min.x = self.max.x -| other.width(), 48 | } 49 | return Rect{ .min = min, .max = min.add(other.diag()) }; 50 | } 51 | 52 | /// Other area must fit inside this area, otherwise the result will be clamped 53 | pub fn alignV(self: Rect, alignment: VAlign, other: Rect) Rect { 54 | var min = Vec2{ 55 | .x = other.min.x, 56 | .y = self.min.y, 57 | }; 58 | switch (alignment) { 59 | .top => {}, 60 | .center => min.y += (self.height() -| other.height()) / 2, 61 | .bottom => min.y = self.max.y -| other.height(), 62 | } 63 | return Rect{ .min = min, .max = min.add(other.diag()) }; 64 | } 65 | 66 | /// Other area must fit inside this area, otherwise the result will be clamped 67 | pub fn alignInside(self: Rect, alignment: Align, other: Rect) Rect { 68 | const h = self.alignH(alignment.h, other); 69 | const v = self.alignV(alignment.v, h); 70 | return v; 71 | } 72 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | To run an example, use `zig build `, e.g. `zig build demo`. 4 | 5 | All examples were recorded using [VHS](https://github.com/charmbracelet/vhs). Corresponding tapes can be found in the [`images`](./images/) folder. 6 | 7 | ## Selecting a Backend 8 | 9 | To select a backend in the examples, add `-Dbackend=` to `zig build` command. For example: 10 | 11 | ```zig 12 | zig build demo -Dbackend=crossterm 13 | ``` 14 | 15 | See [`Backends`](../docs/Backends.md) for more info. 16 | 17 | ## [`demo`](./src/demo.zig) 18 | 19 | Simple demo showcasing the library capabilities. 20 | 21 |
22 | VHS recording 23 | 24 | ![Demo gif](./images/demo.gif) 25 | 26 |
27 | 28 | ## [`palette`](./src/palette.zig) 29 | 30 | 256 bit palette 31 | 32 |
33 | VHS recording 34 | 35 | ![Palette gif](./images/palette.gif) 36 |
37 | 38 | ## [`checkbox`](./src/checkbox.zig) 39 | 40 | Radio buttons and multiselect checkboxes 41 | 42 |
43 | VHS recording 44 | 45 | ![Checkboxes gif](./images/checkbox.gif) 46 |
47 | 48 | ## [`threads`](./src/threads.zig) 49 | 50 | Updating UI from another thread via `scheduleTask` 51 | 52 |
53 | VHS recording 54 | 55 | ![Threads gif](./images/threads.gif) 56 |
57 | 58 | ## [`input`](./src/input.zig) 59 | 60 | Simple user input processing 61 | 62 |
63 | VHS recording 64 | 65 | ![Input gif](./images/input.gif) 66 |
67 | 68 | ## [`event_handler`](./src/event_handler.zig) 69 | 70 | How to handle key presses using a standalone function 71 | 72 |
73 | VHS recording 74 | 75 | ![Event Handler gif](./images/event_handler.png) 76 |
77 | 78 | ## [`fps_counter`](./src/fps_counter.zig) 79 | 80 | How to implement a custom widget using FPS counter as an example. Can be used to debug Tuile event loop. 81 | 82 |
83 | VHS recording 84 | 85 | ![FPS Counter gif](./images/fps_counter.gif) 86 |
87 | 88 | ## [`unicode`](./src/unicode.zig) 89 | 90 | Demo showcasing Unicode support 91 | 92 |
93 | VHS recording 94 | 95 | ![Unicode table](./images/unicode.png) 96 |
97 | -------------------------------------------------------------------------------- /src/widgets/border.zig: -------------------------------------------------------------------------------- 1 | pub const Border = struct { 2 | top: bool = false, 3 | right: bool = false, 4 | bottom: bool = false, 5 | left: bool = false, 6 | 7 | pub fn none() Border { 8 | return .{}; 9 | } 10 | 11 | pub fn all() Border { 12 | return .{ .top = true, .right = true, .bottom = true, .left = true }; 13 | } 14 | }; 15 | 16 | pub const BorderType = enum { 17 | simple, 18 | solid, 19 | rounded, 20 | double, 21 | thick, 22 | }; 23 | 24 | pub const BorderCharacters = struct { 25 | top: []const u8, 26 | bottom: []const u8, 27 | left: []const u8, 28 | right: []const u8, 29 | top_left: []const u8, 30 | top_right: []const u8, 31 | bottom_left: []const u8, 32 | bottom_right: []const u8, 33 | 34 | pub fn fromType(border: BorderType) BorderCharacters { 35 | return switch (border) { 36 | .simple => .{ 37 | .top = "-", 38 | .bottom = "-", 39 | .left = "|", 40 | .right = "|", 41 | .top_left = "+", 42 | .top_right = "+", 43 | .bottom_left = "+", 44 | .bottom_right = "+", 45 | }, 46 | .solid => .{ 47 | .top = "─", 48 | .bottom = "─", 49 | .left = "│", 50 | .right = "│", 51 | .top_left = "┌", 52 | .top_right = "┐", 53 | .bottom_left = "└", 54 | .bottom_right = "┘", 55 | }, 56 | .rounded => .{ 57 | .top = "─", 58 | .bottom = "─", 59 | .left = "│", 60 | .right = "│", 61 | .top_left = "╭", 62 | .top_right = "╮", 63 | .bottom_left = "╰", 64 | .bottom_right = "╯", 65 | }, 66 | .double => .{ 67 | .top = "═", 68 | .bottom = "═", 69 | .left = "║", 70 | .right = "║", 71 | .top_left = "╔", 72 | .top_right = "╗", 73 | .bottom_left = "╚", 74 | .bottom_right = "╝", 75 | }, 76 | .thick => .{ 77 | .top = "━", 78 | .bottom = "━", 79 | .left = "┃", 80 | .right = "┃", 81 | .top_left = "┏", 82 | .top_right = "┓", 83 | .bottom_left = "┗", 84 | .bottom_right = "┛", 85 | }, 86 | }; 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /src/display/Style.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Color = @import("colors.zig").Color; 3 | 4 | const Style = @This(); 5 | 6 | /// Foreground (text) color 7 | fg: ?Color = null, 8 | 9 | /// Background color 10 | bg: ?Color = null, 11 | 12 | /// Which effect to enable when rendering 13 | add_effect: ?Effect = null, 14 | 15 | /// Which effect to disable when rendering 16 | sub_effect: ?Effect = null, 17 | 18 | pub const Effect = struct { 19 | highlight: bool = false, 20 | underline: bool = false, 21 | reverse: bool = false, 22 | blink: bool = false, 23 | dim: bool = false, 24 | bold: bool = false, 25 | italic: bool = false, 26 | 27 | pub fn all() Effect { 28 | return Effect{ 29 | .highlight = true, 30 | .underline = true, 31 | .reverse = true, 32 | .blink = true, 33 | .dim = true, 34 | .bold = true, 35 | .italic = true, 36 | }; 37 | } 38 | 39 | /// Enables the effects in `self` that are enabled in `other`. 40 | /// This is effectively a `self or other` operation. 41 | pub fn add(self: Effect, other: Effect) Effect { 42 | var result: Effect = .{}; 43 | inline for (std.meta.fields(Effect)) |field| { 44 | @field(result, field.name) = @field(self, field.name) or @field(other, field.name); 45 | } 46 | return result; 47 | } 48 | 49 | /// Disables the effects in `self` that are enabled in `other`. 50 | /// This is effectively a `self and !other` operation. 51 | pub fn sub(self: Effect, other: Effect) Effect { 52 | var result: Effect = .{}; 53 | inline for (std.meta.fields(Effect)) |field| { 54 | @field(result, field.name) = @field(self, field.name) and !@field(other, field.name); 55 | } 56 | return result; 57 | } 58 | }; 59 | 60 | /// Adds two styles together. 61 | /// All non-null values from `other` override the values of `self`. 62 | pub fn add(self: Style, other: Style) Style { 63 | var new = self; 64 | if (other.fg) |fg| new.fg = fg; 65 | if (other.bg) |bg| new.bg = bg; 66 | if (other.add_effect) |other_add| { 67 | if (new.add_effect) |*new_add| { 68 | new_add.* = new_add.add(other_add); 69 | } else { 70 | new.add_effect = other_add; 71 | } 72 | } 73 | if (other.sub_effect) |other_sub| { 74 | if (new.sub_effect) |*new_sub| { 75 | new_sub.* = new_sub.add(other_sub); 76 | } else { 77 | new.sub_effect = other_sub; 78 | } 79 | } 80 | return new; 81 | } 82 | -------------------------------------------------------------------------------- /src/backends/Testing.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Backend = @import("Backend.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const events = @import("../events.zig"); 6 | const display = @import("../display.zig"); 7 | const render = @import("../render.zig"); 8 | 9 | const Testing = @This(); 10 | 11 | frame_buffer: std.ArrayListUnmanaged(render.Cell), 12 | 13 | window_size: Vec2, 14 | 15 | frame: render.Frame, 16 | 17 | pub fn create(window_size: Vec2) !*Testing { 18 | const self = try internal.allocator.create(Testing); 19 | var buffer = std.ArrayListUnmanaged(render.Cell){}; 20 | try buffer.resize(internal.allocator, window_size.x * window_size.y); 21 | const frame = render.Frame{ 22 | .size = window_size, 23 | .buffer = buffer.items, 24 | .area = .{ 25 | .min = Vec2.zero(), 26 | .max = window_size, 27 | }, 28 | }; 29 | frame.clear(display.color("black"), display.color("white")); 30 | 31 | self.* = .{ 32 | .window_size = window_size, 33 | .frame_buffer = buffer, 34 | .frame = frame, 35 | }; 36 | return self; 37 | } 38 | 39 | pub fn destroy(self: *Testing) void { 40 | self.frame_buffer.deinit(internal.allocator); 41 | internal.allocator.destroy(self); 42 | } 43 | 44 | pub fn backend(self: *Testing) Backend { 45 | return Backend.init(self); 46 | } 47 | 48 | pub fn pollEvent(_: *Testing) !?events.Event { 49 | return null; 50 | } 51 | 52 | pub fn refresh(_: *Testing) !void {} 53 | 54 | pub fn printAt(self: *Testing, pos: Vec2, text: []const u8) !void { 55 | self.frame.setSymbol(pos, text); 56 | } 57 | 58 | pub fn windowSize(self: *Testing) !Vec2 { 59 | return self.window_size; 60 | } 61 | 62 | pub fn enableEffect(_: *Testing, _: display.Style.Effect) !void {} 63 | 64 | pub fn disableEffect(_: *Testing, _: display.Style.Effect) !void {} 65 | 66 | pub fn useColor(_: *Testing, _: display.ColorPair) !void {} 67 | 68 | pub fn write(self: *Testing, writer: anytype) !void { 69 | for (0..self.window_size.y) |y| { 70 | for (0..self.window_size.x) |x| { 71 | const cell = self.frame.at(.{ 72 | .x = @intCast(x), 73 | .y = @intCast(y), 74 | }); 75 | if (cell.symbol) |symbol| { 76 | try writer.writeAll(symbol); 77 | } else { 78 | try writer.writeByte(' '); 79 | } 80 | } 81 | try writer.writeByte('\n'); 82 | } 83 | } 84 | 85 | pub fn requestMode(_: *Testing, _: u32) !Backend.ReportMode { 86 | return .not_recognized; 87 | } 88 | -------------------------------------------------------------------------------- /src/widgets/Spacer.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Widget = @import("Widget.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const Rect = @import("../Rect.zig"); 6 | const events = @import("../events.zig"); 7 | const Frame = @import("../render/Frame.zig"); 8 | const LayoutProperties = @import("LayoutProperties.zig"); 9 | const Constraints = @import("Constraints.zig"); 10 | const Theme = @import("../display/Theme.zig"); 11 | 12 | const maxInt = std.math.maxInt; 13 | 14 | pub const Config = struct { 15 | /// A unique identifier of the widget to be used in `Tuile.findById` and `Widget.findById`. 16 | id: ?[]const u8 = null, 17 | 18 | /// Layout properties of the widget, see `LayoutProperties`. 19 | /// Spacer must either be flexible, or both max height and width must be defined. 20 | /// Setting flex and max height/width at the same time may result in unexpected layout. 21 | layout: LayoutProperties = .{ .flex = 1 }, 22 | }; 23 | 24 | const Spacer = @This(); 25 | 26 | pub usingnamespace Widget.Leaf.Mixin(Spacer); 27 | pub usingnamespace Widget.Base.Mixin(Spacer, .widget_base); 28 | 29 | widget_base: Widget.Base, 30 | 31 | layout_properties: LayoutProperties, 32 | 33 | pub fn create(config: Config) !*Spacer { 34 | const layout_ = config.layout; 35 | if (layout_.flex == 0 and (layout_.max_height == maxInt(u32) or layout_.max_width == maxInt(u32))) { 36 | @panic("Spacer must either be flexible, or both max height and width must be defined"); 37 | } 38 | 39 | const self = try internal.allocator.create(Spacer); 40 | self.* = Spacer{ 41 | .widget_base = try Widget.Base.init(config.id), 42 | .layout_properties = config.layout, 43 | }; 44 | return self; 45 | } 46 | 47 | pub fn destroy(self: *Spacer) void { 48 | self.widget_base.deinit(); 49 | internal.allocator.destroy(self); 50 | } 51 | 52 | pub fn widget(self: *Spacer) Widget { 53 | return Widget.init(self); 54 | } 55 | 56 | pub fn render(_: *Spacer, _: Rect, _: Frame, _: Theme) !void {} 57 | 58 | pub fn layout(self: *Spacer, constraints: Constraints) !Vec2 { 59 | const props = self.layout_properties; 60 | const size = Vec2{ 61 | .x = @min(props.max_width, constraints.max_width), 62 | .y = @min(props.max_height, constraints.max_height), 63 | }; 64 | // if (size.x == maxInt(u32)) { 65 | // size.x = 0; 66 | // } 67 | // if (size.y == maxInt(u32)) { 68 | // size.y = 0; 69 | // } 70 | return size; 71 | } 72 | 73 | pub fn handleEvent(_: *Spacer, _: events.Event) !events.EventResult { 74 | return .ignored; 75 | } 76 | 77 | pub fn layoutProps(self: *Spacer) LayoutProperties { 78 | return self.layout_properties; 79 | } 80 | -------------------------------------------------------------------------------- /docs/Backends.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Supported Backends 4 | 5 |
6 | Table of Contents 7 |
    8 |
  1. Selecting a Backend
  2. 9 |
  3. Crossterm
  4. 10 |
  5. Ncurses
  6. 11 |
12 |
13 | 14 | ## Selecting a Backend 15 | 16 | Tuile is configured to use a [prebuilt](https://github.com/akarpovskii/tuile-crossterm) version of `crossterm` backend by default. 17 | 18 | To select another backend, pass an option to `Build.dependency` in `build.zig`: 19 | 20 | ```zig 21 | const tuile = b.dependency("tuile", .{ .backend = }) 22 | ``` 23 | 24 | ## Crossterm 25 | 26 | [Crossterm](https://github.com/crossterm-rs/crossterm) is a pure-rust, terminal manipulation library. It supports all UNIX and Windows terminals down to Windows 7 (not all terminals are tested, see Crossterm's [Tested Terminals](https://github.com/crossterm-rs/crossterm?tab=readme-ov-file#tested-terminals) for more info). 27 | 28 | By default, Tuile fetches a prebuilt version of the backend from [tuile-crossterm](https://github.com/akarpovskii/tuile-crossterm/releases). If you want Tuile to build it from source, set `prebuilt` to `false`: 29 | 30 | ```zig 31 | const tuile = b.dependency("tuile", .{ .prebuilt = false }) 32 | ``` 33 | 34 | Tuile uses Crossterm v0.27 which has a minimum requirement of Rust v1.58. Follow [here](https://www.rust-lang.org/tools/install) to download and install Rust. 35 | 36 | ### A word on Windows 37 | By default Rust targets MSVC toolchain on Windows which makes it difficult to link from Zig. For that reason, tuile-crossterm compiles Rust with gnu ABI and does some (albeit small) crimes during linking. See [`build.crab`](https://github.com/akarpovskii/build.crab?tab=readme-ov-file#windows) description if you want to know more. 38 | 39 | Nevertheless, Tuile is designed to be plug and play, and you should be able to just use it without worrying too much about what happens internally. If you face any problems, please submit a [bug report](https://github.com/akarpovskii/tuile/issues/new?labels=bug&template=bug-report.md). 40 | 41 |

(back to top)

42 | 43 | ## Ncurses 44 | 45 | To be able to use `ncurses` backend, [ncurses](https://invisible-island.net/ncurses/) must be installed and available as system library. 46 | 47 | Tuile was tested with ncurses 5.7, but other versions should work regardless. 48 | 49 | * macOS 50 | 51 | A version of ncurses should already be installed in your system (or is it shipped with XCode and the command line tools?), so you don't have to do anything. 52 | 53 | * Linux 54 | 55 | ```sh 56 | sudo apt-get install libncurses5-dev libncursesw5-dev 57 | ``` 58 | 59 | * Windows 60 | 61 | Prebuilt binaries are available on the [official website](https://invisible-island.net/ncurses/#download_mingw). 62 | 63 |

(back to top)

64 | -------------------------------------------------------------------------------- /src/backends/tty.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const builtin = @import("builtin"); 3 | 4 | comptime { 5 | if (builtin.os.tag == .windows) { 6 | @compileError("tty is not supported on Windows"); 7 | } 8 | } 9 | 10 | const c = @cImport({ 11 | @cInclude("sys/select.h"); 12 | }); 13 | 14 | extern "c" fn select( 15 | nfds: c_int, 16 | readfds: [*c]c.fd_set, 17 | writefds: [*c]c.fd_set, 18 | errorfds: [*c]c.fd_set, 19 | timeout: [*c]c.timeval, 20 | ) c_int; 21 | 22 | const SelectError = error{Failure}; 23 | 24 | fn selectReadTimeout(tty: std.fs.File, nanoseconds: u64) SelectError!bool { 25 | // You are supposed to use c.FD_ZERO(&rd), but there's a bug - https://github.com/ziglang/zig/issues/10123 26 | // Luckily std.mem.zeroes does exactly the same thing for extern structs 27 | var rd: c.fd_set = std.mem.zeroes(c.fd_set); 28 | c.FD_SET(tty.handle, &rd); 29 | 30 | var tv: c.timeval = .{}; 31 | tv.tv_sec = 0; 32 | tv.tv_usec = @intCast(nanoseconds / std.time.ns_per_us); 33 | 34 | const ret = select(tty.handle + 1, &rd, null, null, &tv); 35 | return switch (ret) { 36 | -1 => error.Failure, 37 | 0 => false, 38 | else => true, 39 | }; 40 | } 41 | 42 | pub const ReportMode = enum(u3) { 43 | not_recognized = 0, 44 | set = 1, 45 | reset = 2, 46 | permanently_set = 3, 47 | permanently_reset = 4, 48 | }; 49 | 50 | pub fn requestMode(allocator: std.mem.Allocator, mode: u32) !ReportMode { 51 | const tty = try std.fs.cwd().openFile("/dev/tty", .{ .mode = .read_write }); 52 | 53 | // DECRQM: CSI ? Pd $ p - https://vt100.net/docs/vt510-rm/DECRQM.html 54 | // DECRPM: CSI ? Pd; Ps $ y - https://vt100.net/docs/vt510-rm/DECRPM.html 55 | const CSI = "\x1B["; 56 | 57 | // Request mode 58 | try std.fmt.format(tty.writer(), CSI ++ "?{d}$p", .{mode}); 59 | 60 | // Report mode 61 | const mode_str_len = std.fmt.count("{d}", .{mode}); 62 | const buf = try allocator.alloc(u8, CSI.len + mode_str_len + 5); 63 | defer allocator.free(buf); 64 | 65 | const timeout_ns = 100 * std.time.ns_per_ms; 66 | 67 | switch (builtin.os.tag) { 68 | .macos => { 69 | // Can't use poll here because macOS doesn't support polling from /dev/tty* files. 70 | if (!try selectReadTimeout(tty, timeout_ns)) { 71 | return .not_recognized; 72 | } 73 | const read_bytes = try tty.read(buf); 74 | if (read_bytes != buf.len) { 75 | return .not_recognized; 76 | } 77 | }, 78 | else => { 79 | var poller = std.io.poll(allocator, enum { tty }, .{ .tty = tty }); 80 | defer poller.deinit(); 81 | if (!try poller.pollTimeout(timeout_ns)) { 82 | return .not_recognized; 83 | } 84 | const fifo = poller.fifo(.tty); 85 | if (fifo.readableLength() != buf.len) { 86 | return .not_recognized; 87 | } 88 | std.mem.copyForwards(u8, buf, fifo.buf[0..buf.len]); 89 | }, 90 | } 91 | const ps = try std.fmt.charToDigit(buf[CSI.len + 1 + mode_str_len + 1], 10); 92 | return try std.meta.intToEnum(ReportMode, ps); 93 | } 94 | -------------------------------------------------------------------------------- /examples/src/palette.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const tuile = @import("tuile"); 3 | 4 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 5 | pub const tuile_allocator = gpa.allocator(); 6 | 7 | fn generatePalette() !tuile.Span { 8 | var span = tuile.Span.init(tuile_allocator); 9 | for (1..257) |i| { 10 | const rgb = tuile.Palette256.lookup_table[i - 1]; 11 | const bg = tuile.Color{ .rgb = .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] } }; 12 | const fg = tuile.Color{ 13 | .rgb = if (std.mem.max(u8, &rgb) >= 224) 14 | tuile.Rgb.black() 15 | else 16 | tuile.Rgb.white(), 17 | }; 18 | 19 | const fmt: []const u8 = "{d: >4} "; 20 | const fmt_count = comptime std.fmt.count(fmt, .{256}); 21 | var i_text: [fmt_count]u8 = undefined; 22 | _ = std.fmt.bufPrint(&i_text, fmt, .{i}) catch unreachable; 23 | 24 | if (i <= 16) { 25 | try span.append(.{ .text = &i_text, .style = .{ .fg = fg, .bg = bg } }); 26 | if (i == 16) { 27 | try span.appendPlain("\n"); 28 | } 29 | } else if (i <= 232) { 30 | try span.append(.{ .text = &i_text, .style = .{ .fg = fg, .bg = bg } }); 31 | if ((i - 16) % 18 == 0) { 32 | try span.appendPlain("\n"); 33 | } 34 | } else { 35 | try span.append(.{ .text = &i_text, .style = .{ .fg = fg, .bg = bg } }); 36 | if ((i - 232) % 12 == 0) { 37 | try span.appendPlain("\n"); 38 | } 39 | } 40 | } 41 | return span; 42 | } 43 | 44 | const ShowRGB = struct { 45 | label: *tuile.Label, 46 | 47 | pub fn inputChanged(opt_self: ?*ShowRGB, value: []const u8) void { 48 | const self = opt_self.?; 49 | 50 | const palette_idx = std.fmt.parseInt(u8, value, 10) catch return; 51 | const rgb = tuile.Palette256.lookup_table[palette_idx - 1]; 52 | 53 | const fmt: []const u8 = "({d: >3}, {d: >3}, {d: >3})"; 54 | const fmt_count = comptime std.fmt.count(fmt, .{ 256, 256, 256 }); 55 | var rgb_text: [fmt_count]u8 = undefined; 56 | _ = std.fmt.bufPrint(&rgb_text, fmt, .{ rgb[0], rgb[1], rgb[2] }) catch unreachable; 57 | 58 | self.label.setText(&rgb_text) catch unreachable; 59 | } 60 | }; 61 | 62 | pub fn main() !void { 63 | defer _ = gpa.deinit(); 64 | 65 | var tui = try tuile.Tuile.init(.{}); 66 | defer tui.deinit(); 67 | 68 | var palette = try generatePalette(); 69 | defer palette.deinit(); 70 | 71 | var show_rgb: ShowRGB = undefined; 72 | 73 | try tui.add( 74 | tuile.vertical( 75 | .{ .layout = .{ .flex = 1 } }, 76 | .{ 77 | tuile.label(.{ .span = palette.view() }), 78 | tuile.spacer(.{ .layout = .{ .max_height = 1, .max_width = 1 } }), 79 | tuile.label(.{ .id = "rgb-value", .text = "(..., ..., ...)", .span = palette.view() }), 80 | tuile.input(.{ 81 | .placeholder = "index", 82 | .layout = .{ .min_width = 5, .max_width = 5 }, 83 | .on_value_changed = .{ .cb = @ptrCast(&ShowRGB.inputChanged), .payload = &show_rgb }, 84 | }), 85 | }, 86 | ), 87 | ); 88 | 89 | const label = tui.findByIdTyped(tuile.Label, "rgb-value") orelse unreachable; 90 | 91 | show_rgb = ShowRGB{ .label = label }; 92 | 93 | try tui.run(); 94 | } 95 | -------------------------------------------------------------------------------- /examples/src/fps_counter.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const tuile = @import("tuile"); 3 | 4 | const FPSCounter = struct { 5 | const Config = struct { 6 | layout: tuile.LayoutProperties = .{}, 7 | }; 8 | 9 | pub usingnamespace tuile.Widget.Leaf.Mixin(FPSCounter); 10 | pub usingnamespace tuile.Widget.Base.Mixin(FPSCounter, .widget_base); 11 | 12 | widget_base: tuile.Widget.Base, 13 | 14 | allocator: std.mem.Allocator, 15 | 16 | layout_properties: tuile.LayoutProperties, 17 | 18 | last_timestamp: ?std.time.Instant = null, 19 | 20 | frames: usize = 0, 21 | 22 | buffer: ["999.99".len]u8 = undefined, 23 | const buffer_fmt = "{d: >6.2}"; 24 | 25 | const window_size: usize = 60; 26 | 27 | pub fn create(allocator: std.mem.Allocator, config: Config) !*FPSCounter { 28 | const self = try allocator.create(FPSCounter); 29 | self.* = FPSCounter{ 30 | .widget_base = try tuile.Widget.Base.init(null), 31 | .allocator = allocator, 32 | .layout_properties = config.layout, 33 | }; 34 | std.mem.copyForwards(u8, &self.buffer, " 0.00"); 35 | return self; 36 | } 37 | 38 | pub fn destroy(self: *FPSCounter) void { 39 | self.widget_base.deinit(); 40 | self.allocator.destroy(self); 41 | } 42 | 43 | pub fn widget(self: *FPSCounter) tuile.Widget { 44 | return tuile.Widget.init(self); 45 | } 46 | 47 | pub fn render(self: *FPSCounter, area: tuile.Rect, frame: tuile.render.Frame, _: tuile.Theme) !void { 48 | self.frames += 1; 49 | if (self.frames >= window_size) { 50 | const now = try std.time.Instant.now(); 51 | const prev = if (self.last_timestamp) |ts| ts else now; 52 | const elapsed_ns = now.since(prev); 53 | const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / @as(f64, @floatFromInt(std.time.ns_per_s)); 54 | const fps = @as(f64, @floatFromInt(self.frames)) / elapsed_s; 55 | const clamped: f64 = std.math.clamp(fps, 0, 999); 56 | 57 | var list = std.ArrayListUnmanaged(u8).fromOwnedSlice(&self.buffer); 58 | list.clearRetainingCapacity(); 59 | const writer = list.fixedWriter(); 60 | _ = try std.fmt.format(writer, buffer_fmt, .{clamped}); 61 | 62 | self.last_timestamp = now; 63 | self.frames = 0; 64 | } 65 | 66 | _ = try frame.writeSymbols(area.min, &self.buffer, area.width()); 67 | } 68 | 69 | pub fn layout(self: *FPSCounter, _: tuile.Constraints) !tuile.Vec2 { 70 | return .{ .x = @intCast(self.buffer.len), .y = 1 }; 71 | } 72 | 73 | pub fn handleEvent(_: *FPSCounter, _: tuile.events.Event) !tuile.events.EventResult { 74 | return .ignored; 75 | } 76 | 77 | pub fn layoutProps(self: *FPSCounter) tuile.LayoutProperties { 78 | return self.layout_properties; 79 | } 80 | }; 81 | 82 | pub fn main() !void { 83 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 84 | defer _ = gpa.deinit(); 85 | const allocator = gpa.allocator(); 86 | 87 | var tui = try tuile.Tuile.init(.{}); 88 | defer tui.deinit(); 89 | 90 | const layout = tuile.vertical( 91 | .{ .layout = .{ .flex = 1 } }, 92 | .{tuile.block( 93 | .{ .layout = .{ .flex = 1 } }, 94 | FPSCounter.create(allocator, .{}), 95 | )}, 96 | ); 97 | 98 | try tui.add(layout); 99 | 100 | try tui.run(); 101 | } 102 | -------------------------------------------------------------------------------- /src/widgets/Themed.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Widget = @import("Widget.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const Rect = @import("../Rect.zig"); 6 | const events = @import("../events.zig"); 7 | const Frame = @import("../render/Frame.zig"); 8 | const LayoutProperties = @import("LayoutProperties.zig"); 9 | const Constraints = @import("Constraints.zig"); 10 | const display = @import("../display.zig"); 11 | 12 | pub const PartialTheme = init_partial: { 13 | const original = @typeInfo(display.Theme).Struct.fields; 14 | const len = original.len; 15 | var partial: [len]std.builtin.Type.StructField = undefined; 16 | for (original, &partial) |orig, *part| { 17 | part.* = std.builtin.Type.StructField{ 18 | .name = orig.name, 19 | .type = @Type(std.builtin.Type{ .Optional = .{ .child = orig.type } }), 20 | .default_value = &@as(?orig.type, null), 21 | .is_comptime = false, 22 | .alignment = orig.alignment, 23 | }; 24 | } 25 | 26 | break :init_partial @Type(std.builtin.Type{ 27 | .Struct = .{ 28 | .layout = .auto, 29 | .fields = &partial, 30 | .decls = &[_]std.builtin.Type.Declaration{}, 31 | .is_tuple = false, 32 | }, 33 | }); 34 | }; 35 | 36 | pub const Config = struct { 37 | /// A unique identifier of the widget to be used in `Tuile.findById` and `Widget.findById`. 38 | id: ?[]const u8 = null, 39 | 40 | /// Base theme to use. Can be optional, in which case will use whatever is passed to the `render` function. 41 | theme: ?display.Theme = null, 42 | 43 | /// Partially override the current theme. 44 | override: PartialTheme = .{}, 45 | }; 46 | 47 | const Themed = @This(); 48 | 49 | pub usingnamespace Widget.SingleChild.Mixin(Themed, .inner); 50 | pub usingnamespace Widget.Base.Mixin(Themed, .widget_base); 51 | 52 | widget_base: Widget.Base, 53 | 54 | inner: Widget, 55 | 56 | theme: PartialTheme, 57 | 58 | pub fn create(config: Config, inner: anytype) !*Themed { 59 | const self = try internal.allocator.create(Themed); 60 | self.* = Themed{ 61 | .widget_base = try Widget.Base.init(config.id), 62 | .inner = try Widget.fromAny(inner), 63 | .theme = .{}, 64 | }; 65 | if (config.theme) |theme| { 66 | self.setTheme(theme); 67 | } 68 | self.updateTheme(config.override); 69 | return self; 70 | } 71 | 72 | pub fn destroy(self: *Themed) void { 73 | self.widget_base.deinit(); 74 | self.inner.destroy(); 75 | internal.allocator.destroy(self); 76 | } 77 | 78 | pub fn widget(self: *Themed) Widget { 79 | return Widget.init(self); 80 | } 81 | 82 | pub fn setTheme(self: *Themed, theme: display.Theme) void { 83 | inline for (@typeInfo(display.Theme).Struct.fields) |field| { 84 | @field(self.theme, field.name) = @field(theme, field.name); 85 | } 86 | } 87 | 88 | pub fn updateTheme(self: *Themed, update: PartialTheme) void { 89 | inline for (@typeInfo(display.Theme).Struct.fields) |field| { 90 | const override = @field(update, field.name); 91 | if (override) |value| { 92 | @field(self.theme, field.name) = value; 93 | } 94 | } 95 | } 96 | 97 | pub fn render(self: *Themed, area: Rect, frame: Frame, theme: display.Theme) !void { 98 | var new_theme = theme; 99 | inline for (@typeInfo(display.Theme).Struct.fields) |field| { 100 | const part = @field(self.theme, field.name); 101 | const new = &@field(new_theme, field.name); 102 | if (part) |value| { 103 | new.* = value; 104 | } 105 | } 106 | 107 | frame.setStyle(area, .{ .fg = new_theme.text_primary, .bg = new_theme.background_primary }); 108 | return try self.inner.render(area, frame, new_theme); 109 | } 110 | 111 | pub fn layout(self: *Themed, constraints: Constraints) !Vec2 { 112 | return try self.inner.layout(constraints); 113 | } 114 | 115 | pub fn handleEvent(self: *Themed, event: events.Event) !events.EventResult { 116 | return self.inner.handleEvent(event); 117 | } 118 | 119 | pub fn layoutProps(self: *Themed) LayoutProperties { 120 | return self.inner.layoutProps(); 121 | } 122 | 123 | pub fn prepare(self: *Themed) !void { 124 | try self.inner.prepare(); 125 | } 126 | -------------------------------------------------------------------------------- /src/widgets/callbacks.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn Callback(comptime args: anytype) type { 4 | const info = @typeInfo(@TypeOf(args)); 5 | 6 | comptime if (info == .Type and args == void) { 7 | return struct { 8 | const Self = @This(); 9 | 10 | cb: *const fn (_: ?*anyopaque) void, 11 | payload: ?*anyopaque = null, 12 | 13 | pub fn call(self: Self) void { 14 | self.cb(self.payload); 15 | } 16 | }; 17 | } else if (info == .Struct and info.Struct.is_tuple) { 18 | const tuple = info.Struct; 19 | return switch (tuple.fields.len) { 20 | 0 => Callback(void), 21 | 1 => Callback(args[0]), 22 | 2 => struct { 23 | const Self = @This(); 24 | 25 | cb: *const fn (_: ?*anyopaque, _: args[0], _: args[1]) void, 26 | payload: ?*anyopaque = null, 27 | 28 | pub fn call(self: Self, arg0: args[0], arg1: args[1]) void { 29 | self.cb(self.payload, arg0, arg1); 30 | } 31 | }, 32 | else => @compileError(std.fmt.comptimePrint("tuples of length {d} are not supported", .{tuple.fields.len})), 33 | }; 34 | } else { 35 | return struct { 36 | const Self = @This(); 37 | 38 | cb: *const fn (_: ?*anyopaque, _: args) void, 39 | payload: ?*anyopaque = null, 40 | 41 | pub fn call(self: Self, data: args) void { 42 | self.cb(self.payload, data); 43 | } 44 | }; 45 | }; 46 | } 47 | 48 | const TestReceiver = struct { 49 | var called: bool = false; 50 | received: bool = false, 51 | a: u32 = 0, 52 | b: u32 = 0, 53 | 54 | pub fn f0(ptr: ?*anyopaque) void { 55 | called = true; 56 | if (ptr) |self| { 57 | const recv: *TestReceiver = @ptrCast(@alignCast(self)); 58 | recv.received = true; 59 | } 60 | } 61 | pub fn f1(ptr: ?*anyopaque, arg: u32) void { 62 | f0(ptr); 63 | if (ptr) |self| { 64 | const recv: *TestReceiver = @ptrCast(@alignCast(self)); 65 | recv.a = arg; 66 | } 67 | } 68 | pub fn f2(ptr: ?*anyopaque, arg1: u32, arg2: u32) void { 69 | f1(ptr, arg1); 70 | if (ptr) |self| { 71 | const recv: *TestReceiver = @ptrCast(@alignCast(self)); 72 | recv.b = arg2; 73 | } 74 | } 75 | }; 76 | 77 | test "callback with no data and no payload" { 78 | const cb: Callback(void) = .{ 79 | .cb = TestReceiver.f0, 80 | }; 81 | cb.call(); 82 | try std.testing.expect(TestReceiver.called); 83 | } 84 | 85 | test "callback with no data" { 86 | var receiver = TestReceiver{}; 87 | const cb: Callback(void) = .{ 88 | .cb = TestReceiver.f0, 89 | .payload = &receiver, 90 | }; 91 | cb.call(); 92 | try std.testing.expect(receiver.received); 93 | } 94 | 95 | test "callback with one argument and no payload" { 96 | const cb: Callback(u32) = .{ 97 | .cb = TestReceiver.f1, 98 | }; 99 | cb.call(1); 100 | try std.testing.expect(TestReceiver.called); 101 | } 102 | 103 | test "callback with one argument" { 104 | var receiver = TestReceiver{}; 105 | const cb: Callback(u32) = .{ 106 | .cb = TestReceiver.f1, 107 | .payload = &receiver, 108 | }; 109 | cb.call(1); 110 | try std.testing.expect(receiver.received); 111 | try std.testing.expect(receiver.a == 1); 112 | } 113 | 114 | test "callback with two arguments and no payload" { 115 | const cb: Callback(.{ u32, u32 }) = .{ 116 | .cb = TestReceiver.f2, 117 | }; 118 | cb.call(1, 2); 119 | try std.testing.expect(TestReceiver.called); 120 | } 121 | 122 | test "callback with two arguments" { 123 | var receiver = TestReceiver{}; 124 | const cb: Callback(.{ u32, u32 }) = .{ 125 | .cb = TestReceiver.f2, 126 | .payload = &receiver, 127 | }; 128 | cb.call(1, 2); 129 | try std.testing.expect(receiver.received); 130 | try std.testing.expect(receiver.a == 1); 131 | try std.testing.expect(receiver.b == 2); 132 | } 133 | 134 | test "tuple arguments coercion" { 135 | try std.testing.expect(Callback(void) == Callback(.{})); 136 | 137 | try std.testing.expect(Callback(.{u32}) == Callback(u32)); 138 | } 139 | -------------------------------------------------------------------------------- /src/widgets/Button.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Widget = @import("Widget.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const Rect = @import("../Rect.zig"); 6 | const events = @import("../events.zig"); 7 | const Frame = @import("../render/Frame.zig"); 8 | const Label = @import("Label.zig"); 9 | const FocusHandler = @import("FocusHandler.zig"); 10 | const LayoutProperties = @import("LayoutProperties.zig"); 11 | const Constraints = @import("Constraints.zig"); 12 | const display = @import("../display.zig"); 13 | const callbacks = @import("callbacks.zig"); 14 | 15 | pub const Config = struct { 16 | /// A unique identifier of the widget to be used in `Tuile.findById` and `Widget.findById`. 17 | id: ?[]const u8 = null, 18 | 19 | /// `text` and `span` are mutually exclusive, only one of them must be defined. 20 | text: ?[]const u8 = null, 21 | 22 | /// `text` and `span` are mutually exclusive, only one of them must be defined. 23 | span: ?display.SpanView = null, 24 | 25 | /// Button will call this when it is pressed. 26 | on_press: ?callbacks.Callback(void) = null, 27 | 28 | /// Layout properties of the widget, see `LayoutProperties`. 29 | layout: LayoutProperties = .{}, 30 | }; 31 | 32 | pub const Button = @This(); 33 | 34 | pub usingnamespace Widget.Leaf.Mixin(Button); 35 | pub usingnamespace Widget.Base.Mixin(Button, .widget_base); 36 | 37 | widget_base: Widget.Base, 38 | 39 | view: *Label, 40 | 41 | focus_handler: FocusHandler = .{}, 42 | 43 | on_press: ?callbacks.Callback(void), 44 | 45 | pub fn create(config: Config) !*Button { 46 | if (config.text == null and config.span == null) { 47 | @panic("text and span are mutually exclusive, only one of them must be defined"); 48 | } 49 | 50 | var label: display.Span = undefined; 51 | if (config.text) |text| { 52 | label = try createDecoratedLabel(text); 53 | } else if (config.span) |span| { 54 | label = try createDecoratedLabel(span); 55 | } 56 | defer label.deinit(); 57 | 58 | const view = try Label.create( 59 | .{ .span = label.view(), .layout = config.layout }, 60 | ); 61 | 62 | const self = try internal.allocator.create(Button); 63 | self.* = Button{ 64 | .widget_base = try Widget.Base.init(config.id), 65 | .view = view, 66 | .on_press = config.on_press, 67 | }; 68 | return self; 69 | } 70 | 71 | pub fn destroy(self: *Button) void { 72 | self.widget_base.deinit(); 73 | self.view.destroy(); 74 | internal.allocator.destroy(self); 75 | } 76 | 77 | pub fn widget(self: *Button) Widget { 78 | return Widget.init(self); 79 | } 80 | 81 | pub fn setLabelText(self: *Button, text: []const u8) !void { 82 | const label = try createDecoratedLabel(text); 83 | defer label.deinit(); 84 | try self.view.setSpan(label.view()); 85 | } 86 | 87 | pub fn setLabelSpan(self: *Button, span: display.SpanView) !void { 88 | const label = try createDecoratedLabel(span); 89 | defer label.deinit(); 90 | try self.view.setSpan(label.view()); 91 | } 92 | 93 | pub fn render(self: *Button, area: Rect, frame: Frame, theme: display.Theme) !void { 94 | frame.setStyle(area, .{ .bg = theme.interactive }); 95 | self.focus_handler.render(area, frame, theme); 96 | try self.view.render(area, frame, theme); 97 | } 98 | 99 | pub fn layout(self: *Button, constraints: Constraints) !Vec2 { 100 | return self.view.layout(constraints); 101 | } 102 | 103 | pub fn handleEvent(self: *Button, event: events.Event) !events.EventResult { 104 | if (self.focus_handler.handleEvent(event) == .consumed) { 105 | return .consumed; 106 | } 107 | 108 | switch (event) { 109 | .char => |char| switch (char) { 110 | ' ' => { 111 | if (self.on_press) |on_press| { 112 | on_press.call(); 113 | } 114 | return .consumed; 115 | }, 116 | else => {}, 117 | }, 118 | else => {}, 119 | } 120 | return .ignored; 121 | } 122 | 123 | pub fn layoutProps(self: *Button) LayoutProperties { 124 | return self.view.layoutProps(); 125 | } 126 | 127 | fn createDecoratedLabel(text: anytype) !display.Span { 128 | var label = display.Span.init(internal.allocator); 129 | errdefer label.deinit(); 130 | 131 | try label.appendPlain("["); 132 | 133 | const TextT = @TypeOf(text); 134 | if (TextT == []const u8) { 135 | try label.appendPlain(text); 136 | } else if (TextT == display.SpanView) { 137 | try label.appendSpan(text); 138 | } else { 139 | @compileError("expected []const u8 or SpanView, got " ++ @typeName(TextT)); 140 | } 141 | 142 | try label.appendPlain("]"); 143 | 144 | return label; 145 | } 146 | -------------------------------------------------------------------------------- /examples/src/threads.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const tuile = @import("tuile"); 3 | 4 | const AppState = struct { 5 | allocator: std.mem.Allocator, 6 | 7 | mutex: std.Thread.Mutex, 8 | 9 | thread: std.Thread, 10 | 11 | stop: bool = false, 12 | 13 | progress: usize, 14 | 15 | tui: *tuile.Tuile, 16 | 17 | const progress_step: usize = 5; 18 | 19 | pub fn init(allocator: std.mem.Allocator, tui: *tuile.Tuile) !*AppState { 20 | const self = try allocator.create(AppState); 21 | errdefer allocator.destroy(self); 22 | self.* = AppState{ 23 | .allocator = allocator, 24 | .progress = 0, 25 | .mutex = std.Thread.Mutex{}, 26 | .thread = try std.Thread.spawn(.{}, AppState.update_loop, .{self}), 27 | .tui = tui, 28 | }; 29 | return self; 30 | } 31 | 32 | pub fn deinit(self: *AppState) void { 33 | self.mutex.lock(); 34 | self.stop = true; 35 | self.mutex.unlock(); 36 | self.thread.join(); 37 | 38 | self.allocator.destroy(self); 39 | } 40 | 41 | pub fn update_loop(self: *AppState) void { 42 | while (true) { 43 | std.time.sleep(100 * std.time.ns_per_ms); 44 | 45 | self.mutex.lock(); 46 | defer self.mutex.unlock(); 47 | 48 | if (self.stop) break; 49 | if (self.progress == 100) continue; 50 | 51 | self.progress += progress_step; 52 | self.tui.scheduleTask(.{ .cb = @ptrCast(&AppState.updateProgress), .payload = self }) catch unreachable; 53 | } 54 | } 55 | 56 | pub fn onReset(self_opt: ?*AppState) void { 57 | const self = self_opt.?; 58 | { 59 | self.mutex.lock(); 60 | defer self.mutex.unlock(); 61 | self.progress = 0; 62 | } 63 | 64 | const stack = self.tui.findByIdTyped(tuile.StackLayout, "progress-stack") orelse unreachable; 65 | const reset = self.tui.findById("reset-button") orelse unreachable; 66 | 67 | _ = stack.removeChild(reset) catch unreachable; 68 | stack.addChild(tuile.spacer(.{ 69 | .id = "progress-spacer", 70 | .layout = .{ .max_width = 1, .max_height = 1 }, 71 | })) catch unreachable; 72 | 73 | // Safe to call, we are in the main thread 74 | self.updateProgress(); 75 | } 76 | 77 | pub fn updateProgress(self: *AppState) void { 78 | const progress = blk: { 79 | self.mutex.lock(); 80 | defer self.mutex.unlock(); 81 | break :blk self.progress; 82 | }; 83 | 84 | const steps: usize = 100 / AppState.progress_step; 85 | const filled = progress / AppState.progress_step; 86 | var buffer: [steps * "█".len]u8 = undefined; 87 | var bar = buffer[0 .. filled * "█".len]; 88 | for (0..filled) |i| { 89 | std.mem.copyForwards(u8, bar[i * "█".len ..], "█"); 90 | } 91 | const label = self.tui.findByIdTyped(tuile.Label, "progress-label") orelse unreachable; 92 | label.setText(bar) catch unreachable; 93 | 94 | if (progress == 100) { 95 | const stack = self.tui.findByIdTyped(tuile.StackLayout, "progress-stack") orelse unreachable; 96 | const spacer = self.tui.findById("progress-spacer") orelse unreachable; 97 | 98 | _ = stack.removeChild(spacer) catch unreachable; 99 | stack.addChild(tuile.button(.{ 100 | .id = "reset-button", 101 | .text = "Restart", 102 | .on_press = .{ 103 | .cb = @ptrCast(&AppState.onReset), 104 | .payload = self, 105 | }, 106 | })) catch unreachable; 107 | } 108 | } 109 | }; 110 | 111 | pub fn main() !void { 112 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 113 | defer _ = gpa.deinit(); 114 | const allocator = gpa.allocator(); 115 | 116 | var tui = try tuile.Tuile.init(.{}); 117 | defer tui.deinit(); 118 | 119 | const layout = tuile.vertical( 120 | .{ .layout = .{ .flex = 1 } }, 121 | .{tuile.block( 122 | .{ .border = tuile.border.Border.all(), .layout = .{ .flex = 1 } }, 123 | tuile.vertical( 124 | .{ .id = "progress-stack", .layout = .{ .flex = 1 } }, 125 | .{ 126 | tuile.label(.{ .id = "progress-label", .text = "", .layout = .{ .alignment = tuile.LayoutProperties.Align.center() } }), 127 | tuile.spacer(.{ .id = "progress-spacer", .layout = .{ .max_width = 1, .max_height = 1 } }), 128 | }, 129 | ), 130 | )}, 131 | ); 132 | 133 | try tui.add(layout); 134 | 135 | var app_state = try AppState.init(allocator, &tui); 136 | defer app_state.deinit(); 137 | 138 | try tui.run(); 139 | } 140 | -------------------------------------------------------------------------------- /src/render/Frame.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Cell = @import("Cell.zig"); 3 | const Vec2 = @import("../Vec2.zig"); 4 | const Rect = @import("../Rect.zig"); 5 | const Backend = @import("../backends/Backend.zig"); 6 | const display = @import("../display.zig"); 7 | const internal = @import("../internal.zig"); 8 | const text_clustering = @import("../text_clustering.zig"); 9 | 10 | const Frame = @This(); 11 | 12 | /// Doesn't own the memory. Must be at least `size.x * size.y`. 13 | buffer: []Cell, 14 | 15 | /// The size of the buffer. 16 | size: Vec2, 17 | 18 | /// The area on which Frame is allowed to operate. 19 | /// Any writes outside of the area are ignored. 20 | area: Rect, 21 | 22 | pub fn at(self: Frame, pos: Vec2) *Cell { 23 | return &self.buffer[pos.y * self.size.x + pos.x]; 24 | } 25 | 26 | fn inside(self: Frame, pos: Vec2) bool { 27 | return self.area.min.x <= pos.x and pos.x < self.area.max.x and 28 | self.area.min.y <= pos.y and pos.y < self.area.max.y; 29 | } 30 | 31 | pub fn clear(self: Frame, fg: display.Color, bg: display.Color) void { 32 | for (self.buffer) |*cell| { 33 | cell.* = Cell{ 34 | .fg = fg, 35 | .bg = bg, 36 | }; 37 | } 38 | } 39 | 40 | pub fn withArea(self: Frame, area: Rect) Frame { 41 | return Frame{ 42 | .buffer = self.buffer, 43 | .size = self.size, 44 | .area = self.area.intersect(area), 45 | }; 46 | } 47 | 48 | pub fn setStyle(self: Frame, area: Rect, style: display.Style) void { 49 | for (area.min.y..area.max.y) |y| { 50 | for (area.min.x..area.max.x) |x| { 51 | const pos = Vec2{ .x = @intCast(x), .y = @intCast(y) }; 52 | if (self.inside(pos)) { 53 | self.at(pos).setStyle(style); 54 | } 55 | } 56 | } 57 | } 58 | 59 | pub fn setSymbol(self: Frame, pos: Vec2, symbol: []const u8) void { 60 | if (self.inside(pos)) { 61 | self.at(pos).symbol = symbol; 62 | } 63 | } 64 | 65 | // Decodes text as UTF-8, writes all code points separately and returns the number of 'characters' written 66 | pub fn writeSymbols(self: Frame, start: Vec2, bytes: []const u8, max: ?usize) !usize { 67 | var iter = try text_clustering.ClusterIterator.init(internal.text_clustering_type, bytes); 68 | var limit = max orelse std.math.maxInt(usize); 69 | var written: usize = 0; 70 | var cursor = start; 71 | while (iter.next()) |cluster| { 72 | if (cluster.display_width > limit) { 73 | break; 74 | } 75 | self.setSymbol(cursor, cluster.bytes(bytes)); 76 | 77 | cursor.x += @intCast(cluster.display_width); 78 | limit -= @intCast(cluster.display_width); 79 | written += @intCast(cluster.display_width); 80 | } 81 | return written; 82 | } 83 | 84 | pub fn render(self: Frame, backend: Backend) !void { 85 | var last_effect = display.Style.Effect{}; 86 | var last_color: ?display.ColorPair = null; 87 | 88 | try backend.disableEffect(display.Style.Effect.all()); 89 | 90 | for (0..self.size.y) |y| { 91 | // For the characters taking more than 1 column like の 92 | var overflow: usize = 0; 93 | for (0..self.size.x) |x| { 94 | const pos = Vec2{ .x = @intCast(x), .y = @intCast(y) }; 95 | const cell = self.at(pos); 96 | const none = display.Style.Effect{}; 97 | 98 | // Effects that are true in last, but false in cell 99 | const disable = last_effect.sub(cell.effect); 100 | if (!std.meta.eql(disable, none)) { 101 | try backend.disableEffect(disable); 102 | } 103 | 104 | // Effects that are true in cell, but false in last 105 | const enable = cell.effect.sub(last_effect); 106 | if (!std.meta.eql(enable, none)) { 107 | try backend.enableEffect(enable); 108 | } 109 | last_effect = cell.effect; 110 | 111 | if (last_color) |lcolor| { 112 | if (!std.meta.eql(lcolor.fg, cell.fg) or !std.meta.eql(lcolor.bg, cell.bg)) { 113 | try backend.useColor(.{ .fg = cell.fg, .bg = cell.bg }); 114 | } 115 | } else { 116 | try backend.useColor(.{ .fg = cell.fg, .bg = cell.bg }); 117 | } 118 | last_color = .{ .fg = cell.fg, .bg = cell.bg }; 119 | 120 | if (cell.symbol) |symbol| { 121 | try backend.printAt(pos, symbol); 122 | const width = try text_clustering.stringDisplayWidth(symbol, internal.text_clustering_type); 123 | overflow = width -| 1; 124 | } else { 125 | if (overflow > 0) { 126 | // Previous character occupies this column, do nothing 127 | overflow -= 1; 128 | } else { 129 | // Print whitespace to properly display the background 130 | try backend.printAt(pos, " "); 131 | } 132 | } 133 | } 134 | } 135 | try backend.refresh(); 136 | } 137 | -------------------------------------------------------------------------------- /examples/src/checkbox.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const tuile = @import("tuile"); 3 | 4 | const CheckedState = struct { 5 | const labels_on: [3][]const u8 = .{ 6 | "State 1 - 1", 7 | "State 2 - 1", 8 | "State 3 - 1", 9 | }; 10 | 11 | const labels_off: [3][]const u8 = .{ 12 | "State 1 - 0", 13 | "State 2 - 0", 14 | "State 3 - 0", 15 | }; 16 | 17 | checked: [3]bool = .{ false, false, false }, 18 | 19 | tui: *tuile.Tuile, 20 | 21 | ids: [3][]const u8, 22 | 23 | pub fn onGroupState(ptr: ?*CheckedState, idx: usize, state: bool) void { 24 | var self = ptr.?; 25 | self.checked[idx] = state; 26 | self.updateLabels(); 27 | } 28 | 29 | pub fn onState0(ptr: ?*CheckedState, state: bool) void { 30 | var self = ptr.?; 31 | self.checked[0] = state; 32 | self.updateLabels(); 33 | } 34 | 35 | pub fn onState1(ptr: ?*CheckedState, state: bool) void { 36 | var self = ptr.?; 37 | self.checked[1] = state; 38 | self.updateLabels(); 39 | } 40 | 41 | pub fn onState2(ptr: ?*CheckedState, state: bool) void { 42 | var self = ptr.?; 43 | self.checked[2] = state; 44 | self.updateLabels(); 45 | } 46 | 47 | fn updateLabels(self: CheckedState) void { 48 | var labels: [3][]const u8 = undefined; 49 | for (&labels, labels_on, labels_off, self.checked) |*label, on, off, checked| { 50 | if (checked) { 51 | label.* = on; 52 | } else { 53 | label.* = off; 54 | } 55 | } 56 | 57 | for (self.ids, labels) |id, text| { 58 | const label = self.tui.findByIdTyped(tuile.Label, id) orelse unreachable; 59 | label.setText(text) catch unreachable; 60 | } 61 | } 62 | }; 63 | 64 | pub fn main() !void { 65 | var tui = try tuile.Tuile.init(.{}); 66 | defer tui.deinit(); 67 | 68 | var state1 = CheckedState{ .tui = &tui, .ids = [3][]const u8{ "state-1-1", "state-1-2", "state-1-3" } }; 69 | var state2 = CheckedState{ .tui = &tui, .ids = [3][]const u8{ "state-2-1", "state-2-2", "state-2-3" } }; 70 | 71 | const layout = tuile.horizontal( 72 | .{}, 73 | .{ 74 | tuile.spacer(.{}), 75 | tuile.block( 76 | .{ .border = tuile.border.Border.all(), .border_type = .double }, 77 | tuile.vertical(.{}, .{ 78 | tuile.label(.{ .text = "Radio" }), 79 | tuile.checkbox_group( 80 | .{ .multiselect = false }, 81 | .{ 82 | tuile.checkbox(.{ 83 | .text = "Option 1", 84 | .on_state_change = .{ .cb = @ptrCast(&CheckedState.onState0), .payload = &state1 }, 85 | }), 86 | tuile.checkbox(.{ 87 | .text = "Option 2", 88 | .on_state_change = .{ .cb = @ptrCast(&CheckedState.onState1), .payload = &state1 }, 89 | }), 90 | tuile.checkbox(.{ 91 | .text = "Option 3", 92 | .on_state_change = .{ .cb = @ptrCast(&CheckedState.onState2), .payload = &state1 }, 93 | }), 94 | }, 95 | ), 96 | tuile.vertical(.{}, .{ 97 | tuile.label(.{ .id = state1.ids[0], .text = "" }), 98 | tuile.label(.{ .id = state1.ids[1], .text = "" }), 99 | tuile.label(.{ .id = state1.ids[2], .text = "" }), 100 | }), 101 | }), 102 | ), 103 | tuile.spacer(.{ .layout = .{ .max_width = 10, .max_height = 1 } }), 104 | tuile.block( 105 | .{ .border = tuile.border.Border.all(), .border_type = .double }, 106 | tuile.vertical(.{}, .{ 107 | tuile.label(.{ .text = "Multiselect" }), 108 | tuile.checkbox_group( 109 | .{ 110 | .multiselect = true, 111 | .on_state_change = .{ .cb = @ptrCast(&CheckedState.onGroupState), .payload = &state2 }, 112 | }, 113 | .{ 114 | tuile.checkbox(.{ .text = "Option 1" }), 115 | tuile.checkbox(.{ .text = "Option 2" }), 116 | tuile.checkbox(.{ .text = "Option 3" }), 117 | }, 118 | ), 119 | tuile.vertical(.{}, .{ 120 | tuile.label(.{ .id = state2.ids[0], .text = "" }), 121 | tuile.label(.{ .id = state2.ids[1], .text = "" }), 122 | tuile.label(.{ .id = state2.ids[2], .text = "" }), 123 | }), 124 | }), 125 | ), 126 | tuile.spacer(.{}), 127 | }, 128 | ); 129 | 130 | try tui.add(layout); 131 | 132 | state1.updateLabels(); 133 | state2.updateLabels(); 134 | 135 | try tui.run(); 136 | } 137 | -------------------------------------------------------------------------------- /examples/src/unicode.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const tuile = @import("tuile"); 3 | 4 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 5 | pub const tuile_allocator = gpa.allocator(); 6 | 7 | const UnicodeTable = struct { 8 | tui: *tuile.Tuile, 9 | 10 | pub fn inputChanged(self_opt: ?*UnicodeTable, value: []const u8) void { 11 | const self = self_opt.?; 12 | if (value.len > 0) { 13 | const label = self.tui.findByIdTyped(tuile.Label, "unicode-table") orelse unreachable; 14 | const start = std.fmt.parseInt(u21, value, 16) catch { 15 | label.setText("Invalid hex code") catch unreachable; 16 | return; 17 | }; 18 | 19 | const txt = generateUnicodeTable(start); 20 | defer tuile_allocator.free(txt); 21 | label.setText(txt) catch unreachable; 22 | } 23 | } 24 | 25 | fn generateUnicodeTable(start: u21) []const u8 { 26 | var string = std.ArrayListUnmanaged(u8){}; 27 | errdefer string.deinit(tuile_allocator); 28 | const w = 32; 29 | const h = 32; 30 | for (0..h) |y| { 31 | for (0..w) |x| { 32 | const character: u21 = @intCast(start + y * w + x); 33 | var cp = std.mem.zeroes([4]u8); 34 | const len = std.unicode.utf8Encode(character, &cp) catch @panic("Incorrect unicode"); 35 | string.appendSlice(tuile_allocator, cp[0..len]) catch @panic("OOM"); 36 | } 37 | string.append(tuile_allocator, '\n') catch @panic("OOM"); 38 | } 39 | return string.toOwnedSlice(tuile_allocator) catch @panic("OOM"); 40 | } 41 | }; 42 | 43 | const UnicodeBytes = struct { 44 | tui: *tuile.Tuile, 45 | 46 | pub fn inputChanged(self_opt: ?*UnicodeBytes, value: []const u8) void { 47 | const self = self_opt.?; 48 | if (value.len > 0) { 49 | const label = self.tui.findByIdTyped(tuile.Label, "unicode-bytes") orelse unreachable; 50 | 51 | var text = std.ArrayListUnmanaged(u8){}; 52 | defer text.deinit(tuile_allocator); 53 | 54 | var iter = std.mem.tokenizeScalar(u8, value, ' '); 55 | while (iter.next()) |byte| { 56 | const character = std.fmt.parseInt(u8, byte, 0) catch { 57 | label.setText("Invalid hex code") catch unreachable; 58 | return; 59 | }; 60 | text.append(tuile_allocator, character) catch @panic("OOM"); 61 | } 62 | 63 | if (std.unicode.utf8ValidateSlice(text.items)) { 64 | label.setText(text.items) catch unreachable; 65 | } else { 66 | label.setText("Invalid unicode sequence") catch unreachable; 67 | } 68 | } 69 | } 70 | }; 71 | 72 | pub fn main() !void { 73 | var tui = try tuile.Tuile.init(.{}); 74 | defer tui.deinit(); 75 | 76 | var unicode_table = UnicodeTable{ .tui = &tui }; 77 | var unicode_bytes = UnicodeBytes{ .tui = &tui }; 78 | 79 | const layout = tuile.vertical( 80 | .{ .layout = .{ .flex = 1 } }, 81 | .{ 82 | tuile.block( 83 | .{ .border = tuile.border.Border.all(), .layout = .{ .flex = 1 } }, 84 | tuile.label(.{ .id = "unicode-table", .text = "" }), 85 | ), 86 | 87 | tuile.horizontal( 88 | .{}, 89 | .{ 90 | tuile.label(.{ .text = "Starting unicode value: U+" }), 91 | tuile.input(.{ 92 | .id = "start-input", 93 | .layout = .{ .flex = 1 }, 94 | .on_value_changed = .{ 95 | .cb = @ptrCast(&UnicodeTable.inputChanged), 96 | .payload = &unicode_table, 97 | }, 98 | }), 99 | }, 100 | ), 101 | 102 | tuile.block( 103 | .{ .border = tuile.border.Border.all(), .layout = .{ .flex = 0 } }, 104 | tuile.label(.{ .id = "unicode-bytes", .text = "" }), 105 | ), 106 | 107 | tuile.horizontal( 108 | .{}, 109 | .{ 110 | tuile.label(.{ .text = "Unicode bytes (hex): " }), 111 | tuile.input(.{ 112 | .id = "bytes-input", 113 | .layout = .{ .flex = 1 }, 114 | .on_value_changed = .{ 115 | .cb = @ptrCast(&UnicodeBytes.inputChanged), 116 | .payload = &unicode_bytes, 117 | }, 118 | }), 119 | }, 120 | ), 121 | }, 122 | ); 123 | 124 | try tui.add(layout); 125 | 126 | { 127 | const input = tui.findByIdTyped(tuile.Input, "start-input") orelse unreachable; 128 | try input.setValue("1F300"); 129 | UnicodeTable.inputChanged(&unicode_table, "1F300"); 130 | } 131 | 132 | { 133 | const input = tui.findByIdTyped(tuile.Input, "bytes-input") orelse unreachable; 134 | try input.setValue("0xF0 0x9F 0x91 0x8D 0xF0 0x9F 0x8F 0xBD"); 135 | UnicodeBytes.inputChanged(&unicode_bytes, "0xF0 0x9F 0x91 0x8D 0xF0 0x9F 0x8F 0xBD"); 136 | } 137 | 138 | try tui.run(); 139 | } 140 | -------------------------------------------------------------------------------- /src/backends/Backend.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Vec2 = @import("../Vec2.zig"); 3 | const events = @import("../events.zig"); 4 | const display = @import("../display.zig"); 5 | const internal = @import("../internal.zig"); 6 | const builtin = @import("builtin"); 7 | 8 | const Backend = @This(); 9 | 10 | context: *anyopaque, 11 | vtable: *const VTable, 12 | 13 | pub const VTable = struct { 14 | destroy: *const fn (context: *anyopaque) void, 15 | poll_event: *const fn (context: *anyopaque) anyerror!?events.Event, 16 | refresh: *const fn (context: *anyopaque) anyerror!void, 17 | print_at: *const fn (context: *anyopaque, pos: Vec2, text: []const u8) anyerror!void, 18 | window_size: *const fn (context: *anyopaque) anyerror!Vec2, 19 | enable_effect: *const fn (context: *anyopaque, effect: display.Style.Effect) anyerror!void, 20 | disable_effect: *const fn (context: *anyopaque, effect: display.Style.Effect) anyerror!void, 21 | use_color: *const fn (context: *anyopaque, color: display.ColorPair) anyerror!void, 22 | request_mode: *const fn (context: *anyopaque, mode: u32) anyerror!ReportMode, 23 | }; 24 | 25 | pub fn init(context: anytype) Backend { 26 | const T = @TypeOf(context); 27 | const ptr_info = @typeInfo(T); 28 | 29 | const vtable = struct { 30 | pub fn destroy(pointer: *anyopaque) void { 31 | const self: T = @ptrCast(@alignCast(pointer)); 32 | return ptr_info.Pointer.child.destroy(self); 33 | } 34 | 35 | pub fn pollEvent(pointer: *anyopaque) anyerror!?events.Event { 36 | const self: T = @ptrCast(@alignCast(pointer)); 37 | return ptr_info.Pointer.child.pollEvent(self); 38 | } 39 | 40 | pub fn refresh(pointer: *anyopaque) anyerror!void { 41 | const self: T = @ptrCast(@alignCast(pointer)); 42 | return ptr_info.Pointer.child.refresh(self); 43 | } 44 | 45 | pub fn printAt(pointer: *anyopaque, pos: Vec2, text: []const u8) anyerror!void { 46 | const self: T = @ptrCast(@alignCast(pointer)); 47 | return ptr_info.Pointer.child.printAt(self, pos, text); 48 | } 49 | 50 | pub fn windowSize(pointer: *anyopaque) anyerror!Vec2 { 51 | const self: T = @ptrCast(@alignCast(pointer)); 52 | return ptr_info.Pointer.child.windowSize(self); 53 | } 54 | 55 | pub fn enableEffect(pointer: *anyopaque, effect: display.Style.Effect) anyerror!void { 56 | const self: T = @ptrCast(@alignCast(pointer)); 57 | return ptr_info.Pointer.child.enableEffect(self, effect); 58 | } 59 | 60 | pub fn disableEffect(pointer: *anyopaque, effect: display.Style.Effect) anyerror!void { 61 | const self: T = @ptrCast(@alignCast(pointer)); 62 | return ptr_info.Pointer.child.disableEffect(self, effect); 63 | } 64 | 65 | pub fn useColor(pointer: *anyopaque, color: display.ColorPair) anyerror!void { 66 | const self: T = @ptrCast(@alignCast(pointer)); 67 | return ptr_info.Pointer.child.useColor(self, color); 68 | } 69 | 70 | pub fn requestMode(pointer: *anyopaque, mode: u32) !ReportMode { 71 | const self: T = @ptrCast(@alignCast(pointer)); 72 | return ptr_info.Pointer.child.requestMode(self, mode); 73 | } 74 | }; 75 | 76 | return Backend{ 77 | .context = context, 78 | .vtable = &.{ 79 | .destroy = vtable.destroy, 80 | .poll_event = vtable.pollEvent, 81 | .refresh = vtable.refresh, 82 | .print_at = vtable.printAt, 83 | .window_size = vtable.windowSize, 84 | .enable_effect = vtable.enableEffect, 85 | .disable_effect = vtable.disableEffect, 86 | .use_color = vtable.useColor, 87 | .request_mode = vtable.requestMode, 88 | }, 89 | }; 90 | } 91 | 92 | pub fn destroy(self: Backend) void { 93 | return self.vtable.destroy(self.context); 94 | } 95 | 96 | pub fn pollEvent(self: Backend) anyerror!?events.Event { 97 | return self.vtable.poll_event(self.context); 98 | } 99 | 100 | pub fn refresh(self: Backend) anyerror!void { 101 | return self.vtable.refresh(self.context); 102 | } 103 | 104 | pub fn printAt(self: Backend, pos: Vec2, text: []const u8) anyerror!void { 105 | return self.vtable.print_at(self.context, pos, text); 106 | } 107 | 108 | pub fn windowSize(self: Backend) anyerror!Vec2 { 109 | return self.vtable.window_size(self.context); 110 | } 111 | 112 | pub fn enableEffect(self: Backend, effect: display.Style.Effect) anyerror!void { 113 | return self.vtable.enable_effect(self.context, effect); 114 | } 115 | 116 | pub fn disableEffect(self: Backend, effect: display.Style.Effect) anyerror!void { 117 | return self.vtable.disable_effect(self.context, effect); 118 | } 119 | 120 | pub fn useColor(self: Backend, color: display.ColorPair) anyerror!void { 121 | return self.vtable.use_color(self.context, color); 122 | } 123 | 124 | pub fn requestMode(self: Backend, mode: u32) !ReportMode { 125 | return self.vtable.request_mode(self.context, mode); 126 | } 127 | 128 | pub const ReportMode = enum { 129 | not_recognized, 130 | set, 131 | reset, 132 | }; 133 | 134 | pub fn requestModeTty(mode: u32) !ReportMode { 135 | if (builtin.os.tag == .windows) { 136 | return .not_recognized; 137 | } else { 138 | const tty = @import("tty.zig"); 139 | const response = try tty.requestMode(internal.allocator, mode); 140 | return switch (response) { 141 | .not_recognized => .not_recognized, 142 | .set, .permanently_set => .set, 143 | .reset, .permanently_reset => .reset, 144 | }; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/text_clustering.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const grapheme = @import("grapheme"); 3 | const DisplayWidth = @import("DisplayWidth"); 4 | const internal = @import("internal.zig"); 5 | 6 | pub const TextCluster = struct { 7 | len: u8, 8 | offset: usize, 9 | display_width: usize, 10 | 11 | pub fn bytes(self: TextCluster, src: []const u8) []const u8 { 12 | return src[self.offset..][0..self.len]; 13 | } 14 | }; 15 | 16 | pub const GraphemeClusterIterator = struct { 17 | bytes: []const u8, 18 | impl: grapheme.Iterator, 19 | dw: DisplayWidth, 20 | 21 | pub fn init(bytes: []const u8) !GraphemeClusterIterator { 22 | // grapheme.Iterator doesn't validate the slice 23 | // though at this point it should be valid? 24 | if (!std.unicode.utf8ValidateSlice(bytes)) { 25 | return error.InvalidUtf8; 26 | } 27 | return GraphemeClusterIterator{ 28 | .bytes = bytes, 29 | .impl = grapheme.Iterator.init(bytes, &internal.gd), 30 | .dw = DisplayWidth{ .data = &internal.dwd }, 31 | }; 32 | } 33 | 34 | pub fn next(self: *GraphemeClusterIterator) ?TextCluster { 35 | if (self.impl.next()) |gc| { 36 | return TextCluster{ 37 | .len = gc.len, 38 | .offset = gc.offset, 39 | .display_width = self.dw.strWidth(gc.bytes(self.bytes)), 40 | }; 41 | } else { 42 | return null; 43 | } 44 | } 45 | }; 46 | 47 | pub const CodepointClusterIterator = struct { 48 | cp_iter: std.unicode.Utf8Iterator, 49 | 50 | pub fn init(bytes: []const u8) !CodepointClusterIterator { 51 | const view = try std.unicode.Utf8View.init(bytes); 52 | return CodepointClusterIterator{ 53 | .cp_iter = view.iterator(), 54 | }; 55 | } 56 | 57 | pub fn next(self: *CodepointClusterIterator) ?TextCluster { 58 | if (self.cp_iter.nextCodepointSlice()) |slice_const| { 59 | var slice = slice_const; 60 | const cp = std.unicode.utf8Decode(slice) catch @panic("string was checked when constructing the iterator"); 61 | const width: usize = @intCast(@max(0, internal.dwd.codePointWidth(cp))); 62 | 63 | // Put the following zero-width codepoints in the same cluster 64 | var next_slice = self.cp_iter.peek(1); 65 | while (next_slice.len > 0) { 66 | const next_cp = std.unicode.utf8Decode(next_slice) catch @panic("string was checked when constructing the iterator"); 67 | const next_width: usize = @intCast(@max(0, internal.dwd.codePointWidth(next_cp))); 68 | if (next_width > 0) { 69 | break; 70 | } 71 | slice.len += next_slice.len; 72 | self.cp_iter.i += next_slice.len; 73 | next_slice = self.cp_iter.peek(1); 74 | } 75 | 76 | return TextCluster{ 77 | .len = @intCast(slice.len), 78 | .offset = @intFromPtr(slice.ptr) - @intFromPtr(self.cp_iter.bytes.ptr), 79 | .display_width = width, 80 | }; 81 | } else { 82 | return null; 83 | } 84 | } 85 | }; 86 | 87 | pub const ClusteringType = enum { 88 | graphemes, 89 | codepoints, 90 | }; 91 | 92 | pub const ClusterIterator = union(ClusteringType) { 93 | graphemes: GraphemeClusterIterator, 94 | codepoints: CodepointClusterIterator, 95 | 96 | pub fn init(clustering: ClusteringType, bytes: []const u8) !ClusterIterator { 97 | return switch (clustering) { 98 | .graphemes => .{ .graphemes = try GraphemeClusterIterator.init(bytes) }, 99 | .codepoints => .{ .codepoints = try CodepointClusterIterator.init(bytes) }, 100 | }; 101 | } 102 | 103 | pub fn next(self: *ClusterIterator) ?TextCluster { 104 | return switch (self.*) { 105 | .graphemes => |*iter| iter.next(), 106 | .codepoints => |*iter| iter.next(), 107 | }; 108 | } 109 | }; 110 | 111 | pub fn stringDisplayWidth(bytes: []const u8, clustering: ClusteringType) !usize { 112 | var width: usize = 0; 113 | var iter = try ClusterIterator.init(clustering, bytes); 114 | while (iter.next()) |cluster| { 115 | width += cluster.display_width; 116 | } 117 | return width; 118 | } 119 | 120 | test "codepoints cluster iterator" { 121 | try internal.init(.codepoints); 122 | defer internal.deinit(); 123 | const str = "\xF0\x9F\x91\x8D\xF0\x9F\x8F\xBD"; 124 | var iter = try ClusterIterator.init(.codepoints, str); 125 | const cp1 = iter.next(); 126 | try std.testing.expect(cp1 != null); 127 | try std.testing.expectEqualStrings(cp1.?.bytes(str), "\xF0\x9F\x91\x8D"); 128 | 129 | const cp2 = iter.next(); 130 | try std.testing.expect(cp2 != null); 131 | try std.testing.expectEqualStrings(cp2.?.bytes(str), "\xF0\x9F\x8F\xBD"); 132 | } 133 | 134 | test "graphemes cluster iterator" { 135 | try internal.init(.codepoints); 136 | defer internal.deinit(); 137 | const str = "\xF0\x9F\x91\x8D\xF0\x9F\x8F\xBD"; 138 | var iter = try ClusterIterator.init(.graphemes, str); 139 | const cp1 = iter.next(); 140 | try std.testing.expect(cp1 != null); 141 | try std.testing.expectEqualStrings(cp1.?.bytes(str), str); 142 | 143 | const cp2 = iter.next(); 144 | try std.testing.expect(cp2 == null); 145 | } 146 | 147 | test "string display width" { 148 | try internal.init(.codepoints); 149 | defer internal.deinit(); 150 | try std.testing.expectEqual(4, try stringDisplayWidth("\xF0\x9F\x91\x8D\xF0\x9F\x8F\xBD", .codepoints)); 151 | try std.testing.expectEqual(2, try stringDisplayWidth("\xF0\x9F\x91\x8D\xF0\x9F\x8F\xBD", .graphemes)); 152 | } 153 | -------------------------------------------------------------------------------- /src/widgets/Checkbox.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Widget = @import("Widget.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const Rect = @import("../Rect.zig"); 6 | const events = @import("../events.zig"); 7 | const Frame = @import("../render/Frame.zig"); 8 | const Label = @import("Label.zig"); 9 | const FocusHandler = @import("FocusHandler.zig"); 10 | const LayoutProperties = @import("LayoutProperties.zig"); 11 | const Constraints = @import("Constraints.zig"); 12 | const display = @import("../display.zig"); 13 | const callbacks = @import("callbacks.zig"); 14 | 15 | pub const Config = struct { 16 | /// A unique identifier of the widget to be used in `Tuile.findById` and `Widget.findById`. 17 | id: ?[]const u8 = null, 18 | 19 | /// `text` and `span` are mutually exclusive, only one of them must be defined. 20 | text: ?[]const u8 = null, 21 | 22 | /// `text` and `span` are mutually exclusive, only one of them must be defined. 23 | span: ?display.SpanView = null, 24 | 25 | /// See `Role`. 26 | role: Role = .checkbox, 27 | 28 | /// The initial state of the Checkbox. 29 | checked: bool = false, 30 | 31 | /// Checkbox will call this when its state changes. 32 | on_state_change: ?callbacks.Callback(bool) = null, 33 | 34 | /// Layout properties of the widget, see `LayoutProperties`. 35 | layout: LayoutProperties = .{}, 36 | }; 37 | 38 | pub const Role = enum { 39 | checkbox, 40 | radio, 41 | }; 42 | 43 | pub const Checkbox = @This(); 44 | 45 | pub usingnamespace Widget.Leaf.Mixin(Checkbox); 46 | pub usingnamespace Widget.Base.Mixin(Checkbox, .widget_base); 47 | 48 | widget_base: Widget.Base, 49 | 50 | role: Role, 51 | 52 | view: *Label, 53 | 54 | focus_handler: FocusHandler = .{}, 55 | 56 | checked: bool, 57 | 58 | on_state_change: ?callbacks.Callback(bool), 59 | 60 | pub fn create(config: Config) !*Checkbox { 61 | if (config.text == null and config.span == null) { 62 | @panic("text and span are mutually exclusive, only one of them must be defined"); 63 | } 64 | 65 | var label: display.Span = undefined; 66 | if (config.text) |text| { 67 | label = try createDecoratedLabel(text); 68 | } else if (config.span) |span| { 69 | label = try createDecoratedLabel(span); 70 | } 71 | defer label.deinit(); 72 | 73 | const view = try Label.create(.{ .span = label.view(), .layout = config.layout }); 74 | 75 | const self = try internal.allocator.create(Checkbox); 76 | self.* = Checkbox{ 77 | .widget_base = try Widget.Base.init(config.id), 78 | .view = view, 79 | .role = config.role, 80 | .checked = config.checked, 81 | .on_state_change = config.on_state_change, 82 | }; 83 | return self; 84 | } 85 | 86 | pub fn destroy(self: *Checkbox) void { 87 | self.widget_base.deinit(); 88 | self.view.destroy(); 89 | internal.allocator.destroy(self); 90 | } 91 | 92 | pub fn widget(self: *Checkbox) Widget { 93 | return Widget.init(self); 94 | } 95 | 96 | pub fn setText(self: *Checkbox, text: []const u8) !void { 97 | const label = try createDecoratedLabel(text); 98 | defer label.deinit(); 99 | self.view.setSpan(label.view()); 100 | } 101 | 102 | pub fn setSpan(self: *Checkbox, span: display.SpanView) !void { 103 | const label = try createDecoratedLabel(span); 104 | defer label.deinit(); 105 | self.view.setSpan(label.view()); 106 | } 107 | 108 | pub fn render(self: *Checkbox, area: Rect, frame: Frame, theme: display.Theme) !void { 109 | frame.setStyle(area, .{ .bg = theme.interactive }); 110 | self.focus_handler.render(area, frame, theme); 111 | 112 | if (self.view.content.getText().len < 4) { 113 | @panic("inner view must be at least 4 characters long for the bullet marker"); 114 | } 115 | switch (self.role) { 116 | .checkbox => { 117 | std.mem.copyForwards(u8, self.view.content.text.items, "[ ] "); 118 | if (self.checked) { 119 | self.view.content.text.items[1] = 'x'; 120 | } 121 | }, 122 | .radio => { 123 | std.mem.copyForwards(u8, self.view.content.text.items, "( ) "); 124 | if (self.checked) { 125 | self.view.content.text.items[1] = '*'; 126 | } 127 | }, 128 | } 129 | 130 | try self.view.render(area, frame, theme); 131 | } 132 | 133 | pub fn layout(self: *Checkbox, constraints: Constraints) !Vec2 { 134 | return try self.view.layout(constraints); 135 | } 136 | 137 | pub fn handleEvent(self: *Checkbox, event: events.Event) !events.EventResult { 138 | if (self.focus_handler.handleEvent(event) == .consumed) { 139 | return .consumed; 140 | } 141 | switch (event) { 142 | .char => |char| switch (char) { 143 | ' ' => { 144 | self.checked = !self.checked; 145 | if (self.on_state_change) |on_state_change| { 146 | on_state_change.call(self.checked); 147 | } 148 | return .consumed; 149 | }, 150 | else => {}, 151 | }, 152 | else => {}, 153 | } 154 | return .ignored; 155 | } 156 | 157 | pub fn layoutProps(self: *Checkbox) LayoutProperties { 158 | return self.view.layout_properties; 159 | } 160 | 161 | fn createDecoratedLabel(text: anytype) !display.Span { 162 | var label = display.Span.init(internal.allocator); 163 | errdefer label.deinit(); 164 | try label.appendPlain("[ ] "); 165 | 166 | const TextT = @TypeOf(text); 167 | if (TextT == []const u8) { 168 | try label.appendPlain(text); 169 | } else if (TextT == display.SpanView) { 170 | try label.appendSpan(text); 171 | } else { 172 | @compileError("expected []const u8 or SpanView, got " ++ @typeName(TextT)); 173 | } 174 | 175 | return label; 176 | } 177 | -------------------------------------------------------------------------------- /src/widgets/Label.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Widget = @import("Widget.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const Rect = @import("../Rect.zig"); 6 | const events = @import("../events.zig"); 7 | const Frame = @import("../render/Frame.zig"); 8 | const LayoutProperties = @import("LayoutProperties.zig"); 9 | const Constraints = @import("Constraints.zig"); 10 | const display = @import("../display.zig"); 11 | const text_clustering = @import("../text_clustering.zig"); 12 | const stringDisplayWidth = text_clustering.stringDisplayWidth; 13 | 14 | const PartialChunk = struct { 15 | orig: usize, 16 | 17 | start: usize, 18 | 19 | end: usize, 20 | }; 21 | 22 | const Row = struct { 23 | chunks: std.ArrayListUnmanaged(PartialChunk) = .{}, 24 | 25 | pub fn deinit(self: *Row, allocator: std.mem.Allocator) void { 26 | self.chunks.deinit(allocator); 27 | } 28 | }; 29 | 30 | pub const Config = struct { 31 | /// A unique identifier of the widget to be used in `Tuile.findById` and `Widget.findById`. 32 | id: ?[]const u8 = null, 33 | 34 | /// `text` and `span` are mutually exclusive, only one of them must be defined. 35 | text: ?[]const u8 = null, 36 | 37 | /// `text` and `span` are mutually exclusive, only one of them must be defined. 38 | span: ?display.SpanView = null, 39 | 40 | /// Layout properties of the widget, see `LayoutProperties`. 41 | layout: LayoutProperties = .{}, 42 | }; 43 | 44 | pub const Label = @This(); 45 | 46 | pub usingnamespace Widget.Leaf.Mixin(Label); 47 | pub usingnamespace Widget.Base.Mixin(Label, .widget_base); 48 | 49 | widget_base: Widget.Base, 50 | 51 | content: display.SpanUnmanaged, 52 | 53 | rows: std.ArrayListUnmanaged(Row), 54 | 55 | layout_properties: LayoutProperties, 56 | 57 | pub fn create(config: Config) !*Label { 58 | if (config.text == null and config.span == null) { 59 | @panic("text and span are mutually exclusive, only one of them must be defined"); 60 | } 61 | const self = try internal.allocator.create(Label); 62 | self.* = Label{ 63 | .widget_base = try Widget.Base.init(config.id), 64 | .content = display.SpanUnmanaged{}, 65 | .rows = .{}, 66 | .layout_properties = config.layout, 67 | }; 68 | if (config.text) |text| { 69 | try self.content.appendPlain(internal.allocator, text); 70 | } else if (config.span) |span| { 71 | try self.content.appendSpan(internal.allocator, span); 72 | } 73 | return self; 74 | } 75 | 76 | pub fn destroy(self: *Label) void { 77 | self.widget_base.deinit(); 78 | for (self.rows.items) |*row| { 79 | row.deinit(internal.allocator); 80 | } 81 | self.rows.deinit(internal.allocator); 82 | self.content.deinit(internal.allocator); 83 | internal.allocator.destroy(self); 84 | } 85 | 86 | pub fn widget(self: *Label) Widget { 87 | return Widget.init(self); 88 | } 89 | 90 | pub fn setText(self: *Label, text: []const u8) !void { 91 | self.content.deinit(internal.allocator); 92 | self.content = display.SpanUnmanaged{}; 93 | try self.content.appendPlain(internal.allocator, text); 94 | } 95 | 96 | pub fn setSpan(self: *Label, span: display.SpanView) !void { 97 | self.content.deinit(internal.allocator); 98 | self.content = try display.SpanUnmanaged.fromView(internal.allocator, span); 99 | } 100 | 101 | pub fn render(self: *Label, area: Rect, frame: Frame, _: display.Theme) !void { 102 | const rows = self.rows.items; 103 | for (0..area.height()) |y| { 104 | if (y >= rows.len) break; 105 | 106 | const row = rows[y]; 107 | var pos = area.min.add(.{ .x = 0, .y = @intCast(y) }); 108 | for (row.chunks.items) |chunk| { 109 | const text = self.content.getTextForChunk(chunk.orig)[chunk.start..chunk.end]; 110 | const written: u32 = @intCast(try frame.writeSymbols(pos, text, area.width())); 111 | const chunk_area = Rect{ .min = pos, .max = pos.add(.{ .x = written, .y = 1 }) }; 112 | frame.setStyle(chunk_area, self.content.getStyleForChunk(chunk.orig)); 113 | pos.x += written; 114 | } 115 | } 116 | } 117 | 118 | pub fn layout(self: *Label, constraints: Constraints) !Vec2 { 119 | try self.wrapText(constraints); 120 | 121 | var max_len: usize = 0; 122 | for (self.rows.items) |row| { 123 | var len: usize = 0; 124 | for (row.chunks.items) |chunk| { 125 | const text = self.content.getTextForChunk(chunk.orig)[chunk.start..chunk.end]; 126 | len += try stringDisplayWidth(text, internal.text_clustering_type); 127 | } 128 | max_len = @max(max_len, len); 129 | } 130 | 131 | var size = Vec2{ 132 | .x = @intCast(max_len), 133 | .y = @intCast(self.rows.items.len), 134 | }; 135 | 136 | const self_constraints = Constraints.fromProps(self.layout_properties); 137 | size = self_constraints.apply(size); 138 | size = constraints.apply(size); 139 | return size; 140 | } 141 | 142 | pub fn handleEvent(_: *Label, _: events.Event) !events.EventResult { 143 | return .ignored; 144 | } 145 | 146 | pub fn layoutProps(self: *Label) LayoutProperties { 147 | return self.layout_properties; 148 | } 149 | 150 | fn wrapText(self: *Label, _: Constraints) !void { 151 | for (self.rows.items) |*row| { 152 | row.deinit(internal.allocator); 153 | } 154 | self.rows.clearAndFree(internal.allocator); 155 | 156 | for (0..self.content.getChunks().len) |chunk_idx| { 157 | if (self.rows.items.len == 0) { 158 | try self.rows.append(internal.allocator, .{}); 159 | } 160 | 161 | const text = self.content.getTextForChunk(chunk_idx); 162 | var iter = std.mem.tokenizeScalar(u8, text, '\n'); 163 | while (iter.next()) |line| { 164 | const start = @intFromPtr(line.ptr) - @intFromPtr(text.ptr); 165 | const end = start + line.len; 166 | const partial = PartialChunk{ 167 | .orig = chunk_idx, 168 | .start = start, 169 | .end = end, 170 | }; 171 | 172 | var last = &self.rows.items[self.rows.items.len - 1]; 173 | try last.chunks.append(internal.allocator, partial); 174 | if (iter.peek()) |_| { 175 | try self.rows.append(internal.allocator, .{}); 176 | } 177 | } 178 | // tokenize skips delimiters, but we need to add another row 179 | // if newline happens to be at the end 180 | if (text.len > 0 and text[text.len - 1] == '\n') { 181 | try self.rows.append(internal.allocator, .{}); 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/widgets/CheckboxGroup.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Widget = @import("Widget.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const Rect = @import("../Rect.zig"); 6 | const events = @import("../events.zig"); 7 | const Frame = @import("../render/Frame.zig"); 8 | const StackLayout = @import("StackLayout.zig"); 9 | const Checkbox = @import("Checkbox.zig"); 10 | const LayoutProperties = @import("LayoutProperties.zig"); 11 | const Constraints = @import("Constraints.zig"); 12 | const display = @import("../display.zig"); 13 | const callbacks = @import("callbacks.zig"); 14 | 15 | pub const Config = struct { 16 | /// A unique identifier of the widget to be used in `Tuile.findById` and `Widget.findById`. 17 | id: ?[]const u8 = null, 18 | 19 | /// Controls if selecting multiple boxes is allowed. 20 | /// When `multiselect` is `false`, the widget will deselect previously selected option 21 | /// and `on_state_change` callback will be fired for that option. 22 | multiselect: bool = false, 23 | 24 | /// CheckboxGroup will call this on every state change. 25 | on_state_change: ?callbacks.Callback(.{ usize, bool }) = null, 26 | 27 | /// Layout properties of the widget, see `LayoutProperties`. 28 | layout: LayoutProperties = .{}, 29 | }; 30 | 31 | pub const CheckboxGroup = @This(); 32 | 33 | pub usingnamespace Widget.SingleChild.Mixin(CheckboxGroup, .view); 34 | pub usingnamespace Widget.Base.Mixin(CheckboxGroup, .widget_base); 35 | 36 | widget_base: Widget.Base, 37 | 38 | view: Widget, 39 | 40 | multiselect: bool, 41 | 42 | on_state_change: ?callbacks.Callback(.{ usize, bool }), 43 | 44 | fn assertCheckbox(any: anytype) void { 45 | const T = @TypeOf(any); 46 | const info = @typeInfo(T); 47 | 48 | const Underlying = if (info == .ErrorUnion) 49 | info.ErrorUnion.payload 50 | else 51 | T; 52 | 53 | if (Underlying != *Checkbox) @compileError("expected type *Checkbox, found" ++ @typeName(Underlying)); 54 | } 55 | 56 | fn assertCheckboxes(options: anytype) void { 57 | const info = @typeInfo(@TypeOf(options)); 58 | if (info == .Struct and info.Struct.is_tuple) { 59 | // Tuples only support comptime indexing 60 | inline for (options) |opt| { 61 | assertCheckbox(opt); 62 | } 63 | } else { 64 | for (options) |opt| { 65 | assertCheckbox(opt); 66 | } 67 | } 68 | } 69 | 70 | pub fn create(config: Config, options: anytype) !*CheckboxGroup { 71 | assertCheckboxes(options); 72 | 73 | const self = try internal.allocator.create(CheckboxGroup); 74 | self.* = CheckboxGroup{ 75 | .widget_base = try Widget.Base.init(config.id), 76 | .view = try Widget.fromAny( 77 | StackLayout.create( 78 | .{ .layout = config.layout }, 79 | options, 80 | ), 81 | ), 82 | .multiselect = config.multiselect, 83 | .on_state_change = config.on_state_change, 84 | }; 85 | 86 | const stack: *StackLayout = self.view.as(StackLayout) orelse @panic("Created StackLayout, but unable to cast widget"); 87 | if (stack.widgets.items.len > 0) { 88 | var found_checked = false; 89 | for (stack.widgets.items) |child| { 90 | var option: *Checkbox = child.as(Checkbox) orelse @panic("Option was a checkbox, but unable to cast widget"); 91 | option.view.layout_properties.alignment.h = LayoutProperties.HAlign.left; 92 | 93 | if (!self.multiselect and option.checked) { 94 | if (found_checked) { 95 | option.checked = false; 96 | } 97 | found_checked = true; 98 | } 99 | } 100 | } 101 | return self; 102 | } 103 | 104 | pub fn destroy(self: *CheckboxGroup) void { 105 | self.widget_base.deinit(); 106 | self.view.destroy(); 107 | internal.allocator.destroy(self); 108 | } 109 | 110 | pub fn widget(self: *CheckboxGroup) Widget { 111 | return Widget.init(self); 112 | } 113 | 114 | pub fn render(self: *CheckboxGroup, area: Rect, frame: Frame, theme: display.Theme) !void { 115 | try self.view.render(area, frame, theme); 116 | } 117 | 118 | pub fn layout(self: *CheckboxGroup, constraints: Constraints) !Vec2 { 119 | return try self.view.layout(constraints); 120 | } 121 | 122 | pub fn handleEvent(self: *CheckboxGroup, event: events.Event) !events.EventResult { 123 | if (!self.multiselect) { 124 | switch (event) { 125 | .char => |char| switch (char) { 126 | ' ' => { 127 | const stack: *StackLayout = @ptrCast(@alignCast(self.view.context)); 128 | if (stack.focused) |focused| { 129 | const focused_option: *Checkbox = @ptrCast(@alignCast(stack.widgets.items[focused].context)); 130 | if (focused_option.checked) { 131 | return .ignored; 132 | } 133 | } 134 | }, 135 | else => {}, 136 | }, 137 | else => {}, 138 | } 139 | } 140 | 141 | const res = try self.view.handleEvent(event); 142 | if (res == .ignored) { 143 | return res; 144 | } 145 | 146 | switch (event) { 147 | .char => |char| switch (char) { 148 | ' ' => { 149 | // Safe - this option received and consumed the event 150 | const stack: *StackLayout = self.view.as(StackLayout) orelse @panic("Created StackLayout, but unable to cast widget"); 151 | const focused = stack.focused.?; 152 | const checked_option: *Checkbox = stack.widgets.items[focused].as(Checkbox) orelse @panic("Option was a checkbox, but unable to cast widget"); 153 | if (self.on_state_change) |on_state_change| { 154 | on_state_change.call(focused, checked_option.checked); 155 | } 156 | 157 | // Uncheck everything else 158 | if (!self.multiselect) { 159 | for (stack.widgets.items, 0..) |*opt_w, idx| { 160 | if (idx == focused) { 161 | continue; 162 | } 163 | const option: *Checkbox = opt_w.as(Checkbox) orelse @panic("Option was a checkbox, but unable to cast widget"); 164 | if (option.checked) { 165 | const nested_result = try option.handleEvent(.{ .char = ' ' }); 166 | std.debug.assert(nested_result == .consumed); 167 | std.debug.assert(option.checked == false); 168 | 169 | if (self.on_state_change) |on_state_change| { 170 | on_state_change.call(idx, option.checked); 171 | } 172 | } 173 | } 174 | } 175 | return .consumed; 176 | }, 177 | else => {}, 178 | }, 179 | else => {}, 180 | } 181 | return res; 182 | } 183 | 184 | pub fn layoutProps(self: *CheckboxGroup) LayoutProperties { 185 | return self.view.layoutProps(); 186 | } 187 | 188 | pub fn prepare(self: *CheckboxGroup) !void { 189 | try self.view.prepare(); 190 | } 191 | -------------------------------------------------------------------------------- /src/backends/Crossterm.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Backend = @import("Backend.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const events = @import("../events.zig"); 6 | const display = @import("../display.zig"); 7 | const render = @import("../render.zig"); 8 | 9 | // These structs must be identical to the ones in backends/crossterm/src/lib.rs 10 | const RustVec2 = extern struct { x: c_uint, y: c_uint }; 11 | 12 | const RustKey = extern struct { 13 | key_type: RustKeyType, 14 | code: [4]u8, 15 | modifier: RustKeyModifiers, 16 | }; 17 | 18 | const RustKeyModifiers = extern struct { 19 | shift: bool, 20 | control: bool, 21 | alt: bool, 22 | }; 23 | 24 | const RustKeyType = enum(u16) { 25 | None, 26 | Char, 27 | Enter, 28 | Escape, 29 | Backspace, 30 | Tab, 31 | Left, 32 | Right, 33 | Up, 34 | Down, 35 | Insert, 36 | Delete, 37 | Home, 38 | End, 39 | PageUp, 40 | PageDown, 41 | F0, 42 | F1, 43 | F2, 44 | F3, 45 | F4, 46 | F5, 47 | F6, 48 | F7, 49 | F8, 50 | F9, 51 | F10, 52 | F11, 53 | F12, 54 | Resize, 55 | }; 56 | 57 | const RustEffect = extern struct { 58 | highlight: bool = false, 59 | underline: bool = false, 60 | reverse: bool = false, 61 | blink: bool = false, 62 | dim: bool = false, 63 | bold: bool = false, 64 | italic: bool = false, 65 | }; 66 | 67 | const RustColorPair = extern struct { 68 | fg: RustColor, 69 | bg: RustColor, 70 | }; 71 | 72 | const RustColor = extern struct { 73 | tag: enum(u8) { dark, bright, ansi }, 74 | data: extern union { 75 | dark: RustBaseColor, 76 | bright: RustBaseColor, 77 | ansi: u8, 78 | }, 79 | }; 80 | 81 | const RustBaseColor = enum(u8) { 82 | black, 83 | red, 84 | green, 85 | yellow, 86 | blue, 87 | magenta, 88 | cyan, 89 | white, 90 | }; 91 | 92 | extern fn crossterm_init() void; 93 | extern fn crossterm_deinit() void; 94 | extern fn crossterm_poll_event() RustKey; 95 | extern fn crossterm_refresh() void; 96 | extern fn crossterm_print_at(pos: RustVec2, str: [*]const u8, n: c_uint) void; 97 | extern fn crossterm_window_size() RustVec2; 98 | extern fn crossterm_enable_effect(effect: RustEffect) void; 99 | extern fn crossterm_disable_effect(effect: RustEffect) void; 100 | extern fn crossterm_use_color(RustColorPair) void; 101 | 102 | const Crossterm = @This(); 103 | 104 | pub fn create() !*Crossterm { 105 | crossterm_init(); 106 | const self = try internal.allocator.create(Crossterm); 107 | self.* = .{}; 108 | return self; 109 | } 110 | 111 | pub fn destroy(self: *Crossterm) void { 112 | crossterm_deinit(); 113 | internal.allocator.destroy(self); 114 | } 115 | 116 | pub fn backend(self: *Crossterm) Backend { 117 | return Backend.init(self); 118 | } 119 | 120 | pub fn pollEvent(_: *Crossterm) !?events.Event { 121 | const key = crossterm_poll_event(); 122 | return convertEvent(key); 123 | } 124 | 125 | pub fn refresh(_: *Crossterm) !void { 126 | crossterm_refresh(); 127 | } 128 | 129 | pub fn printAt(_: *Crossterm, pos: Vec2, text: []const u8) !void { 130 | crossterm_print_at(.{ .x = @intCast(pos.x), .y = @intCast(pos.y) }, text.ptr, @intCast(text.len)); 131 | } 132 | 133 | pub fn windowSize(_: *Crossterm) !Vec2 { 134 | const size = crossterm_window_size(); 135 | return .{ .x = @intCast(size.x), .y = @intCast(size.y) }; 136 | } 137 | 138 | pub fn enableEffect(_: *Crossterm, effect: display.Style.Effect) !void { 139 | var rust_effect: RustEffect = undefined; 140 | inline for (@typeInfo(display.Style.Effect).Struct.fields) |field| { 141 | @field(rust_effect, field.name) = @field(effect, field.name); 142 | } 143 | crossterm_enable_effect(rust_effect); 144 | } 145 | 146 | pub fn disableEffect(_: *Crossterm, effect: display.Style.Effect) !void { 147 | var rust_effect: RustEffect = undefined; 148 | inline for (@typeInfo(display.Style.Effect).Struct.fields) |field| { 149 | @field(rust_effect, field.name) = @field(effect, field.name); 150 | } 151 | crossterm_disable_effect(rust_effect); 152 | } 153 | 154 | pub fn useColor(_: *Crossterm, color_pair: display.ColorPair) !void { 155 | const rust_pair = RustColorPair{ 156 | .fg = colorToRust(color_pair.fg), 157 | .bg = colorToRust(color_pair.bg), 158 | }; 159 | crossterm_use_color(rust_pair); 160 | } 161 | 162 | fn colorToRust(color: display.Color) RustColor { 163 | switch (color) { 164 | .bright => |c| { 165 | return RustColor{ 166 | .tag = .bright, 167 | .data = .{ .bright = @enumFromInt(@as(u8, @intCast(@intFromEnum(c)))) }, 168 | }; 169 | }, 170 | .dark => |c| { 171 | return RustColor{ 172 | .tag = .dark, 173 | .data = .{ .dark = @enumFromInt(@as(u8, @intCast(@intFromEnum(c)))) }, 174 | }; 175 | }, 176 | .rgb => |rgb| { 177 | return RustColor{ 178 | .tag = .ansi, 179 | .data = .{ .ansi = display.Palette256.findClosestNonSystem(rgb) }, 180 | }; 181 | }, 182 | } 183 | } 184 | 185 | fn convertEvent(rust_key: RustKey) ?events.Event { 186 | const char_or_key = switch (rust_key.key_type) { 187 | .None => return null, 188 | .Char => blk: { 189 | const char: u32 = @bitCast(rust_key.code); 190 | break :blk events.Event{ .char = @intCast(char) }; 191 | }, 192 | .Enter => events.Event{ .key = .Enter }, 193 | .Escape => events.Event{ .key = .Escape }, 194 | .Backspace => events.Event{ .key = .Backspace }, 195 | .Tab => events.Event{ .key = .Tab }, 196 | .Left => events.Event{ .key = .Left }, 197 | .Right => events.Event{ .key = .Right }, 198 | .Up => events.Event{ .key = .Up }, 199 | .Down => events.Event{ .key = .Down }, 200 | .Insert => events.Event{ .key = .Insert }, 201 | .Delete => events.Event{ .key = .Delete }, 202 | .Home => events.Event{ .key = .Home }, 203 | .End => events.Event{ .key = .End }, 204 | .PageUp => events.Event{ .key = .PageUp }, 205 | .PageDown => events.Event{ .key = .PageDown }, 206 | .F0 => events.Event{ .key = .F0 }, 207 | .F1 => events.Event{ .key = .F1 }, 208 | .F2 => events.Event{ .key = .F2 }, 209 | .F3 => events.Event{ .key = .F3 }, 210 | .F4 => events.Event{ .key = .F4 }, 211 | .F5 => events.Event{ .key = .F5 }, 212 | .F6 => events.Event{ .key = .F6 }, 213 | .F7 => events.Event{ .key = .F7 }, 214 | .F8 => events.Event{ .key = .F8 }, 215 | .F9 => events.Event{ .key = .F9 }, 216 | .F10 => events.Event{ .key = .F10 }, 217 | .F11 => events.Event{ .key = .F11 }, 218 | .F12 => events.Event{ .key = .F12 }, 219 | .Resize => events.Event{ .key = .Resize }, 220 | }; 221 | 222 | if (rust_key.modifier.control and char_or_key == .char) { 223 | return events.Event{ .ctrl_char = char_or_key.char }; 224 | } 225 | if (rust_key.modifier.shift and char_or_key == .key) { 226 | return events.Event{ .shift_key = char_or_key.key }; 227 | } 228 | return char_or_key; 229 | } 230 | 231 | pub fn requestMode(_: *Crossterm, mode: u32) !Backend.ReportMode { 232 | return Backend.requestModeTty(mode); 233 | } 234 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 |
13 | 16 | 17 |

Tuile

18 | 19 |

20 | A Text User Interface library for Zig. 21 |
22 | Breaking changes are possible. 23 |
24 | Explore the docs » 25 |
26 |
27 | View Examples 28 | · 29 | Report Bug 30 | · 31 | Request Feature 32 |

33 |
34 | 35 | 36 | 37 | 38 |
39 | Table of Contents 40 |
    41 |
  1. 42 | About The Project 43 |
  2. 44 |
  3. 45 | Getting Started 46 | 50 |
  4. 51 |
  5. Usage
  6. 52 |
  7. Cross-compilation
  8. 53 |
  9. Roadmap
  10. 54 |
  11. Contributing
  12. 55 |
  13. License
  14. 56 |
  15. Contact
  16. 57 |
  17. Acknowledgments
  18. 58 |
59 |
60 | 61 | 62 | 63 | 64 | ## About The Project 65 | 66 | Tuile is a Text User Interface library written in Zig. 67 | 68 | Tuile uses [`crossterm`](docs/Backends.md#crossterm) backend by default which works on all UNIX and Windows terminals and supports cross-compilation (powered by [`build.crab`](https://github.com/akarpovskii/build.crab)). 69 | 70 | See [`Backends`](docs/Backends.md) for the list of supported backends, or file a [feature request](https://github.com/akarpovskii/tuile/issues/new?labels=enhancement&template=feature-request.md) if you want to have another one added. 71 | 72 | ![Demo VHS recording](./examples/images/demo.png) 73 | 74 | Checkout the other examples [here](./examples/). 75 | 76 |

(back to top)

77 | 78 | 79 | 80 | ## Getting Started 81 | 82 | ### Prerequisites 83 | 84 | * Zig 0.12.0+ 85 | 86 | * Non-default [`backends`](docs/Backends.md) may have additional requirements. 87 | 88 | ### Installation 89 | 90 | 1. Add dependency to your `build.zig.zon` 91 | 92 | ```sh 93 | zig fetch --save https://github.com/akarpovskii/tuile/archive/refs/tags/v0.1.3.tar.gz 94 | ``` 95 | 96 | 2. Import Tuile in `build.zig`: 97 | 98 | ```zig 99 | const tuile = b.dependency("tuile", .{}); 100 | exe.root_module.addImport("tuile", tuile.module("tuile")); 101 | ``` 102 | 103 |

(back to top)

104 | 105 | 106 | 107 | 108 | ## Usage 109 | 110 | ```zig 111 | const tuile = @import("tuile"); 112 | 113 | pub fn main() !void { 114 | var tui = try tuile.Tuile.init(.{}); 115 | defer tui.deinit(); 116 | 117 | try tui.add( 118 | tuile.block( 119 | .{ 120 | .border = tuile.Border.all(), 121 | .border_type = .rounded, 122 | .layout = .{ .flex = 1 }, 123 | }, 124 | tuile.label(.{ .text = "Hello World!" }), 125 | ), 126 | ); 127 | 128 | try tui.run(); 129 | } 130 | ``` 131 | 132 | You can find more examples in the [examples folder](./examples/) 133 | 134 |

(back to top)

135 | 136 | 137 | 138 | 139 | ## Cross-compilation 140 | 141 | To compile an application that uses Tuile for another target, just add `-Dtarget=` when building your app and make sure to forward it to Tuile: 142 | 143 | #### **`build.zig`** 144 | ```zig 145 | const target = b.standardTargetOptions(.{}); 146 | const tuile = b.dependency("tuile", .{ 147 | .target = target 148 | }); 149 | ``` 150 | 151 |

(back to top)

152 | 153 | 154 | 155 | 156 | ## Roadmap 157 | 158 | In no particular order: 159 | 160 | - [ ] Documentation 161 | - [ ] Grid layout 162 | - [ ] Windows and dialogs 163 | - [ ] Menu bar 164 | - [ ] More widgets 165 | 166 | See the [open issues][issues-url] for a full list of proposed features (and known issues). 167 | 168 |

(back to top)

169 | 170 | 171 | 172 | 173 | ## Contributing 174 | 175 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". 176 | Don't forget to give the project a star! Thanks! 177 | 178 | 1. Fork the Project 179 | 2. Create your Feature Branch (`git checkout -b feature/amazing-feature`) 180 | 3. Commit your Changes (`git commit -m 'Add some Amazing Feature'`) 181 | 4. Push to the Branch (`git push origin feature/amazing-feature`) 182 | 5. Open a Pull Request 183 | 184 |

(back to top)

185 | 186 | 187 | 188 | 189 | ## License 190 | 191 | Distributed under the MIT License. See [`LICENSE`][license-url] for more information. 192 | 193 |

(back to top)

194 | 195 | 196 | 197 | 198 | ## Acknowledgments 199 | 200 | * [Best-README-Template](https://github.com/othneildrew/Best-README-Template) 201 | 202 |

(back to top)

203 | 204 | 205 | 206 | 207 | 208 | [contributors-shield]: https://img.shields.io/github/contributors/akarpovskii/tuile.svg?style=for-the-badge 209 | [contributors-url]: https://github.com/akarpovskii/tuile/graphs/contributors 210 | [forks-shield]: https://img.shields.io/github/forks/akarpovskii/tuile.svg?style=for-the-badge 211 | [forks-url]: https://github.com/akarpovskii/tuile/network/members 212 | [stars-shield]: https://img.shields.io/github/stars/akarpovskii/tuile.svg?style=for-the-badge 213 | [stars-url]: https://github.com/akarpovskii/tuile/stargazers 214 | [issues-shield]: https://img.shields.io/github/issues/akarpovskii/tuile.svg?style=for-the-badge 215 | [issues-url]: https://github.com/akarpovskii/tuile/issues 216 | [license-shield]: https://img.shields.io/github/license/akarpovskii/tuile.svg?style=for-the-badge 217 | [license-url]: https://github.com/akarpovskii/tuile/blob/master/LICENSE.txt 218 | [examples-url]: https://github.com/akarpovskii/tuile/tree/main/examples 219 | [linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555 220 | [linkedin-url]: https://linkedin.com/in/akarpovskii 221 | [product-screenshot]: images/screenshot.png 222 | -------------------------------------------------------------------------------- /src/backends/Ncurses.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Backend = @import("Backend.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const events = @import("../events.zig"); 6 | const display = @import("../display.zig"); 7 | 8 | const c = @cImport({ 9 | @cInclude("ncurses.h"); 10 | @cInclude("locale.h"); 11 | }); 12 | 13 | const Ncurses = @This(); 14 | 15 | const NcursesErrors = error{ LocaleError, NcursesError }; 16 | 17 | scr: *c.struct__win_st, 18 | 19 | has_colors: bool, 20 | 21 | color_pairs: std.AutoHashMap(display.ColorPair, i16), 22 | 23 | pub fn create() !*Ncurses { 24 | // Initialize the locale to get UTF-8 support, see `man ncurses` - Initialization 25 | if (c.setlocale(c.LC_ALL, "") == null) return error.LocaleError; 26 | 27 | const scr = c.initscr(); 28 | if (c.raw() == c.ERR) return error.NcursesError; 29 | if (c.noecho() == c.ERR) return error.NcursesError; 30 | if (c.keypad(scr, true) == c.ERR) return error.NcursesError; 31 | 32 | var has_colors = false; 33 | if (c.has_colors()) { 34 | if (c.start_color() == c.ERR) return error.NcursesError; 35 | has_colors = true; 36 | } 37 | 38 | const Visibility = enum(c_int) { 39 | invisible = 0, 40 | visible = 1, 41 | very_visible = 2, 42 | }; 43 | if (c.curs_set(@intFromEnum(Visibility.invisible)) == c.ERR) return error.NcursesError; 44 | 45 | c.timeout(0); 46 | 47 | const self = try internal.allocator.create(Ncurses); 48 | self.* = .{ 49 | .scr = scr.?, 50 | .has_colors = has_colors, 51 | .color_pairs = std.AutoHashMap(display.ColorPair, i16).init(internal.allocator), 52 | }; 53 | return self; 54 | } 55 | 56 | pub fn destroy(self: *Ncurses) void { 57 | defer internal.allocator.destroy(self); 58 | defer self.color_pairs.deinit(); 59 | _ = c.endwin(); 60 | } 61 | 62 | pub fn backend(self: *Ncurses) Backend { 63 | return Backend.init(self); 64 | } 65 | 66 | pub fn pollEvent(_: *Ncurses) !?events.Event { 67 | var ch = c.getch(); 68 | if (ch == c.ERR) { 69 | return null; 70 | } 71 | 72 | var ctrl = false; 73 | if (ch >= 1 and ch <= 26 and ch != '\t' and ch != '\n') { 74 | ctrl = true; 75 | ch = 'a' + ch - 1; 76 | return .{ .ctrl_char = @intCast(ch) }; 77 | } 78 | 79 | if (parseKey(ch)) |value| { 80 | return .{ .key = value }; 81 | } 82 | 83 | switch (ch) { 84 | c.KEY_BTAB => return .{ .shift_key = .Tab }, 85 | else => {}, 86 | } 87 | 88 | var cp = std.mem.zeroes([4]u8); 89 | cp[0] = @intCast(ch); 90 | var i: usize = 1; 91 | while (!std.unicode.utf8ValidateSlice(&cp) and i < 4) { 92 | cp[i] = @intCast(c.getch()); 93 | i += 1; 94 | } 95 | return .{ .char = try std.unicode.utf8Decode(cp[0..i]) }; 96 | } 97 | 98 | fn parseKey(ch: c_int) ?events.Key { 99 | switch (ch) { 100 | @as(c_int, '\t') => return .Tab, 101 | @as(c_int, '\n') => return .Enter, 102 | c.KEY_ENTER => return .Enter, 103 | 27 => return .Escape, 104 | 127 => return .Backspace, 105 | c.KEY_BACKSPACE => return .Backspace, 106 | c.KEY_LEFT => return .Left, 107 | c.KEY_RIGHT => return .Right, 108 | c.KEY_UP => return .Up, 109 | c.KEY_DOWN => return .Down, 110 | c.KEY_IC => return .Insert, 111 | c.KEY_DC => return .Delete, 112 | c.KEY_HOME => return .Home, 113 | c.KEY_END => return .End, 114 | c.KEY_PPAGE => return .PageUp, 115 | c.KEY_NPAGE => return .PageDown, 116 | c.KEY_F(0) => return .F0, 117 | c.KEY_F(1) => return .F1, 118 | c.KEY_F(2) => return .F2, 119 | c.KEY_F(3) => return .F3, 120 | c.KEY_F(4) => return .F4, 121 | c.KEY_F(5) => return .F5, 122 | c.KEY_F(6) => return .F6, 123 | c.KEY_F(7) => return .F7, 124 | c.KEY_F(8) => return .F8, 125 | c.KEY_F(9) => return .F9, 126 | c.KEY_F(10) => return .F10, 127 | c.KEY_F(11) => return .F11, 128 | c.KEY_F(12) => return .F12, 129 | c.KEY_RESIZE => return .Resize, 130 | else => {}, 131 | } 132 | return null; 133 | } 134 | 135 | pub fn refresh(_: *Ncurses) !void { 136 | if (c.refresh() == c.ERR) return error.NcursesError; 137 | } 138 | 139 | pub fn printAt(_: *Ncurses, pos: Vec2, text: []const u8) !void { 140 | _ = c.mvaddnstr(@intCast(pos.y), @intCast(pos.x), text.ptr, @intCast(text.len)); 141 | } 142 | 143 | pub fn windowSize(self: *Ncurses) !Vec2 { 144 | _ = self; 145 | const x = c.getmaxx(c.stdscr); 146 | const y = c.getmaxy(c.stdscr); 147 | if (x == c.ERR or y == c.ERR) return error.NcursesError; 148 | return .{ 149 | .x = @intCast(x), 150 | .y = @intCast(y), 151 | }; 152 | } 153 | 154 | pub fn enableEffect(_: *Ncurses, effect: display.Style.Effect) !void { 155 | const attr = attrForEffect(effect); 156 | if (c.attron(attr) == c.ERR) return error.NcursesError; 157 | } 158 | 159 | pub fn disableEffect(_: *Ncurses, effect: display.Style.Effect) !void { 160 | const attr = attrForEffect(effect); 161 | if (c.attroff(attr) == c.ERR) return error.NcursesError; 162 | } 163 | 164 | fn attrForEffect(effect: display.Style.Effect) c_int { 165 | var attr = c.A_NORMAL; 166 | if (effect.highlight) attr |= c.A_STANDOUT; 167 | if (effect.underline) attr |= c.A_UNDERLINE; 168 | if (effect.reverse) attr |= c.A_REVERSE; 169 | if (effect.blink) attr |= c.A_BLINK; 170 | if (effect.dim) attr |= c.A_DIM; 171 | if (effect.bold) attr |= c.A_BOLD; 172 | if (effect.italic) attr |= c.A_ITALIC; 173 | return @bitCast(attr); 174 | } 175 | 176 | pub fn useColor(self: *Ncurses, color_pair: display.ColorPair) !void { 177 | if (!self.has_colors) { 178 | return; 179 | } 180 | const pair = try self.getOrInitColor(color_pair); 181 | if (c.attron(c.COLOR_PAIR(pair)) == c.ERR) return error.NcursesError; 182 | } 183 | 184 | fn getOrInitColor(self: *Ncurses, color_pair: display.ColorPair) !c_int { 185 | if (self.color_pairs.get(color_pair)) |pair| { 186 | return pair; 187 | } 188 | 189 | var pair: c_short = @intCast(self.color_pairs.count() + 1); 190 | if (c.COLOR_PAIRS <= pair) { 191 | var iter = self.color_pairs.iterator(); 192 | const evict = iter.next().?; 193 | pair = @intCast(evict.value_ptr.*); 194 | _ = self.color_pairs.remove(evict.key_ptr.*); 195 | } 196 | 197 | const init_res = c.init_pair( 198 | @intCast(pair), 199 | colorToInt(color_pair.fg), 200 | colorToInt(color_pair.bg), 201 | ); 202 | if (init_res == c.ERR) return error.NcursesError; 203 | try self.color_pairs.put(color_pair, pair); 204 | return pair; 205 | } 206 | 207 | fn colorToInt(color: display.Color) c_short { 208 | // Source - https://en.wikipedia.org/wiki/ANSI_escape_code#Colors 209 | const res = switch (color) { 210 | .dark => |base| switch (base) { 211 | .black => c.COLOR_BLACK, 212 | .red => c.COLOR_RED, 213 | .green => c.COLOR_GREEN, 214 | .yellow => c.COLOR_YELLOW, 215 | .blue => c.COLOR_BLUE, 216 | .magenta => c.COLOR_MAGENTA, 217 | .cyan => c.COLOR_CYAN, 218 | .white => c.COLOR_WHITE, 219 | }, 220 | .bright => |base| switch (base) { 221 | .black => @rem(8 + c.COLOR_BLACK, c.COLORS), 222 | .red => @rem(8 + c.COLOR_RED, c.COLORS), 223 | .green => @rem(8 + c.COLOR_GREEN, c.COLORS), 224 | .yellow => @rem(8 + c.COLOR_YELLOW, c.COLORS), 225 | .blue => @rem(8 + c.COLOR_BLUE, c.COLORS), 226 | .magenta => @rem(8 + c.COLOR_MAGENTA, c.COLORS), 227 | .cyan => @rem(8 + c.COLOR_CYAN, c.COLORS), 228 | .white => @rem(8 + c.COLOR_WHITE, c.COLORS), 229 | }, 230 | .rgb => |rgb| if (c.COLORS >= 256) 231 | display.Palette256.findClosestNonSystem(rgb) 232 | else 233 | display.Palette256.findClosestSystem(rgb), 234 | }; 235 | 236 | return @intCast(res); 237 | } 238 | 239 | pub fn requestMode(_: *Ncurses, mode: u32) !Backend.ReportMode { 240 | return Backend.requestModeTty(mode); 241 | } 242 | -------------------------------------------------------------------------------- /src/widgets/Block.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Widget = @import("Widget.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const Rect = @import("../Rect.zig"); 6 | const events = @import("../events.zig"); 7 | const Frame = @import("../render/Frame.zig"); 8 | const border = @import("border.zig"); 9 | const Padding = @import("Padding.zig"); 10 | const LayoutProperties = @import("LayoutProperties.zig"); 11 | const Constraints = @import("Constraints.zig"); 12 | const display = @import("../display.zig"); 13 | 14 | pub const Config = struct { 15 | /// A unique identifier of the widget to be used in `Tuile.findById` and `Widget.findById`. 16 | id: ?[]const u8 = null, 17 | 18 | /// Which borders are visible, see `Border`. 19 | border: border.Border = border.Border.none(), 20 | 21 | /// The type of border, see `BorderType`. 22 | border_type: border.BorderType = .solid, 23 | 24 | /// Additional padding around the inner widget, see `Padding`. 25 | padding: Padding = .{}, 26 | 27 | /// When `fit_content` is `false`, Block will try to take all the available space 28 | /// in the cross direction of a layout. 29 | fit_content: bool = false, 30 | 31 | /// Layout properties of the widget, see `LayoutProperties`. 32 | layout: LayoutProperties = .{}, 33 | }; 34 | 35 | const Block = @This(); 36 | 37 | pub usingnamespace Widget.SingleChild.Mixin(Block, .inner); 38 | pub usingnamespace Widget.Base.Mixin(Block, .widget_base); 39 | 40 | widget_base: Widget.Base, 41 | 42 | inner: Widget, 43 | 44 | inner_size: Vec2 = Vec2.zero(), 45 | 46 | border: border.Border, 47 | 48 | border_type: border.BorderType, 49 | 50 | padding: Padding, 51 | 52 | fit_content: bool, 53 | 54 | layout_properties: LayoutProperties, 55 | 56 | pub fn create(config: Config, inner: anytype) !*Block { 57 | const self = try internal.allocator.create(Block); 58 | self.* = Block{ 59 | .widget_base = try Widget.Base.init(config.id), 60 | .inner = try Widget.fromAny(inner), 61 | .border = config.border, 62 | .border_type = config.border_type, 63 | .padding = config.padding, 64 | .fit_content = config.fit_content, 65 | .layout_properties = config.layout, 66 | }; 67 | return self; 68 | } 69 | 70 | pub fn destroy(self: *Block) void { 71 | self.widget_base.deinit(); 72 | self.inner.destroy(); 73 | internal.allocator.destroy(self); 74 | } 75 | 76 | pub fn widget(self: *Block) Widget { 77 | return Widget.init(self); 78 | } 79 | 80 | pub fn setInner(self: *Block, new_widget: Widget) void { 81 | self.inner.destroy(); 82 | self.inner = new_widget; 83 | } 84 | 85 | pub fn render(self: *Block, area: Rect, frame: Frame, theme: display.Theme) !void { 86 | var content_area = Rect{ 87 | .min = .{ 88 | .x = area.min.x + @intFromBool(self.border.left) + self.padding.left, 89 | .y = area.min.y + @intFromBool(self.border.top) + self.padding.top, 90 | }, 91 | .max = .{ 92 | .x = area.max.x -| (@intFromBool(self.border.right) + self.padding.right), 93 | .y = area.max.y -| (@intFromBool(self.border.bottom) + self.padding.bottom), 94 | }, 95 | }; 96 | 97 | if (content_area.min.x > content_area.max.x or content_area.min.y > content_area.max.y) { 98 | self.renderBorder(area, frame, theme); 99 | } else { 100 | var inner_area = Rect{ 101 | .min = content_area.min, 102 | .max = .{ 103 | .x = content_area.min.x + @min(content_area.width(), self.inner_size.x), 104 | .y = content_area.min.y + @min(content_area.height(), self.inner_size.y), 105 | }, 106 | }; 107 | 108 | const props = self.inner.layoutProps(); 109 | inner_area = content_area.alignInside(props.alignment, inner_area); 110 | 111 | try self.inner.render(inner_area, frame.withArea(inner_area), theme); 112 | self.renderBorder(area, frame, theme); 113 | } 114 | } 115 | 116 | pub fn layout(self: *Block, constraints: Constraints) !Vec2 { 117 | const props = self.layout_properties; 118 | const self_constraints = Constraints{ 119 | .min_width = @max(props.min_width, constraints.min_width), 120 | .min_height = @max(props.min_height, constraints.min_height), 121 | .max_width = @min(props.max_width, constraints.max_width), 122 | .max_height = @min(props.max_height, constraints.max_height), 123 | }; 124 | const border_size = Vec2{ 125 | .x = @intFromBool(self.border.left) + self.padding.left + @intFromBool(self.border.right) + self.padding.right, 126 | .y = @intFromBool(self.border.top) + self.padding.top + @intFromBool(self.border.bottom) + self.padding.bottom, 127 | }; 128 | const maxInt = std.math.maxInt; 129 | const inner_constraints = Constraints{ 130 | .min_width = 0, 131 | .min_height = 0, 132 | 133 | .max_width = if (self_constraints.max_width == maxInt(u32)) 134 | self_constraints.max_width 135 | else 136 | self_constraints.max_width -| border_size.x, 137 | 138 | .max_height = if (self_constraints.max_height == maxInt(u32)) 139 | self_constraints.max_height 140 | else 141 | self_constraints.max_height -| border_size.y, 142 | }; 143 | self.inner_size = try self.inner.layout(inner_constraints); 144 | 145 | var size = .{ 146 | .x = self_constraints.max_width, 147 | .y = self_constraints.max_height, 148 | }; 149 | if (self.fit_content or size.x == maxInt(u32)) { 150 | size.x = @min(size.x, self.inner_size.x +| border_size.x); 151 | } 152 | if (self.fit_content or size.y == maxInt(u32)) { 153 | size.y = @min(size.y, self.inner_size.y +| border_size.y); 154 | } 155 | 156 | return size; 157 | } 158 | 159 | pub fn handleEvent(self: *Block, event: events.Event) !events.EventResult { 160 | return self.inner.handleEvent(event); 161 | } 162 | 163 | pub fn layoutProps(self: *Block) LayoutProperties { 164 | return self.layout_properties; 165 | } 166 | 167 | fn renderBorder(self: *Block, area: Rect, frame: Frame, theme: display.Theme) void { 168 | const min = area.min; 169 | const max = area.max; 170 | const chars = border.BorderCharacters.fromType(self.border_type); 171 | 172 | if (area.height() > 0) { 173 | if (self.border.top) 174 | frame.setStyle(.{ .min = min, .max = .{ .x = max.x, .y = min.y + 1 } }, .{ .fg = theme.borders }); 175 | if (self.border.bottom) 176 | frame.setStyle(.{ .min = .{ .x = min.x, .y = max.y - 1 }, .max = max }, .{ .fg = theme.borders }); 177 | 178 | var x = min.x; 179 | while (x < area.max.x) : (x += 1) { 180 | if (self.border.top) 181 | frame.setSymbol(.{ .x = x, .y = min.y }, chars.top); 182 | if (self.border.bottom) 183 | frame.setSymbol(.{ .x = x, .y = max.y - 1 }, chars.bottom); 184 | } 185 | } 186 | 187 | if (area.width() > 0) { 188 | if (self.border.left) 189 | frame.setStyle(.{ .min = min, .max = .{ .x = min.x + 1, .y = max.y } }, .{ .fg = theme.borders }); 190 | if (self.border.right) 191 | frame.setStyle(.{ .min = .{ .x = max.x - 1, .y = min.y }, .max = max }, .{ .fg = theme.borders }); 192 | 193 | var y = min.y; 194 | while (y < max.y) : (y += 1) { 195 | if (self.border.left) 196 | frame.setSymbol(.{ .x = min.x, .y = y }, chars.left); 197 | if (self.border.right) 198 | frame.setSymbol(.{ .x = max.x - 1, .y = y }, chars.right); 199 | } 200 | } 201 | 202 | if (area.height() > 1 and area.width() > 1) { 203 | if (self.border.top and self.border.left) 204 | frame.setSymbol(.{ .x = min.x, .y = min.y }, chars.top_left); 205 | 206 | if (self.border.top and self.border.right) 207 | frame.setSymbol(.{ .x = max.x - 1, .y = min.y }, chars.top_right); 208 | 209 | if (self.border.bottom and self.border.left) 210 | frame.setSymbol(.{ .x = min.x, .y = max.y - 1 }, chars.bottom_left); 211 | 212 | if (self.border.bottom and self.border.right) 213 | frame.setSymbol(.{ .x = max.x - 1, .y = max.y - 1 }, chars.bottom_right); 214 | } 215 | } 216 | 217 | pub fn prepare(self: *Block) !void { 218 | try self.inner.prepare(); 219 | } 220 | -------------------------------------------------------------------------------- /src/tuile.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("internal.zig"); 3 | pub const Vec2 = @import("Vec2.zig"); 4 | pub const Rect = @import("Rect.zig"); 5 | pub const backends = @import("backends.zig"); 6 | pub const render = @import("render.zig"); 7 | pub const events = @import("events.zig"); 8 | pub const widgets = @import("widgets.zig"); 9 | pub usingnamespace widgets; 10 | pub const display = @import("display.zig"); 11 | pub usingnamespace display; 12 | pub const text_clustering = @import("text_clustering.zig"); 13 | 14 | const Task = widgets.Callback(void); 15 | 16 | // Make it user-driven? 17 | const FRAMES_PER_SECOND = 30; 18 | const FRAME_TIME_NS = std.time.ns_per_s / FRAMES_PER_SECOND; 19 | const GRAPHEME_CLUSTERING_MODE = 2027; // https://mitchellh.com/writing/grapheme-clusters-in-terminals 20 | 21 | pub const EventHandler = struct { 22 | handler: *const fn (payload: ?*anyopaque, event: events.Event) anyerror!events.EventResult, 23 | 24 | payload: ?*anyopaque = null, 25 | 26 | pub fn call(self: EventHandler, event: events.Event) anyerror!events.EventResult { 27 | return self.handler(self.payload, event); 28 | } 29 | }; 30 | 31 | pub const Tuile = struct { 32 | const Config = struct { 33 | // Takes ownership of the backend and destroys it afterwards 34 | backend: ?backends.Backend = null, 35 | }; 36 | 37 | backend: backends.Backend, 38 | 39 | is_running: std.atomic.Value(bool), 40 | 41 | root: *widgets.StackLayout, 42 | 43 | theme: display.Theme, 44 | 45 | last_frame_time: u64, 46 | last_sleep_error: i64, 47 | 48 | event_handlers: std.ArrayListUnmanaged(EventHandler), 49 | 50 | frame_buffer: std.ArrayListUnmanaged(render.Cell), 51 | window_size: Vec2, 52 | 53 | task_queue: std.fifo.LinearFifo(Task, .Dynamic), 54 | task_queue_mutex: std.Thread.Mutex, 55 | 56 | pub fn init(config: Config) !Tuile { 57 | const backend = if (config.backend) |backend| backend else try backends.createBackend(); 58 | errdefer backend.destroy(); 59 | const text_clustering_type: text_clustering.ClusteringType = 60 | switch (try backend.requestMode(GRAPHEME_CLUSTERING_MODE)) { 61 | .not_recognized, .reset => .codepoints, 62 | .set => .graphemes, 63 | }; 64 | 65 | try internal.init(text_clustering_type); 66 | var self = blk: { 67 | const root = try widgets.StackLayout.create(.{ .orientation = .vertical }, .{}); 68 | errdefer root.destroy(); 69 | 70 | break :blk Tuile{ 71 | .backend = backend, 72 | .is_running = std.atomic.Value(bool).init(false), 73 | .root = root, 74 | .theme = display.Theme.sky(), 75 | .last_frame_time = 0, 76 | .last_sleep_error = 0, 77 | .event_handlers = .{}, 78 | .frame_buffer = .{}, 79 | .window_size = Vec2.zero(), 80 | .task_queue = std.fifo.LinearFifo(Task, .Dynamic).init(internal.allocator), 81 | .task_queue_mutex = .{}, 82 | }; 83 | }; 84 | errdefer self.deinit(); 85 | try self.handleResize(); 86 | return self; 87 | } 88 | 89 | pub fn deinit(self: *Tuile) void { 90 | self.task_queue.deinit(); 91 | self.backend.destroy(); 92 | self.frame_buffer.deinit(internal.allocator); 93 | self.root.destroy(); 94 | internal.deinit(); 95 | } 96 | 97 | pub fn add(self: *Tuile, child: anytype) !void { 98 | try self.root.addChild(child); 99 | } 100 | 101 | pub fn findById(self: *Tuile, id: []const u8) ?widgets.Widget { 102 | return self.root.widget().findById(id); 103 | } 104 | 105 | pub fn findByIdTyped(self: *Tuile, comptime T: type, id: []const u8) ?*T { 106 | if (self.root.widget().findById(id)) |found| { 107 | return found.as(T); 108 | } 109 | return null; 110 | } 111 | 112 | pub fn scheduleTask(self: *Tuile, task: Task) !void { 113 | self.task_queue_mutex.lock(); 114 | defer self.task_queue_mutex.unlock(); 115 | try self.task_queue.writeItem(task); 116 | } 117 | 118 | pub fn addEventHandler(self: *Tuile, handler: EventHandler) !void { 119 | try self.event_handlers.append(internal.allocator, handler); 120 | } 121 | 122 | pub fn stop(self: *Tuile) void { 123 | self.is_running.store(false, .release); 124 | } 125 | 126 | pub fn step(self: *Tuile) !void { 127 | var frame_timer = try std.time.Timer.start(); 128 | 129 | var prepared = false; 130 | while (try self.backend.pollEvent()) |event| { 131 | switch (try self.handleEvent(event)) { 132 | .consumed => continue, 133 | .ignored => { 134 | if (!prepared) { 135 | try self.prepare(); 136 | prepared = true; 137 | } 138 | try self.propagateEvent(event); 139 | }, 140 | } 141 | } 142 | 143 | if (!prepared) { 144 | try self.prepare(); 145 | } 146 | try self.redraw(); 147 | 148 | const queued_task = blk: { 149 | self.task_queue_mutex.lock(); 150 | defer self.task_queue_mutex.unlock(); 151 | break :blk self.task_queue.readItem(); 152 | }; 153 | if (queued_task) |task| { 154 | task.call(); 155 | } 156 | 157 | self.last_frame_time = frame_timer.lap(); 158 | 159 | const total_frame_time: i64 = @as(i64, @intCast(self.last_frame_time)) + self.last_sleep_error; 160 | if (total_frame_time < FRAME_TIME_NS) { 161 | const left_until_frame = FRAME_TIME_NS - @as(u64, @intCast(total_frame_time)); 162 | 163 | var sleep_timer = try std.time.Timer.start(); 164 | std.time.sleep(left_until_frame); 165 | const actual_sleep_time = sleep_timer.lap(); 166 | 167 | self.last_sleep_error = @as(i64, @intCast(actual_sleep_time)) - @as(i64, @intCast(left_until_frame)); 168 | } 169 | } 170 | 171 | pub fn run(self: *Tuile) !void { 172 | self.is_running.store(true, .release); 173 | while (self.is_running.load(.acquire)) { 174 | try self.step(); 175 | } 176 | } 177 | 178 | fn prepare(self: *Tuile) !void { 179 | try self.root.prepare(); 180 | } 181 | 182 | fn redraw(self: *Tuile) !void { 183 | const constraints = .{ 184 | .max_width = self.window_size.x, 185 | .max_height = self.window_size.y, 186 | }; 187 | _ = try self.root.layout(constraints); 188 | 189 | var frame = render.Frame{ 190 | .buffer = self.frame_buffer.items, 191 | .size = self.window_size, 192 | .area = .{ 193 | .min = Vec2.zero(), 194 | .max = self.window_size, 195 | }, 196 | }; 197 | frame.clear(self.theme.text_primary, self.theme.background_primary); 198 | 199 | try self.root.render(frame.area, frame, self.theme); 200 | 201 | try frame.render(self.backend); 202 | } 203 | 204 | fn handleEvent(self: *Tuile, event: events.Event) !events.EventResult { 205 | for (self.event_handlers.items) |handler| { 206 | switch (try handler.call(event)) { 207 | .consumed => return .consumed, 208 | .ignored => {}, 209 | } 210 | } 211 | 212 | switch (event) { 213 | .ctrl_char => |value| { 214 | if (value == 'c') { 215 | self.stop(); 216 | // pass down the event to widgets 217 | return .ignored; 218 | } 219 | }, 220 | .key => |key| if (key == .Resize) { 221 | try self.handleResize(); 222 | return .consumed; 223 | }, 224 | else => {}, 225 | } 226 | return .ignored; 227 | } 228 | 229 | fn propagateEvent(self: *Tuile, event: events.Event) !void { 230 | _ = try self.root.handleEvent(event); 231 | } 232 | 233 | fn handleResize(self: *Tuile) !void { 234 | self.window_size = try self.backend.windowSize(); 235 | const new_len = self.window_size.x * self.window_size.y; 236 | try self.frame_buffer.resize(internal.allocator, new_len); 237 | } 238 | }; 239 | -------------------------------------------------------------------------------- /src/widgets/Input.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const internal = @import("../internal.zig"); 3 | const Widget = @import("Widget.zig"); 4 | const Vec2 = @import("../Vec2.zig"); 5 | const Rect = @import("../Rect.zig"); 6 | const events = @import("../events.zig"); 7 | const Frame = @import("../render/Frame.zig"); 8 | const FocusHandler = @import("FocusHandler.zig"); 9 | const LayoutProperties = @import("LayoutProperties.zig"); 10 | const Constraints = @import("Constraints.zig"); 11 | const display = @import("../display.zig"); 12 | const callbacks = @import("callbacks.zig"); 13 | const text_clustering = @import("../text_clustering.zig"); 14 | const TextCluster = text_clustering.TextCluster; 15 | const stringDisplayWidth = text_clustering.stringDisplayWidth; 16 | const ClusterIterator = text_clustering.ClusterIterator; 17 | const GraphemeClusterIterator = text_clustering.GraphemeClusterIterator; 18 | 19 | pub const Config = struct { 20 | /// A unique identifier of the widget to be used in `Tuile.findById` and `Widget.findById`. 21 | id: ?[]const u8 = null, 22 | 23 | /// Text to be used as a placeholder when the input is empty. 24 | placeholder: []const u8 = "", 25 | 26 | /// Input will call this when its value changes. 27 | on_value_changed: ?callbacks.Callback([]const u8) = null, 28 | 29 | /// Layout properties of the widget, see `LayoutProperties`. 30 | layout: LayoutProperties = .{}, 31 | }; 32 | 33 | const Input = @This(); 34 | 35 | pub usingnamespace Widget.Leaf.Mixin(Input); 36 | pub usingnamespace Widget.Base.Mixin(Input, .widget_base); 37 | 38 | widget_base: Widget.Base, 39 | 40 | placeholder: []const u8, 41 | 42 | on_value_changed: ?callbacks.Callback([]const u8), 43 | 44 | value: std.ArrayListUnmanaged(u8), 45 | 46 | focus_handler: FocusHandler = .{}, 47 | 48 | layout_properties: LayoutProperties, 49 | 50 | graphemes: std.ArrayListUnmanaged(TextCluster), 51 | 52 | grapheme_cursor: u32 = 0, 53 | 54 | view_start: usize = 0, 55 | 56 | pub fn create(config: Config) !*Input { 57 | const self = try internal.allocator.create(Input); 58 | self.* = Input{ 59 | .widget_base = try Widget.Base.init(config.id), 60 | .on_value_changed = config.on_value_changed, 61 | .placeholder = try internal.allocator.dupe(u8, config.placeholder), 62 | .value = std.ArrayListUnmanaged(u8){}, 63 | .layout_properties = config.layout, 64 | .graphemes = std.ArrayListUnmanaged(TextCluster){}, 65 | }; 66 | try self.graphemes.append(internal.allocator, TextCluster{ .offset = 0, .len = 0, .display_width = 0 }); 67 | return self; 68 | } 69 | 70 | pub fn destroy(self: *Input) void { 71 | self.widget_base.deinit(); 72 | self.graphemes.deinit(internal.allocator); 73 | self.value.deinit(internal.allocator); 74 | internal.allocator.free(self.placeholder); 75 | internal.allocator.destroy(self); 76 | } 77 | 78 | pub fn widget(self: *Input) Widget { 79 | return Widget.init(self); 80 | } 81 | 82 | pub fn setPlaceholder(self: *Input, text: []const u8) !void { 83 | internal.allocator.free(self.placeholder); 84 | self.placeholder = try internal.allocator.dupe(u8, text); 85 | } 86 | 87 | pub fn setValue(self: *Input, value: []const u8) !void { 88 | self.value.deinit(internal.allocator); 89 | self.value = std.ArrayListUnmanaged(u8){}; 90 | try self.value.appendSlice(internal.allocator, value); 91 | try self.rebuildGraphemes(0); 92 | self.grapheme_cursor = @intCast(self.graphemes.items.len -| 1); 93 | } 94 | 95 | fn cursor(self: Input) usize { 96 | return self.graphemes.items[self.grapheme_cursor].offset; 97 | } 98 | 99 | pub fn render(self: *Input, area: Rect, frame: Frame, theme: display.Theme) !void { 100 | if (area.height() < 1) { 101 | return; 102 | } 103 | frame.setStyle(area, .{ .bg = theme.interactive, .add_effect = .{ .underline = true } }); 104 | self.focus_handler.render(area, frame, theme); 105 | 106 | const render_placeholder = self.value.items.len == 0; 107 | if (render_placeholder) frame.setStyle(area, .{ .fg = theme.text_secondary }); 108 | 109 | _ = try frame.writeSymbols(area.min, self.visibleText(), area.width()); 110 | 111 | if (self.focus_handler.focused) { 112 | var cursor_pos = area.min; 113 | const current_text = self.currentText(); 114 | cursor_pos.x += @intCast(try stringDisplayWidth( 115 | current_text[self.view_start..self.cursor()], 116 | internal.text_clustering_type, 117 | )); 118 | if (cursor_pos.x >= area.max.x) { 119 | cursor_pos.x = area.max.x - 1; 120 | } 121 | const end_area = Rect{ 122 | .min = cursor_pos, 123 | .max = cursor_pos.add(.{ .x = 1, .y = 1 }), 124 | }; 125 | frame.setStyle(end_area, .{ 126 | .bg = theme.solid, 127 | }); 128 | } 129 | } 130 | 131 | pub fn layout(self: *Input, constraints: Constraints) !Vec2 { 132 | if (self.cursor() < self.view_start) { 133 | self.view_start = self.cursor(); 134 | } else { 135 | const max_width = std.math.clamp(self.layout_properties.max_width, constraints.min_width, constraints.max_width); 136 | // +1 is for the cursor itself 137 | const visible_text = self.currentText()[self.view_start..self.cursor()]; 138 | var visible = try stringDisplayWidth(visible_text, internal.text_clustering_type) + 1; 139 | if (visible > max_width) { 140 | var iter = try ClusterIterator.init(internal.text_clustering_type, visible_text); 141 | while (iter.next()) |cluster| { 142 | self.view_start += cluster.len; 143 | visible -= 1; 144 | if (visible <= max_width) { 145 | break; 146 | } 147 | } 148 | } 149 | } 150 | 151 | const visible = self.visibleText(); 152 | // +1 for the cursor 153 | const len = try stringDisplayWidth(visible, internal.text_clustering_type) + 1; 154 | 155 | var size = Vec2{ 156 | .x = @intCast(len), 157 | .y = 1, 158 | }; 159 | 160 | const self_constraints = Constraints.fromProps(self.layout_properties); 161 | size = self_constraints.apply(size); 162 | size = constraints.apply(size); 163 | return size; 164 | } 165 | 166 | pub fn handleEvent(self: *Input, event: events.Event) !events.EventResult { 167 | if (self.focus_handler.handleEvent(event) == .consumed) { 168 | return .consumed; 169 | } 170 | 171 | switch (event) { 172 | .key, .shift_key => |key| switch (key) { 173 | .Left => { 174 | if (self.grapheme_cursor > 0) { 175 | self.grapheme_cursor -= 1; 176 | } 177 | return .consumed; 178 | }, 179 | .Right => { 180 | if (self.grapheme_cursor + 1 < self.graphemes.items.len) { 181 | self.grapheme_cursor += 1; 182 | } 183 | return .consumed; 184 | }, 185 | .Backspace => { 186 | if (self.grapheme_cursor > 0) { 187 | // Delete by code points é -> e 188 | const target_idx = self.grapheme_cursor - 1; 189 | const target = self.graphemes.items[target_idx]; 190 | var cp_cursor = target.offset + target.len - 1; 191 | while (cp_cursor >= target.offset) { 192 | if (std.unicode.utf8ValidateSlice(self.value.items[cp_cursor .. target.offset + target.len])) { 193 | break; 194 | } 195 | cp_cursor -= 1; 196 | } 197 | const dl: u8 = target.len - @as(u8, @intCast(cp_cursor - target.offset)); 198 | self.graphemes.items[target_idx].len -= dl; 199 | self.value.replaceRangeAssumeCapacity(cp_cursor, dl, &.{}); 200 | for (self.grapheme_cursor..self.graphemes.items.len) |i| { 201 | self.graphemes.items[i].offset -= dl; 202 | } 203 | if (self.graphemes.items[target_idx].len == 0) { 204 | _ = self.graphemes.orderedRemove(target_idx); 205 | self.grapheme_cursor -= 1; 206 | } 207 | if (self.on_value_changed) |cb| cb.call(self.value.items); 208 | } 209 | return .consumed; 210 | }, 211 | .Delete => { 212 | if (self.grapheme_cursor < self.graphemes.items.len - 1) { 213 | const gr = self.graphemes.items[self.grapheme_cursor]; 214 | self.value.replaceRangeAssumeCapacity(gr.offset, gr.len, &.{}); 215 | _ = self.graphemes.orderedRemove(self.grapheme_cursor); 216 | for (self.grapheme_cursor..self.graphemes.items.len) |i| { 217 | self.graphemes.items[i].offset -= gr.len; 218 | } 219 | if (self.on_value_changed) |cb| cb.call(self.value.items); 220 | } 221 | return .consumed; 222 | }, 223 | else => {}, 224 | }, 225 | 226 | .char => |char| { 227 | var cp = std.mem.zeroes([4]u8); 228 | const cp_len = try std.unicode.utf8Encode(char, &cp); 229 | try self.value.insertSlice(internal.allocator, self.cursor(), cp[0..cp_len]); 230 | // TODO: Optimize 231 | try self.rebuildGraphemes(self.graphemes.items[self.grapheme_cursor].offset + cp_len); 232 | 233 | if (self.on_value_changed) |cb| cb.call(self.value.items); 234 | return .consumed; 235 | }, 236 | else => {}, 237 | } 238 | return .ignored; 239 | } 240 | 241 | fn rebuildGraphemes(self: *Input, byte_cursor_position: usize) !void { 242 | // const cursor_offset = self.graphemes.items[self.grapheme_cursor].offset; 243 | self.graphemes.deinit(internal.allocator); 244 | self.graphemes = std.ArrayListUnmanaged(TextCluster){}; 245 | var iter = try GraphemeClusterIterator.init(self.value.items); 246 | while (iter.next()) |gc| { 247 | try self.graphemes.append(internal.allocator, gc); 248 | } 249 | 250 | try self.graphemes.append(internal.allocator, TextCluster{ 251 | .offset = if (self.graphemes.getLastOrNull()) |last| last.offset + last.len else 0, 252 | .len = 0, 253 | .display_width = 0, 254 | }); 255 | 256 | self.grapheme_cursor = 0; 257 | for (self.graphemes.items, 0..) |gc, i| { 258 | if (gc.offset <= byte_cursor_position) { 259 | self.grapheme_cursor = @intCast(i); 260 | } else { 261 | break; 262 | } 263 | } 264 | } 265 | 266 | fn currentText(self: *Input) []const u8 { 267 | const show_placeholder = self.value.items.len == 0; 268 | return if (show_placeholder) self.placeholder else self.value.items; 269 | } 270 | 271 | fn visibleText(self: *Input) []const u8 { 272 | return self.currentText()[self.view_start..]; 273 | } 274 | 275 | pub fn layoutProps(self: *Input) LayoutProperties { 276 | return self.layout_properties; 277 | } 278 | -------------------------------------------------------------------------------- /src/widgets/Widget.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Vec2 = @import("../Vec2.zig"); 3 | const Rect = @import("../Rect.zig"); 4 | const Frame = @import("../render/Frame.zig"); 5 | const LayoutProperties = @import("LayoutProperties.zig"); 6 | const Constraints = @import("Constraints.zig"); 7 | const events = @import("../events.zig"); 8 | const display = @import("../display.zig"); 9 | const internal = @import("../internal.zig"); 10 | 11 | pub const Widget = @This(); 12 | 13 | context: *anyopaque, 14 | 15 | vtable: *const VTable, 16 | 17 | const VTable = struct { 18 | /// Tuile owns the widgets and calls `destroy` when needed. 19 | destroy: *const fn (context: *anyopaque) void, 20 | 21 | /// A unique identifier of a widget to be used in `Tuile.findById` and `Widget.findById`. 22 | id: *const fn (context: *anyopaque) ?[]const u8, 23 | 24 | /// See `Leaf`, `SingleChild`, `MultiChild`. 25 | children: *const fn (context: *anyopaque) []Widget, 26 | 27 | /// Layout properties of a widget 28 | layout_props: *const fn (context: *anyopaque) LayoutProperties, 29 | 30 | /// Optional, widgets may not implement this method unless they have children. 31 | /// In which case they must call prepare() on all of their children. 32 | /// Tuile guarantees to call `prepare` before any other method in the event loop. 33 | prepare: *const fn (context: *anyopaque) anyerror!void, 34 | 35 | /// Constraints go down, sizes go up, parent sets position. 36 | /// * A widget receives its constraints from the parent. 37 | /// * Then it goes over its children, calculates their constraints, 38 | /// and asks each one what size it wants to be. 39 | /// * Then it positions each children 40 | /// * Finally, it calculates its own size and returns the size to the parent. 41 | /// 42 | /// * A widget must satisfy the constraints given to it by its parent. 43 | /// * A widget doesn't decide its position on the screen. 44 | /// * A parent might set `max_width` or `max_height` to std.math.maxInt(u32) 45 | /// if it doesn't have anough information to align the child. In which case 46 | /// the child should tell the parent a finite desired size. 47 | layout: *const fn (context: *anyopaque, constraints: Constraints) anyerror!Vec2, 48 | 49 | /// Widgets must draw themselves inside of `area`. Writes outside of `area` are ignored. 50 | /// `theme` is the currently used theme which can be overridden by the `Themed` widget. 51 | /// `render` is guaranteed to be called after `layout`. 52 | render: *const fn (context: *anyopaque, area: Rect, frame: Frame, theme: display.Theme) anyerror!void, 53 | 54 | /// If a widget returns .consumed, the event is considered fulfilled and is not propagated further. 55 | /// If a widget returns .ignored, the event is passed to the next widget in the tree. 56 | handle_event: *const fn (context: *anyopaque, event: events.Event) anyerror!events.EventResult, 57 | }; 58 | 59 | /// Marks a widget with no children. 60 | /// To reduce the boilerplate, widgets can use the inner `Mixin` to generate the `children` method. 61 | /// To mark a widget add the following: 62 | /// `pub usingnamespace Leaf.Mixin(@This());` 63 | /// See `Label` for an example. 64 | pub const Leaf = struct { 65 | pub fn Mixin(comptime Self: type) type { 66 | return struct { 67 | pub fn children(_: *Self) []Widget { 68 | return &.{}; 69 | } 70 | }; 71 | } 72 | }; 73 | 74 | /// Marks a widget with one child 75 | /// To reduce the boilerplate, widgets can use the inner `Mixin` to generate the `children` method. 76 | /// To mark a widget add the following: 77 | /// `pub usingnamespace SingleChild.Mixin(@This(), .child_field_name);` 78 | /// where `child_field_name` has type Widget. 79 | /// See `Block` for an example. 80 | pub const SingleChild = struct { 81 | pub fn Mixin(comptime Self: type, comptime child: std.meta.FieldEnum(Self)) type { 82 | const child_field = std.meta.fieldInfo(Self, child); 83 | return struct { 84 | pub fn children(self: *Self) []Widget { 85 | return (&@field(self, child_field.name))[0..1]; 86 | } 87 | }; 88 | } 89 | }; 90 | 91 | /// Marks a widget with multiple children. 92 | /// To reduce the boilerplate, widgets can use the inner `Mixin` to generate the `children` method. 93 | /// To mark a widget add the following: 94 | /// `pub usingnamespace MultiChild.Mixin(@This(), .child_field_name);` 95 | /// where `child_field_name` must either be `[]Widget`, or it must have `items` field of type `Widget[]` (like std.ArrayList). 96 | /// See `StackLayout` for an example. 97 | pub const MultiChild = struct { 98 | pub fn Mixin(comptime Self: type, comptime children_: std.meta.FieldEnum(Self)) type { 99 | const child_field = std.meta.fieldInfo(Self, children_); 100 | return struct { 101 | pub fn children(self: *Self) []Widget { 102 | if (@hasField(child_field.type, "items")) { 103 | return @field(self, child_field.name).items; 104 | } else { 105 | return @field(self, child_field.name); 106 | } 107 | } 108 | }; 109 | } 110 | }; 111 | 112 | pub const Base = struct { 113 | /// A unique identifier of the widget to be used in `Tuile.findById` and `Widget.findById`. 114 | id: ?[]const u8, 115 | 116 | pub fn init(widget_id: ?[]const u8) !Base { 117 | return Base{ 118 | .id = if (widget_id) |id_value| try internal.allocator.dupe(u8, id_value) else null, 119 | }; 120 | } 121 | 122 | pub fn deinit(self: *Base) void { 123 | if (self.id) |v| { 124 | internal.allocator.free(v); 125 | } 126 | } 127 | 128 | pub fn Mixin(comptime Self: type, comptime base: std.meta.FieldEnum(Self)) type { 129 | const base_field = std.meta.fieldInfo(Self, base); 130 | return struct { 131 | pub fn id(self: *Self) ?[]const u8 { 132 | return @field(self, base_field.name).id; 133 | } 134 | }; 135 | } 136 | }; 137 | 138 | pub fn constructVTable(comptime T: type) VTable { 139 | const vtable = struct { 140 | pub fn destroy(pointer: *anyopaque) void { 141 | const self: *T = @ptrCast(@alignCast(pointer)); 142 | return T.destroy(self); 143 | } 144 | 145 | pub fn render(pointer: *anyopaque, area: Rect, frame: Frame, theme: display.Theme) anyerror!void { 146 | std.debug.assert(area.max.x != std.math.maxInt(u32)); 147 | std.debug.assert(area.max.y != std.math.maxInt(u32)); 148 | const self: *T = @ptrCast(@alignCast(pointer)); 149 | return T.render(self, area, frame, theme); 150 | } 151 | 152 | pub fn layout(pointer: *anyopaque, constraints: Constraints) anyerror!Vec2 { 153 | std.debug.assert(constraints.min_width <= constraints.max_width); 154 | std.debug.assert(constraints.min_height <= constraints.max_height); 155 | // std.debug.print("{any} - {any}\n", .{ *T, constraints }); 156 | const self: *T = @ptrCast(@alignCast(pointer)); 157 | const size = try T.layout(self, constraints); 158 | return size; 159 | } 160 | 161 | pub fn handleEvent(pointer: *anyopaque, event: events.Event) anyerror!events.EventResult { 162 | const self: *T = @ptrCast(@alignCast(pointer)); 163 | return T.handleEvent(self, event); 164 | } 165 | 166 | pub fn layoutProps(pointer: *anyopaque) LayoutProperties { 167 | const self: *T = @ptrCast(@alignCast(pointer)); 168 | return T.layoutProps(self); 169 | } 170 | 171 | pub fn prepare(pointer: *anyopaque) anyerror!void { 172 | const self: *T = @ptrCast(@alignCast(pointer)); 173 | if (@hasDecl(T, "prepare")) { 174 | try T.prepare(self); 175 | } 176 | } 177 | 178 | pub fn children(pointer: *anyopaque) []Widget { 179 | const self: *T = @ptrCast(@alignCast(pointer)); 180 | return T.children(self); 181 | } 182 | 183 | pub fn id(pointer: *anyopaque) ?[]const u8 { 184 | const self: *T = @ptrCast(@alignCast(pointer)); 185 | return T.id(self); 186 | } 187 | }; 188 | 189 | return VTable{ 190 | .destroy = vtable.destroy, 191 | .render = vtable.render, 192 | .layout = vtable.layout, 193 | .handle_event = vtable.handleEvent, 194 | .layout_props = vtable.layoutProps, 195 | .prepare = vtable.prepare, 196 | .children = vtable.children, 197 | .id = vtable.id, 198 | }; 199 | } 200 | 201 | pub fn init(context: anytype) Widget { 202 | const PtrT = @TypeOf(context); 203 | comptime if (@typeInfo(PtrT) != .Pointer) { 204 | @compileError("expected a widget pointer, got " ++ @typeName(PtrT)); 205 | }; 206 | const T = std.meta.Child(PtrT); 207 | 208 | const vtable = comptime constructVTable(T); 209 | 210 | return Widget{ 211 | .context = context, 212 | .vtable = &vtable, 213 | }; 214 | } 215 | 216 | pub inline fn destroy(self: Widget) void { 217 | return self.vtable.destroy(self.context); 218 | } 219 | 220 | pub inline fn render(self: Widget, area: Rect, frame: Frame, theme: display.Theme) !void { 221 | return self.vtable.render(self.context, area, frame, theme); 222 | } 223 | 224 | pub inline fn layout(self: Widget, constraints: Constraints) anyerror!Vec2 { 225 | return try self.vtable.layout(self.context, constraints); 226 | } 227 | 228 | pub inline fn handleEvent(self: Widget, event: events.Event) anyerror!events.EventResult { 229 | return self.vtable.handle_event(self.context, event); 230 | } 231 | 232 | pub inline fn layoutProps(self: Widget) LayoutProperties { 233 | return self.vtable.layout_props(self.context); 234 | } 235 | 236 | pub inline fn prepare(self: Widget) anyerror!void { 237 | return self.vtable.prepare(self.context); 238 | } 239 | 240 | pub inline fn children(self: Widget) []Widget { 241 | return self.vtable.children(self.context); 242 | } 243 | 244 | pub inline fn id(self: Widget) ?[]const u8 { 245 | return self.vtable.id(self.context); 246 | } 247 | 248 | pub fn fromAny(any: anytype) anyerror!Widget { 249 | const ok = if (@typeInfo(@TypeOf(any)) == .ErrorUnion) 250 | try any 251 | else 252 | any; 253 | 254 | return if (@TypeOf(ok) == Widget) 255 | ok 256 | else 257 | ok.widget(); 258 | } 259 | 260 | /// Does a "dynamic cast" by comparing the vtables of `self` and `T`. 261 | /// If all the fields are equal, returns *T, otherwise returns null. 262 | /// For this method to work, widgets are encouraged to delegate 263 | /// the vtable creation to `Widget.init`. 264 | pub fn as(self: Widget, T: type) ?*T { 265 | const vtable_t = constructVTable(T); 266 | inline for (std.meta.fields(VTable)) |field| { 267 | if (@field(self.vtable, field.name) != @field(vtable_t, field.name)) { 268 | return null; 269 | } 270 | } 271 | return @ptrCast(@alignCast(self.context)); 272 | } 273 | 274 | /// Searches for a child (or self) with `id` equals `widget_id`. 275 | pub fn findById(self: Widget, widget_id: []const u8) ?Widget { 276 | if (self.id()) |w_id| { 277 | if (std.mem.eql(u8, w_id, widget_id)) { 278 | return self; 279 | } 280 | } 281 | for (self.children()) |child| { 282 | if (child.findById(widget_id)) |found| { 283 | return found; 284 | } 285 | } 286 | return null; 287 | } 288 | -------------------------------------------------------------------------------- /examples/src/demo.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const tuile = @import("tuile"); 3 | 4 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 5 | pub const tuile_allocator = gpa.allocator(); 6 | 7 | fn generateStyles() !tuile.Span { 8 | var span = tuile.Span.init(tuile_allocator); 9 | try span.appendPlain("Styles: "); 10 | try span.append(.{ .text = "bold", .style = .{ .add_effect = .{ .bold = true } } }); 11 | try span.appendPlain(", "); 12 | try span.append(.{ .text = "italic", .style = .{ .add_effect = .{ .italic = true } } }); 13 | try span.appendPlain(", "); 14 | try span.append(.{ .text = "underline", .style = .{ .add_effect = .{ .underline = true } } }); 15 | try span.appendPlain(", "); 16 | try span.append(.{ .text = "dim", .style = .{ .add_effect = .{ .dim = true } } }); 17 | try span.appendPlain(", "); 18 | try span.append(.{ .text = "blink", .style = .{ .add_effect = .{ .blink = true } } }); 19 | try span.appendPlain(", "); 20 | try span.append(.{ .text = "reverse", .style = .{ .add_effect = .{ .reverse = true } } }); 21 | try span.appendPlain(", "); 22 | try span.append(.{ .text = "highlight", .style = .{ .add_effect = .{ .highlight = true } } }); 23 | return span; 24 | } 25 | 26 | fn generateMultilineSpan() !tuile.Span { 27 | var span = tuile.Span.init(tuile_allocator); 28 | try span.append(.{ .style = .{ .fg = tuile.color("red") }, .text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n" }); 29 | try span.append(.{ .style = .{ .fg = tuile.color("green") }, .text = "Nullam aliquam mollis sapien, eget pretium dui.\n" }); 30 | try span.append(.{ .style = .{ .fg = tuile.color("blue") }, .text = "Nam lobortis turpis ac nunc vehicula cursus in vitae leo.\n" }); 31 | try span.append(.{ .style = .{ .fg = tuile.color("magenta") }, .text = "Donec malesuada accumsan tortor at porta." }); 32 | return span; 33 | } 34 | 35 | const CustomThemeState = struct { 36 | theme: usize = 0, 37 | 38 | border: tuile.Border = tuile.Border.all(), 39 | 40 | border_type: tuile.BorderType = .solid, 41 | 42 | tui: *tuile.Tuile, 43 | 44 | pub fn onThemeChange(ptr: ?*anyopaque, idx: usize, state: bool) void { 45 | var self: *CustomThemeState = @ptrCast(@alignCast(ptr.?)); 46 | if (state) { 47 | self.theme = idx; 48 | self.updateTheme(); 49 | } 50 | } 51 | 52 | pub fn onBorderChange(ptr: ?*anyopaque, idx: usize, state: bool) void { 53 | var self: *CustomThemeState = @ptrCast(@alignCast(ptr.?)); 54 | switch (idx) { 55 | 0 => self.border.top = state, 56 | 1 => self.border.right = state, 57 | 2 => self.border.bottom = state, 58 | 3 => self.border.left = state, 59 | else => unreachable, 60 | } 61 | self.updateBorder(); 62 | } 63 | 64 | pub fn onBorderTypeChange(ptr: ?*anyopaque, idx: usize, state: bool) void { 65 | var self: *CustomThemeState = @ptrCast(@alignCast(ptr.?)); 66 | if (state) { 67 | switch (idx) { 68 | 0 => self.border_type = .simple, 69 | 1 => self.border_type = .solid, 70 | 2 => self.border_type = .rounded, 71 | 3 => self.border_type = .double, 72 | else => unreachable, 73 | } 74 | self.updateBorder(); 75 | } 76 | } 77 | 78 | fn updateTheme(self: CustomThemeState) void { 79 | var theme = switch (self.theme) { 80 | 0 => tuile.Theme.amber(), 81 | 1 => tuile.Theme.lime(), 82 | 2 => tuile.Theme.sky(), 83 | else => unreachable, 84 | }; 85 | theme.background_primary = theme.background_secondary; 86 | const themed = self.tui.findByIdTyped(tuile.Themed, "themed") orelse unreachable; 87 | themed.setTheme(theme); 88 | } 89 | 90 | fn updateBorder(self: CustomThemeState) void { 91 | const block = self.tui.findByIdTyped(tuile.Block, "borders") orelse unreachable; 92 | block.border = self.border; 93 | block.border_type = self.border_type; 94 | } 95 | }; 96 | 97 | const UserInputState = struct { 98 | input: []const u8 = "", 99 | 100 | tui: *tuile.Tuile, 101 | 102 | pub fn onPress(ptr: ?*anyopaque) void { 103 | const self: *UserInputState = @ptrCast(@alignCast(ptr.?)); 104 | if (self.input.len > 0) { 105 | const label = self.tui.findByIdTyped(tuile.Label, "user-input") orelse unreachable; 106 | label.setText(self.input) catch unreachable; 107 | } 108 | } 109 | 110 | pub fn inputChanged(ptr: ?*anyopaque, value: []const u8) void { 111 | const self: *UserInputState = @ptrCast(@alignCast(ptr.?)); 112 | self.input = value; 113 | } 114 | }; 115 | 116 | pub fn main() !void { 117 | defer _ = gpa.deinit(); 118 | 119 | var tui = try tuile.Tuile.init(.{}); 120 | defer tui.deinit(); 121 | 122 | var styles = try generateStyles(); 123 | defer styles.deinit(); 124 | 125 | var multiline_span = try generateMultilineSpan(); 126 | defer multiline_span.deinit(); 127 | 128 | var custom_theme_state = CustomThemeState{ .tui = &tui }; 129 | 130 | var user_input_state = UserInputState{ .tui = &tui }; 131 | 132 | const layout = tuile.vertical( 133 | .{ .layout = .{ .flex = 1 } }, 134 | .{ 135 | tuile.label(.{ .span = styles.view() }), 136 | 137 | tuile.themed( 138 | .{ .id = "themed", .theme = tuile.Theme.amber() }, 139 | tuile.block( 140 | .{ 141 | .id = "borders", 142 | .border = custom_theme_state.border, 143 | .border_type = custom_theme_state.border_type, 144 | .padding = .{ .top = 1, .bottom = 1 }, 145 | }, 146 | tuile.horizontal(.{}, .{ 147 | tuile.spacer(.{}), 148 | tuile.vertical(.{}, .{ 149 | tuile.label(.{ .text = "Customizable themes:" }), 150 | tuile.checkbox_group( 151 | .{ .multiselect = false, .on_state_change = .{ .cb = @ptrCast(&CustomThemeState.onThemeChange), .payload = &custom_theme_state } }, 152 | .{ 153 | tuile.checkbox(.{ .text = "Amber", .checked = true, .role = .radio }), 154 | tuile.checkbox(.{ .text = "Lime", .role = .radio }), 155 | tuile.checkbox(.{ .text = "Sky", .role = .radio }), 156 | }, 157 | ), 158 | }), 159 | tuile.spacer(.{}), 160 | tuile.vertical(.{}, .{ 161 | tuile.label(.{ .text = "Borders:" }), 162 | tuile.horizontal(.{}, .{ 163 | tuile.checkbox_group( 164 | .{ .multiselect = true, .on_state_change = .{ .cb = @ptrCast(&CustomThemeState.onBorderChange), .payload = &custom_theme_state } }, 165 | .{ 166 | tuile.checkbox(.{ .text = "Top", .checked = custom_theme_state.border.top }), 167 | tuile.checkbox(.{ .text = "Right", .checked = custom_theme_state.border.right }), 168 | tuile.checkbox(.{ .text = "Bottom", .checked = custom_theme_state.border.bottom }), 169 | tuile.checkbox(.{ .text = "Left", .checked = custom_theme_state.border.left }), 170 | }, 171 | ), 172 | tuile.spacer(.{ .layout = .{ .max_height = 3, .max_width = 3 } }), 173 | tuile.checkbox_group( 174 | .{ .multiselect = false, .on_state_change = .{ .cb = @ptrCast(&CustomThemeState.onBorderTypeChange), .payload = &custom_theme_state } }, 175 | .{ 176 | tuile.checkbox(.{ .text = "Simple", .checked = (custom_theme_state.border_type == .simple), .role = .radio }), 177 | tuile.checkbox(.{ .text = "Solid", .checked = (custom_theme_state.border_type == .solid), .role = .radio }), 178 | tuile.checkbox(.{ .text = "Rounded", .checked = (custom_theme_state.border_type == .rounded), .role = .radio }), 179 | tuile.checkbox(.{ .text = "Double", .checked = (custom_theme_state.border_type == .double), .role = .radio }), 180 | }, 181 | ), 182 | }), 183 | }), 184 | tuile.spacer(.{}), 185 | }), 186 | ), 187 | ), 188 | 189 | tuile.block( 190 | .{ 191 | .border = tuile.border.Border.all(), 192 | .border_type = .rounded, 193 | .padding = .{ .top = 1, .bottom = 1, .left = 1, .right = 1 }, 194 | }, 195 | tuile.vertical(.{}, .{ 196 | tuile.label(.{ .text = "Multiline text:" }), 197 | tuile.label(.{ .span = multiline_span.view() }), 198 | }), 199 | ), 200 | 201 | tuile.label(.{ .text = "Alignment" }), 202 | tuile.horizontal(.{}, .{ 203 | tuile.block( 204 | .{ .layout = .{ .flex = 1, .max_height = 6 }, .border = tuile.Border.all() }, 205 | tuile.label(.{ .text = "TL", .layout = .{ .alignment = tuile.Align.topLeft() } }), 206 | ), 207 | tuile.block( 208 | .{ .layout = .{ .flex = 1, .max_height = 6 }, .border = tuile.Border.all() }, 209 | tuile.label(.{ .text = "TR", .layout = .{ .alignment = tuile.Align.topRight() } }), 210 | ), 211 | tuile.block( 212 | .{ .layout = .{ .flex = 1, .max_height = 6 }, .border = tuile.Border.all() }, 213 | tuile.label(.{ .text = "BL", .layout = .{ .alignment = tuile.Align.bottomLeft() } }), 214 | ), 215 | tuile.block( 216 | .{ .layout = .{ .flex = 1, .max_height = 6 }, .border = tuile.Border.all() }, 217 | tuile.label(.{ .text = "BR", .layout = .{ .alignment = tuile.Align.bottomRight() } }), 218 | ), 219 | }), 220 | tuile.label(.{ .text = "User inputs" }), 221 | tuile.block( 222 | .{ .layout = .{ .flex = 2, .max_height = 5 }, .border = tuile.Border.all() }, 223 | tuile.label(.{ .id = "user-input", .text = "" }), 224 | ), 225 | tuile.horizontal(.{}, .{ 226 | tuile.input(.{ 227 | .placeholder = "placeholder", 228 | .layout = .{ .flex = 1 }, 229 | .on_value_changed = .{ .cb = UserInputState.inputChanged, .payload = &user_input_state }, 230 | }), 231 | tuile.spacer(.{ .layout = .{ .max_width = 1, .max_height = 1 } }), 232 | tuile.button(.{ .text = "Submit", .on_press = .{ .cb = UserInputState.onPress, .payload = &user_input_state } }), 233 | }), 234 | tuile.spacer(.{}), 235 | tuile.label(.{ .text = "Tab/Shift+Tab to move between elements, Space to interract" }), 236 | }, 237 | ); 238 | 239 | try tui.add(layout); 240 | 241 | custom_theme_state.updateBorder(); 242 | custom_theme_state.updateTheme(); 243 | 244 | try tui.run(); 245 | } 246 | -------------------------------------------------------------------------------- /src/display/colors.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub const BaseColor = enum { 4 | black, 5 | red, 6 | green, 7 | yellow, 8 | blue, 9 | magenta, 10 | cyan, 11 | white, 12 | }; 13 | 14 | pub const Rgb = struct { 15 | r: u8, 16 | g: u8, 17 | b: u8, 18 | 19 | pub fn black() Rgb { 20 | return .{ .r = 0, .g = 0, .b = 0 }; 21 | } 22 | 23 | pub fn red() Rgb { 24 | return .{ .r = 255, .g = 0, .b = 0 }; 25 | } 26 | 27 | pub fn green() Rgb { 28 | return .{ .r = 0, .g = 255, .b = 0 }; 29 | } 30 | 31 | pub fn yellow() Rgb { 32 | return .{ .r = 255, .g = 255, .b = 0 }; 33 | } 34 | 35 | pub fn blue() Rgb { 36 | return .{ .r = 0, .g = 0, .b = 255 }; 37 | } 38 | 39 | pub fn magenta() Rgb { 40 | return .{ .r = 255, .g = 0, .b = 255 }; 41 | } 42 | 43 | pub fn cyan() Rgb { 44 | return .{ .r = 0, .g = 255, .b = 255 }; 45 | } 46 | 47 | pub fn white() Rgb { 48 | return .{ .r = 255, .g = 255, .b = 255 }; 49 | } 50 | }; 51 | 52 | pub const Color = union(enum) { 53 | /// Usually the first 8 system colors 54 | dark: BaseColor, 55 | /// Usually the second 8 system colors 56 | bright: BaseColor, 57 | /// 8-bit RGB color. 58 | /// Backends might coerse it to the closest supported color variant, see `Palette256`. 59 | rgb: Rgb, 60 | 61 | /// Parses a string representation of a color. 62 | /// Supports named colors like `dark red`, `bright white`, or `red` (RGB), 63 | /// as well as hex rgb codes like `#D3E7A6` or functional notation `rgb(r, g, b)`. 64 | pub fn fromString(str: []const u8) error{UnrecognizedColor}!Color { 65 | const eql = std.ascii.eqlIgnoreCase; 66 | if (eql(str, "dark black")) return .{ .dark = .black }; 67 | if (eql(str, "dark red")) return .{ .dark = .red }; 68 | if (eql(str, "dark green")) return .{ .dark = .green }; 69 | if (eql(str, "dark yellow")) return .{ .dark = .yellow }; 70 | if (eql(str, "dark blue")) return .{ .dark = .blue }; 71 | if (eql(str, "dark magenta")) return .{ .dark = .magenta }; 72 | if (eql(str, "dark cyan")) return .{ .dark = .cyan }; 73 | if (eql(str, "dark white")) return .{ .dark = .white }; 74 | 75 | if (eql(str, "bright black")) return .{ .bright = .black }; 76 | if (eql(str, "bright red")) return .{ .bright = .red }; 77 | if (eql(str, "bright green")) return .{ .bright = .green }; 78 | if (eql(str, "bright yellow")) return .{ .bright = .yellow }; 79 | if (eql(str, "bright blue")) return .{ .bright = .blue }; 80 | if (eql(str, "bright magenta")) return .{ .bright = .magenta }; 81 | if (eql(str, "bright cyan")) return .{ .bright = .cyan }; 82 | if (eql(str, "bright white")) return .{ .bright = .white }; 83 | 84 | if (eql(str, "black")) return .{ .rgb = Rgb.black() }; 85 | if (eql(str, "red")) return .{ .rgb = Rgb.red() }; 86 | if (eql(str, "green")) return .{ .rgb = Rgb.green() }; 87 | if (eql(str, "yellow")) return .{ .rgb = Rgb.yellow() }; 88 | if (eql(str, "blue")) return .{ .rgb = Rgb.blue() }; 89 | if (eql(str, "magenta")) return .{ .rgb = Rgb.magenta() }; 90 | if (eql(str, "cyan")) return .{ .rgb = Rgb.cyan() }; 91 | if (eql(str, "white")) return .{ .rgb = Rgb.white() }; 92 | 93 | if (std.mem.startsWith(u8, str, "#")) { 94 | // hex 95 | if (str.len != 7) return error.UnrecognizedColor; 96 | const r = str[1..3]; 97 | const g = str[3..5]; 98 | const b = str[5..7]; 99 | const parse = std.fmt.parseUnsigned; 100 | return .{ .rgb = Rgb{ 101 | .r = parse(u8, r, 16) catch return error.UnrecognizedColor, 102 | .g = parse(u8, g, 16) catch return error.UnrecognizedColor, 103 | .b = parse(u8, b, 16) catch return error.UnrecognizedColor, 104 | } }; 105 | } 106 | if (std.mem.startsWith(u8, str, "rgb")) { 107 | // rgb(r, g, b) 108 | // 7 is minimum for rgb(,,) 109 | if (str.len <= 7) return error.UnrecognizedColor; 110 | if (str[3] != '(' or str[str.len - 1] != ')') return error.UnrecognizedColor; 111 | // r, g, b without parentheses 112 | const rgb = str[4 .. str.len - 1]; 113 | const r_start = std.mem.indexOfNone(u8, rgb, " ") orelse return error.UnrecognizedColor; 114 | const r_end = std.mem.indexOfAnyPos(u8, rgb, r_start, ", ") orelse return error.UnrecognizedColor; 115 | const g_start = std.mem.indexOfNonePos(u8, rgb, r_end + 1, ", ") orelse return error.UnrecognizedColor; 116 | const g_end = std.mem.indexOfAnyPos(u8, rgb, g_start, ", ") orelse return error.UnrecognizedColor; 117 | const b_start = std.mem.indexOfNonePos(u8, rgb, g_end + 1, ", ") orelse return error.UnrecognizedColor; 118 | const b_end = std.mem.indexOfAnyPos(u8, rgb, b_start + 1, " ") orelse rgb.len; 119 | 120 | const parse = std.fmt.parseUnsigned; 121 | return .{ .rgb = Rgb{ 122 | .r = parse(u8, rgb[r_start..r_end], 0) catch return error.UnrecognizedColor, 123 | .g = parse(u8, rgb[g_start..g_end], 0) catch return error.UnrecognizedColor, 124 | .b = parse(u8, rgb[b_start..b_end], 0) catch return error.UnrecognizedColor, 125 | } }; 126 | } 127 | 128 | return error.UnrecognizedColor; 129 | } 130 | }; 131 | 132 | pub const ColorPair = struct { 133 | fg: Color, 134 | bg: Color, 135 | }; 136 | 137 | /// This function is intended to be used with strings knows at comptime. 138 | /// For everything else use `Color.fromString` directly. 139 | pub fn color(comptime str: []const u8) Color { 140 | return comptime Color.fromString(str) catch @compileError("unrecognized color " ++ str); 141 | } 142 | 143 | /// Xterm 256 color palette 144 | pub const Palette256 = struct { 145 | pub const lookup_table: [256][3]u8 = init_lut: { 146 | var palette: [256][3]u8 = undefined; 147 | 148 | palette[0] = .{ 0, 0, 0 }; 149 | palette[1] = .{ 128, 0, 0 }; 150 | palette[2] = .{ 0, 128, 0 }; 151 | palette[3] = .{ 128, 128, 0 }; 152 | palette[4] = .{ 0, 0, 128 }; 153 | palette[5] = .{ 128, 0, 128 }; 154 | palette[6] = .{ 0, 128, 128 }; 155 | palette[7] = .{ 192, 192, 192 }; 156 | palette[8] = .{ 128, 128, 128 }; 157 | palette[9] = .{ 255, 0, 0 }; 158 | palette[10] = .{ 0, 255, 0 }; 159 | palette[11] = .{ 255, 255, 0 }; 160 | palette[12] = .{ 0, 0, 255 }; 161 | palette[13] = .{ 255, 0, 255 }; 162 | palette[14] = .{ 0, 255, 255 }; 163 | palette[15] = .{ 255, 255, 255 }; 164 | 165 | for (16..256) |idx| { 166 | if (idx < 232) { 167 | const i = idx - 16; 168 | const steps = [_]u8{ 0, 95, 135, 175, 215, 255 }; 169 | palette[idx] = .{ 170 | steps[i / 36], 171 | steps[(i / 6) % 6], 172 | steps[i % 6], 173 | }; 174 | } else { 175 | // 232..256 represent grayscale from dark to light in 24 steps 176 | // from black 8 to almost white 238 with step 10 177 | const start = 8; 178 | const step = 10; 179 | const grayscale = start + step * (idx - 232); 180 | palette[idx] = .{ 181 | grayscale, 182 | grayscale, 183 | grayscale, 184 | }; 185 | } 186 | } 187 | 188 | break :init_lut palette; 189 | }; 190 | 191 | /// Uses Manhatten distance to find the closest color 192 | pub fn findClosest(rgb: Rgb) u8 { 193 | return findClosestInRange(rgb, 0, null); 194 | } 195 | 196 | /// Uses Manhatten distance to find the closest color 197 | /// Ignores the first 16 colors 198 | pub fn findClosestNonSystem(rgb: Rgb) u8 { 199 | return findClosestInRange(rgb, 16, null); 200 | } 201 | 202 | /// Uses Manhatten distance to find the closest color of the first 16 203 | pub fn findClosestSystem(rgb: Rgb) u8 { 204 | return findClosestInRange(rgb, 0, 16); 205 | } 206 | 207 | /// Uses Manhatten distance to find the closest color 208 | pub fn findClosestInRange(rgb: Rgb, start: u8, end: ?u8) u8 { 209 | var lut_idx: u8 = start; 210 | var distance: u32 = std.math.maxInt(u32); 211 | const needle: [3]u8 = .{ rgb.r, rgb.g, rgb.b }; 212 | 213 | for (lookup_table[start .. end orelse lookup_table.len], start..) |palette_color, idx| { 214 | var new_distance: u32 = 0; 215 | for (palette_color, needle) |a, b| { 216 | new_distance += @abs(@as(i32, a) - @as(i32, b)); 217 | } 218 | if (new_distance < distance) { 219 | distance = new_distance; 220 | lut_idx = @intCast(idx); 221 | } 222 | } 223 | return lut_idx; 224 | } 225 | }; 226 | 227 | test "simple color from string" { 228 | const expect = std.testing.expect; 229 | const eql = std.meta.eql; 230 | 231 | try expect(eql(color("bright black"), Color{ .bright = .black })); 232 | try expect(eql(color("bright red"), Color{ .bright = .red })); 233 | try expect(eql(color("bright green"), Color{ .bright = .green })); 234 | try expect(eql(color("bright yellow"), Color{ .bright = .yellow })); 235 | try expect(eql(color("bright blue"), Color{ .bright = .blue })); 236 | try expect(eql(color("bright magenta"), Color{ .bright = .magenta })); 237 | try expect(eql(color("bright cyan"), Color{ .bright = .cyan })); 238 | try expect(eql(color("bright white"), Color{ .bright = .white })); 239 | 240 | try expect(eql(color("dark black"), Color{ .dark = .black })); 241 | try expect(eql(color("dark red"), Color{ .dark = .red })); 242 | try expect(eql(color("dark green"), Color{ .dark = .green })); 243 | try expect(eql(color("dark yellow"), Color{ .dark = .yellow })); 244 | try expect(eql(color("dark blue"), Color{ .dark = .blue })); 245 | try expect(eql(color("dark magenta"), Color{ .dark = .magenta })); 246 | try expect(eql(color("dark cyan"), Color{ .dark = .cyan })); 247 | try expect(eql(color("dark white"), Color{ .dark = .white })); 248 | } 249 | 250 | test "rgb by name" { 251 | const expect = std.testing.expect; 252 | const eql = std.meta.eql; 253 | 254 | try expect(eql(color("black"), Color{ .rgb = Rgb.black() })); 255 | try expect(eql(color("red"), Color{ .rgb = Rgb.red() })); 256 | try expect(eql(color("green"), Color{ .rgb = Rgb.green() })); 257 | try expect(eql(color("yellow"), Color{ .rgb = Rgb.yellow() })); 258 | try expect(eql(color("blue"), Color{ .rgb = Rgb.blue() })); 259 | try expect(eql(color("magenta"), Color{ .rgb = Rgb.magenta() })); 260 | try expect(eql(color("cyan"), Color{ .rgb = Rgb.cyan() })); 261 | try expect(eql(color("white"), Color{ .rgb = Rgb.white() })); 262 | } 263 | 264 | test "rgb from hex string" { 265 | const expect = std.testing.expect; 266 | const eql = std.meta.eql; 267 | 268 | try expect(eql(color("#FF0000"), Color{ .rgb = .{ .r = 255, .g = 0, .b = 0 } })); 269 | try expect(eql(color("#00FF00"), Color{ .rgb = .{ .r = 0, .g = 255, .b = 0 } })); 270 | try expect(eql(color("#0000FF"), Color{ .rgb = .{ .r = 0, .g = 0, .b = 255 } })); 271 | 272 | try expect(eql(color("#ff0000"), Color{ .rgb = .{ .r = 255, .g = 0, .b = 0 } })); 273 | try expect(eql(color("#00ff00"), Color{ .rgb = .{ .r = 0, .g = 255, .b = 0 } })); 274 | try expect(eql(color("#0000ff"), Color{ .rgb = .{ .r = 0, .g = 0, .b = 255 } })); 275 | 276 | try expect(eql(color("#9520e6"), Color{ .rgb = .{ .r = 149, .g = 32, .b = 230 } })); 277 | try expect(eql(color("#d6f184"), Color{ .rgb = .{ .r = 214, .g = 241, .b = 132 } })); 278 | try expect(eql(color("#5ded68"), Color{ .rgb = .{ .r = 93, .g = 237, .b = 104 } })); 279 | 280 | try expect(eql(color("#9520E6"), Color{ .rgb = .{ .r = 149, .g = 32, .b = 230 } })); 281 | try expect(eql(color("#D6F184"), Color{ .rgb = .{ .r = 214, .g = 241, .b = 132 } })); 282 | try expect(eql(color("#5DED68"), Color{ .rgb = .{ .r = 93, .g = 237, .b = 104 } })); 283 | } 284 | 285 | test "rgb from rgb(r, g, b) string" { 286 | const expect = std.testing.expect; 287 | const eql = std.meta.eql; 288 | 289 | try expect(eql(color("rgb(0, 0, 0)"), Color{ .rgb = .{ .r = 0, .g = 0, .b = 0 } })); 290 | try expect(eql(color("rgb(255, 0, 0)"), Color{ .rgb = .{ .r = 255, .g = 0, .b = 0 } })); 291 | try expect(eql(color("rgb(0, 255, 0)"), Color{ .rgb = .{ .r = 0, .g = 255, .b = 0 } })); 292 | try expect(eql(color("rgb(0, 0, 255)"), Color{ .rgb = .{ .r = 0, .g = 0, .b = 255 } })); 293 | try expect(eql(color("rgb(255, 255, 255)"), Color{ .rgb = .{ .r = 255, .g = 255, .b = 255 } })); 294 | 295 | try expect(eql(color("rgb(149, 32, 230)"), Color{ .rgb = .{ .r = 149, .g = 32, .b = 230 } })); 296 | try expect(eql(color("rgb(214, 241, 132)"), Color{ .rgb = .{ .r = 214, .g = 241, .b = 132 } })); 297 | try expect(eql(color("rgb(93, 237, 104)"), Color{ .rgb = .{ .r = 93, .g = 237, .b = 104 } })); 298 | 299 | try expect(eql(color("rgb(0x95, 0x20, 0xe6)"), Color{ .rgb = .{ .r = 149, .g = 32, .b = 230 } })); 300 | try expect(eql(color("rgb(0xd6, 0xf1, 0x84)"), Color{ .rgb = .{ .r = 214, .g = 241, .b = 132 } })); 301 | try expect(eql(color("rgb(0x5d, 0xed, 0x68)"), Color{ .rgb = .{ .r = 93, .g = 237, .b = 104 } })); 302 | 303 | try expect(eql(color("rgb(255, 255, 255)"), Color{ .rgb = .{ .r = 255, .g = 255, .b = 255 } })); 304 | try expect(eql(color("rgb(255,255,255)"), Color{ .rgb = .{ .r = 255, .g = 255, .b = 255 } })); 305 | try expect(eql(color("rgb(255,255,255 )"), Color{ .rgb = .{ .r = 255, .g = 255, .b = 255 } })); 306 | try expect(eql(color("rgb( 255 , 255 , 255 )"), Color{ .rgb = .{ .r = 255, .g = 255, .b = 255 } })); 307 | } 308 | --------------------------------------------------------------------------------