a modern tui library written in zig

Compare changes

Choose any two refs to compare.

+4 -4
.github/workflows/docs.yml
··· 25 25 uses: actions/checkout@v3 26 26 - name: Setup Pages 27 27 uses: actions/configure-pages@v2 28 - - uses: mlugg/setup-zig@v1 28 + - uses: mlugg/setup-zig@v2 29 29 with: 30 - version: 0.13.0 30 + version: 0.15.1 31 31 - run: zig build docs 32 32 - name: Upload artifact 33 - uses: actions/upload-pages-artifact@v1 33 + uses: actions/upload-pages-artifact@v3 34 34 with: 35 35 path: "zig-out/docs" 36 36 - name: Deploy to GitHub Pages 37 37 id: deployment 38 - uses: actions/deploy-pages@v1 38 + uses: actions/deploy-pages@v4
+29
.github/workflows/mirror.yml
··· 1 + name: Mirror to tangled 2 + 3 + on: 4 + push: 5 + branches: 6 + - main 7 + 8 + jobs: 9 + mirror: 10 + runs-on: ubuntu-latest 11 + 12 + steps: 13 + - name: Checkout source repo 14 + uses: actions/checkout@v3 15 + with: 16 + fetch-depth: 0 17 + 18 + - name: Set up SSH key 19 + run: | 20 + mkdir -p ~/.ssh 21 + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 22 + chmod 600 ~/.ssh/id_ed25519 23 + ssh-keyscan -H tangled.sh >> ~/.ssh/known_hosts 24 + shell: bash 25 + 26 + - name: Mirror 27 + run: | 28 + git remote add tangled git@tangled.sh:rockorager.dev/libvaxis 29 + git push --mirror tangled
+4 -4
.github/workflows/test.yml
··· 13 13 runs-on: ${{matrix.os}} 14 14 steps: 15 15 - uses: actions/checkout@v3 16 - - uses: mlugg/setup-zig@v1 16 + - uses: mlugg/setup-zig@v2 17 17 with: 18 - version: 0.13.0 18 + version: 0.15.1 19 19 - run: zig build test 20 20 check-fmt: 21 21 runs-on: ubuntu-latest 22 22 steps: 23 23 - uses: actions/checkout@v3 24 - - uses: mlugg/setup-zig@v1 24 + - uses: mlugg/setup-zig@v2 25 25 with: 26 - version: 0.13.0 26 + version: 0.15.1 27 27 - run: zig fmt --check .
+2
.gitignore
··· 3 3 zig-out/ 4 4 *.log 5 5 Session*.*vim 6 + commit_msg 7 + *.sw?
+248 -32
README.md
··· 9 9 Libvaxis _does not use terminfo_. Support for vt features is detected through 10 10 terminal queries. 11 11 12 - Contributions are welcome. 13 - 14 - Vaxis uses zig `0.13.0`. 12 + Vaxis uses zig `0.15.1`. 15 13 16 14 ## Features 17 15 18 16 libvaxis supports all major platforms: macOS, Windows, Linux/BSD/and other 19 17 Unix-likes. 20 18 21 - | Feature | libvaxis | 22 - | ------------------------------ | :------: | 23 - | RGB | โœ… | 24 - | Hyperlinks | โœ… | 25 - | Bracketed Paste | โœ… | 26 - | Kitty Keyboard | โœ… | 27 - | Styled Underlines | โœ… | 28 - | Mouse Shapes (OSC 22) | โœ… | 29 - | System Clipboard (OSC 52) | โœ… | 30 - | System Notifications (OSC 9) | โœ… | 31 - | System Notifications (OSC 777) | โœ… | 32 - | Synchronized Output (DEC 2026) | โœ… | 33 - | Unicode Core (DEC 2027) | โœ… | 34 - | Color Mode Updates (DEC 2031) | โœ… | 35 - | [In-Band Resize Reports](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) | โœ… | 36 - | Images (kitty) | โœ… | 19 + - RGB 20 + - [Hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) (OSC 8) 21 + - Bracketed Paste 22 + - [Kitty Keyboard Protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) 23 + - [Fancy underlines](https://sw.kovidgoyal.net/kitty/underlines/) (undercurl, etc) 24 + - Mouse Shapes (OSC 22) 25 + - System Clipboard (OSC 52) 26 + - System Notifications (OSC 9) 27 + - System Notifications (OSC 777) 28 + - Synchronized Output (Mode 2026) 29 + - [Unicode Core](https://github.com/contour-terminal/terminal-unicode-core) (Mode 2027) 30 + - Color Mode Updates (Mode 2031) 31 + - [In-Band Resize Reports](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) (Mode 2048) 32 + - Images ([kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/)) 33 + - [Explicit Width](https://github.com/kovidgoyal/kitty/blob/master/docs/text-sizing-protocol.rst) (width modifiers only) 37 34 38 35 ## Usage 39 36 40 37 [Documentation](https://rockorager.github.io/libvaxis/#vaxis.Vaxis) 41 38 42 - [Starter repo](https://github.com/rockorager/libvaxis-starter) 39 + The library provides both a low level API suitable for making applications of 40 + any sort as well as a higher level framework. The low level API is suitable for 41 + making applications of any type, providing your own event loop, and gives you 42 + full control over each cell on the screen. 43 + 44 + The high level API, called `vxfw` (Vaxis framework), provides a Flutter-like 45 + style of API. The framework provides an application runtime which handles the 46 + event loop, focus management, mouse handling, and more. Several widgets are 47 + provided, and custom widgets are easy to build. This API is most likely what you 48 + want to use for typical TUI applications. 49 + 50 + ### Add libvaxis to your project 51 + 52 + ```console 53 + zig fetch --save git+https://github.com/rockorager/libvaxis.git 54 + ``` 55 + Add this to your build.zig 56 + 57 + ```zig 58 + const vaxis = b.dependency("vaxis", .{ 59 + .target = target, 60 + .optimize = optimize, 61 + }); 62 + 63 + exe.root_module.addImport("vaxis", vaxis.module("vaxis")); 64 + ``` 65 + 66 + or for ZLS support 67 + 68 + ```zig 69 + // create module 70 + const exe_mod = b.createModule(.{ 71 + .root_source_file = b.path("src/main.zig"), 72 + .target = target, 73 + .optimize = optimize, 74 + }); 75 + 76 + // add vaxis dependency to module 77 + const vaxis = b.dependency("vaxis", .{ 78 + .target = target, 79 + .optimize = optimize, 80 + }); 81 + exe_mod.addImport("vaxis", vaxis.module("vaxis")); 82 + 83 + //create executable 84 + const exe = b.addExecutable(.{ 85 + .name = "project_foo", 86 + .root_module = exe_mod, 87 + }); 88 + // install exe below 89 + ``` 90 + 91 + ### vxfw (Vaxis framework) 92 + 93 + Let's build a simple button counter application. This example can be run using 94 + the command `zig build example -Dexample=counter`. The below application has 95 + full mouse support: the button *and mouse shape* will change style on hover, on 96 + click, and has enough logic to cancel a press if the release does not occur over 97 + the button. Try it! Click the button, move the mouse off the button and release. 98 + All of this logic is baked into the base `Button` widget. 99 + 100 + ```zig 101 + const std = @import("std"); 102 + const vaxis = @import("vaxis"); 103 + const vxfw = vaxis.vxfw; 104 + 105 + /// Our main application state 106 + const Model = struct { 107 + /// State of the counter 108 + count: u32 = 0, 109 + /// The button. This widget is stateful and must live between frames 110 + button: vxfw.Button, 111 + 112 + /// Helper function to return a vxfw.Widget struct 113 + pub fn widget(self: *Model) vxfw.Widget { 114 + return .{ 115 + .userdata = self, 116 + .eventHandler = Model.typeErasedEventHandler, 117 + .drawFn = Model.typeErasedDrawFn, 118 + }; 119 + } 120 + 121 + /// This function will be called from the vxfw runtime. 122 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 123 + const self: *Model = @ptrCast(@alignCast(ptr)); 124 + switch (event) { 125 + // The root widget is always sent an init event as the first event. Users of the 126 + // library can also send this event to other widgets they create if they need to do 127 + // some initialization. 128 + .init => return ctx.requestFocus(self.button.widget()), 129 + .key_press => |key| { 130 + if (key.matches('c', .{ .ctrl = true })) { 131 + ctx.quit = true; 132 + return; 133 + } 134 + }, 135 + // We can request a specific widget gets focus. In this case, we always want to focus 136 + // our button. Having focus means that key events will be sent up the widget tree to 137 + // the focused widget, and then bubble back down the tree to the root. Users can tell 138 + // the runtime the event was handled and the capture or bubble phase will stop 139 + .focus_in => return ctx.requestFocus(self.button.widget()), 140 + else => {}, 141 + } 142 + } 143 + 144 + /// This function is called from the vxfw runtime. It will be called on a regular interval, and 145 + /// only when any event handler has marked the redraw flag in EventContext as true. By 146 + /// explicitly requiring setting the redraw flag, vxfw can prevent excessive redraws for events 147 + /// which don't change state (ie mouse motion, unhandled key events, etc) 148 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface { 149 + const self: *Model = @ptrCast(@alignCast(ptr)); 150 + // The DrawContext is inspired from Flutter. Each widget will receive a minimum and maximum 151 + // constraint. The minimum constraint will always be set, even if it is set to 0x0. The 152 + // maximum constraint can have null width and/or height - meaning there is no constraint in 153 + // that direction and the widget should take up as much space as it needs. By calling size() 154 + // on the max, we assert that it has some constrained size. This is *always* the case for 155 + // the root widget - the maximum size will always be the size of the terminal screen. 156 + const max_size = ctx.max.size(); 157 + 158 + // The DrawContext also contains an arena allocator that can be used for each frame. The 159 + // lifetime of this allocation is until the next time we draw a frame. This is useful for 160 + // temporary allocations such as the one below: we have an integer we want to print as text. 161 + // We can safely allocate this with the ctx arena since we only need it for this frame. 162 + const count_text = try std.fmt.allocPrint(ctx.arena, "{d}", .{self.count}); 163 + const text: vxfw.Text = .{ .text = count_text }; 164 + 165 + // Each widget returns a Surface from its draw function. A Surface contains the rectangular 166 + // area of the widget, as well as some information about the surface or widget: can we focus 167 + // it? does it handle the mouse? 168 + // 169 + // It DOES NOT contain the location it should be within its parent. Only the parent can set 170 + // this via a SubSurface. Here, we will return a Surface for the root widget (Model), which 171 + // has two SubSurfaces: one for the text and one for the button. A SubSurface is a Surface 172 + // with an offset and a z-index - the offset can be negative. This lets a parent draw a 173 + // child and place it within itself 174 + const text_child: vxfw.SubSurface = .{ 175 + .origin = .{ .row = 0, .col = 0 }, 176 + .surface = try text.draw(ctx), 177 + }; 178 + 179 + const button_child: vxfw.SubSurface = .{ 180 + .origin = .{ .row = 2, .col = 0 }, 181 + .surface = try self.button.draw(ctx.withConstraints( 182 + ctx.min, 183 + // Here we explicitly set a new maximum size constraint for the Button. A Button will 184 + // expand to fill its area and must have some hard limit in the maximum constraint 185 + .{ .width = 16, .height = 3 }, 186 + )), 187 + }; 188 + 189 + // We also can use our arena to allocate the slice for our SubSurfaces. This slice only 190 + // needs to live until the next frame, making this safe. 191 + const children = try ctx.arena.alloc(vxfw.SubSurface, 2); 192 + children[0] = text_child; 193 + children[1] = button_child; 194 + 195 + return .{ 196 + // A Surface must have a size. Our root widget is the size of the screen 197 + .size = max_size, 198 + .widget = self.widget(), 199 + // We didn't actually need to draw anything for the root. In this case, we can set 200 + // buffer to a zero length slice. If this slice is *not zero length*, the runtime will 201 + // assert that its length is equal to the size.width * size.height. 202 + .buffer = &.{}, 203 + .children = children, 204 + }; 205 + } 206 + 207 + /// The onClick callback for our button. This is also called if we press enter while the button 208 + /// has focus 209 + fn onClick(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext) anyerror!void { 210 + const ptr = maybe_ptr orelse return; 211 + const self: *Model = @ptrCast(@alignCast(ptr)); 212 + self.count +|= 1; 213 + return ctx.consumeAndRedraw(); 214 + } 215 + }; 216 + 217 + pub fn main() !void { 218 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 219 + defer _ = gpa.deinit(); 220 + 221 + const allocator = gpa.allocator(); 222 + 223 + var app = try vxfw.App.init(allocator); 224 + defer app.deinit(); 225 + 226 + // We heap allocate our model because we will require a stable pointer to it in our Button 227 + // widget 228 + const model = try allocator.create(Model); 229 + defer allocator.destroy(model); 230 + 231 + // Set the initial state of our button 232 + model.* = .{ 233 + .count = 0, 234 + .button = .{ 235 + .label = "Click me!", 236 + .onClick = Model.onClick, 237 + .userdata = model, 238 + }, 239 + }; 240 + 241 + try app.run(model.widget(), .{}); 242 + } 243 + ``` 244 + 245 + ### Low level API 43 246 44 247 Vaxis requires three basic primitives to operate: 45 248 ··· 52 255 use the event loop of their choice. The event loop is responsible for reading 53 256 the TTY, passing the read bytes to the vaxis parser, and handling events. 54 257 55 - A core feature of Vaxis is it's ability to detect features via terminal queries 258 + A core feature of Vaxis is its ability to detect features via terminal queries 56 259 instead of relying on a terminfo database. This requires that the event loop 57 260 also handle these query responses and update the Vaxis.caps struct accordingly. 58 261 See the `Loop` implementation to see how this is done if writing your own event 59 262 loop. 60 263 61 - ## Example 62 - 63 264 ```zig 64 265 const std = @import("std"); 65 266 const vaxis = @import("vaxis"); ··· 89 290 const alloc = gpa.allocator(); 90 291 91 292 // Initialize a tty 92 - var tty = try vaxis.Tty.init(); 293 + var buffer: [1024]u8 = undefined; 294 + var tty = try vaxis.Tty.init(&buffer); 93 295 defer tty.deinit(); 94 296 95 297 // Initialize Vaxis 96 298 var vx = try vaxis.init(alloc, .{}); 97 299 // deinit takes an optional allocator. If your program is exiting, you can 98 300 // choose to pass a null allocator to save some exit time. 99 - defer vx.deinit(alloc, tty.anyWriter()); 301 + defer vx.deinit(alloc, tty.writer()); 100 302 101 303 102 304 // The event loop requires an intrusive init. We create an instance with ··· 116 318 defer loop.stop(); 117 319 118 320 // Optionally enter the alternate screen 119 - try vx.enterAltScreen(tty.anyWriter()); 321 + try vx.enterAltScreen(tty.writer()); 120 322 121 323 // We'll adjust the color index every keypress for the border 122 324 var color_idx: u8 = 0; 123 325 124 326 // init our text input widget. The text input widget needs an allocator to 125 327 // store the contents of the input 126 - var text_input = TextInput.init(alloc, &vx.unicode); 328 + var text_input = TextInput.init(alloc); 127 329 defer text_input.deinit(); 128 330 129 331 // Sends queries to terminal to detect certain features. This should always 130 332 // be called after entering the alt screen, if you are using the alt screen 131 - try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); 333 + try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s); 132 334 133 335 while (true) { 134 336 // nextEvent blocks until an event is in the queue ··· 164 366 // more than one byte will incur an allocation on the first render 165 367 // after it is drawn. Thereafter, it will not allocate unless the 166 368 // screen is resized 167 - .winsize => |ws| try vx.resize(alloc, tty.anyWriter(), ws), 369 + .winsize => |ws| try vx.resize(alloc, tty.writer(), ws), 168 370 else => {}, 169 371 } 170 372 ··· 187 389 const child = win.child(.{ 188 390 .x_off = win.width / 2 - 20, 189 391 .y_off = win.height / 2 - 3, 190 - .width = .{ .limit = 40 }, 191 - .height = .{ .limit = 3 }, 392 + .width = 40 , 393 + .height = 3 , 192 394 .border = .{ 193 395 .where = .all, 194 396 .style = style, ··· 200 402 201 403 // Render the screen. Using a buffered writer will offer much better 202 404 // performance, but is not required 203 - try vx.render(tty.anyWriter()); 405 + try vx.render(tty.writer()); 204 406 } 205 407 } 206 408 ``` 409 + 410 + ## Contributing 411 + 412 + Contributions are welcome. Please submit a PR on Github, 413 + [tangled](https://tangled.sh/@rockorager.dev/libvaxis), or a patch on the 414 + [mailing list](mailto:~rockorager/libvaxis@lists.sr.ht) 415 + 416 + ## Community 417 + 418 + We use [Github Discussions](https://github.com/rockorager/libvaxis/discussions) 419 + as the primary location for community support, showcasing what you are working 420 + on, and discussing library features and usage. 421 + 422 + We also have an IRC channel on libera.chat: join us in #vaxis.
+502
USAGE.md
··· 1 + # Usage 2 + 3 + ## Custom Event Loops 4 + 5 + Vaxis provides an abstract enough API to allow the usage of a custom event loop. 6 + An event loop implementation is responsible for three primary tasks: 7 + 8 + 1. Read raw bytes from the TTY 9 + 2. Pass bytes to the Vaxis input event parser 10 + 3. Handle the returned events 11 + 12 + Everything after this can be left up to user code, or brought into an event loop 13 + to be a more abstract application layer. One important part of handling the 14 + events is to update the Vaxis struct with discovered terminal capabilities. This 15 + lets Vaxis know what features it can use. For example, the Kitty Keyboard 16 + protocol, in-band-resize reports, and Unicode width measurements are just a few 17 + examples. 18 + 19 + ### `libxev` 20 + 21 + Below is an example [`libxev`](https://github.com/mitchellh/libxev) event loop. 22 + Note that this code is not necessarily up-to-date with the latest `libxev` 23 + release and is shown here merely as a proof of concept. 24 + 25 + ```zig 26 + const std = @import("std"); 27 + const xev = @import("xev"); 28 + 29 + const Tty = @import("main.zig").Tty; 30 + const Winsize = @import("main.zig").Winsize; 31 + const Vaxis = @import("Vaxis.zig"); 32 + const Parser = @import("Parser.zig"); 33 + const Key = @import("Key.zig"); 34 + const Mouse = @import("Mouse.zig"); 35 + const Color = @import("Cell.zig").Color; 36 + 37 + const log = std.log.scoped(.vaxis_xev); 38 + 39 + pub const Event = union(enum) { 40 + key_press: Key, 41 + key_release: Key, 42 + mouse: Mouse, 43 + focus_in, 44 + focus_out, 45 + paste_start, // bracketed paste start 46 + paste_end, // bracketed paste end 47 + paste: []const u8, // osc 52 paste, caller must free 48 + color_report: Color.Report, // osc 4, 10, 11, 12 response 49 + color_scheme: Color.Scheme, 50 + winsize: Winsize, 51 + }; 52 + 53 + pub fn TtyWatcher(comptime Userdata: type) type { 54 + return struct { 55 + const Self = @This(); 56 + 57 + file: xev.File, 58 + tty: *Tty, 59 + 60 + read_buf: [4096]u8, 61 + read_buf_start: usize, 62 + read_cmp: xev.Completion, 63 + 64 + winsize_wakeup: xev.Async, 65 + winsize_cmp: xev.Completion, 66 + 67 + callback: *const fn ( 68 + ud: ?*Userdata, 69 + loop: *xev.Loop, 70 + watcher: *Self, 71 + event: Event, 72 + ) xev.CallbackAction, 73 + 74 + ud: ?*Userdata, 75 + vx: *Vaxis, 76 + parser: Parser, 77 + 78 + pub fn init( 79 + self: *Self, 80 + tty: *Tty, 81 + vaxis: *Vaxis, 82 + loop: *xev.Loop, 83 + userdata: ?*Userdata, 84 + callback: *const fn ( 85 + ud: ?*Userdata, 86 + loop: *xev.Loop, 87 + watcher: *Self, 88 + event: Event, 89 + ) xev.CallbackAction, 90 + ) !void { 91 + self.* = .{ 92 + .tty = tty, 93 + .file = xev.File.initFd(tty.fd), 94 + .read_buf = undefined, 95 + .read_buf_start = 0, 96 + .read_cmp = .{}, 97 + 98 + .winsize_wakeup = try xev.Async.init(), 99 + .winsize_cmp = .{}, 100 + 101 + .callback = callback, 102 + .ud = userdata, 103 + .vx = vaxis, 104 + .parser = .{ .grapheme_data = &vaxis.unicode.width_data.g_data }, 105 + }; 106 + 107 + self.file.read( 108 + loop, 109 + &self.read_cmp, 110 + .{ .slice = &self.read_buf }, 111 + Self, 112 + self, 113 + Self.ttyReadCallback, 114 + ); 115 + self.winsize_wakeup.wait( 116 + loop, 117 + &self.winsize_cmp, 118 + Self, 119 + self, 120 + winsizeCallback, 121 + ); 122 + const handler: Tty.SignalHandler = .{ 123 + .context = self, 124 + .callback = Self.signalCallback, 125 + }; 126 + try Tty.notifyWinsize(handler); 127 + } 128 + 129 + fn signalCallback(ptr: *anyopaque) void { 130 + const self: *Self = @ptrCast(@alignCast(ptr)); 131 + self.winsize_wakeup.notify() catch |err| { 132 + log.warn("couldn't wake up winsize callback: {}", .{err}); 133 + }; 134 + } 135 + 136 + fn ttyReadCallback( 137 + ud: ?*Self, 138 + loop: *xev.Loop, 139 + c: *xev.Completion, 140 + _: xev.File, 141 + buf: xev.ReadBuffer, 142 + r: xev.ReadError!usize, 143 + ) xev.CallbackAction { 144 + const n = r catch |err| { 145 + log.err("read error: {}", .{err}); 146 + return .disarm; 147 + }; 148 + const self = ud orelse unreachable; 149 + 150 + // reset read start state 151 + self.read_buf_start = 0; 152 + 153 + var seq_start: usize = 0; 154 + parse_loop: while (seq_start < n) { 155 + const result = self.parser.parse(buf.slice[seq_start..n], null) catch |err| { 156 + log.err("couldn't parse input: {}", .{err}); 157 + return .disarm; 158 + }; 159 + if (result.n == 0) { 160 + // copy the read to the beginning. We don't use memcpy because 161 + // this could be overlapping, and it's also rare 162 + const initial_start = seq_start; 163 + while (seq_start < n) : (seq_start += 1) { 164 + self.read_buf[seq_start - initial_start] = self.read_buf[seq_start]; 165 + } 166 + self.read_buf_start = seq_start - initial_start + 1; 167 + return .rearm; 168 + } 169 + seq_start += n; 170 + const event_inner = result.event orelse { 171 + log.debug("unknown event: {s}", .{self.read_buf[seq_start - n + 1 .. seq_start]}); 172 + continue :parse_loop; 173 + }; 174 + 175 + // Capture events we want to bubble up 176 + const event: ?Event = switch (event_inner) { 177 + .key_press => |key| .{ .key_press = key }, 178 + .key_release => |key| .{ .key_release = key }, 179 + .mouse => |mouse| .{ .mouse = mouse }, 180 + .focus_in => .focus_in, 181 + .focus_out => .focus_out, 182 + .paste_start => .paste_start, 183 + .paste_end => .paste_end, 184 + .paste => |paste| .{ .paste = paste }, 185 + .color_report => |report| .{ .color_report = report }, 186 + .color_scheme => |scheme| .{ .color_scheme = scheme }, 187 + .winsize => |ws| .{ .winsize = ws }, 188 + 189 + // capability events which we handle below 190 + .cap_kitty_keyboard, 191 + .cap_kitty_graphics, 192 + .cap_rgb, 193 + .cap_unicode, 194 + .cap_sgr_pixels, 195 + .cap_color_scheme_updates, 196 + .cap_da1, 197 + => null, // handled below 198 + }; 199 + 200 + if (event) |ev| { 201 + const action = self.callback(self.ud, loop, self, ev); 202 + switch (action) { 203 + .disarm => return .disarm, 204 + else => continue :parse_loop, 205 + } 206 + } 207 + 208 + switch (event_inner) { 209 + .key_press, 210 + .key_release, 211 + .mouse, 212 + .focus_in, 213 + .focus_out, 214 + .paste_start, 215 + .paste_end, 216 + .paste, 217 + .color_report, 218 + .color_scheme, 219 + .winsize, 220 + => unreachable, // handled above 221 + 222 + .cap_kitty_keyboard => { 223 + log.info("kitty keyboard capability detected", .{}); 224 + self.vx.caps.kitty_keyboard = true; 225 + }, 226 + .cap_kitty_graphics => { 227 + if (!self.vx.caps.kitty_graphics) { 228 + log.info("kitty graphics capability detected", .{}); 229 + self.vx.caps.kitty_graphics = true; 230 + } 231 + }, 232 + .cap_rgb => { 233 + log.info("rgb capability detected", .{}); 234 + self.vx.caps.rgb = true; 235 + }, 236 + .cap_unicode => { 237 + log.info("unicode capability detected", .{}); 238 + self.vx.caps.unicode = .unicode; 239 + self.vx.screen.width_method = .unicode; 240 + }, 241 + .cap_sgr_pixels => { 242 + log.info("pixel mouse capability detected", .{}); 243 + self.vx.caps.sgr_pixels = true; 244 + }, 245 + .cap_color_scheme_updates => { 246 + log.info("color_scheme_updates capability detected", .{}); 247 + self.vx.caps.color_scheme_updates = true; 248 + }, 249 + .cap_da1 => { 250 + self.vx.enableDetectedFeatures(self.tty.writer()) catch |err| { 251 + log.err("couldn't enable features: {}", .{err}); 252 + }; 253 + }, 254 + } 255 + } 256 + 257 + self.file.read( 258 + loop, 259 + c, 260 + .{ .slice = &self.read_buf }, 261 + Self, 262 + self, 263 + Self.ttyReadCallback, 264 + ); 265 + return .disarm; 266 + } 267 + 268 + fn winsizeCallback( 269 + ud: ?*Self, 270 + l: *xev.Loop, 271 + c: *xev.Completion, 272 + r: xev.Async.WaitError!void, 273 + ) xev.CallbackAction { 274 + _ = r catch |err| { 275 + log.err("async error: {}", .{err}); 276 + return .disarm; 277 + }; 278 + const self = ud orelse unreachable; // no userdata 279 + const winsize = Tty.getWinsize(self.tty.fd) catch |err| { 280 + log.err("couldn't get winsize: {}", .{err}); 281 + return .disarm; 282 + }; 283 + const ret = self.callback(self.ud, l, self, .{ .winsize = winsize }); 284 + if (ret == .disarm) return .disarm; 285 + 286 + self.winsize_wakeup.wait( 287 + l, 288 + c, 289 + Self, 290 + self, 291 + winsizeCallback, 292 + ); 293 + return .disarm; 294 + } 295 + }; 296 + } 297 + ``` 298 + 299 + ### zig-aio 300 + 301 + Below is an example [`zig-aio`](https://github.com/Cloudef/zig-aio) event loop. 302 + Note that this code is not necessarily up-to-date with the latest `zig-aio` 303 + release and is shown here merely as a proof of concept. 304 + 305 + ```zig 306 + const builtin = @import("builtin"); 307 + const std = @import("std"); 308 + const vaxis = @import("vaxis"); 309 + const handleEventGeneric = vaxis.loop.handleEventGeneric; 310 + const log = std.log.scoped(.vaxis_aio); 311 + 312 + const Yield = enum { no_state, took_event }; 313 + 314 + /// zig-aio based event loop 315 + /// <https://github.com/Cloudef/zig-aio> 316 + pub fn LoopWithModules(T: type, aio: type, coro: type) type { 317 + return struct { 318 + const Event = T; 319 + 320 + winsize_task: ?coro.Task.Generic2(winsizeTask) = null, 321 + reader_task: ?coro.Task.Generic2(ttyReaderTask) = null, 322 + queue: std.BoundedArray(T, 512) = .{}, 323 + source: aio.EventSource, 324 + fatal: bool = false, 325 + 326 + pub fn init() !@This() { 327 + return .{ .source = try aio.EventSource.init() }; 328 + } 329 + 330 + pub fn deinit(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { 331 + vx.deviceStatusReport(tty.writer()) catch {}; 332 + if (self.winsize_task) |task| task.cancel(); 333 + if (self.reader_task) |task| task.cancel(); 334 + self.source.deinit(); 335 + self.* = undefined; 336 + } 337 + 338 + fn winsizeInner(self: *@This(), tty: *vaxis.Tty) !void { 339 + const Context = struct { 340 + loop: *@TypeOf(self.*), 341 + tty: *vaxis.Tty, 342 + winsize: ?vaxis.Winsize = null, 343 + fn cb(ptr: *anyopaque) void { 344 + std.debug.assert(coro.current() == null); 345 + const ctx: *@This() = @ptrCast(@alignCast(ptr)); 346 + ctx.winsize = vaxis.Tty.getWinsize(ctx.tty.fd) catch return; 347 + ctx.loop.source.notify(); 348 + } 349 + }; 350 + 351 + // keep on stack 352 + var ctx: Context = .{ .loop = self, .tty = tty }; 353 + if (builtin.target.os.tag != .windows) { 354 + if (@hasField(Event, "winsize")) { 355 + const handler: vaxis.Tty.SignalHandler = .{ .context = &ctx, .callback = Context.cb }; 356 + try vaxis.Tty.notifyWinsize(handler); 357 + } 358 + } 359 + 360 + while (true) { 361 + try coro.io.single(aio.WaitEventSource{ .source = &self.source }); 362 + if (ctx.winsize) |winsize| { 363 + if (!@hasField(Event, "winsize")) unreachable; 364 + ctx.loop.postEvent(.{ .winsize = winsize }) catch {}; 365 + ctx.winsize = null; 366 + } 367 + } 368 + } 369 + 370 + fn winsizeTask(self: *@This(), tty: *vaxis.Tty) void { 371 + self.winsizeInner(tty) catch |err| { 372 + if (err != error.Canceled) log.err("winsize: {}", .{err}); 373 + self.fatal = true; 374 + }; 375 + } 376 + 377 + fn windowsReadEvent(tty: *vaxis.Tty) !vaxis.Event { 378 + var state: vaxis.Tty.EventState = .{}; 379 + while (true) { 380 + var bytes_read: usize = 0; 381 + var input_record: vaxis.Tty.INPUT_RECORD = undefined; 382 + try coro.io.single(aio.ReadTty{ 383 + .tty = .{ .handle = tty.stdin }, 384 + .buffer = std.mem.asBytes(&input_record), 385 + .out_read = &bytes_read, 386 + }); 387 + 388 + if (try tty.eventFromRecord(&input_record, &state)) |ev| { 389 + return ev; 390 + } 391 + } 392 + } 393 + 394 + fn ttyReaderWindows(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty) !void { 395 + var cache: vaxis.GraphemeCache = .{}; 396 + while (true) { 397 + const event = try windowsReadEvent(tty); 398 + try handleEventGeneric(self, vx, &cache, Event, event, null); 399 + } 400 + } 401 + 402 + fn ttyReaderPosix(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_allocator: ?std.mem.Allocator) !void { 403 + // initialize a grapheme cache 404 + var cache: vaxis.GraphemeCache = .{}; 405 + 406 + // get our initial winsize 407 + const winsize = try vaxis.Tty.getWinsize(tty.fd); 408 + if (@hasField(Event, "winsize")) { 409 + try self.postEvent(.{ .winsize = winsize }); 410 + } 411 + 412 + var parser: vaxis.Parser = .{ 413 + .grapheme_data = &vx.unicode.width_data.g_data, 414 + }; 415 + 416 + const file: std.fs.File = .{ .handle = tty.fd }; 417 + while (true) { 418 + var buf: [4096]u8 = undefined; 419 + var n: usize = undefined; 420 + var read_start: usize = 0; 421 + try coro.io.single(aio.ReadTty{ .tty = file, .buffer = buf[read_start..], .out_read = &n }); 422 + var seq_start: usize = 0; 423 + while (seq_start < n) { 424 + const result = try parser.parse(buf[seq_start..n], paste_allocator); 425 + if (result.n == 0) { 426 + // copy the read to the beginning. We don't use memcpy because 427 + // this could be overlapping, and it's also rare 428 + const initial_start = seq_start; 429 + while (seq_start < n) : (seq_start += 1) { 430 + buf[seq_start - initial_start] = buf[seq_start]; 431 + } 432 + read_start = seq_start - initial_start + 1; 433 + continue; 434 + } 435 + read_start = 0; 436 + seq_start += result.n; 437 + 438 + const event = result.event orelse continue; 439 + try handleEventGeneric(self, vx, &cache, Event, event, paste_allocator); 440 + } 441 + } 442 + } 443 + 444 + fn ttyReaderTask(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_allocator: ?std.mem.Allocator) void { 445 + return switch (builtin.target.os.tag) { 446 + .windows => self.ttyReaderWindows(vx, tty), 447 + else => self.ttyReaderPosix(vx, tty, paste_allocator), 448 + } catch |err| { 449 + if (err != error.Canceled) log.err("ttyReader: {}", .{err}); 450 + self.fatal = true; 451 + }; 452 + } 453 + 454 + /// Spawns tasks to handle winsize signal and tty 455 + pub fn spawn( 456 + self: *@This(), 457 + scheduler: *coro.Scheduler, 458 + vx: *vaxis.Vaxis, 459 + tty: *vaxis.Tty, 460 + paste_allocator: ?std.mem.Allocator, 461 + spawn_options: coro.Scheduler.SpawnOptions, 462 + ) coro.Scheduler.SpawnError!void { 463 + if (self.reader_task) |_| unreachable; // programming error 464 + // This is required even if app doesn't care about winsize 465 + // It is because it consumes the EventSource, so it can wakeup the scheduler 466 + // Without that custom `postEvent`'s wouldn't wake up the scheduler and UI wouldn't update 467 + self.winsize_task = try scheduler.spawn(winsizeTask, .{ self, tty }, spawn_options); 468 + self.reader_task = try scheduler.spawn(ttyReaderTask, .{ self, vx, tty, paste_allocator }, spawn_options); 469 + } 470 + 471 + pub const PopEventError = error{TtyCommunicationSevered}; 472 + 473 + /// Call this in a while loop in the main event handler until it returns null 474 + pub fn popEvent(self: *@This()) PopEventError!?T { 475 + if (self.fatal) return error.TtyCommunicationSevered; 476 + defer self.winsize_task.?.wakeupIf(Yield.took_event); 477 + defer self.reader_task.?.wakeupIf(Yield.took_event); 478 + return self.queue.popOrNull(); 479 + } 480 + 481 + pub const PostEventError = error{Overflow}; 482 + 483 + pub fn postEvent(self: *@This(), event: T) !void { 484 + if (coro.current()) |_| { 485 + while (true) { 486 + self.queue.insert(0, event) catch { 487 + // wait for the app to take event 488 + try coro.yield(Yield.took_event); 489 + continue; 490 + }; 491 + break; 492 + } 493 + } else { 494 + // queue can be full, app could handle this error by spinning the scheduler 495 + try self.queue.insert(0, event); 496 + } 497 + // wakes up the scheduler, so custom events update UI 498 + self.source.notify(); 499 + } 500 + }; 501 + } 502 + ```
+62
bench/bench.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + 4 + fn parseIterations(allocator: std.mem.Allocator) !usize { 5 + var args = try std.process.argsWithAllocator(allocator); 6 + defer args.deinit(); 7 + _ = args.next(); 8 + if (args.next()) |val| { 9 + return std.fmt.parseUnsigned(usize, val, 10); 10 + } 11 + return 200; 12 + } 13 + 14 + fn printResults(writer: anytype, label: []const u8, iterations: usize, elapsed_ns: u64, total_bytes: usize) !void { 15 + const ns_per_frame = elapsed_ns / @as(u64, @intCast(iterations)); 16 + const bytes_per_frame = total_bytes / iterations; 17 + try writer.print( 18 + "{s}: frames={d} total_ns={d} ns/frame={d} bytes={d} bytes/frame={d}\n", 19 + .{ label, iterations, elapsed_ns, ns_per_frame, total_bytes, bytes_per_frame }, 20 + ); 21 + } 22 + 23 + pub fn main() !void { 24 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 25 + defer _ = gpa.deinit(); 26 + const allocator = gpa.allocator(); 27 + 28 + const iterations = try parseIterations(allocator); 29 + 30 + var vx = try vaxis.init(allocator, .{}); 31 + var init_writer = std.io.Writer.Allocating.init(allocator); 32 + defer init_writer.deinit(); 33 + defer vx.deinit(allocator, &init_writer.writer); 34 + 35 + const winsize = vaxis.Winsize{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; 36 + try vx.resize(allocator, &init_writer.writer, winsize); 37 + 38 + const stdout = std.fs.File.stdout().deprecatedWriter(); 39 + 40 + var idle_writer = std.io.Writer.Allocating.init(allocator); 41 + defer idle_writer.deinit(); 42 + var timer = try std.time.Timer.start(); 43 + var i: usize = 0; 44 + while (i < iterations) : (i += 1) { 45 + try vx.render(&idle_writer.writer); 46 + } 47 + const idle_ns = timer.read(); 48 + const idle_bytes: usize = idle_writer.writer.end; 49 + try printResults(stdout, "idle", iterations, idle_ns, idle_bytes); 50 + 51 + var dirty_writer = std.io.Writer.Allocating.init(allocator); 52 + defer dirty_writer.deinit(); 53 + timer.reset(); 54 + i = 0; 55 + while (i < iterations) : (i += 1) { 56 + vx.queueRefresh(); 57 + try vx.render(&dirty_writer.writer); 58 + } 59 + const dirty_ns = timer.read(); 60 + const dirty_bytes: usize = dirty_writer.writer.end; 61 + try printResults(stdout, "dirty", iterations, dirty_ns, dirty_bytes); 62 + }
+61 -73
build.zig
··· 1 1 const std = @import("std"); 2 2 3 3 pub fn build(b: *std.Build) void { 4 - const include_libxev = b.option(bool, "libxev", "Enable support for libxev library (default: true)") orelse true; 5 - const include_images = b.option(bool, "images", "Enable support for images (default: true)") orelse true; 6 - const include_text_input = b.option(bool, "text_input", "Enable support for the TextInput widget (default: true)") orelse true; 7 - const include_aio = b.option(bool, "aio", "Enable support for zig-aio library (default: false)") orelse false; 8 - 9 - const options = b.addOptions(); 10 - options.addOption(bool, "libxev", include_libxev); 11 - options.addOption(bool, "images", include_images); 12 - options.addOption(bool, "text_input", include_text_input); 13 - options.addOption(bool, "aio", include_aio); 14 - 15 - const options_mod = options.createModule(); 16 - 17 4 const target = b.standardTargetOptions(.{}); 18 5 const optimize = b.standardOptimizeOption(.{}); 19 6 const root_source_file = b.path("src/main.zig"); 20 7 21 8 // Dependencies 22 - const zg_dep = b.dependency("zg", .{ 9 + const zigimg_dep = b.dependency("zigimg", .{ 23 10 .optimize = optimize, 24 11 .target = target, 25 12 }); 26 - const zigimg_dep = if (include_images) b.lazyDependency("zigimg", .{ 27 - .optimize = optimize, 13 + const uucode_dep = b.dependency("uucode", .{ 28 14 .target = target, 29 - }) else null; 30 - const gap_buffer_dep = if (include_text_input) b.lazyDependency("gap_buffer", .{ 31 15 .optimize = optimize, 32 - .target = target, 33 - }) else null; 34 - const xev_dep = if (include_libxev) b.lazyDependency("libxev", .{ 35 - .optimize = optimize, 36 - .target = target, 37 - }) else null; 38 - const aio_dep = if (include_aio) b.lazyDependency("aio", .{ 39 - .optimize = optimize, 40 - .target = target, 41 - }) else null; 16 + .fields = @as([]const []const u8, &.{ 17 + "east_asian_width", 18 + "grapheme_break", 19 + "general_category", 20 + "is_emoji_presentation", 21 + }), 22 + }); 42 23 43 24 // Module 44 25 const vaxis_mod = b.addModule("vaxis", .{ ··· 46 27 .target = target, 47 28 .optimize = optimize, 48 29 }); 49 - vaxis_mod.addImport("code_point", zg_dep.module("code_point")); 50 - vaxis_mod.addImport("grapheme", zg_dep.module("grapheme")); 51 - vaxis_mod.addImport("DisplayWidth", zg_dep.module("DisplayWidth")); 52 - if (zigimg_dep) |dep| vaxis_mod.addImport("zigimg", dep.module("zigimg")); 53 - if (gap_buffer_dep) |dep| vaxis_mod.addImport("gap_buffer", dep.module("gap_buffer")); 54 - if (xev_dep) |dep| vaxis_mod.addImport("xev", dep.module("xev")); 55 - if (aio_dep) |dep| vaxis_mod.addImport("aio", dep.module("aio")); 56 - if (aio_dep) |dep| vaxis_mod.addImport("coro", dep.module("coro")); 57 - vaxis_mod.addImport("build_options", options_mod); 30 + vaxis_mod.addImport("zigimg", zigimg_dep.module("zigimg")); 31 + vaxis_mod.addImport("uucode", uucode_dep.module("uucode")); 58 32 59 33 // Examples 60 34 const Example = enum { 61 35 cli, 36 + counter, 37 + fuzzy, 62 38 image, 63 39 main, 64 - nvim, 40 + scroll, 41 + split_view, 65 42 table, 66 43 text_input, 44 + text_view, 45 + list_view, 67 46 vaxis, 47 + view, 68 48 vt, 69 - xev, 70 - aio, 71 49 }; 72 50 const example_option = b.option(Example, "example", "Example to run (default: text_input)") orelse .text_input; 73 51 const example_step = b.step("example", "Run example"); 74 52 const example = b.addExecutable(.{ 75 53 .name = "example", 76 - // future versions should use b.path, see zig PR #19597 77 - .root_source_file = b.path( 78 - b.fmt("examples/{s}.zig", .{@tagName(example_option)}), 79 - ), 80 - .target = target, 81 - .optimize = optimize, 54 + .root_module = b.createModule(.{ 55 + .root_source_file = b.path( 56 + b.fmt("examples/{s}.zig", .{@tagName(example_option)}), 57 + ), 58 + .target = target, 59 + .optimize = optimize, 60 + .imports = &.{ 61 + .{ .name = "vaxis", .module = vaxis_mod }, 62 + }, 63 + }), 82 64 }); 83 - example.root_module.addImport("vaxis", vaxis_mod); 84 - if (xev_dep) |dep| example.root_module.addImport("xev", dep.module("xev")); 85 - if (aio_dep) |dep| example.root_module.addImport("aio", dep.module("aio")); 86 - if (aio_dep) |dep| example.root_module.addImport("coro", dep.module("coro")); 87 65 88 66 const example_run = b.addRunArtifact(example); 89 67 example_step.dependOn(&example_run.step); 90 68 69 + // Benchmarks 70 + const bench_step = b.step("bench", "Run benchmarks"); 71 + const bench = b.addExecutable(.{ 72 + .name = "bench", 73 + .root_module = b.createModule(.{ 74 + .root_source_file = b.path("bench/bench.zig"), 75 + .target = target, 76 + .optimize = optimize, 77 + .imports = &.{ 78 + .{ .name = "vaxis", .module = vaxis_mod }, 79 + }, 80 + }), 81 + }); 82 + const bench_run = b.addRunArtifact(bench); 83 + if (b.args) |args| { 84 + bench_run.addArgs(args); 85 + } 86 + bench_step.dependOn(&bench_run.step); 87 + 91 88 // Tests 92 89 const tests_step = b.step("test", "Run tests"); 93 90 94 91 const tests = b.addTest(.{ 95 - .root_source_file = b.path("src/main.zig"), 96 - .target = target, 97 - .optimize = optimize, 92 + .root_module = b.createModule(.{ 93 + .root_source_file = b.path("src/main.zig"), 94 + .target = target, 95 + .optimize = optimize, 96 + .imports = &.{ 97 + .{ .name = "zigimg", .module = zigimg_dep.module("zigimg") }, 98 + .{ .name = "uucode", .module = uucode_dep.module("uucode") }, 99 + }, 100 + }), 98 101 }); 99 - tests.root_module.addImport("code_point", zg_dep.module("code_point")); 100 - tests.root_module.addImport("grapheme", zg_dep.module("grapheme")); 101 - tests.root_module.addImport("DisplayWidth", zg_dep.module("DisplayWidth")); 102 - if (zigimg_dep) |dep| tests.root_module.addImport("zigimg", dep.module("zigimg")); 103 - if (gap_buffer_dep) |dep| tests.root_module.addImport("gap_buffer", dep.module("gap_buffer")); 104 - tests.root_module.addImport("build_options", options_mod); 105 102 106 103 const tests_run = b.addRunArtifact(tests); 107 104 b.installArtifact(tests); 108 105 tests_step.dependOn(&tests_run.step); 109 106 110 - // Lints 111 - const lints_step = b.step("lint", "Run lints"); 112 - 113 - const lints = b.addFmt(.{ 114 - .paths = &.{ "src", "build.zig" }, 115 - .check = true, 116 - }); 117 - 118 - lints_step.dependOn(&lints.step); 119 - b.default_step.dependOn(lints_step); 120 - 121 107 // Docs 122 108 const docs_step = b.step("docs", "Build the vaxis library docs"); 123 109 const docs_obj = b.addObject(.{ 124 110 .name = "vaxis", 125 - .root_source_file = root_source_file, 126 - .target = target, 127 - .optimize = optimize, 111 + .root_module = b.createModule(.{ 112 + .root_source_file = root_source_file, 113 + .target = target, 114 + .optimize = optimize, 115 + }), 128 116 }); 129 117 const docs = docs_obj.getEmittedDocs(); 130 118 docs_step.dependOn(&b.addInstallDirectory(.{
+9 -23
build.zig.zon
··· 1 1 .{ 2 - .name = "vaxis", 3 - .version = "0.1.0", 4 - .minimum_zig_version = "0.13.0", 2 + .name = .vaxis, 3 + .fingerprint = 0x14fbbb94fc556305, 4 + .version = "0.5.1", 5 + .minimum_zig_version = "0.15.1", 5 6 .dependencies = .{ 6 7 .zigimg = .{ 7 - .url = "git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e", 8 - .hash = "1220dd654ef941fc76fd96f9ec6adadf83f69b9887a0d3f4ee5ac0a1a3e11be35cf5", 9 - .lazy = true, 10 - }, 11 - .gap_buffer = .{ 12 - .url = "git+https://github.com/ryleelyman/GapBuffer.zig#9039708e09fc3eb5f698ab5694a436afe503c6a6", 13 - .hash = "1220f525973ae804ec0284556bfc47db7b6a8dc86464a853956ef859d6e0fb5fa93b", 14 - }, 15 - .zg = .{ 16 - .url = "git+https://codeberg.org/dude_the_builder/zg?ref=master#689ab6b83d08c02724b99d199d650ff731250998", 17 - .hash = "12200d1ce5f9733a9437415d85665ad5fbc85a4d27689fd337fecad8014acffe3aa5", 18 - }, 19 - .libxev = .{ 20 - .url = "git+https://github.com/mitchellh/libxev#f6a672a78436d8efee1aa847a43a900ad773618b", 21 - .hash = "12207b7a5b538ffb7fb18f954ae17d2f8490b6e3778a9e30564ad82c58ee8da52361", 22 - .lazy = true, 8 + .url = "git+https://github.com/zigimg/zigimg#eab2522c023b9259db8b13f2f90d609b7437e5f6", 9 + .hash = "zigimg-0.1.0-8_eo2vUZFgAAtN1c6dAO5DdqL0d4cEWHtn6iR5ucZJti", 23 10 }, 24 - .aio = .{ 25 - .url = "git+https://github.com/Cloudef/zig-aio#b5a407344379508466c5dcbe4c74438a6166e2ca", 26 - .hash = "1220a55aedabdd10578d0c514719ea39ae1bc6d7ed990f508dc100db7f0ccf391437", 27 - .lazy = true, 11 + .uucode = .{ 12 + .url = "git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732", 13 + .hash = "uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM", 28 14 }, 29 15 }, 30 16 .paths = .{
-172
examples/aio.zig
··· 1 - const builtin = @import("builtin"); 2 - const std = @import("std"); 3 - const vaxis = @import("vaxis"); 4 - const aio = @import("aio"); 5 - const coro = @import("coro"); 6 - 7 - pub const panic = vaxis.panic_handler; 8 - 9 - const Event = union(enum) { 10 - key_press: vaxis.Key, 11 - winsize: vaxis.Winsize, 12 - }; 13 - 14 - const Loop = vaxis.aio.Loop(Event); 15 - 16 - const Video = enum { no_state, ready, end }; 17 - const Audio = enum { no_state, ready, end }; 18 - 19 - fn downloadTask(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { 20 - var client: std.http.Client = .{ .allocator = allocator }; 21 - defer client.deinit(); 22 - var body = std.ArrayList(u8).init(allocator); 23 - _ = try client.fetch(.{ 24 - .location = .{ .url = url }, 25 - .response_storage = .{ .dynamic = &body }, 26 - .max_append_size = 1.6e+7, 27 - }); 28 - return try body.toOwnedSlice(); 29 - } 30 - 31 - fn audioTask(allocator: std.mem.Allocator) !void { 32 - // signals end of audio in case there's a error 33 - errdefer coro.yield(Audio.end) catch {}; 34 - 35 - // var child = std.process.Child.init(&.{ "aplay", "-Dplug:default", "-q", "-f", "S16_LE", "-r", "8000" }, allocator); 36 - var child = std.process.Child.init(&.{ "mpv", "--audio-samplerate=16000", "--audio-channels=mono", "--audio-format=s16", "-" }, allocator); 37 - child.stdin_behavior = .Pipe; 38 - child.stdout_behavior = .Ignore; 39 - child.stderr_behavior = .Ignore; 40 - try child.spawn(); 41 - defer _ = child.kill() catch {}; 42 - 43 - const sound = blk: { 44 - var tpool = try coro.ThreadPool.init(allocator, .{}); 45 - defer tpool.deinit(); 46 - break :blk try tpool.yieldForCompletition(downloadTask, .{ allocator, "https://keroserene.net/lol/roll.s16" }, .{}); 47 - }; 48 - defer allocator.free(sound); 49 - 50 - try coro.yield(Audio.ready); 51 - 52 - var audio_off: usize = 0; 53 - while (audio_off < sound.len) { 54 - var written: usize = 0; 55 - try coro.io.single(aio.Write{ .file = child.stdin.?, .buffer = sound[audio_off..], .out_written = &written }); 56 - audio_off += written; 57 - } 58 - 59 - // the audio is already fed to the player and the defer 60 - // would kill the child, so stay here chilling 61 - coro.yield(Audio.end) catch {}; 62 - } 63 - 64 - fn videoTask(writer: std.io.AnyWriter) !void { 65 - // signals end of video 66 - defer coro.yield(Video.end) catch {}; 67 - 68 - var socket: std.posix.socket_t = undefined; 69 - try coro.io.single(aio.Socket{ 70 - .domain = std.posix.AF.INET, 71 - .flags = std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC, 72 - .protocol = std.posix.IPPROTO.TCP, 73 - .out_socket = &socket, 74 - }); 75 - defer std.posix.close(socket); 76 - 77 - const address = std.net.Address.initIp4(.{ 44, 224, 41, 160 }, 1987); 78 - try coro.io.single(aio.Connect{ 79 - .socket = socket, 80 - .addr = &address.any, 81 - .addrlen = address.getOsSockLen(), 82 - }); 83 - 84 - try coro.yield(Video.ready); 85 - 86 - var buf: [1024]u8 = undefined; 87 - while (true) { 88 - var read: usize = 0; 89 - try coro.io.single(aio.Recv{ .socket = socket, .buffer = &buf, .out_read = &read }); 90 - if (read == 0) break; 91 - _ = try writer.write(buf[0..read]); 92 - } 93 - } 94 - 95 - fn loadingTask(vx: *vaxis.Vaxis, writer: std.io.AnyWriter) !void { 96 - var color_idx: u8 = 30; 97 - var dir: enum { up, down } = .up; 98 - 99 - while (true) { 100 - try coro.io.single(aio.Timeout{ .ns = 8 * std.time.ns_per_ms }); 101 - 102 - const style: vaxis.Style = .{ .fg = .{ .rgb = [_]u8{ color_idx, color_idx, color_idx } } }; 103 - const segment: vaxis.Segment = .{ .text = vaxis.logo, .style = style }; 104 - 105 - const win = vx.window(); 106 - win.clear(); 107 - 108 - var loc = vaxis.widgets.alignment.center(win, 28, 4); 109 - _ = try loc.printSegment(segment, .{ .wrap = .grapheme }); 110 - 111 - switch (dir) { 112 - .up => { 113 - color_idx += 1; 114 - if (color_idx == 255) dir = .down; 115 - }, 116 - .down => { 117 - color_idx -= 1; 118 - if (color_idx == 30) dir = .up; 119 - }, 120 - } 121 - 122 - try vx.render(writer); 123 - } 124 - } 125 - 126 - pub fn main() !void { 127 - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 128 - defer _ = gpa.deinit(); 129 - const allocator = gpa.allocator(); 130 - 131 - var tty = try vaxis.Tty.init(); 132 - defer tty.deinit(); 133 - 134 - var vx = try vaxis.init(allocator, .{}); 135 - defer vx.deinit(allocator, tty.anyWriter()); 136 - 137 - var scheduler = try coro.Scheduler.init(allocator, .{}); 138 - defer scheduler.deinit(); 139 - 140 - var loop = try Loop.init(); 141 - try loop.spawn(&scheduler, &vx, &tty, null, .{}); 142 - defer loop.deinit(&vx, &tty); 143 - 144 - try vx.enterAltScreen(tty.anyWriter()); 145 - try vx.queryTerminalSend(tty.anyWriter()); 146 - 147 - var buffered_tty_writer = tty.bufferedWriter(); 148 - const loading = try scheduler.spawn(loadingTask, .{ &vx, buffered_tty_writer.writer().any() }, .{}); 149 - const audio = try scheduler.spawn(audioTask, .{allocator}, .{}); 150 - const video = try scheduler.spawn(videoTask, .{buffered_tty_writer.writer().any()}, .{}); 151 - 152 - main: while (try scheduler.tick(.blocking) > 0) { 153 - while (try loop.popEvent()) |event| switch (event) { 154 - .key_press => |key| { 155 - if (key.matches('c', .{ .ctrl = true })) { 156 - break :main; 157 - } 158 - }, 159 - .winsize => |ws| try vx.resize(allocator, buffered_tty_writer.writer().any(), ws), 160 - }; 161 - 162 - if (audio.state(Video) == .ready and video.state(Audio) == .ready) { 163 - loading.cancel(); 164 - audio.wakeup(); 165 - video.wakeup(); 166 - } else if (audio.state(Audio) == .end and video.state(Video) == .end) { 167 - break :main; 168 - } 169 - 170 - try buffered_tty_writer.flush(); 171 - } 172 - }
+9 -8
examples/cli.zig
··· 14 14 } 15 15 const alloc = gpa.allocator(); 16 16 17 - var tty = try vaxis.Tty.init(); 17 + var buffer: [1024]u8 = undefined; 18 + var tty = try vaxis.Tty.init(&buffer); 18 19 defer tty.deinit(); 19 20 20 21 var vx = try vaxis.init(alloc, .{}); 21 - defer vx.deinit(alloc, tty.anyWriter()); 22 + defer vx.deinit(alloc, tty.writer()); 22 23 23 24 var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx }; 24 25 try loop.init(); ··· 26 27 try loop.start(); 27 28 defer loop.stop(); 28 29 29 - try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); 30 + try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s); 30 31 31 - var text_input = TextInput.init(alloc, &vx.unicode); 32 + var text_input = TextInput.init(alloc); 32 33 defer text_input.deinit(); 33 34 34 35 var selected_option: ?usize = null; ··· 62 63 } else { 63 64 selected_option.? = selected_option.? -| 1; 64 65 } 65 - } else if (key.matches(vaxis.Key.enter, .{})) { 66 + } else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) { 66 67 if (selected_option) |i| { 67 68 log.err("enter", .{}); 68 69 try text_input.insertSliceAtCursor(options[i]); ··· 74 75 } 75 76 }, 76 77 .winsize => |ws| { 77 - try vx.resize(alloc, tty.anyWriter(), ws); 78 + try vx.resize(alloc, tty.writer(), ws); 78 79 }, 79 80 else => {}, 80 81 } ··· 92 93 .text = opt, 93 94 .style = if (j == i) .{ .reverse = true } else .{}, 94 95 }}; 95 - _ = try win.print(&seg, .{ .row_offset = j + 1 }); 96 + _ = win.print(&seg, .{ .row_offset = @intCast(j + 1) }); 96 97 } 97 98 } 98 - try vx.render(tty.anyWriter()); 99 + try vx.render(tty.writer()); 99 100 } 100 101 } 101 102
+139
examples/counter.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const vxfw = vaxis.vxfw; 4 + 5 + /// Our main application state 6 + const Model = struct { 7 + /// State of the counter 8 + count: u32 = 0, 9 + /// The button. This widget is stateful and must live between frames 10 + button: vxfw.Button, 11 + 12 + /// Helper function to return a vxfw.Widget struct 13 + pub fn widget(self: *Model) vxfw.Widget { 14 + return .{ 15 + .userdata = self, 16 + .eventHandler = Model.typeErasedEventHandler, 17 + .drawFn = Model.typeErasedDrawFn, 18 + }; 19 + } 20 + 21 + /// This function will be called from the vxfw runtime. 22 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 23 + const self: *Model = @ptrCast(@alignCast(ptr)); 24 + switch (event) { 25 + // The root widget is always sent an init event as the first event. Users of the 26 + // library can also send this event to other widgets they create if they need to do 27 + // some initialization. 28 + .init => return ctx.requestFocus(self.button.widget()), 29 + .key_press => |key| { 30 + if (key.matches('c', .{ .ctrl = true })) { 31 + ctx.quit = true; 32 + return; 33 + } 34 + }, 35 + // We can request a specific widget gets focus. In this case, we always want to focus 36 + // our button. Having focus means that key events will be sent up the widget tree to 37 + // the focused widget, and then bubble back down the tree to the root. Users can tell 38 + // the runtime the event was handled and the capture or bubble phase will stop 39 + .focus_in => return ctx.requestFocus(self.button.widget()), 40 + else => {}, 41 + } 42 + } 43 + 44 + /// This function is called from the vxfw runtime. It will be called on a regular interval, and 45 + /// only when any event handler has marked the redraw flag in EventContext as true. By 46 + /// explicitly requiring setting the redraw flag, vxfw can prevent excessive redraws for events 47 + /// which don't change state (ie mouse motion, unhandled key events, etc) 48 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface { 49 + const self: *Model = @ptrCast(@alignCast(ptr)); 50 + // The DrawContext is inspired from Flutter. Each widget will receive a minimum and maximum 51 + // constraint. The minimum constraint will always be set, even if it is set to 0x0. The 52 + // maximum constraint can have null width and/or height - meaning there is no constraint in 53 + // that direction and the widget should take up as much space as it needs. By calling size() 54 + // on the max, we assert that it has some constrained size. This is *always* the case for 55 + // the root widget - the maximum size will always be the size of the terminal screen. 56 + const max_size = ctx.max.size(); 57 + 58 + // The DrawContext also contains an arena allocator that can be used for each frame. The 59 + // lifetime of this allocation is until the next time we draw a frame. This is useful for 60 + // temporary allocations such as the one below: we have an integer we want to print as text. 61 + // We can safely allocate this with the ctx arena since we only need it for this frame. 62 + if (self.count > 0) { 63 + self.button.label = try std.fmt.allocPrint(ctx.arena, "Clicks: {d}", .{self.count}); 64 + } else { 65 + self.button.label = "Click me!"; 66 + } 67 + 68 + // Each widget returns a Surface from it's draw function. A Surface contains the rectangular 69 + // area of the widget, as well as some information about the surface or widget: can we focus 70 + // it? does it handle the mouse? 71 + // 72 + // It DOES NOT contain the location it should be within it's parent. Only the parent can set 73 + // this via a SubSurface. Here, we will return a Surface for the root widget (Model), which 74 + // has two SubSurfaces: one for the text and one for the button. A SubSurface is a Surface 75 + // with an offset and a z-index - the offset can be negative. This lets a parent draw a 76 + // child and place it within itself 77 + const button_child: vxfw.SubSurface = .{ 78 + .origin = .{ .row = 0, .col = 0 }, 79 + .surface = try self.button.draw(ctx.withConstraints( 80 + ctx.min, 81 + // Here we explicitly set a new maximum size constraint for the Button. A Button will 82 + // expand to fill it's area and must have some hard limit in the maximum constraint 83 + .{ .width = 16, .height = 3 }, 84 + )), 85 + }; 86 + 87 + // We also can use our arena to allocate the slice for our SubSurfaces. This slice only 88 + // needs to live until the next frame, making this safe. 89 + const children = try ctx.arena.alloc(vxfw.SubSurface, 1); 90 + children[0] = button_child; 91 + 92 + return .{ 93 + // A Surface must have a size. Our root widget is the size of the screen 94 + .size = max_size, 95 + .widget = self.widget(), 96 + // We didn't actually need to draw anything for the root. In this case, we can set 97 + // buffer to a zero length slice. If this slice is *not zero length*, the runtime will 98 + // assert that it's length is equal to the size.width * size.height. 99 + .buffer = &.{}, 100 + .children = children, 101 + }; 102 + } 103 + 104 + /// The onClick callback for our button. This is also called if we press enter while the button 105 + /// has focus 106 + fn onClick(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext) anyerror!void { 107 + const ptr = maybe_ptr orelse return; 108 + const self: *Model = @ptrCast(@alignCast(ptr)); 109 + self.count +|= 1; 110 + return ctx.consumeAndRedraw(); 111 + } 112 + }; 113 + 114 + pub fn main() !void { 115 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 116 + defer _ = gpa.deinit(); 117 + 118 + const allocator = gpa.allocator(); 119 + 120 + var app = try vxfw.App.init(allocator); 121 + defer app.deinit(); 122 + 123 + // We heap allocate our model because we will require a stable pointer to it in our Button 124 + // widget 125 + const model = try allocator.create(Model); 126 + defer allocator.destroy(model); 127 + 128 + // Set the initial state of our button 129 + model.* = .{ 130 + .count = 0, 131 + .button = .{ 132 + .label = "Click me!", 133 + .onClick = Model.onClick, 134 + .userdata = model, 135 + }, 136 + }; 137 + 138 + try app.run(model.widget(), .{}); 139 + }
+243
examples/fuzzy.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const vxfw = vaxis.vxfw; 4 + 5 + const Model = struct { 6 + list: std.ArrayList(vxfw.Text), 7 + /// Memory owned by .arena 8 + filtered: std.ArrayList(vxfw.RichText), 9 + list_view: vxfw.ListView, 10 + text_field: vxfw.TextField, 11 + 12 + /// Used for filtered RichText Spans and result 13 + arena: std.heap.ArenaAllocator, 14 + filtered: std.ArrayList(vxfw.RichText), 15 + result: []const u8, 16 + 17 + pub fn init(gpa: std.mem.Allocator) !*Model { 18 + const model = try gpa.create(Model); 19 + errdefer gpa.destroy(model); 20 + 21 + model.* = .{ 22 + .list = .empty, 23 + .filtered = .empty, 24 + .list_view = .{ 25 + .children = .{ 26 + .builder = .{ 27 + .userdata = model, 28 + .buildFn = Model.widgetBuilder, 29 + }, 30 + }, 31 + }, 32 + .text_field = .{ 33 + .buf = vxfw.TextField.Buffer.init(gpa), 34 + .userdata = model, 35 + .onChange = Model.onChange, 36 + .onSubmit = Model.onSubmit, 37 + }, 38 + .result = "", 39 + .arena = std.heap.ArenaAllocator.init(gpa), 40 + }; 41 + 42 + return model; 43 + } 44 + 45 + pub fn deinit(self: *Model, gpa: std.mem.Allocator) void { 46 + self.arena.deinit(); 47 + self.text_field.deinit(); 48 + self.list.deinit(gpa); 49 + gpa.destroy(self); 50 + } 51 + 52 + pub fn widget(self: *Model) vxfw.Widget { 53 + return .{ 54 + .userdata = self, 55 + .eventHandler = Model.typeErasedEventHandler, 56 + .drawFn = Model.typeErasedDrawFn, 57 + }; 58 + } 59 + 60 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 61 + const self: *Model = @ptrCast(@alignCast(ptr)); 62 + switch (event) { 63 + .init => { 64 + // Initialize the filtered list 65 + const arena = self.arena.allocator(); 66 + for (self.list.items) |line| { 67 + var spans = std.ArrayList(vxfw.RichText.TextSpan).empty; 68 + const span: vxfw.RichText.TextSpan = .{ .text = line.text }; 69 + try spans.append(arena, span); 70 + try self.filtered.append(arena, .{ .text = spans.items }); 71 + } 72 + 73 + return ctx.requestFocus(self.text_field.widget()); 74 + }, 75 + .key_press => |key| { 76 + if (key.matches('c', .{ .ctrl = true })) { 77 + ctx.quit = true; 78 + return; 79 + } 80 + return self.list_view.handleEvent(ctx, event); 81 + }, 82 + .focus_in => { 83 + return ctx.requestFocus(self.text_field.widget()); 84 + }, 85 + else => {}, 86 + } 87 + } 88 + 89 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface { 90 + const self: *Model = @ptrCast(@alignCast(ptr)); 91 + const max = ctx.max.size(); 92 + 93 + const list_view: vxfw.SubSurface = .{ 94 + .origin = .{ .row = 2, .col = 0 }, 95 + .surface = try self.list_view.draw(ctx.withConstraints( 96 + ctx.min, 97 + .{ .width = max.width, .height = max.height - 3 }, 98 + )), 99 + }; 100 + 101 + const text_field: vxfw.SubSurface = .{ 102 + .origin = .{ .row = 0, .col = 2 }, 103 + .surface = try self.text_field.draw(ctx.withConstraints( 104 + ctx.min, 105 + .{ .width = max.width, .height = 1 }, 106 + )), 107 + }; 108 + 109 + const prompt: vxfw.Text = .{ .text = "๏”", .style = .{ .fg = .{ .index = 4 } } }; 110 + 111 + const prompt_surface: vxfw.SubSurface = .{ 112 + .origin = .{ .row = 0, .col = 0 }, 113 + .surface = try prompt.draw(ctx.withConstraints(ctx.min, .{ .width = 2, .height = 1 })), 114 + }; 115 + 116 + const children = try ctx.arena.alloc(vxfw.SubSurface, 3); 117 + children[0] = list_view; 118 + children[1] = text_field; 119 + children[2] = prompt_surface; 120 + 121 + return .{ 122 + .size = max, 123 + .widget = self.widget(), 124 + .buffer = &.{}, 125 + .children = children, 126 + }; 127 + } 128 + 129 + fn widgetBuilder(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget { 130 + const self: *const Model = @ptrCast(@alignCast(ptr)); 131 + if (idx >= self.filtered.items.len) return null; 132 + 133 + return self.filtered.items[idx].widget(); 134 + } 135 + 136 + fn onChange(maybe_ptr: ?*anyopaque, _: *vxfw.EventContext, str: []const u8) anyerror!void { 137 + const ptr = maybe_ptr orelse return; 138 + const self: *Model = @ptrCast(@alignCast(ptr)); 139 + const arena = self.arena.allocator(); 140 + self.filtered.clearAndFree(arena); 141 + _ = self.arena.reset(.free_all); 142 + 143 + const hasUpper = for (str) |b| { 144 + if (std.ascii.isUpper(b)) break true; 145 + } else false; 146 + 147 + // Loop each line 148 + // If our input is only lowercase, we convert the line to lowercase 149 + // Iterate the input graphemes, looking for them _in order_ in the line 150 + outer: for (self.list.items) |item| { 151 + const tgt = if (hasUpper) 152 + item.text 153 + else 154 + try toLower(arena, item.text); 155 + 156 + var spans = std.ArrayList(vxfw.RichText.TextSpan).empty; 157 + var i: usize = 0; 158 + var iter = vaxis.unicode.graphemeIterator(str); 159 + while (iter.next()) |g| { 160 + if (std.mem.indexOfPos(u8, tgt, i, g.bytes(str))) |idx| { 161 + const up_to_here: vxfw.RichText.TextSpan = .{ .text = item.text[i..idx] }; 162 + const match: vxfw.RichText.TextSpan = .{ 163 + .text = item.text[idx .. idx + g.len], 164 + .style = .{ .fg = .{ .index = 4 }, .reverse = true }, 165 + }; 166 + try spans.append(arena, up_to_here); 167 + try spans.append(arena, match); 168 + i = idx + g.len; 169 + } else continue :outer; 170 + } 171 + const up_to_here: vxfw.RichText.TextSpan = .{ .text = item.text[i..] }; 172 + try spans.append(arena, up_to_here); 173 + try self.filtered.append(arena, .{ .text = spans.items }); 174 + } 175 + self.list_view.scroll.top = 0; 176 + self.list_view.scroll.offset = 0; 177 + self.list_view.cursor = 0; 178 + } 179 + 180 + fn onSubmit(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext, _: []const u8) anyerror!void { 181 + const ptr = maybe_ptr orelse return; 182 + const self: *Model = @ptrCast(@alignCast(ptr)); 183 + if (self.list_view.cursor < self.filtered.items.len) { 184 + const selected = self.filtered.items[self.list_view.cursor]; 185 + const arena = self.arena.allocator(); 186 + var result = std.ArrayList(u8).empty; 187 + for (selected.text) |span| { 188 + try result.appendSlice(arena, span.text); 189 + } 190 + self.result = result.items; 191 + } 192 + ctx.quit = true; 193 + } 194 + }; 195 + 196 + fn toLower(arena: std.mem.Allocator, src: []const u8) std.mem.Allocator.Error![]const u8 { 197 + const lower = try arena.alloc(u8, src.len); 198 + for (src, 0..) |b, i| { 199 + lower[i] = std.ascii.toLower(b); 200 + } 201 + return lower; 202 + } 203 + 204 + pub fn main() !void { 205 + var debug_allocator = std.heap.GeneralPurposeAllocator(.{}){}; 206 + defer _ = debug_allocator.deinit(); 207 + 208 + const gpa = debug_allocator.allocator(); 209 + 210 + var app = try vxfw.App.init(gpa); 211 + errdefer app.deinit(); 212 + 213 + const model = try Model.init(gpa); 214 + defer model.deinit(gpa); 215 + 216 + // Run the command 217 + var fd = std.process.Child.init(&.{"fd"}, gpa); 218 + fd.stdout_behavior = .Pipe; 219 + fd.stderr_behavior = .Pipe; 220 + var stdout = std.ArrayList(u8).empty; 221 + var stderr = std.ArrayList(u8).empty; 222 + defer stdout.deinit(gpa); 223 + defer stderr.deinit(gpa); 224 + try fd.spawn(); 225 + try fd.collectOutput(gpa, &stdout, &stderr, 10_000_000); 226 + _ = try fd.wait(); 227 + 228 + var iter = std.mem.splitScalar(u8, stdout.items, '\n'); 229 + while (iter.next()) |line| { 230 + if (line.len == 0) continue; 231 + try model.list.append(gpa, .{ .text = line }); 232 + } 233 + 234 + try app.run(model.widget(), .{}); 235 + app.deinit(); 236 + 237 + if (model.result.len > 0) { 238 + _ = try std.posix.write(std.posix.STDOUT_FILENO, model.result); 239 + _ = try std.posix.write(std.posix.STDOUT_FILENO, "\n"); 240 + } else { 241 + std.process.exit(130); 242 + } 243 + }
+17 -13
examples/image.zig
··· 18 18 } 19 19 const alloc = gpa.allocator(); 20 20 21 - var tty = try vaxis.Tty.init(); 21 + var buffer: [1024]u8 = undefined; 22 + var tty = try vaxis.Tty.init(&buffer); 22 23 defer tty.deinit(); 23 24 24 25 var vx = try vaxis.init(alloc, .{}); 25 - defer vx.deinit(alloc, tty.anyWriter()); 26 + defer vx.deinit(alloc, tty.writer()); 26 27 27 28 var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx }; 28 29 try loop.init(); ··· 30 31 try loop.start(); 31 32 defer loop.stop(); 32 33 33 - try vx.enterAltScreen(tty.anyWriter()); 34 - try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); 34 + try vx.enterAltScreen(tty.writer()); 35 + try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s); 36 + 37 + var read_buffer: [1024 * 1024]u8 = undefined; // 1MB buffer 38 + var img1 = try vaxis.zigimg.Image.fromFilePath(alloc, "examples/zig.png", &read_buffer); 39 + defer img1.deinit(alloc); 35 40 36 - var img1 = try vaxis.zigimg.Image.fromFilePath(alloc, "examples/zig.png"); 37 41 const imgs = [_]vaxis.Image{ 38 - try vx.transmitImage(alloc, tty.anyWriter(), &img1, .rgba), 42 + try vx.transmitImage(alloc, tty.writer(), &img1, .rgba), 39 43 // var img1 = try vaxis.zigimg.Image.fromFilePath(alloc, "examples/zig.png"); 40 - // try vx.loadImage(alloc, tty.anyWriter(), .{ .path = "examples/zig.png" }), 41 - try vx.loadImage(alloc, tty.anyWriter(), .{ .path = "examples/vaxis.png" }), 44 + // try vx.loadImage(alloc, tty.writer(), .{ .path = "examples/zig.png" }), 45 + try vx.loadImage(alloc, tty.writer(), .{ .path = "examples/vaxis.png" }), 42 46 }; 43 - defer vx.freeImage(tty.anyWriter(), imgs[0].id); 44 - defer vx.freeImage(tty.anyWriter(), imgs[1].id); 47 + defer vx.freeImage(tty.writer(), imgs[0].id); 48 + defer vx.freeImage(tty.writer(), imgs[1].id); 45 49 46 50 var n: usize = 0; 47 51 48 - var clip_y: usize = 0; 52 + var clip_y: u16 = 0; 49 53 50 54 while (true) { 51 55 const event = loop.nextEvent(); ··· 60 64 else if (key.matches('k', .{})) 61 65 clip_y -|= 1; 62 66 }, 63 - .winsize => |ws| try vx.resize(alloc, tty.anyWriter(), ws), 67 + .winsize => |ws| try vx.resize(alloc, tty.writer(), ws), 64 68 } 65 69 66 70 n = (n + 1) % imgs.len; ··· 74 78 .y = clip_y, 75 79 } }); 76 80 77 - try vx.render(tty.anyWriter()); 81 + try vx.render(tty.writer()); 78 82 } 79 83 }
+99
examples/list_view.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const vxfw = vaxis.vxfw; 4 + 5 + const Text = vxfw.Text; 6 + const ListView = vxfw.ListView; 7 + const Widget = vxfw.Widget; 8 + 9 + const Model = struct { 10 + list_view: ListView, 11 + 12 + pub fn widget(self: *Model) Widget { 13 + return .{ 14 + .userdata = self, 15 + .eventHandler = Model.typeErasedEventHandler, 16 + .drawFn = Model.typeErasedDrawFn, 17 + }; 18 + } 19 + 20 + pub fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 21 + const self: *Model = @ptrCast(@alignCast(ptr)); 22 + try ctx.requestFocus(self.list_view.widget()); 23 + switch (event) { 24 + .key_press => |key| { 25 + if (key.matches('q', .{}) or key.matchExact('c', .{ .ctrl = true })) { 26 + ctx.quit = true; 27 + return; 28 + } 29 + }, 30 + else => {}, 31 + } 32 + } 33 + 34 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface { 35 + const self: *Model = @ptrCast(@alignCast(ptr)); 36 + const max = ctx.max.size(); 37 + 38 + const list_view: vxfw.SubSurface = .{ 39 + .origin = .{ .row = 1, .col = 1 }, 40 + .surface = try self.list_view.draw(ctx), 41 + }; 42 + 43 + const children = try ctx.arena.alloc(vxfw.SubSurface, 1); 44 + children[0] = list_view; 45 + 46 + return .{ 47 + .size = max, 48 + .widget = self.widget(), 49 + .buffer = &.{}, 50 + .children = children, 51 + }; 52 + } 53 + }; 54 + 55 + pub fn main() !void { 56 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 57 + defer _ = gpa.deinit(); 58 + 59 + const allocator = gpa.allocator(); 60 + 61 + var app = try vxfw.App.init(allocator); 62 + defer app.deinit(); 63 + 64 + const model = try allocator.create(Model); 65 + defer allocator.destroy(model); 66 + 67 + const n = 80; 68 + var texts = try std.ArrayList(Widget).initCapacity(allocator, n); 69 + 70 + var allocs = try std.ArrayList(*Text).initCapacity(allocator, n); 71 + defer { 72 + for (allocs.items) |tw| { 73 + allocator.free(tw.text); 74 + allocator.destroy(tw); 75 + } 76 + allocs.deinit(allocator); 77 + texts.deinit(allocator); 78 + } 79 + 80 + for (0..n) |i| { 81 + const t = std.fmt.allocPrint(allocator, "List Item {d}", .{i}) catch "placeholder"; 82 + const tw = try allocator.create(Text); 83 + tw.* = .{ .text = t }; 84 + _ = try allocs.append(allocator, tw); 85 + _ = try texts.append(allocator, tw.widget()); 86 + } 87 + 88 + model.* = .{ 89 + .list_view = .{ 90 + .wheel_scroll = 3, 91 + .scroll = .{ 92 + .wants_cursor = true, 93 + }, 94 + .children = .{ .slice = texts.items }, 95 + }, 96 + }; 97 + 98 + try app.run(model.widget(), .{}); 99 + }
+35 -8
examples/main.zig
··· 14 14 } 15 15 const alloc = gpa.allocator(); 16 16 17 - var tty = try vaxis.Tty.init(); 17 + var buffer: [1024]u8 = undefined; 18 + var tty = try vaxis.Tty.init(&buffer); 18 19 defer tty.deinit(); 19 20 20 21 var vx = try vaxis.init(alloc, .{}); 21 - defer vx.deinit(alloc, tty.anyWriter()); 22 + defer vx.deinit(alloc, tty.writer()); 22 23 23 24 var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx }; 24 25 try loop.init(); ··· 27 28 defer loop.stop(); 28 29 29 30 // Optionally enter the alternate screen 30 - try vx.enterAltScreen(tty.anyWriter()); 31 - try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); 31 + try vx.enterAltScreen(tty.writer()); 32 + try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s); 32 33 33 34 // We'll adjust the color index every keypress 34 35 var color_idx: u8 = 0; 35 36 const msg = "Hello, world!"; 37 + 38 + var scale: u3 = 1; 36 39 37 40 // The main event loop. Vaxis provides a thread safe, blocking, buffered 38 41 // queue which can serve as the primary event queue for an application ··· 51 54 if (key.codepoint == 'c' and key.mods.ctrl) { 52 55 break; 53 56 } 57 + if (key.matches('j', .{})) { 58 + if (vx.caps.scaled_text and scale > 1) { 59 + scale -= 1; 60 + } 61 + } 62 + if (key.matches('k', .{})) { 63 + if (vx.caps.scaled_text and scale < 7) { 64 + scale += 1; 65 + } 66 + } 54 67 }, 55 68 .winsize => |ws| { 56 - try vx.resize(alloc, tty.anyWriter(), ws); 69 + try vx.resize(alloc, tty.writer(), ws); 57 70 }, 58 71 else => {}, 59 72 } ··· 67 80 // the old and only updated cells will be drawn 68 81 win.clear(); 69 82 83 + const msg_len: u16 = @intCast(msg.len); 70 84 // Create some child window. .expand means the height and width will 71 85 // fill the remaining space of the parent. Child windows do not store a 72 86 // reference to their parent: this is true immediate mode. Do not store 73 87 // windows, always create new windows each render cycle 74 - const child = win.initChild(win.width / 2 - msg.len / 2, win.height / 2, .expand, .expand); 88 + const child = win.child( 89 + .{ .x_off = win.width / 2 - msg_len / 2, .y_off = win.height / 2 }, 90 + ); 75 91 // Loop through the message and print the cells to the screen 76 92 for (msg, 0..) |_, i| { 77 93 const cell: Cell = .{ ··· 83 99 .style = .{ 84 100 .fg = .{ .index = color_idx }, 85 101 }, 102 + .scale = .{ 103 + .scale = scale, 104 + }, 86 105 }; 87 - child.writeCell(i, 0, cell); 106 + const second_cell: Cell = .{ 107 + .char = .{ .grapheme = msg[i .. i + 1] }, 108 + .style = .{ 109 + .fg = .{ .index = color_idx }, 110 + }, 111 + }; 112 + child.writeCell(@intCast(i * scale), 0, cell); 113 + child.writeCell(@intCast(i), scale - 1, second_cell); 114 + child.writeCell(@intCast(i), scale, second_cell); 88 115 } 89 116 // Render the screen 90 - try vx.render(tty.anyWriter()); 117 + try vx.render(tty.writer()); 91 118 } 92 119 } 93 120
+214
examples/scroll.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const vxfw = vaxis.vxfw; 4 + 5 + const ModelRow = struct { 6 + text: []const u8, 7 + idx: usize, 8 + wrap_lines: bool = true, 9 + 10 + pub fn widget(self: *ModelRow) vxfw.Widget { 11 + return .{ 12 + .userdata = self, 13 + .drawFn = ModelRow.typeErasedDrawFn, 14 + }; 15 + } 16 + 17 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface { 18 + const self: *ModelRow = @ptrCast(@alignCast(ptr)); 19 + 20 + const idx_text = try std.fmt.allocPrint(ctx.arena, "{d: >4}", .{self.idx}); 21 + const idx_widget: vxfw.Text = .{ .text = idx_text }; 22 + 23 + const idx_surf: vxfw.SubSurface = .{ 24 + .origin = .{ .row = 0, .col = 0 }, 25 + .surface = try idx_widget.draw(ctx.withConstraints( 26 + // We're only interested in constraining the width, and we know the height will 27 + // always be 1 row. 28 + .{ .width = 1, .height = 1 }, 29 + .{ .width = 4, .height = 1 }, 30 + )), 31 + }; 32 + 33 + const text_widget: vxfw.Text = .{ .text = self.text, .softwrap = self.wrap_lines }; 34 + const text_surf: vxfw.SubSurface = .{ 35 + .origin = .{ .row = 0, .col = 6 }, 36 + .surface = try text_widget.draw(ctx.withConstraints( 37 + ctx.min, 38 + // We've shifted the origin over 6 columns so we need to take that into account or 39 + // we'll draw outside the window. 40 + if (self.wrap_lines) 41 + .{ .width = ctx.min.width -| 6, .height = ctx.max.height } 42 + else 43 + .{ .width = if (ctx.max.width) |w| w - 6 else null, .height = ctx.max.height }, 44 + )), 45 + }; 46 + 47 + const children = try ctx.arena.alloc(vxfw.SubSurface, 2); 48 + children[0] = idx_surf; 49 + children[1] = text_surf; 50 + 51 + return .{ 52 + .size = .{ 53 + .width = 6 + text_surf.surface.size.width, 54 + .height = @max(idx_surf.surface.size.height, text_surf.surface.size.height), 55 + }, 56 + .widget = self.widget(), 57 + .buffer = &.{}, 58 + .children = children, 59 + }; 60 + } 61 + }; 62 + 63 + const Model = struct { 64 + scroll_bars: vxfw.ScrollBars, 65 + rows: std.ArrayList(ModelRow), 66 + 67 + pub fn widget(self: *Model) vxfw.Widget { 68 + return .{ 69 + .userdata = self, 70 + .eventHandler = Model.typeErasedEventHandler, 71 + .drawFn = Model.typeErasedDrawFn, 72 + }; 73 + } 74 + 75 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 76 + const self: *Model = @ptrCast(@alignCast(ptr)); 77 + switch (event) { 78 + .key_press => |key| { 79 + if (key.matches('c', .{ .ctrl = true })) { 80 + ctx.quit = true; 81 + return; 82 + } 83 + if (key.matches('w', .{ .ctrl = true })) { 84 + for (self.rows.items) |*row| { 85 + row.wrap_lines = !row.wrap_lines; 86 + } 87 + self.scroll_bars.estimated_content_height = 88 + if (self.scroll_bars.estimated_content_height == 800) 89 + @intCast(self.rows.items.len) 90 + else 91 + 800; 92 + 93 + return ctx.consumeAndRedraw(); 94 + } 95 + if (key.matches('e', .{ .ctrl = true })) { 96 + if (self.scroll_bars.estimated_content_height == null) 97 + self.scroll_bars.estimated_content_height = 800 98 + else 99 + self.scroll_bars.estimated_content_height = null; 100 + 101 + return ctx.consumeAndRedraw(); 102 + } 103 + if (key.matches(vaxis.Key.tab, .{})) { 104 + self.scroll_bars.scroll_view.draw_cursor = !self.scroll_bars.scroll_view.draw_cursor; 105 + return ctx.consumeAndRedraw(); 106 + } 107 + if (key.matches('v', .{ .ctrl = true })) { 108 + self.scroll_bars.draw_vertical_scrollbar = !self.scroll_bars.draw_vertical_scrollbar; 109 + return ctx.consumeAndRedraw(); 110 + } 111 + if (key.matches('h', .{ .ctrl = true })) { 112 + self.scroll_bars.draw_horizontal_scrollbar = !self.scroll_bars.draw_horizontal_scrollbar; 113 + return ctx.consumeAndRedraw(); 114 + } 115 + if (key.matches(vaxis.Key.tab, .{ .shift = true })) { 116 + self.scroll_bars.draw_vertical_scrollbar = !self.scroll_bars.draw_vertical_scrollbar; 117 + self.scroll_bars.draw_horizontal_scrollbar = !self.scroll_bars.draw_horizontal_scrollbar; 118 + return ctx.consumeAndRedraw(); 119 + } 120 + return self.scroll_bars.scroll_view.handleEvent(ctx, event); 121 + }, 122 + else => {}, 123 + } 124 + } 125 + 126 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface { 127 + const self: *Model = @ptrCast(@alignCast(ptr)); 128 + const max = ctx.max.size(); 129 + 130 + const scroll_view: vxfw.SubSurface = .{ 131 + .origin = .{ .row = 0, .col = 0 }, 132 + .surface = try self.scroll_bars.draw(ctx), 133 + }; 134 + 135 + const children = try ctx.arena.alloc(vxfw.SubSurface, 1); 136 + children[0] = scroll_view; 137 + 138 + return .{ 139 + .size = max, 140 + .widget = self.widget(), 141 + .buffer = &.{}, 142 + .children = children, 143 + }; 144 + } 145 + 146 + fn widgetBuilder(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget { 147 + const self: *const Model = @ptrCast(@alignCast(ptr)); 148 + if (idx >= self.rows.items.len) return null; 149 + 150 + return self.rows.items[idx].widget(); 151 + } 152 + }; 153 + 154 + pub fn main() !void { 155 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 156 + defer _ = gpa.deinit(); 157 + 158 + const allocator = gpa.allocator(); 159 + 160 + var app = try vxfw.App.init(allocator); 161 + errdefer app.deinit(); 162 + 163 + var arena = std.heap.ArenaAllocator.init(allocator); 164 + defer arena.deinit(); 165 + 166 + const model = try allocator.create(Model); 167 + defer allocator.destroy(model); 168 + model.* = .{ 169 + .scroll_bars = .{ 170 + .scroll_view = .{ 171 + .children = .{ 172 + .builder = .{ 173 + .userdata = model, 174 + .buildFn = Model.widgetBuilder, 175 + }, 176 + }, 177 + }, 178 + // NOTE: This is not the actual content height, but rather an estimate. In reality 179 + // you would want to do some calculations to keep this up to date and as close to 180 + // the real value as possible, but this suffices for the sake of the example. Try 181 + // playing around with the value to see how it affects the scrollbar. Try removing 182 + // it as well to see what that does. 183 + .estimated_content_height = 800, 184 + }, 185 + .rows = std.ArrayList(ModelRow).empty, 186 + }; 187 + defer model.rows.deinit(allocator); 188 + 189 + var lipsum = std.ArrayList([]const u8).empty; 190 + defer lipsum.deinit(allocator); 191 + 192 + try lipsum.append(allocator, " Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc sit amet nunc porta, commodo tellus eu, blandit lectus. Aliquam dignissim rhoncus mi eu ultrices. Suspendisse lectus massa, bibendum sed lorem sit amet, egestas aliquam ante. Mauris venenatis nibh neque. Nulla a mi eget purus porttitor malesuada. Sed ac porta felis. Morbi ultricies urna nisi, et maximus elit convallis a. Morbi ut felis nec orci euismod congue efficitur egestas ex. Quisque eu feugiat magna. Pellentesque porttitor tortor ut iaculis dictum. Nulla erat neque, sollicitudin vitae enim nec, pharetra blandit tortor. Sed orci ante, condimentum vitae sodales in, sodales ut nulla. Suspendisse quam felis, aliquet ut neque a, lacinia sagittis turpis. Vivamus nec dui purus. Proin tempor nisl et porttitor consequat."); 193 + try lipsum.append(allocator, " Vivamus elit massa, commodo in laoreet nec, scelerisque ac orci. Donec nec ante sit amet nisi ullamcorper dictum quis non enim. Proin ante libero, consequat sit amet semper a, vulputate non odio. Mauris ut suscipit lacus. Mauris nec dolor id ex mollis tempor at quis ligula. Integer varius commodo ipsum id gravida. Sed ut lobortis est, id egestas nunc. In fringilla ullamcorper porttitor. Donec quis dignissim arcu, vitae sagittis tortor. Sed tempor porttitor arcu, sit amet elementum est ornare id. Morbi rhoncus, ipsum eget tincidunt volutpat, mauris enim vestibulum nibh, mollis iaculis ante enim quis enim. Donec pharetra odio vel ex fringilla, ut laoreet ipsum commodo. Praesent tempus, leo a pellentesque sodales, erat ipsum pretium nulla, id faucibus sem turpis at nibh. Aenean ut dui luctus, vehicula felis vel, aliquam nulla."); 194 + try lipsum.append(allocator, " Cras interdum mattis elit non varius. In condimentum velit a tellus sollicitudin interdum. Etiam pulvinar semper ex, eget congue ante tristique ut. Phasellus commodo magna magna, at fermentum tortor porttitor ac. Fusce a efficitur diam, a congue ante. Mauris maximus ultrices leo, non viverra ex hendrerit eu. Donec laoreet turpis nulla, eget imperdiet tortor mollis aliquam. Donec a est eget ante consequat rhoncus."); 195 + try lipsum.append(allocator, " Morbi facilisis libero nec viverra imperdiet. Ut dictum faucibus bibendum. Vestibulum ut nisl eu magna sollicitudin elementum vel eu ante. Phasellus euismod ligula massa, vel rutrum elit hendrerit ut. Vivamus id luctus lectus, at ullamcorper leo. Pellentesque in risus finibus, viverra ligula sed, porta nisl. Aliquam pretium accumsan placerat. Etiam a elit posuere, varius erat sed, aliquet quam. Morbi finibus gravida erat, non imperdiet dolor sollicitudin dictum. Aenean eget ullamcorper lacus, et hendrerit lorem. Quisque sed varius mauris."); 196 + try lipsum.append(allocator, " Nullam vitae euismod mauris, eu gravida dolor. Nunc vel urna laoreet justo faucibus tempus. Vestibulum tincidunt sagittis metus ac dignissim. Curabitur eleifend dolor consequat malesuada posuere. In hac habitasse platea dictumst. Fusce eget ipsum tincidunt, placerat orci ut, malesuada ante. Vivamus ultrices purus vel orci posuere, sed posuere eros porta. Vestibulum a tellus et tortor scelerisque varius. Pellentesque vel leo sed est semper bibendum. Mauris tellus ante, cursus et nunc vitae, dictum pellentesque ex. In tristique purus felis, non efficitur ante mollis id. Nulla quam nisi, suscipit sit amet mattis vel, placerat sit amet lectus. Vestibulum cursus auctor quam, at convallis felis euismod non. Sed nec magna nisi. Morbi scelerisque accumsan nunc, sed sagittis sem varius sit amet. Maecenas arcu dui, euismod et sem quis, condimentum blandit tellus."); 197 + try lipsum.append(allocator, " Nullam auctor lobortis libero non viverra. Mauris a imperdiet eros, a luctus est. Integer pellentesque eros et metus rhoncus egestas. Suspendisse eu risus mauris. Mauris posuere nulla in justo pharetra molestie. Maecenas sagittis at nunc et finibus. Vestibulum quis leo ac mauris malesuada vestibulum vitae eu enim. Ut et maximus elit. Pellentesque lorem felis, tristique vitae posuere vitae, auctor tempus magna. Fusce cursus purus sit amet risus pulvinar, non egestas ligula imperdiet."); 198 + try lipsum.append(allocator, " Proin rhoncus tincidunt congue. Curabitur pretium mauris eu erat iaculis semper. Vestibulum augue tortor, vehicula id maximus at, semper eu leo. Vivamus feugiat at purus eu dapibus. Mauris luctus sollicitudin nibh, in placerat est mattis vitae. Morbi ut risus felis. Etiam lobortis mollis diam, id tempor odio sollicitudin a. Morbi congue, lacus ac accumsan consequat, ipsum eros facilisis est, in congue metus ex nec ligula. Vestibulum dolor ligula, interdum nec iaculis vel, interdum a diam. Curabitur mattis, risus at rhoncus gravida, diam est viverra diam, ut mattis augue nulla sed lacus."); 199 + try lipsum.append(allocator, " Duis rutrum orci sit amet dui imperdiet porta. In pulvinar imperdiet enim nec tristique. Etiam egestas pulvinar arcu, viverra mollis ipsum. Ut sit amet sapien nibh. Maecenas ut velit egestas, suscipit dolor vel, interdum tellus. Pellentesque faucibus euismod risus, ac vehicula erat sodales a. Aliquam egestas sit amet enim ac posuere. In id venenatis eros, et pharetra neque. Proin facilisis, odio id vehicula elementum, sapien ligula interdum dui, quis vestibulum est quam sit amet nisl. Aliquam in orci et felis aliquet tempus quis id magna. Sed interdum malesuada sem. Proin sagittis est metus, eu vestibulum nunc lacinia in. Vestibulum enim erat, cursus at justo at, porta feugiat quam. Phasellus vestibulum finibus nulla, at egestas augue imperdiet dapibus. Nunc in felis at ante congue interdum ut nec sapien."); 200 + try lipsum.append(allocator, " Etiam lacinia ornare mauris, ut lacinia elit sollicitudin non. Morbi cursus dictum enim, et vulputate mi sollicitudin vel. Fusce rutrum augue justo. Phasellus et mauris tincidunt erat lacinia bibendum sed eu orci. Sed nunc lectus, dignissim sit amet ultricies sit amet, efficitur eu urna. Fusce feugiat malesuada ipsum nec congue. Praesent ultrices metus eu pulvinar laoreet. Maecenas pellentesque, metus ac lobortis rhoncus, ligula eros consequat urna, eget dictum lectus sem ut orci. Donec lobortis, lacus sed bibendum auctor, odio turpis suscipit odio, vitae feugiat leo metus ac lectus. Curabitur sed sem arcu."); 201 + try lipsum.append(allocator, " Mauris nisi tortor, auctor venenatis turpis a, finibus condimentum lectus. Donec id velit odio. Curabitur ac varius lorem. Nam cursus quam in velit gravida, in bibendum purus fermentum. Sed non rutrum dui, nec ultrices ligula. Integer lacinia blandit nisl non sollicitudin. Praesent nec malesuada eros, sit amet tincidunt nunc."); 202 + 203 + // Try playing around with the amount of items in the scroll view to see how the scrollbar 204 + // reacts. 205 + for (0..10) |i| { 206 + for (lipsum.items, 0..) |paragraph, j| { 207 + const number = i * 10 + j; 208 + try model.rows.append(allocator, .{ .idx = number, .text = paragraph }); 209 + } 210 + } 211 + 212 + try app.run(model.widget(), .{}); 213 + app.deinit(); 214 + }
+73
examples/split_view.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const vxfw = vaxis.vxfw; 4 + 5 + const Model = struct { 6 + split: vxfw.SplitView, 7 + lhs: vxfw.Text, 8 + rhs: vxfw.Text, 9 + children: [1]vxfw.SubSurface = undefined, 10 + 11 + pub fn widget(self: *Model) vxfw.Widget { 12 + return .{ 13 + .userdata = self, 14 + .eventHandler = Model.typeErasedEventHandler, 15 + .drawFn = Model.typeErasedDrawFn, 16 + }; 17 + } 18 + 19 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 20 + const self: *Model = @ptrCast(@alignCast(ptr)); 21 + switch (event) { 22 + .init => { 23 + self.split.lhs = self.lhs.widget(); 24 + self.split.rhs = self.rhs.widget(); 25 + }, 26 + .key_press => |key| { 27 + if (key.matches('c', .{ .ctrl = true })) { 28 + ctx.quit = true; 29 + return; 30 + } 31 + }, 32 + else => {}, 33 + } 34 + } 35 + 36 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface { 37 + const self: *Model = @ptrCast(@alignCast(ptr)); 38 + const surf = try self.split.widget().draw(ctx); 39 + self.children[0] = .{ 40 + .surface = surf, 41 + .origin = .{ .row = 0, .col = 0 }, 42 + }; 43 + return .{ 44 + .size = ctx.max.size(), 45 + .widget = self.widget(), 46 + .buffer = &.{}, 47 + .children = &self.children, 48 + }; 49 + } 50 + }; 51 + 52 + pub fn main() !void { 53 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 54 + defer _ = gpa.deinit(); 55 + 56 + const allocator = gpa.allocator(); 57 + 58 + var app = try vxfw.App.init(allocator); 59 + defer app.deinit(); 60 + 61 + const model = try allocator.create(Model); 62 + defer allocator.destroy(model); 63 + model.* = .{ 64 + .lhs = .{ .text = "Left hand side" }, 65 + .rhs = .{ .text = "right hand side" }, 66 + .split = .{ .lhs = undefined, .rhs = undefined, .width = 10 }, 67 + }; 68 + 69 + model.split.lhs = model.lhs.widget(); 70 + model.split.rhs = model.rhs.widget(); 71 + 72 + try app.run(model.widget(), .{}); 73 + }
+142 -52
examples/table.zig
··· 21 21 22 22 // Users set up below the main function 23 23 const users_buf = try alloc.dupe(User, users[0..]); 24 - const user_list = std.ArrayList(User).fromOwnedSlice(alloc, users_buf); 25 - defer user_list.deinit(); 26 24 27 - var tty = try vaxis.Tty.init(); 25 + var buffer: [1024]u8 = undefined; 26 + var tty = try vaxis.Tty.init(&buffer); 28 27 defer tty.deinit(); 29 - 30 - var vx = try vaxis.init(alloc, .{}); 31 - defer vx.deinit(alloc, tty.anyWriter()); 28 + const tty_writer = tty.writer(); 29 + var vx = try vaxis.init(alloc, .{ 30 + .kitty_keyboard_flags = .{ .report_events = true }, 31 + }); 32 + defer vx.deinit(alloc, tty.writer()); 32 33 33 34 var loop: vaxis.Loop(union(enum) { 34 35 key_press: vaxis.Key, 35 36 winsize: vaxis.Winsize, 37 + table_upd, 36 38 }) = .{ .tty = &tty, .vaxis = &vx }; 37 - 39 + try loop.init(); 38 40 try loop.start(); 39 41 defer loop.stop(); 40 - try vx.enterAltScreen(tty.anyWriter()); 41 - try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); 42 + try vx.enterAltScreen(tty.writer()); 43 + try vx.queryTerminal(tty.writer(), 250 * std.time.ns_per_ms); 42 44 43 45 const logo = 44 46 \\โ–‘โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–€โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–‘โ–€โ–ˆโ–€โ–‘โ–ˆโ–€โ–€โ–‘โ–‘โ–‘โ–€โ–ˆโ–€โ–‘โ–ˆโ–€โ–ˆโ–‘โ–ˆโ–€โ–„โ–‘โ–ˆโ–‘โ–‘โ–‘โ–ˆโ–€โ–€โ–‘ ··· 59 61 }; 60 62 var title_segs = [_]vaxis.Cell.Segment{ title_logo, title_info, title_disclaimer }; 61 63 62 - var cmd_input = vaxis.widgets.TextInput.init(alloc, &vx.unicode); 64 + var cmd_input = vaxis.widgets.TextInput.init(alloc); 63 65 defer cmd_input.deinit(); 64 66 65 67 // Colors 66 - const selected_bg: vaxis.Cell.Color = .{ .rgb = .{ 64, 128, 255 } }; 68 + const active_bg: vaxis.Cell.Color = .{ .rgb = .{ 64, 128, 255 } }; 69 + const selected_bg: vaxis.Cell.Color = .{ .rgb = .{ 32, 64, 255 } }; 67 70 const other_bg: vaxis.Cell.Color = .{ .rgb = .{ 32, 32, 48 } }; 68 71 69 72 // Table Context 70 - var demo_tbl: vaxis.widgets.Table.TableContext = .{ .selected_bg = selected_bg }; 73 + var demo_tbl: vaxis.widgets.Table.TableContext = .{ 74 + .active_bg = active_bg, 75 + .active_fg = .{ .rgb = .{ 0, 0, 0 } }, 76 + .row_bg_1 = .{ .rgb = .{ 8, 8, 8 } }, 77 + .selected_bg = selected_bg, 78 + .header_names = .{ .custom = &.{ "First", "Last", "Username", "Phone#", "Email" } }, 79 + //.header_align = .left, 80 + .col_indexes = .{ .by_idx = &.{ 0, 1, 2, 4, 3 } }, 81 + //.col_align = .{ .by_idx = &.{ .left, .left, .center, .center, .left } }, 82 + //.col_align = .{ .all = .center }, 83 + //.header_borders = true, 84 + //.col_borders = true, 85 + //.col_width = .{ .static_all = 15 }, 86 + //.col_width = .{ .dynamic_header_len = 3 }, 87 + //.col_width = .{ .static_individual = &.{ 10, 20, 15, 25, 15 } }, 88 + //.col_width = .dynamic_fill, 89 + //.y_off = 10, 90 + }; 91 + defer if (demo_tbl.sel_rows) |rows| alloc.free(rows); 71 92 72 93 // TUI State 73 94 var active: ActiveSection = .mid; 74 95 var moving = false; 96 + var see_content = false; 75 97 98 + // Create an Arena Allocator for easy allocations on each Event. 99 + var event_arena = heap.ArenaAllocator.init(alloc); 100 + defer event_arena.deinit(); 76 101 while (true) { 77 - // Create an Arena Allocator for easy allocations on each Event. 78 - var event_arena = heap.ArenaAllocator.init(alloc); 79 - defer event_arena.deinit(); 102 + defer _ = event_arena.reset(.retain_capacity); 103 + defer tty_writer.flush() catch {}; 80 104 const event_alloc = event_arena.allocator(); 81 105 const event = loop.nextEvent(); 82 106 ··· 101 125 key.matchesAny(&.{ ':', '/', 'g', 'G' }, .{})) 102 126 { 103 127 active = .btm; 104 - for (0..cmd_input.buf.items.len) |_| _ = cmd_input.buf.orderedRemove(0); 128 + cmd_input.clearAndFree(); 105 129 try cmd_input.update(.{ .key_press = key }); 106 - cmd_input.cursor_idx = 1; 107 130 break :keyEvt; 108 131 } 109 132 ··· 123 146 // Change Column 124 147 if (key.matchesAny(&.{ vaxis.Key.left, 'h' }, .{})) demo_tbl.col -|= 1; 125 148 if (key.matchesAny(&.{ vaxis.Key.right, 'l' }, .{})) demo_tbl.col +|= 1; 149 + // Select/Unselect Row 150 + if (key.matches(vaxis.Key.space, .{})) { 151 + const rows = demo_tbl.sel_rows orelse createRows: { 152 + demo_tbl.sel_rows = try alloc.alloc(u16, 1); 153 + break :createRows demo_tbl.sel_rows.?; 154 + }; 155 + var rows_list = std.ArrayList(u16).fromOwnedSlice(rows); 156 + for (rows_list.items, 0..) |row, idx| { 157 + if (row != demo_tbl.row) continue; 158 + _ = rows_list.orderedRemove(idx); 159 + break; 160 + } else try rows_list.append(alloc, demo_tbl.row); 161 + demo_tbl.sel_rows = try rows_list.toOwnedSlice(alloc); 162 + } 163 + // See Row Content 164 + if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) see_content = !see_content; 126 165 }, 127 166 .btm => { 128 167 if (key.matchesAny(&.{ vaxis.Key.up, 'k' }, .{}) and moving) active = .mid 129 - // Run Command and Clear Command Bar 130 - else if (key.matchExact(vaxis.Key.enter, .{})) { 131 - const cmd = cmd_input.buf.items; 168 + // Run Command and Clear Command Bar 169 + else if (key.matchExact(vaxis.Key.enter, .{}) or key.matchExact('j', .{ .ctrl = true })) { 170 + const cmd = try cmd_input.toOwnedSlice(); 171 + defer alloc.free(cmd); 132 172 if (mem.eql(u8, ":q", cmd) or 133 173 mem.eql(u8, ":quit", cmd) or 134 174 mem.eql(u8, ":exit", cmd)) return; 135 175 if (mem.eql(u8, "G", cmd)) { 136 - demo_tbl.row = user_list.items.len - 1; 176 + demo_tbl.row = @intCast(users_buf.len - 1); 137 177 active = .mid; 138 178 } 139 179 if (cmd.len >= 2 and mem.eql(u8, "gg", cmd[0..2])) { 140 - const goto_row = fmt.parseInt(usize, cmd[2..], 0) catch 0; 180 + const goto_row = fmt.parseInt(u16, cmd[2..], 0) catch 0; 141 181 demo_tbl.row = goto_row; 142 182 active = .mid; 143 183 } 144 - for (0..cmd_input.buf.items.len) |_| _ = cmd_input.buf.orderedRemove(0); 145 - cmd_input.cursor_idx = 0; 146 184 } else try cmd_input.update(.{ .key_press = key }); 147 185 }, 148 186 } 149 187 moving = false; 150 188 }, 151 - .winsize => |ws| try vx.resize(alloc, tty.anyWriter(), ws), 152 - //else => {}, 189 + .winsize => |ws| try vx.resize(alloc, tty.writer(), ws), 190 + else => {}, 191 + } 192 + 193 + // Content 194 + seeRow: { 195 + if (!see_content) { 196 + demo_tbl.active_content_fn = null; 197 + demo_tbl.active_ctx = &{}; 198 + break :seeRow; 199 + } 200 + const RowContext = struct { 201 + row: []const u8, 202 + bg: vaxis.Color, 203 + }; 204 + const row_ctx = RowContext{ 205 + .row = try fmt.allocPrint(event_alloc, "Row #: {d}", .{demo_tbl.row}), 206 + .bg = demo_tbl.active_bg, 207 + }; 208 + demo_tbl.active_ctx = &row_ctx; 209 + demo_tbl.active_content_fn = struct { 210 + fn see(win: *vaxis.Window, ctx_raw: *const anyopaque) !u16 { 211 + const ctx: *const RowContext = @ptrCast(@alignCast(ctx_raw)); 212 + win.height = 5; 213 + const see_win = win.child(.{ 214 + .x_off = 0, 215 + .y_off = 1, 216 + .width = win.width, 217 + .height = 4, 218 + }); 219 + see_win.fill(.{ .style = .{ .bg = ctx.bg } }); 220 + const content_logo = 221 + \\ 222 + \\โ–‘โ–ˆโ–€โ–„โ–‘โ–ˆโ–€โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–‘โ–‘โ–‘โ–ˆโ–€โ–€โ–‘โ–ˆโ–€โ–ˆโ–‘โ–ˆโ–€โ–ˆโ–‘โ–€โ–ˆโ–€โ–‘โ–ˆโ–€โ–€โ–‘โ–ˆโ–€โ–ˆโ–‘โ–€โ–ˆโ–€ 223 + \\โ–‘โ–ˆโ–€โ–„โ–‘โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–„โ–ˆโ–‘โ–‘โ–‘โ–ˆโ–‘โ–‘โ–‘โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–‘โ–‘โ–ˆโ–‘โ–‘โ–ˆโ–€โ–€โ–‘โ–ˆโ–‘โ–ˆโ–‘โ–‘โ–ˆโ–‘ 224 + \\โ–‘โ–€โ–‘โ–€โ–‘โ–€โ–€โ–€โ–‘โ–€โ–‘โ–€โ–‘โ–‘โ–‘โ–€โ–€โ–€โ–‘โ–€โ–€โ–€โ–‘โ–€โ–‘โ–€โ–‘โ–‘โ–€โ–‘โ–‘โ–€โ–€โ–€โ–‘โ–€โ–‘โ–€โ–‘โ–‘โ–€โ–‘ 225 + ; 226 + const content_segs: []const vaxis.Cell.Segment = &.{ 227 + .{ 228 + .text = ctx.row, 229 + .style = .{ .bg = ctx.bg }, 230 + }, 231 + .{ 232 + .text = content_logo, 233 + .style = .{ .bg = ctx.bg }, 234 + }, 235 + }; 236 + _ = see_win.print(content_segs, .{}); 237 + return see_win.height; 238 + } 239 + }.see; 240 + loop.postEvent(.table_upd); 153 241 } 154 242 155 243 // Sections ··· 159 247 160 248 // - Top 161 249 const top_div = 6; 162 - const top_bar = win.initChild( 163 - 0, 164 - 0, 165 - .{ .limit = win.width }, 166 - .{ .limit = win.height / top_div }, 167 - ); 250 + const top_bar = win.child(.{ 251 + .x_off = 0, 252 + .y_off = 0, 253 + .width = win.width, 254 + .height = win.height / top_div, 255 + }); 168 256 for (title_segs[0..]) |*title_seg| 169 - title_seg.*.style.bg = if (active == .top) selected_bg else other_bg; 257 + title_seg.style.bg = if (active == .top) selected_bg else other_bg; 170 258 top_bar.fill(.{ .style = .{ 171 259 .bg = if (active == .top) selected_bg else other_bg, 172 260 } }); ··· 175 263 44, 176 264 top_bar.height - (top_bar.height / 3), 177 265 ); 178 - _ = try logo_bar.print(title_segs[0..], .{ .wrap = .word }); 266 + _ = logo_bar.print(title_segs[0..], .{ .wrap = .word }); 179 267 180 268 // - Middle 181 - const middle_bar = win.initChild( 182 - 0, 183 - win.height / top_div, 184 - .{ .limit = win.width }, 185 - .{ .limit = win.height - (top_bar.height + 1) }, 186 - ); 187 - if (user_list.items.len > 0) { 269 + const middle_bar = win.child(.{ 270 + .x_off = 0, 271 + .y_off = win.height / top_div, 272 + .width = win.width, 273 + .height = win.height - (top_bar.height + 1), 274 + }); 275 + if (users_buf.len > 0) { 188 276 demo_tbl.active = active == .mid; 189 277 try vaxis.widgets.Table.drawTable( 190 - event_alloc, 278 + null, 279 + // event_alloc, 191 280 middle_bar, 192 - &.{ "First", "Last", "Username", "Email", "Phone#" }, 193 - user_list, 281 + //users_buf[0..], 282 + //user_list, 283 + users_buf, 194 284 &demo_tbl, 195 285 ); 196 286 } 197 287 198 288 // - Bottom 199 - const bottom_bar = win.initChild( 200 - 0, 201 - win.height - 1, 202 - .{ .limit = win.width }, 203 - .{ .limit = 1 }, 204 - ); 205 - if (active == .btm) bottom_bar.fill(.{ .style = .{ .bg = selected_bg } }); 289 + const bottom_bar = win.child(.{ 290 + .x_off = 0, 291 + .y_off = win.height - 1, 292 + .width = win.width, 293 + .height = 1, 294 + }); 295 + if (active == .btm) bottom_bar.fill(.{ .style = .{ .bg = active_bg } }); 206 296 cmd_input.draw(bottom_bar); 207 297 208 298 // Render the screen 209 - try vx.render(tty.anyWriter()); 299 + try vx.render(tty_writer); 210 300 } 211 301 } 212 302
+14 -14
examples/text_input.zig
··· 30 30 const alloc = gpa.allocator(); 31 31 32 32 // Initalize a tty 33 - var tty = try vaxis.Tty.init(); 33 + var buffer: [1024]u8 = undefined; 34 + var tty = try vaxis.Tty.init(&buffer); 34 35 defer tty.deinit(); 35 36 36 37 // Use a buffered writer for better performance. There are a lot of writes 37 38 // in the render loop and this can have a significant savings 38 - var buffered_writer = tty.bufferedWriter(); 39 - const writer = buffered_writer.writer().any(); 39 + const writer = tty.writer(); 40 40 41 41 // Initialize Vaxis 42 42 var vx = try vaxis.init(alloc, .{ 43 43 .kitty_keyboard_flags = .{ .report_events = true }, 44 44 }); 45 - defer vx.deinit(alloc, tty.anyWriter()); 45 + defer vx.deinit(alloc, tty.writer()); 46 46 47 47 var loop: vaxis.Loop(Event) = .{ 48 48 .vaxis = &vx, ··· 63 63 64 64 // init our text input widget. The text input widget needs an allocator to 65 65 // store the contents of the input 66 - var text_input = TextInput.init(alloc, &vx.unicode); 66 + var text_input = TextInput.init(alloc); 67 67 defer text_input.deinit(); 68 68 69 69 try vx.setMouseMode(writer, true); 70 70 71 - try buffered_writer.flush(); 71 + try writer.flush(); 72 72 // Sends queries to terminal to detect certain features. This should 73 73 // _always_ be called, but is left to the application to decide when 74 - try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); 74 + try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s); 75 75 76 76 // The main event loop. Vaxis provides a thread safe, blocking, buffered 77 77 // queue which can serve as the primary event queue for an application ··· 92 92 } else if (key.matches('l', .{ .ctrl = true })) { 93 93 vx.queueRefresh(); 94 94 } else if (key.matches('n', .{ .ctrl = true })) { 95 - try vx.notify(tty.anyWriter(), "vaxis", "hello from vaxis"); 95 + try vx.notify(tty.writer(), "vaxis", "hello from vaxis"); 96 96 loop.stop(); 97 97 var child = std.process.Child.init(&.{"nvim"}, alloc); 98 98 _ = try child.spawnAndWait(); 99 99 try loop.start(); 100 - try vx.enterAltScreen(tty.anyWriter()); 100 + try vx.enterAltScreen(tty.writer()); 101 101 vx.queueRefresh(); 102 - } else if (key.matches(vaxis.Key.enter, .{})) { 102 + } else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) { 103 103 text_input.clearAndFree(); 104 104 } else { 105 105 try text_input.update(.{ .key_press = key }); ··· 121 121 // more than one byte will incur an allocation on the first render 122 122 // after it is drawn. Thereafter, it will not allocate unless the 123 123 // screen is resized 124 - .winsize => |ws| try vx.resize(alloc, tty.anyWriter(), ws), 124 + .winsize => |ws| try vx.resize(alloc, tty.writer(), ws), 125 125 else => {}, 126 126 } 127 127 ··· 141 141 const child = win.child(.{ 142 142 .x_off = win.width / 2 - 20, 143 143 .y_off = win.height / 2 - 3, 144 - .width = .{ .limit = 40 }, 145 - .height = .{ .limit = 3 }, 144 + .width = 40, 145 + .height = 3, 146 146 .border = .{ 147 147 .where = .all, 148 148 .style = style, ··· 152 152 153 153 // Render the screen 154 154 try vx.render(writer); 155 - try buffered_writer.flush(); 155 + try writer.flush(); 156 156 } 157 157 }
+66
examples/text_view.zig
··· 1 + const std = @import("std"); 2 + const log = std.log.scoped(.main); 3 + const vaxis = @import("vaxis"); 4 + 5 + const TextView = vaxis.widgets.TextView; 6 + 7 + const Event = union(enum) { 8 + key_press: vaxis.Key, 9 + winsize: vaxis.Winsize, 10 + }; 11 + 12 + pub fn main() !void { 13 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 14 + 15 + defer { 16 + const deinit_status = gpa.deinit(); 17 + if (deinit_status == .leak) { 18 + log.err("memory leak", .{}); 19 + } 20 + } 21 + 22 + const alloc = gpa.allocator(); 23 + var buffer: [1024]u8 = undefined; 24 + var tty = try vaxis.Tty.init(&buffer); 25 + defer tty.deinit(); 26 + var vx = try vaxis.init(alloc, .{}); 27 + defer vx.deinit(alloc, tty.writer()); 28 + var loop: vaxis.Loop(Event) = .{ 29 + .vaxis = &vx, 30 + .tty = &tty, 31 + }; 32 + try loop.init(); 33 + try loop.start(); 34 + defer loop.stop(); 35 + try vx.enterAltScreen(tty.writer()); 36 + try vx.queryTerminal(tty.writer(), 20 * std.time.ns_per_s); 37 + var text_view = TextView{}; 38 + var text_view_buffer = TextView.Buffer{}; 39 + defer text_view_buffer.deinit(alloc); 40 + try text_view_buffer.append(alloc, .{ .bytes = "Press Enter to add a line, Up/Down to scroll, 'c' to close." }); 41 + 42 + var counter: i32 = 0; 43 + var lineBuf: [128]u8 = undefined; 44 + 45 + while (true) { 46 + const event = loop.nextEvent(); 47 + switch (event) { 48 + .key_press => |key| { 49 + // Close demo 50 + if (key.matches('c', .{})) break; 51 + if (key.matches(vaxis.Key.enter, .{})) { 52 + counter += 1; 53 + const new_content = try std.fmt.bufPrint(&lineBuf, "\nLine {d}", .{counter}); 54 + try text_view_buffer.append(alloc, .{ .bytes = new_content }); 55 + } 56 + text_view.input(key); 57 + }, 58 + .winsize => |ws| try vx.resize(alloc, tty.writer(), ws), 59 + } 60 + const win = vx.window(); 61 + win.clear(); 62 + text_view.draw(win, text_view_buffer); 63 + try vx.render(tty.writer()); 64 + try tty.writer.flush(); 65 + } 66 + }
+15 -11
examples/vaxis.zig
··· 20 20 } 21 21 const alloc = gpa.allocator(); 22 22 23 - var tty = try vaxis.Tty.init(); 23 + var buffer: [1024]u8 = undefined; 24 + var tty = try vaxis.Tty.init(&buffer); 24 25 defer tty.deinit(); 25 26 26 27 var vx = try vaxis.init(alloc, .{}); 27 - defer vx.deinit(alloc, tty.anyWriter()); 28 + defer vx.deinit(alloc, tty.writer()); 28 29 29 30 var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx }; 30 31 try loop.init(); ··· 32 33 try loop.start(); 33 34 defer loop.stop(); 34 35 35 - try vx.enterAltScreen(tty.anyWriter()); 36 - try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); 36 + try vx.enterAltScreen(tty.writer()); 37 + try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s); 37 38 38 - try vx.queryColor(tty.anyWriter(), .fg); 39 - try vx.queryColor(tty.anyWriter(), .bg); 39 + try vx.queryColor(tty.writer(), .fg); 40 + try vx.queryColor(tty.writer(), .bg); 40 41 var pct: u8 = 0; 41 42 var dir: enum { 42 43 up, ··· 52 53 switch (event) { 53 54 .key_press => |key| if (key.matches('c', .{ .ctrl = true })) return, 54 55 .winsize => |ws| { 55 - try vx.resize(alloc, tty.anyWriter(), ws); 56 + try vx.resize(alloc, tty.writer(), ws); 56 57 break; 57 58 }, 58 59 } ··· 62 63 while (loop.tryEvent()) |event| { 63 64 switch (event) { 64 65 .key_press => |key| if (key.matches('c', .{ .ctrl = true })) return, 65 - .winsize => |ws| try vx.resize(alloc, tty.anyWriter(), ws), 66 + .winsize => |ws| try vx.resize(alloc, tty.writer(), ws), 66 67 } 67 68 } 68 69 ··· 78 79 .style = style, 79 80 }; 80 81 const center = vaxis.widgets.alignment.center(win, 28, 4); 81 - _ = try center.printSegment(segment, .{ .wrap = .grapheme }); 82 - try vx.render(tty.anyWriter()); 83 - std.time.sleep(16 * std.time.ns_per_ms); 82 + _ = center.printSegment(segment, .{ .wrap = .grapheme }); 83 + // var bw = tty.bufferedWriter(); 84 + // try vx.render(bw.writer().any()); 85 + // try bw.flush(); 86 + try vx.render(tty.writer()); 87 + std.Thread.sleep(16 * std.time.ns_per_ms); 84 88 switch (dir) { 85 89 .up => { 86 90 pct += 1;
+361
examples/view.zig
··· 1 + const std = @import("std"); 2 + const log = std.log.scoped(.main); 3 + const mem = std.mem; 4 + const process = std.process; 5 + 6 + const vaxis = @import("vaxis"); 7 + const View = vaxis.widgets.View; 8 + const Cell = vaxis.Cell; 9 + const border = vaxis.widgets.border; 10 + 11 + const Event = union(enum) { 12 + key_press: vaxis.Key, 13 + winsize: vaxis.Winsize, 14 + }; 15 + 16 + pub fn main() !void { 17 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 18 + defer { 19 + const deinit_status = gpa.deinit(); 20 + if (deinit_status == .leak) { 21 + log.err("memory leak", .{}); 22 + } 23 + } 24 + const alloc = gpa.allocator(); 25 + 26 + var world_map: []const u8 = lg_world_map; 27 + var map_width = lg_map_width; 28 + var map_height = lg_map_height; 29 + var use_sm_map = false; 30 + var use_mini_view = false; 31 + 32 + var x: u16 = 0; 33 + var y: u16 = 0; 34 + var h: u16 = 0; 35 + var w: u16 = 0; 36 + defer log.info( 37 + \\Results: 38 + \\ - Map Size: {d}x{d} 39 + \\ - Screen Size: {d}x{d} 40 + \\ - Position: {d}, {d} 41 + , .{ 42 + map_width, map_height, 43 + w, h, 44 + x, y, 45 + }); 46 + 47 + var buffer: [1024]u8 = undefined; 48 + var tty = try vaxis.Tty.init(&buffer); 49 + defer tty.deinit(); 50 + 51 + const writer = tty.writer(); 52 + 53 + // Initialize Vaxis 54 + var vx = try vaxis.init(alloc, .{ 55 + .kitty_keyboard_flags = .{ .report_events = true }, 56 + }); 57 + defer vx.deinit(alloc, tty.writer()); 58 + var loop: vaxis.Loop(Event) = .{ 59 + .vaxis = &vx, 60 + .tty = &tty, 61 + }; 62 + try loop.init(); 63 + try loop.start(); 64 + defer loop.stop(); 65 + try vx.enterAltScreen(writer); 66 + try writer.flush(); 67 + try vx.queryTerminal(tty.writer(), 20 * std.time.ns_per_s); 68 + 69 + // Initialize Views 70 + // - Large Map 71 + var lg_map_view = try View.init(alloc, .{ .width = lg_map_width, .height = lg_map_height }); 72 + defer lg_map_view.deinit(); 73 + //w = lg_map_view.screen.width; 74 + //h = lg_map_view.screen.height; 75 + var lg_map_buf: [lg_map_width * lg_map_height]u8 = undefined; 76 + _ = mem.replace(u8, lg_world_map, "\n", "", lg_map_buf[0..]); 77 + _ = lg_map_view.printSegment(.{ .text = lg_map_buf[0..] }, .{ .wrap = .grapheme }); 78 + // - Small Map 79 + var sm_map_view = try View.init(alloc, .{ .width = sm_map_width, .height = sm_map_height }); 80 + defer sm_map_view.deinit(); 81 + w = sm_map_view.screen.width; 82 + h = sm_map_view.screen.height; 83 + var sm_map_buf: [sm_map_width * sm_map_height]u8 = undefined; 84 + _ = mem.replace(u8, sm_world_map, "\n", "", sm_map_buf[0..]); 85 + _ = sm_map_view.printSegment(.{ .text = sm_map_buf[0..] }, .{ .wrap = .grapheme }); 86 + // - Active Map 87 + var map_view = lg_map_view; 88 + 89 + // TUI Loop 90 + while (true) { 91 + const event = loop.nextEvent(); 92 + switch (event) { 93 + .key_press => |key| { 94 + // Close Demo 95 + if (key.matches('c', .{ .ctrl = true })) break; 96 + // Scroll 97 + if (key.matches(vaxis.Key.left, .{})) x -|= 1; 98 + if (key.matches(vaxis.Key.right, .{})) x +|= 1; 99 + if (key.matches(vaxis.Key.up, .{})) y -|= 1; 100 + if (key.matches(vaxis.Key.down, .{})) y +|= 1; 101 + // Quick Scroll 102 + if (key.matches(vaxis.Key.left, .{ .ctrl = true })) x -|= 30; 103 + if (key.matches(vaxis.Key.right, .{ .ctrl = true })) x +|= 30; 104 + if (key.matches(vaxis.Key.up, .{ .ctrl = true })) y -|= 10; 105 + if (key.matches(vaxis.Key.down, .{ .ctrl = true })) y +|= 10; 106 + // Goto Side 107 + if (key.matches(vaxis.Key.left, .{ .shift = true })) x -|= map_width; 108 + if (key.matches(vaxis.Key.right, .{ .shift = true })) x +|= map_width; 109 + if (key.matches(vaxis.Key.up, .{ .shift = true })) y -|= map_height; 110 + if (key.matches(vaxis.Key.down, .{ .shift = true })) y +|= map_height; 111 + // Change Zoom (Swap Map Views) 112 + if (key.matches('z', .{})) { 113 + if (use_sm_map) { 114 + world_map = lg_world_map; 115 + map_width = lg_map_width; 116 + map_height = lg_map_height; 117 + map_view = lg_map_view; 118 + } else { 119 + world_map = sm_world_map; 120 + map_width = sm_map_width; 121 + map_height = sm_map_height; 122 + map_view = sm_map_view; 123 + } 124 + use_sm_map = !use_sm_map; 125 + w = map_width; 126 + h = map_height; 127 + } 128 + // Mini View (Forced Width & Height Limits) 129 + if (key.matches('m', .{})) use_mini_view = !use_mini_view; 130 + }, 131 + .winsize => |ws| try vx.resize(alloc, tty.writer(), ws), 132 + } 133 + 134 + const win = vx.window(); 135 + win.clear(); 136 + 137 + const controls_win = win.child(.{ 138 + .height = 1, 139 + }); 140 + _ = controls_win.print( 141 + if (win.width >= 112) &.{ 142 + .{ .text = "Controls:", .style = .{ .bold = true, .ul_style = .single } }, 143 + .{ .text = " Exit: ctrl + c | Scroll: dpad | Quick Scroll: ctrl + dpad | Goto Side: shift + dpad | Zoom: z | Mini: m" }, 144 + } else if (win.width >= 25) &.{ 145 + .{ .text = "Controls:", .style = .{ .bold = true, .ul_style = .single } }, 146 + .{ .text = " Win too small!" }, 147 + } else &.{ 148 + .{ .text = "" }, 149 + }, 150 + .{ .wrap = .none }, 151 + ); 152 + 153 + // Views require a Window to render to. 154 + const map_win = if (use_mini_view) 155 + win.child(.{ 156 + .y_off = controls_win.height, 157 + .border = .{ .where = .top }, 158 + .width = 45, 159 + .height = 15, 160 + }) 161 + else 162 + win.child(.{ 163 + .y_off = controls_win.height, 164 + .border = .{ .where = .top }, 165 + }); 166 + 167 + // Clamp x and y 168 + x = @min(x, map_width -| map_win.width); 169 + y = @min(y, map_height -| map_win.height); 170 + 171 + map_view.draw(map_win, .{ 172 + .x_off = x, 173 + .y_off = y, 174 + }); 175 + if (use_mini_view) { 176 + _ = win.printSegment( 177 + .{ .text = "This is a mini portion of the View." }, 178 + .{ .row_offset = 16, .col_offset = 5, .wrap = .word }, 179 + ); 180 + } 181 + 182 + // Render the screen 183 + try vx.render(writer); 184 + try writer.flush(); 185 + } 186 + } 187 + 188 + const sm_world_map = 189 + \\ +NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN+ 190 + \\ W................................................................................................E 191 + \\ W................................................................................................E 192 + \\ W............................ @:::::::::@ ..................... .............................E 193 + \\ W..... . ..... ::: ...::::::: ........ ...... %@%...........:*%@ . ..............E 194 + \\ W..@:::::::::::::::::::: ::: ::::@ @@ ..... %#%%%%%%%%%%%%...........................* .......E 195 + \\ W ::::@::::::::::::: .. :: ... * ....... . %%% %%%%%%%%%%%%%..................... ..* .........E 196 + \\ W .....@::::::::::::.=::::: ........... @% @. %% %%%-...................:%% @. ..........E 197 + \\ W...... :::%@@:@:::::::::@ :-........... @%%%%%%%%%%%%%%%%........................ .. ..........E 198 + \\ W......@::::@@@.=-:::::=: ............. %@%%@# %%%%%%%..%%%.........@............ @=............E 199 + \\ W..... :::::::::::::: ................. %%% @ % ........................... . . .............E 200 + \\ W..... #:::::::::::@ .................. @=====@ .........................@ .= ............E 201 + \\ W...... :::::@ @ ................... ============== .... ..................... .. .............E 202 + \\ W....... .::=.. :# ................. ================ ...... ............... .................E 203 + \\ W......... ::::... @-............... ================- ....@ ...-... . ....@ .. ...............E 204 + \\ W............. :=.. % ...............==================+ ......@. .... ... .. @ ..............E 205 + \\ W............... ######@ ............ ========@=@@@======@........ @.... . .. ................E 206 + \\ W................ ######### ................. ===========@............... .@ ..@ =..............E 207 + \\ W............... #############= ............. ========== ................. . ..*. . --- .......E 208 + \\ W................ ###@@@#@#####%.............. ======== .................... @.@.... .*-=- ......E 209 + \\ W................. ##@##@@#@### .............. ========-. -...................... -- + ........E 210 + \\ W................... ##########................=======% *= .................... @-------- .......E 211 + \\ W................... #######* ................-====== .-+....................#-@--@-@#--- ......E 212 + \\ W................... ####### .................. ====- ........................@----------- ......E 213 + \\ W................... ###### ....................@-* .......................... = ----:..... .E 214 + \\ W....................####@ ......................................................... - .... -@ E 215 + \\ W.................... ###.................................................................%-@ ...E 216 + \\ W..................... #@........................................................................E 217 + \\ W...................... ......................................................................E 218 + \\ +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS+ 219 + \\ 220 + ; 221 + const sm_map_width = mapWidth: { 222 + @setEvalBranchQuota(100_000); 223 + break :mapWidth mem.indexOfScalar(u8, sm_world_map, '\n').?; 224 + }; 225 + const sm_map_height = mapHeight: { 226 + @setEvalBranchQuota(100_000); 227 + break :mapHeight mem.count(u8, sm_world_map, "\n"); 228 + }; 229 + 230 + const lg_world_map = 231 + \\ +NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN+ 232 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 233 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 234 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 235 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 236 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 237 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 238 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 239 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 240 + \\ W........................................................................................................................................................ ............................................................................................................................................................................................................................................E 241 + \\ W................................................................................................................................ ................................................................................................................................................................................................................................E 242 + \\ W........................................................................................................................... @@@@@@@@%-::::---::::::=#@@@@.... ................................................................................................................................................................................................................................E 243 + \\ W..................................................................................................................... .@@@@:::::::::::::::::::::::::::::::::::::-@@ ...............................................................................................................................................................................................................................E 244 + \\ W.................................................................................................................... %@@@#:::::::::::::::::::::::::::::::::::::@@- ...................................................................................... .........................................................................................................................E 245 + \\ W................................................................................................................... @:::::::::::::::::::::::::::::::::::::::::@ .................................................................................... .@....@@: .......................................................................................................................E 246 + \\ W............................................................................................... .... +:::::::::::::::::::::::::::::::::- ........................................................................... .. #@-.................@ ......................................................................................................E 247 + \\ W......................................................................................... +@@@@@@@@@@ .... @:::::::::::::::::::::::::::::::@: ........................................................................ @@...........................%%%%*....%%@@= ..................................................................................E 248 + \\ W........................ ... .................... @:::::::::::::@ ........ @::::::::::::::::::::::::::::+% ............................... ....................... @%%%%@@@%%%............................................-@@ %@@*******@@ .. ............................................................E 249 + \\ W................... *@@#+++++++++@@%=== ======== ... @@::@ +::::::::::::::::-@ ....... *:::::::::::::::::::::::::::%@ ............................. %@@@@@= .. :@%%%%%%%%%%#.........................................................................+@= ................................................E 250 + \\ W................ :@%=:::::::::::::::::::::::%%%-.:::::::::::::+%@@ =:::::--:::::@##%%::::::::@ ..... @:::::::::::::::::::::::%@+ ............................ @@%#%%%%%%%%%%#@@* +***@@%%%%%%%%%%%%%##..........................................................................................-%@@@@@+ .........................................E 251 + \\ W............. .*::::::::::::::::::::::::::::::::::::::::::::::::::::::::::%@@@=:::::::::::::::@@ @::::::::@ .... @:::::::::::::::::::@@. ...................... @%%%%%%%%%%%%%%%#%%%%%%%@@%%%%@@@%%%%%%%%%#%%%%%%%%%%%%=.....................................................................................................@@@%. ....................................E 252 + \\ W............ =#-:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::=@* @+:::::=:::+@ ... @::::::::::::::::@. @@@@@%%@@@ ................... @@%#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%%%%%%%%%%%%%%............................................................................................................@@@ ...................................E 253 + \\ W......... *::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::@ *%::::::::::@ @@ ... *::::::::::@@@ ... %%%%%%%%@ ................ @@%%%%%##%* -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%%......................................................................................................* @...@ ...................................E 254 + \\ W....... =@@=::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::@ =@@@@ @:::::=@ ..... *:::::::::@ ....... :=@%%@ ............. %@%%%%%%%%%@: @%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#=.......................................................................................................@ ...................................E 255 + \\ W...... @:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::@: @:::-@ @@* ........ @::::::@- .............. ............... @@@%%%%%%%%%%@ @%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%.....................................................................................................+@@ .......................................E 256 + \\ W..... :@::::::::::::::::#%*::::::::::::::::::::::::::::::::::::::::::::::::::::@ ...... @:::::::@ .......... ..@:@: .............................. .... *@%%%%%%%%%%%@* +@%#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%....................................................................................@ .@.=@.........@ .........................................E 257 + \\ W.... @::::::::::=%@@ #%:::::::::::::::::::::::::::::::::::::::::::::- ........ @::::::::@:@=::@ ........... ................................ -@@ .. @%%%@@%%%%%%%=# @%%%%%%%%%%%%%%%%%#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%.........................................................................%@@@@@####@# +....#% ............................................E 258 + \\ W. -@-:::*@- .. @::::::::::::::::::::::::::::::::::::::::::::@+ ..... @:::::::::::::::.- ................................................... @%%%@% ... @%%%@# #@%%% : : - %= :: : +%%%%%%%%%%%%%%%%%%%%%%.......................................................................-. @.....@ ...............................................E 259 + \\ W @:::::%@ ............. ::::::::::::::::::::::::::::::::::::::::::::::::%+ +@:::::::::::::::::@ ............................................... %@%%%= ... %@ @%%%@ @%%%%% %. . - : . . +%%%%%%%%%%%%%%%#*%##%-.......................................................................:* ..... @......@: ..............................................E 260 + \\ W@::::+@ .................. ::::::::::::::::::::::::::::::::::::::::::::::::::.+@ @*::::::::::::::::::::@ ........................................... :@@ @%%@# . #%%- @@@%%%%%% . . - # : . *#%%%%%%%%%%%#...............................................................................-@%% .... @........ ..............................................E 261 + \\ W ...................... :::::::::::::::::::::::::::::::::::::::::::::::::::-. @::::::::::::::::::::::::* ........................................... @%%%@ @%%%@ @@@%%%%%%%%%%%%%%%%%%% @. . - : *#. *%%%%%%%%%%%%%#.........................................................................................@ .... @.....% ..............................................E 262 + \\ W.. ......................... ::::::::::@:@:@@@:%@@%%@@@@@=@:::::::::::::::::::::= #=:::::::::::::::::::::::@ ........................................... :@%%%@ #%%%%%@ @@%%%%%%%%%%%%%%%%%%%%%% % % . .* += *%: -%%%%%%%%%#%............................................................................................@ ... +@..# ..............................................E 263 + \\ W............................... .@.::::::::@#@.@+@@%@*@:%@:@@#@:::::::::::::::::::::::::::::::::::::::::::::::::@ ........................................... @@@ @@%@@@@ @@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%...............................................................................................@- .... @ ..............................................E 264 + \\ W............................... @::::::::::@@@:@+@%%@%@:%@:@@@@:::::::::::::::::::::::::::::::::::::::%@@ @:::::@ ........................................... @@@@@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%.................................................................................................@ ..... ...............................................E 265 + \\ W............................... @:::::::::@=@:@*@#%@+@:%@:@@=@::::::::::::::::::::::::::::::::::::::*@ @::::::* ................................................ .@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%##%##%%%%%%%%%%%%%.............................................................................................@@=.-: .........................................................E 266 + \\ W............................... @:::::::::::::::::::::::::::.::::::::::::::::::::::::::::::::::::::. @@@:@-:+= ................................................ @%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%#.......*%##%%%%%%%#%...........................................................................................- @.@ .........................................................E 267 + \\ W.............................. @::::::::.:@@::@@:@@+@@.@@@:@*:@@*:@@*:::::::::::::::::::::::::::::::::@ ............................................. . @%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%%#............%%%%%%%%%%..............................@@.+@-@.@.@@@................................................@ .....................................................E 268 + \\ W............................ -=:::::::::::@@*:@@*@@+@::@:@:@*@@=@:@@@::::::::::::::::::::::::::::::@@@ ..................................................... @%%%%%%%%%#%%%%%%@-@@%%%#%%%%%%%%%%%%%%%%%%%%##%#.........%#%%%%%%%%%=...........................@@=.@@..@.@.@...............................................@ *@@ ....................................................E 269 + \\ W........................... @:::::::::::::@=@:@@@%@+@@.@@@:@*@@:::@.@:::::::::::::::::::::@@# :%@ ...................................................... @%%%%%%%%%%%%%@@@@@- *@%#@- .@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%........#%%%%%%%%%%..........................*@@@.:*@.@.@+@..............................................@ =....# ....................................................E 270 + \\ W.......................... @:::::::::::::+@@@:@=@%@+@::@.@:@*#@=@:@@@:::::::::::::::::::.@ ........................................................ @%%%%%%%%%%%%%%@ #@%%@@ .@%%%%%%%%%%%%%%%#%%%%%%%%%%..........%%%%%%%#%=.........................@@=@.@@@.@.@.@............................................@ . =..++ .....................................................E 271 + \\ W......................... @::::::::::::::-=:=:=:--=:==:=:=:=::*@::=:=::::::::::::::::@# ................................................................. @%%%%%%%%%%%@@- .... .@@%%@* @%%%%@@@***%%%#......................%%%%%%%%%..................................................................................% ... .....................................................E 272 + \\ W......................... @.:::::::::::::::::::::::::::::::::::::::::::::::::::::::-% ................................................................... @%%%%%%%%%%%@ ....... @%@ @%%#@ -...............................#%%%%#.........................................................................% #*@@....+ ..... ..@ .....................................................E 273 + \\ W......................... @::::::::::::::::::::::::::::::::::::::::::::::::::::::@ ....................................................................... @%%%%%%%%%@: @%%%%@ :%%% .@.............................................................................................................@ @....@ ... ...@ .....................................................E 274 + \\ W.......................... @:::::::::::::::::::::::::::::::::::::::::::::::::::::- .......................................................................... ==-@@@@@ .@@+--*#####@# @%@ @@@ @................................................................................................................= @... +...@ .....................................................E 275 + \\ W.......................... @::::::::::::::::::::::::::::::::::::::::::::::::::::.@ ........................................................................... %@@@@@@=-============@ . @+.=.....................................................................................................@ #..@ @@%....@ .....................................................E 276 + \\ W.......................... =@::::::::::::::::::::::::::::::::::::::::::::::::::@ ........................................................................... @=====================@ .. ...... @......................................................................................................@ +- @.....@ .....................................................E 277 + \\ W........................... *::::::::::::::::::::::::::::::::::::::::::::::@= ............................................................................ @-======================-@+.. ... @.......................................................................................................@ .@...%*+ .......................................................E 278 + \\ W............................ *:::::::::::::::::::::::::::::::::::::::::::@ ............................................................................. :*-============================-@:. +====-@@@@@@@@* %.........................................................................................................@ .. @..-@ @@ .........................................................E 279 + \\ W............................ @:::@@::::::::::::::::::::::::::::-*=:::::=. ............................................................................... @==================================--===============-@#..........................................................................................................- .. *@ @.@ .........................................................E 280 + \\ W............................. @:@ @:::::::::::::::::::.@...... #@::: ............................................................................. ##=====================================================@ *................- #.....................................................................................= ... :.. .........................................................E 281 + \\ W.............................. @:@ @::::::::::::::::@ :-:: ............................................................................ @@-======================================================% -...............@ @..................................................................................+. ... ............................................................E 282 + \\ W.............................. @::@ %::::::::::::::+ ........... @.: .......................................................................... @=========================================================-@ %.................@# @.:@@:...........................................................................@ ...... ..............................................................E 283 + \\ W.............................. @:.- *-:::::::::::.@ ............ .@ ......................................................................... @============================================================-= *+.................@ . ==@@......................................................................:. .........................................................................E 284 + \\ W............................... =@:+% @::::::::::.@ ........... ........................................................................ @-============================================================-@. @................:%%:..:@ :+-...........................................................% .........................................................................E 285 + \\ W................................ =.@ -@::::::::.% ..... -@::::*@@ .................................................................... #-===============================================================:@ @:........................+ .. %........................................................@% ...........................................................................E 286 + \\ W................................. =::::::::.@ ... #@ @: -@:::@@: ............................................................. @=================================================================% @........................@ ...... @ .....................-@@-@.........................** ............................................................................E 287 + \\ W....................................... @:::::::::.@ #::% *:::=: ........................................................... *-=================================================================- @.......................@ ........ ...................@ @..................@ -@@ ....... ....................................................................E 288 + \\ W....................................... +@.:::::::::%@@@@@@:::@ ..... @::::@ .......................................................... @-=================================================================@. @....................@ ............ :@...............@ . :-...............- ........ ..................................................................E 289 + \\ W........................................ -@::::::::::::::::@ ....... .-+@@-: ........................................................... :==================================================================-@ @.................@ ............. @..............@ .... *................+@ ......... @..@ ..................................................................E 290 + \\ W.......................................... @@**+:.::::::::@ ......... ........................................................... @====================================================================-+ @.............@+ ............... :*..........*@ ....... +.................@ ........ +:..- .................................................................E 291 + \\ W............................................. *%::::::::::@ ............................................................................. +-=====================================================================*@ #........-#@ .................. #........** ........ @..............- ....... @:..@# ................................................................E 292 + \\ W.................................................... -@::::::::% ......... .............................................................. +-========================================================================% @...:@@ ...................... @.......@ ............. =.............+ ....... @@..# ...............................................................E 293 + \\ W........................................................ @+::::@ ...... :+: ....................................................... @-========================================================================-@ =%- ....................... @......* ............... ..............@ ......... *@@ ...............................................................E 294 + \\ W.......................................................... @:::@ ... @@%####@. ................................................... %+===================================@@=-@@=@@@-@@-@@@-@@@=================. .#@+-=@ ........................ @.....: ................ ..= @.......-= ........... ................................................................E 295 + \\ W............................................................. -::+@ ###################@ ................................................... #-================================-@%%-@--@=@+@@=@-@-@+@-=========================@ .......................... @...@- ................ ..@ @...@@ ..................................................................................E 296 + \\ W............................................................. -@@:+@@@#######################@ ................................................... *-================================@=@-@=-@=@+@@=@-=-@:@=========================@ ........................... @.% ................ ...@ @@: ........ ........................................................................E 297 + \\ W............................................................... @%########################@ ............................................... --==============================*@*@-@--@=@+@@-@-@-@+@========================-* ............................. @+ ............. @...@ ......... : ......................................................................E 298 + \\ W................................................................... =###########################@@* .............................................. @-================---========================================================@ ............................... *.- ........... %%...@ ......... #.@ ....................................................................E 299 + \\ W....................................................................... .##############################+@ ............................................. @%=---=-*+--%@ @-====================================================-@ .................................. ........... @.@ @...@ ....... @#....-: ....................................................................E 300 + \\ W...................................................................... @################################=@ ............................................... @-==============================================-@ .................................... .............. @..@ @..@ ... =@.....@ .. ..............................................................E 301 + \\ W..................................................................... @##################################@ ................................................................... +============================================--* ......................................................... *...@ @.@ @........@ .............................................................E 302 + \\ W.................................................................... @####################################@ .................................................................. *===========================================%# ............................................................ @..#@@ @............@ .++#@.@ .... ....................................................E 303 + \\ W................................................................... @######################################@+ ............................................................. @==========================================-@ ............................................................... +....@ @..........@# *.....% .. ............................................E 304 + \\ W................................................................... @#############################################%@+ ......................................................... @-========================================-@ .................................................................. @-...@- @.........+: @...*@ .. @---+: .......................................E 305 + \\ W................................................................... @##################################################@@ ...................................................... =-=====================================-@ .................................................................... @....% @........@ @....# .... #+--+##---=@% .....................................E 306 + \\ W................................................................... %##############%@@@#@@@#@%@%@@@#@%@@#################@. ..................................................... @-===================================-@ ........................................................................ %@..@ @@@@#@ .....+ ..... @+=-----------+@ ..................................E 307 + \\ W................................................................... @###############@@###@#@#@%@%#@%#@@@@####################@= .................................................... @-==================================@ .......................................................................... @..@ .:@@@@ ...... @------------#@ .................................E 308 + \\ W................................................................... +%################@@#@#@#@%@%#@%#@@@@#####################* ..................................................... :-=================================@ ............................................................................. *...:@@@@# ........... #-----------@ ................................E 309 + \\ W.................................................................... @#############%@%@#@#@#@%@##@%#@%@@#####################@ ...................................................... @=================================@ .............................................................................. @........@# ...................... @------------@ ...............................E 310 + \\ W..................................................................... #######################################################. ....................................................... @=================================@ .............................................................................. @@%.++@ ............... .. :+:@---@ @--*% ..............................E 311 + \\ W..................................................................... @############@@#%@%%@%@@@#@@@#@#%@@%#@@##############% ........................................................ .*=================================== .................................................................................. .............. @%%@@ .............................E 312 + \\ W....................................................................... @###########@@#%@@@@%@@##@#@#@#@%@@#@@@#############* ........................................................ %=================================-# .... ................................................................................................. %=@@@@= . :@ .............................E 313 + \\ W........................................................................ @#########%@@@%@@@@%@@@%@@@#@#@%###@%@###########@ ........................................................ @-=================================% ... :*@ ........................................................................................ @------+ . -=-@ .... ...............................E 314 + \\ W......................................................................... @########@@@@%@@@@%@@#%@#@#@#@%@@%@@@########### ......................................................... @===================================% @-=+ ....................................................................................... #@@@@%@--------: @--+ .....................................E 315 + \\ W.......................................................................... @############################%#################+ ......................................................... ===================================-@ =@@===+ .................................................................................... %=--------------= .=---=+ ....................................E 316 + \\ W........................................................................... @@#########################################** ......................................................... ==================================-@ @-======@ ................................................................................... %+------------------*@= @-----# ....................................E 317 + \\ W.............................................................................. @#######################################@ ......................................................... +===============================-@ =======% ................................................................................. .@-----------------------------# ....................................E 318 + \\ W................................................................................. =#######################################@ ......................................................... -+============================-@ . @=====+ ................................................................................ :@--------------------------------%* ..................................E 319 + \\ W................................................................................. @#####################################@ ........................................................... --==========================@ .. @====-@ .............................................................................. @--------------------------------------@ .................................E 320 + \\ W.................................................................................. @#####################################@ ............................................................. %==========================@ ... @-=====@ ............................................................................. @-----------------------------------------% ................................E 321 + \\ W.................................................................................. @*#################################%@ .............................................................. %=========================@ ... +-====* ............................................................................. @-----+@@-@*@@-@@@*@@@*@@#-@@=-@--@-*@@-----=@ ...............................E 322 + \\ W.................................................................................. :*############################+@* ................................................................ %========================-@ ... @-==-@ .............................................................................. *-----@+@-@*@@+@---#@-*@*@-@*#-@--@-@+@-------* ..............................E 323 + \\ W.................................................................................. :*###########################@ ..................................................................... @-=======================* ..... @@-@ .............................................................................. *-----@-@-@*@@--@@-#@-*@%@-@=@-@--@-@-@-------@ ..............................E 324 + \\ W.................................................................................. :*########################### ........................................................................ =+=====================@ ....... ............................................................................... @-----@#@+@#@@+@-@-#@-*@+@*@%@-@--@-@#@+------@ ..............................E 325 + \\ W.................................................................................. :############################@ ........................................................................ #===================*. ................................................................................................ @---------------------------------------------= ...............................E 326 + \\ W.................................................................................. %##########################@ ......................................................................... +================-@ ................................................................................................ =--------------------------------------------# ...............................E 327 + \\ W.................................................................................. @#########################@ ........................................................................... @================@ .................................................................................................. @-------------------------------------------@ ................................E 328 + \\ W................................................................................... @#######################%* ............................................................................. @-===========-@: .................................................................................................... *----------------------------------------+@ .................................E 329 + \\ W................................................................................... @#######################* ............................................................................... *:=========-@* ...................................................................................................... *----% @--------------------@ .................. ..........E 330 + \\ W................................................................................... @#####################%= ................................................................................ .@-=-#%%%%@ ........................................................................................................ :*... @------------------@ .................. @@ ..........E 331 + \\ W................................................................................... @####################@ ................................................................................. ........................................................................................................... ........... @--------------@ .................... @--@ .........E 332 + \\ W................................................................................... %#################### ..................................................................................... ...................................................................................................................................... @-----------=. ..................... @---@: .......E 333 + \\ W................................................................................... @##################% ................................................................................................................................................................................................................................... .%=---------@ .................... @#-----@ .......E 334 + \\ W.................................................................................... @##############%@- .................................................................................................................................................................................................................................... @@@ .................. ==----=@ .......E 335 + \\ W.................................................................................... @#############@ ......................................................................................................................................................................................................................................... ................... @+----@ .........E 336 + \\ W..................................................................................... @############ .................................................................................................................................................................................................................................................................... %@----*@: .............E 337 + \\ W...................................................................................... %##########= ................................................................................................................................................................................................................................................................. @-----@ ...............E 338 + \\ W...................................................................................... @#########@ ................................................................................................................................................................................................................................................................. =@-----#@# ..................E 339 + \\ W....................................................................................... %#######@ ................................................................................................................................................................................................................................................................. %--+@#% ....................E 340 + \\ W....................................................................................... @#######@@. ................................................................................................................................................................................................................................................................. ........................E 341 + \\ W........................................................................................ @########@ .................................................................................................................................................................................................................................................................. .............................E 342 + \\ W......................................................................................... @#####%. ....................................................................................................................................................................................................................................................................................................E 343 + \\ W.......................................................................................... @#####+ ....................................................................................................................................................................................................................................................................................................E 344 + \\ W........................................................................................... @#####@ .................................................................................................................................................................................................................................................................................................E 345 + \\ W............................................................................................ +@@@##%@# ...............................................................................................................................................................................................................................................................................................E 346 + \\ W............................................................................................. +@@@@@ ...............................................................................................................................................................................................................................................................................................E 347 + \\ W................................................................................................. ................................................................................................................................................................................................................................................................................................E 348 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 349 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 350 + \\ W...........................................................................................................................................................................................................................................................................................................................................................................................................E 351 + \\ +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS+ 352 + \\ 353 + ; 354 + const lg_map_width: u16 = mapWidth: { 355 + @setEvalBranchQuota(100_000); 356 + break :mapWidth @intCast(mem.indexOfScalar(u8, lg_world_map, '\n').?); 357 + }; 358 + const lg_map_height: u16 = mapHeight: { 359 + @setEvalBranchQuota(100_000); 360 + break :mapHeight @intCast(mem.count(u8, lg_world_map, "\n")); 361 + };
+14 -16
examples/vt.zig
··· 20 20 } 21 21 const alloc = gpa.allocator(); 22 22 23 - var tty = try vaxis.Tty.init(); 23 + var buffer: [1024]u8 = undefined; 24 + var tty = try vaxis.Tty.init(&buffer); 25 + const writer = tty.writer(); 24 26 var vx = try vaxis.init(alloc, .{}); 25 - defer vx.deinit(alloc, tty.anyWriter()); 27 + defer vx.deinit(alloc, writer); 26 28 27 29 var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx }; 28 30 try loop.init(); ··· 30 32 try loop.start(); 31 33 defer loop.stop(); 32 34 33 - var buffered = tty.bufferedWriter(); 34 - 35 - try vx.enterAltScreen(tty.anyWriter()); 36 - try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); 35 + try vx.enterAltScreen(writer); 36 + try vx.queryTerminal(writer, 1 * std.time.ns_per_s); 37 37 var env = try std.process.getEnvMap(alloc); 38 38 defer env.deinit(); 39 39 ··· 49 49 }; 50 50 const shell = env.get("SHELL") orelse "bash"; 51 51 const argv = [_][]const u8{shell}; 52 + var write_buf: [4096]u8 = undefined; 52 53 var vt = try vaxis.widgets.Terminal.init( 53 54 alloc, 54 55 &argv, 55 56 &env, 56 - &vx.unicode, 57 57 vt_opts, 58 + &write_buf, 58 59 ); 59 60 defer vt.deinit(); 60 61 try vt.spawn(); 61 62 62 63 var redraw: bool = false; 63 64 while (true) { 64 - std.time.sleep(8 * std.time.ns_per_ms); 65 + std.Thread.sleep(8 * std.time.ns_per_ms); 65 66 // try vt events first 66 67 while (vt.tryEvent()) |event| { 67 68 redraw = true; ··· 80 81 if (key.matches('c', .{ .ctrl = true })) return; 81 82 try vt.update(.{ .key_press = key }); 82 83 }, 83 - .winsize => |ws| { 84 - try vx.resize(alloc, tty.anyWriter(), ws); 85 - }, 84 + .winsize => |ws| try vx.resize(alloc, writer, ws), 86 85 } 87 86 } 88 87 if (!redraw) continue; ··· 94 93 const child = win.child(.{ 95 94 .x_off = 4, 96 95 .y_off = 2, 97 - .width = .{ .limit = win.width - 8 }, 98 - .height = .{ .limit = win.width - 6 }, 96 + .width = 120, 97 + .height = 40, 99 98 .border = .{ 100 99 .where = .all, 101 100 }, ··· 107 106 .x_pixel = 0, 108 107 .y_pixel = 0, 109 108 }); 110 - try vt.draw(child); 109 + try vt.draw(alloc, child); 111 110 112 - try vx.render(buffered.writer().any()); 113 - try buffered.flush(); 111 + try vx.render(writer); 114 112 } 115 113 }
-127
examples/xev.zig
··· 1 - const std = @import("std"); 2 - const vaxis = @import("vaxis"); 3 - const xev = @import("xev"); 4 - const Cell = vaxis.Cell; 5 - 6 - pub const panic = vaxis.panic_handler; 7 - 8 - const App = struct { 9 - const lower_limit: u8 = 30; 10 - const next_ms: u64 = 8; 11 - 12 - allocator: std.mem.Allocator, 13 - vx: *vaxis.Vaxis, 14 - buffered_writer: std.io.BufferedWriter(4096, std.io.AnyWriter), 15 - color_idx: u8, 16 - dir: enum { 17 - up, 18 - down, 19 - }, 20 - 21 - fn draw(self: *App) !void { 22 - const style: vaxis.Style = .{ .fg = .{ .rgb = [_]u8{ self.color_idx, self.color_idx, self.color_idx } } }; 23 - 24 - const segment: vaxis.Segment = .{ 25 - .text = vaxis.logo, 26 - .style = style, 27 - }; 28 - const win = self.vx.window(); 29 - win.clear(); 30 - const center = vaxis.widgets.alignment.center(win, 28, 4); 31 - _ = try center.printSegment(segment, .{ .wrap = .grapheme }); 32 - switch (self.dir) { 33 - .up => { 34 - self.color_idx += 1; 35 - if (self.color_idx == 255) self.dir = .down; 36 - }, 37 - .down => { 38 - self.color_idx -= 1; 39 - if (self.color_idx == lower_limit) self.dir = .up; 40 - }, 41 - } 42 - try self.vx.render(self.buffered_writer.writer().any()); 43 - try self.buffered_writer.flush(); 44 - } 45 - }; 46 - 47 - pub fn main() !void { 48 - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 49 - defer { 50 - const deinit_status = gpa.deinit(); 51 - //fail test; can't try in defer as defer is executed after we return 52 - if (deinit_status == .leak) { 53 - std.log.err("memory leak", .{}); 54 - } 55 - } 56 - const alloc = gpa.allocator(); 57 - 58 - var tty = try vaxis.Tty.init(); 59 - defer tty.deinit(); 60 - 61 - var vx = try vaxis.init(alloc, .{}); 62 - defer vx.deinit(alloc, tty.anyWriter()); 63 - 64 - var pool = xev.ThreadPool.init(.{}); 65 - var loop = try xev.Loop.init(.{ 66 - .thread_pool = &pool, 67 - }); 68 - defer loop.deinit(); 69 - 70 - var app: App = .{ 71 - .allocator = alloc, 72 - .buffered_writer = tty.bufferedWriter(), 73 - .color_idx = App.lower_limit, 74 - .dir = .up, 75 - .vx = &vx, 76 - }; 77 - 78 - var vx_loop: vaxis.xev.TtyWatcher(App) = undefined; 79 - try vx_loop.init(&tty, &vx, &loop, &app, eventCallback); 80 - 81 - try vx.enterAltScreen(tty.anyWriter()); 82 - // send queries asynchronously 83 - try vx.queryTerminalSend(tty.anyWriter()); 84 - 85 - const timer = try xev.Timer.init(); 86 - var timer_cmp: xev.Completion = .{}; 87 - timer.run(&loop, &timer_cmp, App.next_ms, App, &app, timerCallback); 88 - 89 - try loop.run(.until_done); 90 - } 91 - 92 - fn eventCallback( 93 - ud: ?*App, 94 - loop: *xev.Loop, 95 - watcher: *vaxis.xev.TtyWatcher(App), 96 - event: vaxis.xev.Event, 97 - ) xev.CallbackAction { 98 - const app = ud orelse unreachable; 99 - switch (event) { 100 - .key_press => |key| { 101 - if (key.matches('c', .{ .ctrl = true })) { 102 - loop.stop(); 103 - return .disarm; 104 - } 105 - }, 106 - .winsize => |ws| watcher.vx.resize(app.allocator, watcher.tty.anyWriter(), ws) catch @panic("TODO"), 107 - else => {}, 108 - } 109 - return .rearm; 110 - } 111 - 112 - fn timerCallback( 113 - ud: ?*App, 114 - l: *xev.Loop, 115 - c: *xev.Completion, 116 - r: xev.Timer.RunError!void, 117 - ) xev.CallbackAction { 118 - _ = r catch @panic("timer error"); 119 - 120 - var app = ud orelse return .disarm; 121 - app.draw() catch @panic("couldn't draw"); 122 - 123 - const timer = try xev.Timer.init(); 124 - timer.run(l, c, App.next_ms, App, ud, timerCallback); 125 - 126 - return .disarm; 127 - }
+22 -4
src/Cell.zig
··· 9 9 /// Set to true if this cell is the last cell printed in a row before wrap. Vaxis will determine if 10 10 /// it should rely on the terminal's autowrap feature which can help with primary screen resizes 11 11 wrapped: bool = false, 12 + scale: Scale = .{}, 12 13 13 14 /// Segment is a contiguous run of text that has a constant style 14 15 pub const Segment = struct { ··· 23 24 /// will measure the same width. This can be ensure by using the gwidth method 24 25 /// included in libvaxis. If width is 0, libvaxis will measure the glyph at 25 26 /// render time 26 - width: usize = 1, 27 + width: u8 = 1, 27 28 }; 28 29 29 30 pub const CursorShape = enum { ··· 40 41 uri: []const u8 = "", 41 42 /// ie "id=app-1234" 42 43 params: []const u8 = "", 44 + }; 45 + 46 + pub const Scale = packed struct { 47 + scale: u3 = 1, 48 + // The spec allows up to 15, but we limit to 7 49 + numerator: u4 = 1, 50 + // The spec allows up to 15, but we limit to 7 51 + denominator: u4 = 1, 52 + vertical_alignment: enum(u2) { 53 + top = 0, 54 + bottom = 1, 55 + center = 2, 56 + } = .top, 57 + 58 + pub fn eql(self: Scale, other: Scale) bool { 59 + const a_scale: u13 = @bitCast(self); 60 + const b_scale: u13 = @bitCast(other); 61 + return a_scale == b_scale; 62 + } 43 63 }; 44 64 45 65 pub const Style = struct { ··· 93 113 .invisible = b.invisible, 94 114 .strikethrough = b.strikethrough, 95 115 }; 96 - const a_cast: u7 = @bitCast(a_sgr); 97 - const b_cast: u7 = @bitCast(b_sgr); 98 - return a_cast == b_cast and 116 + return a_sgr == b_sgr and 99 117 Color.eql(a.fg, b.fg) and 100 118 Color.eql(a.bg, b.bg) and 101 119 Color.eql(a.ul, b.ul) and
+23 -17
src/Image.zig
··· 21 21 png, 22 22 }; 23 23 24 + pub const TransmitMedium = enum { 25 + file, 26 + temp_file, 27 + shared_mem, 28 + }; 29 + 24 30 pub const Placement = struct { 25 31 img_id: u32, 26 32 options: Image.DrawOptions, 27 33 }; 28 34 29 35 pub const CellSize = struct { 30 - rows: usize, 31 - cols: usize, 36 + rows: u16, 37 + cols: u16, 32 38 }; 33 39 34 40 pub const DrawOptions = struct { ··· 36 42 /// origin of the image. These must be less than the pixel size of a single 37 43 /// cell 38 44 pixel_offset: ?struct { 39 - x: usize, 40 - y: usize, 45 + x: u16, 46 + y: u16, 41 47 } = null, 42 48 /// the vertical stacking order 43 49 /// < 0: Drawn beneath text ··· 45 51 z_index: ?i32 = null, 46 52 /// A clip region of the source image to draw. 47 53 clip_region: ?struct { 48 - x: ?usize = null, 49 - y: ?usize = null, 50 - width: ?usize = null, 51 - height: ?usize = null, 54 + x: ?u16 = null, 55 + y: ?u16 = null, 56 + width: ?u16 = null, 57 + height: ?u16 = null, 52 58 } = null, 53 59 /// Scaling to apply to the Image 54 60 scale: enum { ··· 65 71 /// field, and should prefer to use scale. `draw` will fill in this field with 66 72 /// the correct values if a scale method is applied. 67 73 size: ?struct { 68 - rows: ?usize = null, 69 - cols: ?usize = null, 74 + rows: ?u16 = null, 75 + cols: ?u16 = null, 70 76 } = null, 71 77 }; 72 78 ··· 74 80 id: u32, 75 81 76 82 /// width in pixels 77 - width: usize, 83 + width: u16, 78 84 /// height in pixels 79 - height: usize, 85 + height: u16, 80 86 81 87 pub fn draw(self: Image, win: Window, opts: DrawOptions) !void { 82 88 var p_opts = opts; ··· 115 121 .rows = win.height, 116 122 } 117 123 118 - // Does the image require horizontal scaling? 124 + // Does the image require horizontal scaling? 119 125 else if (!fit_x and fit_y) 120 126 p_opts.size = .{ 121 127 .cols = win.width, ··· 170 176 const w = win.screen.width; 171 177 const h = win.screen.height; 172 178 173 - const pix_per_col = try std.math.divCeil(usize, x_pix, w); 174 - const pix_per_row = try std.math.divCeil(usize, y_pix, h); 179 + const pix_per_col = try std.math.divCeil(u16, x_pix, w); 180 + const pix_per_row = try std.math.divCeil(u16, y_pix, h); 175 181 176 - const cell_width = std.math.divCeil(usize, self.width, pix_per_col) catch 0; 177 - const cell_height = std.math.divCeil(usize, self.height, pix_per_row) catch 0; 182 + const cell_width = std.math.divCeil(u16, self.width, pix_per_col) catch 0; 183 + const cell_height = std.math.divCeil(u16, self.height, pix_per_row) catch 0; 178 184 return .{ 179 185 .rows = cell_height, 180 186 .cols = cell_width,
+66 -42
src/InternalScreen.zig
··· 10 10 const InternalScreen = @This(); 11 11 12 12 pub const InternalCell = struct { 13 - char: std.ArrayList(u8) = undefined, 13 + char: std.ArrayListUnmanaged(u8) = .empty, 14 14 style: Style = .{}, 15 - uri: std.ArrayList(u8) = undefined, 16 - uri_id: std.ArrayList(u8) = undefined, 15 + uri: std.ArrayListUnmanaged(u8) = .empty, 16 + uri_id: std.ArrayListUnmanaged(u8) = .empty, 17 17 // if we got skipped because of a wide character 18 18 skipped: bool = false, 19 19 default: bool = true, 20 20 21 + // If we should skip rendering *this* round due to being printed over previously (from a scaled 22 + // cell, for example) 23 + skip: bool = false, 24 + 25 + scale: Cell.Scale = .{}, 26 + 21 27 pub fn eql(self: InternalCell, cell: Cell) bool { 28 + 22 29 // fastpath when both cells are default 23 30 if (self.default and cell.default) return true; 24 - // this is actually faster than std.meta.eql on the individual items. 25 - // Our strings are always small, usually less than 4 bytes so the simd 26 - // usage in std.mem.eql has too much overhead vs looping the bytes 27 - if (!std.mem.eql(u8, self.char.items, cell.char.grapheme)) return false; 28 - if (!Style.eql(self.style, cell.style)) return false; 29 - if (!std.mem.eql(u8, self.uri.items, cell.link.uri)) return false; 30 - if (!std.mem.eql(u8, self.uri_id.items, cell.link.params)) return false; 31 - return true; 31 + 32 + return std.mem.eql(u8, self.char.items, cell.char.grapheme) and 33 + Style.eql(self.style, cell.style) and 34 + std.mem.eql(u8, self.uri.items, cell.link.uri) and 35 + std.mem.eql(u8, self.uri_id.items, cell.link.params); 32 36 } 33 37 }; 34 38 35 - width: usize = 0, 36 - height: usize = 0, 39 + arena: *std.heap.ArenaAllocator, 40 + width: u16 = 0, 41 + height: u16 = 0, 37 42 38 - buf: []InternalCell = undefined, 43 + buf: []InternalCell, 39 44 40 - cursor_row: usize = 0, 41 - cursor_col: usize = 0, 45 + cursor_row: u16 = 0, 46 + cursor_col: u16 = 0, 42 47 cursor_vis: bool = false, 43 48 cursor_shape: CursorShape = .default, 44 49 45 50 mouse_shape: MouseShape = .default, 46 51 47 52 /// sets each cell to the default cell 48 - pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !InternalScreen { 53 + pub fn init(alloc: std.mem.Allocator, w: u16, h: u16) !InternalScreen { 54 + const arena = try alloc.create(std.heap.ArenaAllocator); 55 + arena.* = .init(alloc); 49 56 var screen = InternalScreen{ 50 - .buf = try alloc.alloc(InternalCell, w * h), 57 + .arena = arena, 58 + .buf = try arena.allocator().alloc(InternalCell, @as(usize, @intCast(w)) * h), 51 59 }; 52 60 for (screen.buf, 0..) |_, i| { 53 61 screen.buf[i] = .{ 54 - .char = try std.ArrayList(u8).initCapacity(alloc, 1), 55 - .uri = std.ArrayList(u8).init(alloc), 56 - .uri_id = std.ArrayList(u8).init(alloc), 62 + .char = try std.ArrayListUnmanaged(u8).initCapacity(arena.allocator(), 1), 63 + .uri = .empty, 64 + .uri_id = .empty, 57 65 }; 58 - try screen.buf[i].char.append(' '); 66 + screen.buf[i].char.appendAssumeCapacity(' '); 59 67 } 60 68 screen.width = w; 61 69 screen.height = h; ··· 63 71 } 64 72 65 73 pub fn deinit(self: *InternalScreen, alloc: std.mem.Allocator) void { 66 - for (self.buf, 0..) |_, i| { 67 - self.buf[i].char.deinit(); 68 - self.buf[i].uri.deinit(); 69 - self.buf[i].uri_id.deinit(); 70 - } 71 - 72 - alloc.free(self.buf); 74 + self.arena.deinit(); 75 + alloc.destroy(self.arena); 76 + self.* = undefined; 73 77 } 74 78 75 79 /// writes a cell to a location. 0 indexed 76 80 pub fn writeCell( 77 81 self: *InternalScreen, 78 - col: usize, 79 - row: usize, 82 + col: u16, 83 + row: u16, 80 84 cell: Cell, 81 85 ) void { 82 - if (self.width < col) { 86 + if (self.width <= col) { 83 87 // column out of bounds 84 88 return; 85 89 } 86 - if (self.height < row) { 90 + if (self.height <= row) { 87 91 // height out of bounds 88 92 return; 89 93 } 90 - const i = (row * self.width) + col; 94 + const i = (@as(usize, @intCast(row)) * self.width) + col; 91 95 assert(i < self.buf.len); 92 96 self.buf[i].char.clearRetainingCapacity(); 93 - self.buf[i].char.appendSlice(cell.char.grapheme) catch { 97 + self.buf[i].char.appendSlice(self.arena.allocator(), cell.char.grapheme) catch { 94 98 log.warn("couldn't write grapheme", .{}); 95 99 }; 96 100 self.buf[i].uri.clearRetainingCapacity(); 97 - self.buf[i].uri.appendSlice(cell.link.uri) catch { 101 + self.buf[i].uri.appendSlice(self.arena.allocator(), cell.link.uri) catch { 98 102 log.warn("couldn't write uri", .{}); 99 103 }; 100 104 self.buf[i].uri_id.clearRetainingCapacity(); 101 - self.buf[i].uri_id.appendSlice(cell.link.params) catch { 105 + self.buf[i].uri_id.appendSlice(self.arena.allocator(), cell.link.params) catch { 102 106 log.warn("couldn't write uri_id", .{}); 103 107 }; 104 108 self.buf[i].style = cell.style; 105 109 self.buf[i].default = cell.default; 106 110 } 107 111 108 - pub fn readCell(self: *InternalScreen, col: usize, row: usize) ?Cell { 109 - if (self.width < col) { 112 + pub fn readCell(self: *InternalScreen, col: u16, row: u16) ?Cell { 113 + if (self.width <= col) { 110 114 // column out of bounds 111 115 return null; 112 116 } 113 - if (self.height < row) { 117 + if (self.height <= row) { 114 118 // height out of bounds 115 119 return null; 116 120 } 117 121 const i = (row * self.width) + col; 118 122 assert(i < self.buf.len); 123 + const cell = self.buf[i]; 119 124 return .{ 120 - .char = .{ .grapheme = self.buf[i].char.items }, 121 - .style = self.buf[i].style, 125 + .char = .{ .grapheme = cell.char.items }, 126 + .style = cell.style, 127 + .link = .{ 128 + .uri = cell.uri.items, 129 + .params = cell.uri_id.items, 130 + }, 131 + .default = cell.default, 122 132 }; 123 133 } 134 + 135 + test "InternalScreen: out-of-bounds read/write are ignored" { 136 + var screen = try InternalScreen.init(std.testing.allocator, 2, 2); 137 + defer screen.deinit(std.testing.allocator); 138 + 139 + const sentinel: Cell = .{ .char = .{ .grapheme = "A", .width = 1 } }; 140 + screen.writeCell(0, 1, sentinel); 141 + 142 + const oob_cell: Cell = .{ .char = .{ .grapheme = "X", .width = 1 } }; 143 + screen.writeCell(2, 0, oob_cell); 144 + const read_back = screen.readCell(0, 1) orelse return error.TestUnexpectedResult; 145 + try std.testing.expect(std.mem.eql(u8, read_back.char.grapheme, "A")); 146 + try std.testing.expect(screen.readCell(2, 0) == null); 147 + }
+25 -1
src/Key.zig
··· 13 13 meta: bool = false, 14 14 caps_lock: bool = false, 15 15 num_lock: bool = false, 16 + 17 + pub fn eql(self: Modifiers, other: Modifiers) bool { 18 + const a: u8 = @bitCast(self); 19 + const b: u8 = @bitCast(other); 20 + return a == b; 21 + } 16 22 }; 17 23 18 24 /// Flags for the Kitty Protocol. ··· 105 111 self_mods.shift = false; 106 112 self_mods.caps_lock = false; 107 113 var arg_mods = mods; 114 + 115 + // TODO: Use zg case_data for full unicode support. We'll need to allocate the case data 116 + // somewhere 117 + const _cp: u21 = if (cp < 128 and (mods.shift or mods.caps_lock)) 118 + // Uppercase our codepoint 119 + std.ascii.toUpper(@intCast(cp)) 120 + else 121 + cp; 122 + 108 123 arg_mods.num_lock = false; 109 124 arg_mods.shift = false; 110 125 arg_mods.caps_lock = false; 111 126 112 127 var buf: [4]u8 = undefined; 113 - const n = std.unicode.utf8Encode(cp, buf[0..]) catch return false; 128 + const n = std.unicode.utf8Encode(_cp, &buf) catch return false; 114 129 return std.mem.eql(u8, self.text.?, buf[0..n]) and std.meta.eql(self_mods, arg_mods); 115 130 } 116 131 ··· 274 289 .{ "comma", ',' }, 275 290 276 291 // special keys 292 + .{ "tab", tab }, 293 + .{ "enter", enter }, 294 + .{ "escape", escape }, 295 + .{ "space", space }, 296 + .{ "backspace", backspace }, 277 297 .{ "insert", insert }, 278 298 .{ "delete", delete }, 279 299 .{ "left", left }, ··· 388 408 const key: Key = .{ 389 409 .codepoint = 'a', 390 410 .mods = .{ .num_lock = true }, 411 + .text = "a", 391 412 }; 392 413 try testing.expect(key.matches('a', .{})); 414 + try testing.expect(!key.matches('a', .{ .shift = true })); 393 415 } 394 416 395 417 test "matches 'shift+a'" { 396 418 const key: Key = .{ 397 419 .codepoint = 'a', 420 + .shifted_codepoint = 'A', 398 421 .mods = .{ .shift = true }, 399 422 .text = "A", 400 423 }; 401 424 try testing.expect(key.matches('a', .{ .shift = true })); 425 + try testing.expect(!key.matches('a', .{})); 402 426 try testing.expect(key.matches('A', .{})); 403 427 try testing.expect(!key.matches('A', .{ .ctrl = true })); 404 428 }
+113 -17
src/Loop.zig
··· 1 1 const std = @import("std"); 2 2 const builtin = @import("builtin"); 3 3 4 - const grapheme = @import("grapheme"); 5 - 6 4 const GraphemeCache = @import("GraphemeCache.zig"); 7 5 const Parser = @import("Parser.zig"); 8 6 const Queue = @import("queue.zig").Queue; ··· 31 29 switch (builtin.os.tag) { 32 30 .windows => {}, 33 31 else => { 34 - const handler: Tty.SignalHandler = .{ 35 - .context = self, 36 - .callback = Self.winsizeCallback, 37 - }; 38 - try Tty.notifyWinsize(handler); 32 + if (!builtin.is_test) { 33 + const handler: Tty.SignalHandler = .{ 34 + .context = self, 35 + .callback = Self.winsizeCallback, 36 + }; 37 + try Tty.notifyWinsize(handler); 38 + } 39 39 }, 40 40 } 41 41 } ··· 45 45 if (self.thread) |_| return; 46 46 self.thread = try std.Thread.spawn(.{}, Self.ttyRun, .{ 47 47 self, 48 - &self.vaxis.unicode.grapheme_data, 49 48 self.vaxis.opts.system_clipboard_allocator, 50 49 }); 51 50 } ··· 56 55 if (self.thread == null) return; 57 56 self.should_quit = true; 58 57 // trigger a read 59 - self.vaxis.deviceStatusReport(self.tty.anyWriter()) catch {}; 58 + self.vaxis.deviceStatusReport(self.tty.writer()) catch {}; 60 59 61 60 if (self.thread) |thread| { 62 61 thread.join(); ··· 105 104 /// read input from the tty. This is run in a separate thread 106 105 fn ttyRun( 107 106 self: *Self, 108 - grapheme_data: *const grapheme.GraphemeData, 109 107 paste_allocator: ?std.mem.Allocator, 110 108 ) !void { 109 + // Return early if we're in test mode to avoid infinite loops 110 + if (builtin.is_test) return; 111 + 111 112 // initialize a grapheme cache 112 113 var cache: GraphemeCache = .{}; 113 114 114 115 switch (builtin.os.tag) { 115 116 .windows => { 116 - var parser: Parser = .{ 117 - .grapheme_data = grapheme_data, 118 - }; 117 + var parser: Parser = .{}; 119 118 while (!self.should_quit) { 120 119 const event = try self.tty.nextEvent(&parser, paste_allocator); 121 120 try handleEventGeneric(self, self.vaxis, &cache, Event, event, null); ··· 128 127 self.postEvent(.{ .winsize = winsize }); 129 128 } 130 129 131 - var parser: Parser = .{ 132 - .grapheme_data = grapheme_data, 133 - }; 130 + var parser: Parser = .{}; 134 131 135 132 // initialize the read buffer 136 133 var buf: [1024]u8 = undefined; ··· 175 172 } 176 173 }, 177 174 .key_press => |key| { 175 + // Check for a cursor position response for our explicit width query. This will 176 + // always be an F3 key with shift = true, and we must be looking for queries 177 + if (key.codepoint == vaxis.Key.f3 and 178 + key.mods.shift and 179 + !vx.queries_done.load(.unordered)) 180 + { 181 + log.info("explicit width capability detected", .{}); 182 + vx.caps.explicit_width = true; 183 + vx.caps.unicode = .unicode; 184 + vx.screen.width_method = .unicode; 185 + return; 186 + } 187 + // Check for a cursor position response for our scaled text query. This will 188 + // always be an F3 key with alt = true, and we must be looking for queries 189 + if (key.codepoint == vaxis.Key.f3 and 190 + key.mods.alt and 191 + !vx.queries_done.load(.unordered)) 192 + { 193 + log.info("scaled text capability detected", .{}); 194 + vx.caps.scaled_text = true; 195 + return; 196 + } 178 197 if (@hasField(Event, "key_press")) { 179 198 // HACK: yuck. there has to be a better way 180 199 var mut_key = key; ··· 196 215 }, 197 216 .cap_da1 => { 198 217 std.Thread.Futex.wake(&vx.query_futex, 10); 218 + vx.queries_done.store(true, .unordered); 199 219 }, 200 - .mouse => {}, // Unsupported currently 220 + .mouse => |mouse| { 221 + if (@hasField(Event, "mouse")) { 222 + return self.postEvent(.{ .mouse = vx.translateMouse(mouse) }); 223 + } 224 + }, 225 + .focus_in => { 226 + if (@hasField(Event, "focus_in")) { 227 + return self.postEvent(.focus_in); 228 + } 229 + }, 230 + .focus_out => { 231 + if (@hasField(Event, "focus_out")) { 232 + return self.postEvent(.focus_out); 233 + } 234 + }, // Unsupported currently 201 235 else => {}, 202 236 } 203 237 }, 204 238 else => { 205 239 switch (event) { 206 240 .key_press => |key| { 241 + // Check for a cursor position response for our explicity width query. This will 242 + // always be an F3 key with shift = true, and we must be looking for queries 243 + if (key.codepoint == vaxis.Key.f3 and 244 + key.mods.shift and 245 + !vx.queries_done.load(.unordered)) 246 + { 247 + log.info("explicit width capability detected", .{}); 248 + vx.caps.explicit_width = true; 249 + vx.caps.unicode = .unicode; 250 + vx.screen.width_method = .unicode; 251 + return; 252 + } 253 + // Check for a cursor position response for our scaled text query. This will 254 + // always be an F3 key with alt = true, and we must be looking for queries 255 + if (key.codepoint == vaxis.Key.f3 and 256 + key.mods.alt and 257 + !vx.queries_done.load(.unordered)) 258 + { 259 + log.info("scaled text capability detected", .{}); 260 + vx.caps.scaled_text = true; 261 + return; 262 + } 207 263 if (@hasField(Event, "key_press")) { 208 264 // HACK: yuck. there has to be a better way 209 265 var mut_key = key; ··· 226 282 .mouse => |mouse| { 227 283 if (@hasField(Event, "mouse")) { 228 284 return self.postEvent(.{ .mouse = vx.translateMouse(mouse) }); 285 + } 286 + }, 287 + .mouse_leave => { 288 + if (@hasField(Event, "mouse_leave")) { 289 + return self.postEvent(.mouse_leave); 229 290 } 230 291 }, 231 292 .focus_in => { ··· 293 354 log.info("color_scheme_updates capability detected", .{}); 294 355 vx.caps.color_scheme_updates = true; 295 356 }, 357 + .cap_multi_cursor => { 358 + log.info("multi cursor capability detected", .{}); 359 + vx.caps.multi_cursor = true; 360 + }, 296 361 .cap_da1 => { 297 362 std.Thread.Futex.wake(&vx.query_futex, 10); 363 + vx.queries_done.store(true, .unordered); 298 364 }, 299 365 .winsize => |winsize| { 300 366 vx.state.in_band_resize = true; 367 + switch (builtin.os.tag) { 368 + .windows => {}, 369 + // Reset the signal handler if we are receiving in_band_resize 370 + else => Tty.resetSignalHandler(), 371 + } 301 372 if (@hasField(Event, "winsize")) { 302 373 return self.postEvent(.{ .winsize = winsize }); 303 374 } ··· 306 377 }, 307 378 } 308 379 } 380 + 381 + test Loop { 382 + const Event = union(enum) { 383 + key_press: vaxis.Key, 384 + winsize: vaxis.Winsize, 385 + focus_in, 386 + foo: u8, 387 + }; 388 + 389 + var tty = try vaxis.Tty.init(&.{}); 390 + defer tty.deinit(); 391 + 392 + var vx = try vaxis.init(std.testing.allocator, .{}); 393 + defer vx.deinit(std.testing.allocator, tty.writer()); 394 + 395 + var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx }; 396 + try loop.init(); 397 + 398 + try loop.start(); 399 + defer loop.stop(); 400 + 401 + // Optionally enter the alternate screen 402 + try vx.enterAltScreen(tty.writer()); 403 + try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_ms); 404 + }
+6 -4
src/Mouse.zig
··· 20 20 none, 21 21 wheel_up = 64, 22 22 wheel_down = 65, 23 + wheel_right = 66, 24 + wheel_left = 67, 23 25 button_8 = 128, 24 26 button_9 = 129, 25 27 button_10 = 130, ··· 39 41 drag, 40 42 }; 41 43 42 - col: usize, 43 - row: usize, 44 - xoffset: usize = 0, 45 - yoffset: usize = 0, 44 + col: i16, 45 + row: i16, 46 + xoffset: u16 = 0, 47 + yoffset: u16 = 0, 46 48 button: Button, 47 49 mods: Modifiers, 48 50 type: Type,
+277 -103
src/Parser.zig
··· 4 4 const Event = @import("event.zig").Event; 5 5 const Key = @import("Key.zig"); 6 6 const Mouse = @import("Mouse.zig"); 7 - const code_point = @import("code_point"); 8 - const grapheme = @import("grapheme"); 7 + const uucode = @import("uucode"); 9 8 const Winsize = @import("main.zig").Winsize; 10 9 11 10 const log = std.log.scoped(.vaxis_parser); ··· 25 24 const shift: u8 = 0b00000100; 26 25 const alt: u8 = 0b00001000; 27 26 const ctrl: u8 = 0b00010000; 27 + const leave: u16 = 0b100000000; 28 28 }; 29 29 30 30 // the state of the parser ··· 44 44 // a buffer to temporarily store text in. We need this to encode 45 45 // text-as-codepoints 46 46 buf: [128]u8 = undefined, 47 - 48 - grapheme_data: *const grapheme.GraphemeData, 49 47 50 48 /// Parse the first event from the input buffer. If a completion event is not 51 49 /// present, Result.event will be null and Result.n will be 0 ··· 77 75 }; 78 76 }, 79 77 } 80 - } else return parseGround(input, self.grapheme_data); 78 + } else return parseGround(input); 81 79 } 82 80 83 81 /// Parse ground state 84 - inline fn parseGround(input: []const u8, data: *const grapheme.GraphemeData) !Result { 82 + inline fn parseGround(input: []const u8) !Result { 85 83 std.debug.assert(input.len > 0); 86 84 87 85 const b = input[0]; ··· 94 92 0x00 => .{ .codepoint = '@', .mods = .{ .ctrl = true } }, 95 93 0x08 => .{ .codepoint = Key.backspace }, 96 94 0x09 => .{ .codepoint = Key.tab }, 97 - 0x0A, 98 - 0x0D, 99 - => .{ .codepoint = Key.enter }, 95 + 0x0A => .{ .codepoint = 'j', .mods = .{ .ctrl = true } }, 96 + 0x0D => .{ .codepoint = Key.enter }, 100 97 0x01...0x07, 101 98 0x0B...0x0C, 102 99 0x0E...0x1A, ··· 109 106 }, 110 107 0x7F => .{ .codepoint = Key.backspace }, 111 108 else => blk: { 112 - var iter: code_point.Iterator = .{ .bytes = input }; 109 + var iter = uucode.utf8.Iterator.init(input); 113 110 // return null if we don't have a valid codepoint 114 - const cp = iter.next() orelse return error.InvalidUTF8; 111 + const first_cp = iter.next() orelse return error.InvalidUTF8; 115 112 116 - n = cp.len; 113 + n = std.unicode.utf8CodepointSequenceLength(first_cp) catch return error.InvalidUTF8; 117 114 118 115 // Check if we have a multi-codepoint grapheme 119 - var code = cp.code; 120 - var g_state: grapheme.State = .{}; 121 - var prev_cp = code; 122 - while (iter.next()) |next_cp| { 123 - if (grapheme.graphemeBreak(prev_cp, next_cp.code, data, &g_state)) { 116 + var code = first_cp; 117 + var grapheme_iter = uucode.grapheme.Iterator(uucode.utf8.Iterator).init(.init(input)); 118 + var grapheme_len: usize = 0; 119 + var cp_count: usize = 0; 120 + 121 + while (grapheme_iter.next()) |result| { 122 + cp_count += 1; 123 + if (result.is_break) { 124 + // Found the first grapheme boundary 125 + grapheme_len = grapheme_iter.i; 124 126 break; 125 127 } 126 - prev_cp = next_cp.code; 127 - code = Key.multicodepoint; 128 - n += next_cp.len; 128 + } 129 + 130 + if (grapheme_len > 0) { 131 + n = grapheme_len; 132 + if (cp_count > 1) { 133 + code = Key.multicodepoint; 134 + } 129 135 } 130 136 131 137 break :blk .{ .codepoint = code, .text = input[0..n] }; ··· 468 474 469 475 'I' => return .{ .event = .focus_in, .n = sequence.len }, 470 476 'O' => return .{ .event = .focus_out, .n = sequence.len }, 471 - 'M', 'm' => return parseMouse(sequence), 477 + 'M', 'm' => return parseMouse(sequence, input), 472 478 'c' => { 473 479 // Primary DA (CSI ? Pm c) 474 480 std.debug.assert(sequence.len >= 4); // ESC [ ? c == 4 bytes ··· 522 528 const width_pix = iter.next() orelse "0"; 523 529 524 530 const winsize: Winsize = .{ 525 - .rows = std.fmt.parseUnsigned(usize, height_char, 10) catch return null_event, 526 - .cols = std.fmt.parseUnsigned(usize, width_char, 10) catch return null_event, 527 - .x_pixel = std.fmt.parseUnsigned(usize, width_pix, 10) catch return null_event, 528 - .y_pixel = std.fmt.parseUnsigned(usize, height_pix, 10) catch return null_event, 531 + .rows = std.fmt.parseUnsigned(u16, height_char, 10) catch return null_event, 532 + .cols = std.fmt.parseUnsigned(u16, width_char, 10) catch return null_event, 533 + .x_pixel = std.fmt.parseUnsigned(u16, width_pix, 10) catch return null_event, 534 + .y_pixel = std.fmt.parseUnsigned(u16, height_pix, 10) catch return null_event, 529 535 }; 530 536 return .{ 531 537 .event = .{ .winsize = winsize }, ··· 593 599 key.text = text_buf[0..total]; 594 600 } 595 601 602 + { 603 + // We check if we have *only* shift, no text, and a printable character. This can 604 + // happen when we have disambiguate on and a key is pressed and encoded as CSI u, 605 + // for example shift + space can produce CSI 32 ; 2 u 606 + const mod_test: Key.Modifiers = .{ 607 + .shift = true, 608 + .caps_lock = key.mods.caps_lock, 609 + .num_lock = key.mods.num_lock, 610 + }; 611 + if (key.text == null and 612 + key.mods.eql(mod_test) and 613 + key.codepoint <= std.math.maxInt(u8) and 614 + std.ascii.isPrint(@intCast(key.codepoint))) 615 + { 616 + // Encode the codepoint as upper 617 + const upper = std.ascii.toUpper(@intCast(key.codepoint)); 618 + const n = std.unicode.utf8Encode(upper, text_buf) catch unreachable; 619 + key.text = text_buf[0..n]; 620 + key.shifted_codepoint = upper; 621 + } 622 + } 623 + 596 624 const event: Event = if (is_release) 597 625 .{ .key_release = key } 598 626 else ··· 624 652 else => return null_event, 625 653 } 626 654 }, 655 + 'q' => { 656 + // kitty multi cursor cap (CSI > 1;2;3;29;30;40;100;101 TRAILER) (TRAILER is " q") 657 + const second_final = sequence[sequence.len - 2]; 658 + if (second_final != ' ') return null_event; 659 + // check for any digits. we're not too picky about checking the supported cursor types here 660 + for (sequence[0 .. sequence.len - 2]) |c| switch (c) { 661 + '0'...'9' => return .{ .event = .cap_multi_cursor, .n = sequence.len }, 662 + else => continue, 663 + }; 664 + return null_event; 665 + }, 627 666 else => return null_event, 628 667 } 629 668 } ··· 631 670 /// Parse a param buffer, returning a default value if the param was empty 632 671 inline fn parseParam(comptime T: type, buf: []const u8, default: ?T) ?T { 633 672 if (buf.len == 0) return default; 634 - return std.fmt.parseUnsigned(T, buf, 10) catch return null; 673 + return std.fmt.parseInt(T, buf, 10) catch return null; 635 674 } 636 675 637 676 /// Parse a mouse event 638 - inline fn parseMouse(input: []const u8) Result { 639 - std.debug.assert(input.len >= 4); // ESC [ < [Mm] 677 + inline fn parseMouse(input: []const u8, full_input: []const u8) Result { 640 678 const null_event: Result = .{ .event = null, .n = input.len }; 641 679 642 - if (input[2] != '<') return null_event; 680 + var button_mask: u16 = undefined; 681 + var px: i16 = undefined; 682 + var py: i16 = undefined; 683 + var xterm: bool = undefined; 684 + if (input.len == 3 and (input[2] == 'M') and full_input.len >= 6) { 685 + xterm = true; 686 + button_mask = full_input[3] - 32; 687 + px = full_input[4] - 32; 688 + py = full_input[5] - 32; 689 + } else if (input.len >= 4 and input[2] == '<') { 690 + xterm = false; 691 + const delim1 = std.mem.indexOfScalarPos(u8, input, 3, ';') orelse return null_event; 692 + button_mask = parseParam(u16, input[3..delim1], null) orelse return null_event; 693 + const delim2 = std.mem.indexOfScalarPos(u8, input, delim1 + 1, ';') orelse return null_event; 694 + px = parseParam(i16, input[delim1 + 1 .. delim2], 1) orelse return null_event; 695 + py = parseParam(i16, input[delim2 + 1 .. input.len - 1], 1) orelse return null_event; 696 + } else { 697 + return null_event; 698 + } 643 699 644 - const delim1 = std.mem.indexOfScalarPos(u8, input, 3, ';') orelse return null_event; 645 - const button_mask = parseParam(u16, input[3..delim1], null) orelse return null_event; 646 - const delim2 = std.mem.indexOfScalarPos(u8, input, delim1 + 1, ';') orelse return null_event; 647 - const px = parseParam(u16, input[delim1 + 1 .. delim2], 1) orelse return null_event; 648 - const py = parseParam(u16, input[delim2 + 1 .. input.len - 1], 1) orelse return null_event; 700 + if (button_mask & mouse_bits.leave > 0) 701 + return .{ .event = .mouse_leave, .n = if (xterm) 6 else input.len }; 649 702 650 703 const button: Mouse.Button = @enumFromInt(button_mask & mouse_bits.buttons); 651 704 const motion = button_mask & mouse_bits.motion > 0; ··· 669 722 if (motion and button == Mouse.Button.none) { 670 723 break :blk .motion; 671 724 } 725 + if (xterm) { 726 + if (button == Mouse.Button.none) { 727 + break :blk .release; 728 + } 729 + break :blk .press; 730 + } 672 731 if (input[input.len - 1] == 'm') break :blk .release; 673 732 break :blk .press; 674 733 }, 675 734 }; 676 - return .{ .event = .{ .mouse = mouse }, .n = input.len }; 735 + return .{ .event = .{ .mouse = mouse }, .n = if (xterm) 6 else input.len }; 677 736 } 678 737 679 738 test "parse: single xterm keypress" { 680 739 const alloc = testing.allocator_instance.allocator(); 681 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 682 - defer grapheme_data.deinit(); 683 740 const input = "a"; 684 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 741 + var parser: Parser = .{}; 685 742 const result = try parser.parse(input, alloc); 686 743 const expected_key: Key = .{ 687 744 .codepoint = 'a', ··· 695 752 696 753 test "parse: single xterm keypress backspace" { 697 754 const alloc = testing.allocator_instance.allocator(); 698 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 699 - defer grapheme_data.deinit(); 700 755 const input = "\x08"; 701 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 756 + var parser: Parser = .{}; 702 757 const result = try parser.parse(input, alloc); 703 758 const expected_key: Key = .{ 704 759 .codepoint = Key.backspace, ··· 711 766 712 767 test "parse: single xterm keypress with more buffer" { 713 768 const alloc = testing.allocator_instance.allocator(); 714 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 715 - defer grapheme_data.deinit(); 716 769 const input = "ab"; 717 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 770 + var parser: Parser = .{}; 718 771 const result = try parser.parse(input, alloc); 719 772 const expected_key: Key = .{ 720 773 .codepoint = 'a', ··· 729 782 730 783 test "parse: xterm escape keypress" { 731 784 const alloc = testing.allocator_instance.allocator(); 732 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 733 - defer grapheme_data.deinit(); 734 785 const input = "\x1b"; 735 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 786 + var parser: Parser = .{}; 736 787 const result = try parser.parse(input, alloc); 737 788 const expected_key: Key = .{ .codepoint = Key.escape }; 738 789 const expected_event: Event = .{ .key_press = expected_key }; ··· 743 794 744 795 test "parse: xterm ctrl+a" { 745 796 const alloc = testing.allocator_instance.allocator(); 746 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 747 - defer grapheme_data.deinit(); 748 797 const input = "\x01"; 749 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 798 + var parser: Parser = .{}; 750 799 const result = try parser.parse(input, alloc); 751 800 const expected_key: Key = .{ .codepoint = 'a', .mods = .{ .ctrl = true } }; 752 801 const expected_event: Event = .{ .key_press = expected_key }; ··· 757 806 758 807 test "parse: xterm alt+a" { 759 808 const alloc = testing.allocator_instance.allocator(); 760 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 761 - defer grapheme_data.deinit(); 762 809 const input = "\x1ba"; 763 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 810 + var parser: Parser = .{}; 764 811 const result = try parser.parse(input, alloc); 765 812 const expected_key: Key = .{ .codepoint = 'a', .mods = .{ .alt = true } }; 766 813 const expected_event: Event = .{ .key_press = expected_key }; ··· 771 818 772 819 test "parse: xterm key up" { 773 820 const alloc = testing.allocator_instance.allocator(); 774 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 775 - defer grapheme_data.deinit(); 776 821 { 777 822 // normal version 778 823 const input = "\x1b[A"; 779 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 824 + var parser: Parser = .{}; 780 825 const result = try parser.parse(input, alloc); 781 826 const expected_key: Key = .{ .codepoint = Key.up }; 782 827 const expected_event: Event = .{ .key_press = expected_key }; ··· 788 833 { 789 834 // application keys version 790 835 const input = "\x1bOA"; 791 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 836 + var parser: Parser = .{}; 792 837 const result = try parser.parse(input, alloc); 793 838 const expected_key: Key = .{ .codepoint = Key.up }; 794 839 const expected_event: Event = .{ .key_press = expected_key }; ··· 800 845 801 846 test "parse: xterm shift+up" { 802 847 const alloc = testing.allocator_instance.allocator(); 803 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 804 - defer grapheme_data.deinit(); 805 848 const input = "\x1b[1;2A"; 806 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 849 + var parser: Parser = .{}; 807 850 const result = try parser.parse(input, alloc); 808 851 const expected_key: Key = .{ .codepoint = Key.up, .mods = .{ .shift = true } }; 809 852 const expected_event: Event = .{ .key_press = expected_key }; ··· 814 857 815 858 test "parse: xterm insert" { 816 859 const alloc = testing.allocator_instance.allocator(); 817 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 818 - defer grapheme_data.deinit(); 819 860 const input = "\x1b[2~"; 820 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 861 + var parser: Parser = .{}; 821 862 const result = try parser.parse(input, alloc); 822 863 const expected_key: Key = .{ .codepoint = Key.insert, .mods = .{} }; 823 864 const expected_event: Event = .{ .key_press = expected_key }; ··· 828 869 829 870 test "parse: paste_start" { 830 871 const alloc = testing.allocator_instance.allocator(); 831 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 832 - defer grapheme_data.deinit(); 833 872 const input = "\x1b[200~"; 834 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 873 + var parser: Parser = .{}; 835 874 const result = try parser.parse(input, alloc); 836 875 const expected_event: Event = .paste_start; 837 876 ··· 841 880 842 881 test "parse: paste_end" { 843 882 const alloc = testing.allocator_instance.allocator(); 844 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 845 - defer grapheme_data.deinit(); 846 883 const input = "\x1b[201~"; 847 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 884 + var parser: Parser = .{}; 848 885 const result = try parser.parse(input, alloc); 849 886 const expected_event: Event = .paste_end; 850 887 ··· 854 891 855 892 test "parse: osc52 paste" { 856 893 const alloc = testing.allocator_instance.allocator(); 857 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 858 - defer grapheme_data.deinit(); 859 894 const input = "\x1b]52;c;b3NjNTIgcGFzdGU=\x1b\\"; 860 895 const expected_text = "osc52 paste"; 861 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 896 + var parser: Parser = .{}; 862 897 const result = try parser.parse(input, alloc); 863 898 864 899 try testing.expectEqual(25, result.n); ··· 873 908 874 909 test "parse: focus_in" { 875 910 const alloc = testing.allocator_instance.allocator(); 876 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 877 - defer grapheme_data.deinit(); 878 911 const input = "\x1b[I"; 879 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 912 + var parser: Parser = .{}; 880 913 const result = try parser.parse(input, alloc); 881 914 const expected_event: Event = .focus_in; 882 915 ··· 886 919 887 920 test "parse: focus_out" { 888 921 const alloc = testing.allocator_instance.allocator(); 889 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 890 - defer grapheme_data.deinit(); 891 922 const input = "\x1b[O"; 892 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 923 + var parser: Parser = .{}; 893 924 const result = try parser.parse(input, alloc); 894 925 const expected_event: Event = .focus_out; 895 926 ··· 899 930 900 931 test "parse: kitty: shift+a without text reporting" { 901 932 const alloc = testing.allocator_instance.allocator(); 902 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 903 - defer grapheme_data.deinit(); 904 933 const input = "\x1b[97:65;2u"; 905 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 934 + var parser: Parser = .{}; 906 935 const result = try parser.parse(input, alloc); 907 936 const expected_key: Key = .{ 908 937 .codepoint = 'a', 909 938 .shifted_codepoint = 'A', 910 939 .mods = .{ .shift = true }, 940 + .text = "A", 911 941 }; 912 942 const expected_event: Event = .{ .key_press = expected_key }; 913 943 914 944 try testing.expectEqual(10, result.n); 915 - try testing.expectEqual(expected_event, result.event); 945 + try testing.expectEqualDeep(expected_event, result.event); 916 946 } 917 947 918 948 test "parse: kitty: alt+shift+a without text reporting" { 919 949 const alloc = testing.allocator_instance.allocator(); 920 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 921 - defer grapheme_data.deinit(); 922 950 const input = "\x1b[97:65;4u"; 923 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 951 + var parser: Parser = .{}; 924 952 const result = try parser.parse(input, alloc); 925 953 const expected_key: Key = .{ 926 954 .codepoint = 'a', ··· 935 963 936 964 test "parse: kitty: a without text reporting" { 937 965 const alloc = testing.allocator_instance.allocator(); 938 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 939 - defer grapheme_data.deinit(); 940 966 const input = "\x1b[97u"; 941 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 967 + var parser: Parser = .{}; 942 968 const result = try parser.parse(input, alloc); 943 969 const expected_key: Key = .{ 944 970 .codepoint = 'a', ··· 951 977 952 978 test "parse: kitty: release event" { 953 979 const alloc = testing.allocator_instance.allocator(); 954 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 955 - defer grapheme_data.deinit(); 956 980 const input = "\x1b[97;1:3u"; 957 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 981 + var parser: Parser = .{}; 958 982 const result = try parser.parse(input, alloc); 959 983 const expected_key: Key = .{ 960 984 .codepoint = 'a', ··· 967 991 968 992 test "parse: single codepoint" { 969 993 const alloc = testing.allocator_instance.allocator(); 970 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 971 - defer grapheme_data.deinit(); 972 994 const input = "๐Ÿ™‚"; 973 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 995 + var parser: Parser = .{}; 974 996 const result = try parser.parse(input, alloc); 975 997 const expected_key: Key = .{ 976 998 .codepoint = 0x1F642, ··· 984 1006 985 1007 test "parse: single codepoint with more in buffer" { 986 1008 const alloc = testing.allocator_instance.allocator(); 987 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 988 - defer grapheme_data.deinit(); 989 1009 const input = "๐Ÿ™‚a"; 990 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 1010 + var parser: Parser = .{}; 991 1011 const result = try parser.parse(input, alloc); 992 1012 const expected_key: Key = .{ 993 1013 .codepoint = 0x1F642, ··· 1001 1021 1002 1022 test "parse: multiple codepoint grapheme" { 1003 1023 const alloc = testing.allocator_instance.allocator(); 1004 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 1005 - defer grapheme_data.deinit(); 1006 1024 const input = "๐Ÿ‘ฉโ€๐Ÿš€"; 1007 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 1025 + var parser: Parser = .{}; 1008 1026 const result = try parser.parse(input, alloc); 1009 1027 const expected_key: Key = .{ 1010 1028 .codepoint = Key.multicodepoint, ··· 1018 1036 1019 1037 test "parse: multiple codepoint grapheme with more after" { 1020 1038 const alloc = testing.allocator_instance.allocator(); 1021 - const grapheme_data = try grapheme.GraphemeData.init(alloc); 1022 - defer grapheme_data.deinit(); 1023 1039 const input = "๐Ÿ‘ฉโ€๐Ÿš€abc"; 1024 - var parser: Parser = .{ .grapheme_data = &grapheme_data }; 1040 + var parser: Parser = .{}; 1025 1041 const result = try parser.parse(input, alloc); 1026 1042 const expected_key: Key = .{ 1027 1043 .codepoint = Key.multicodepoint, ··· 1034 1050 try testing.expectEqual(expected_key.codepoint, actual.codepoint); 1035 1051 } 1036 1052 1053 + test "parse: flag emoji" { 1054 + const alloc = testing.allocator_instance.allocator(); 1055 + const input = "๐Ÿ‡บ๐Ÿ‡ธ"; 1056 + var parser: Parser = .{}; 1057 + const result = try parser.parse(input, alloc); 1058 + const expected_key: Key = .{ 1059 + .codepoint = Key.multicodepoint, 1060 + .text = input, 1061 + }; 1062 + const expected_event: Event = .{ .key_press = expected_key }; 1063 + 1064 + try testing.expectEqual(input.len, result.n); 1065 + try testing.expectEqual(expected_event, result.event); 1066 + } 1067 + 1068 + test "parse: combining mark" { 1069 + const alloc = testing.allocator_instance.allocator(); 1070 + // a with combining acute accent (NFD form) 1071 + const input = "a\u{0301}"; 1072 + var parser: Parser = .{}; 1073 + const result = try parser.parse(input, alloc); 1074 + const expected_key: Key = .{ 1075 + .codepoint = Key.multicodepoint, 1076 + .text = input, 1077 + }; 1078 + const expected_event: Event = .{ .key_press = expected_key }; 1079 + 1080 + try testing.expectEqual(input.len, result.n); 1081 + try testing.expectEqual(expected_event, result.event); 1082 + } 1083 + 1084 + test "parse: skin tone emoji" { 1085 + const alloc = testing.allocator_instance.allocator(); 1086 + const input = "๐Ÿ‘‹๐Ÿฟ"; 1087 + var parser: Parser = .{}; 1088 + const result = try parser.parse(input, alloc); 1089 + const expected_key: Key = .{ 1090 + .codepoint = Key.multicodepoint, 1091 + .text = input, 1092 + }; 1093 + const expected_event: Event = .{ .key_press = expected_key }; 1094 + 1095 + try testing.expectEqual(input.len, result.n); 1096 + try testing.expectEqual(expected_event, result.event); 1097 + } 1098 + 1099 + test "parse: text variation selector" { 1100 + const alloc = testing.allocator_instance.allocator(); 1101 + // Heavy black heart with text variation selector 1102 + const input = "โค๏ธŽ"; 1103 + var parser: Parser = .{}; 1104 + const result = try parser.parse(input, alloc); 1105 + const expected_key: Key = .{ 1106 + .codepoint = Key.multicodepoint, 1107 + .text = input, 1108 + }; 1109 + const expected_event: Event = .{ .key_press = expected_key }; 1110 + 1111 + try testing.expectEqual(input.len, result.n); 1112 + try testing.expectEqual(expected_event, result.event); 1113 + } 1114 + 1115 + test "parse: keycap sequence" { 1116 + const alloc = testing.allocator_instance.allocator(); 1117 + const input = "1๏ธโƒฃ"; 1118 + var parser: Parser = .{}; 1119 + const result = try parser.parse(input, alloc); 1120 + const expected_key: Key = .{ 1121 + .codepoint = Key.multicodepoint, 1122 + .text = input, 1123 + }; 1124 + const expected_event: Event = .{ .key_press = expected_key }; 1125 + 1126 + try testing.expectEqual(input.len, result.n); 1127 + try testing.expectEqual(expected_event, result.event); 1128 + } 1129 + 1130 + test "parse(csi): kitty multi cursor" { 1131 + var buf: [1]u8 = undefined; 1132 + { 1133 + const input = "\x1b[>1;2;3;29;30;40;100;101 q"; 1134 + const result = parseCsi(input, &buf); 1135 + const expected: Result = .{ 1136 + .event = .cap_multi_cursor, 1137 + .n = input.len, 1138 + }; 1139 + 1140 + try testing.expectEqual(expected.n, result.n); 1141 + try testing.expectEqual(expected.event, result.event); 1142 + } 1143 + { 1144 + const input = "\x1b[> q"; 1145 + const result = parseCsi(input, &buf); 1146 + const expected: Result = .{ 1147 + .event = null, 1148 + .n = input.len, 1149 + }; 1150 + 1151 + try testing.expectEqual(expected.n, result.n); 1152 + try testing.expectEqual(expected.event, result.event); 1153 + } 1154 + } 1155 + 1037 1156 test "parse(csi): decrpm" { 1038 1157 var buf: [1]u8 = undefined; 1039 1158 { ··· 1128 1247 try testing.expectEqual(expected.n, result.n); 1129 1248 try testing.expectEqual(expected.event, result.event); 1130 1249 } 1250 + 1251 + test "parse(csi): mouse (negative)" { 1252 + var buf: [1]u8 = undefined; 1253 + const input = "\x1b[<35;-50;-100m"; 1254 + const result = parseCsi(input, &buf); 1255 + const expected: Result = .{ 1256 + .event = .{ .mouse = .{ 1257 + .col = -51, 1258 + .row = -101, 1259 + .button = .none, 1260 + .type = .motion, 1261 + .mods = .{}, 1262 + } }, 1263 + .n = input.len, 1264 + }; 1265 + 1266 + try testing.expectEqual(expected.n, result.n); 1267 + try testing.expectEqual(expected.event, result.event); 1268 + } 1269 + 1270 + test "parse(csi): xterm mouse" { 1271 + var buf: [1]u8 = undefined; 1272 + const input = "\x1b[M\x20\x21\x21"; 1273 + const result = parseCsi(input, &buf); 1274 + const expected: Result = .{ 1275 + .event = .{ .mouse = .{ 1276 + .col = 0, 1277 + .row = 0, 1278 + .button = .left, 1279 + .type = .press, 1280 + .mods = .{}, 1281 + } }, 1282 + .n = input.len, 1283 + }; 1284 + 1285 + try testing.expectEqual(expected.n, result.n); 1286 + try testing.expectEqual(expected.event, result.event); 1287 + } 1288 + 1289 + test "parse: disambiguate shift + space" { 1290 + const alloc = testing.allocator_instance.allocator(); 1291 + const input = "\x1b[32;2u"; 1292 + var parser: Parser = .{}; 1293 + const result = try parser.parse(input, alloc); 1294 + const expected_key: Key = .{ 1295 + .codepoint = ' ', 1296 + .shifted_codepoint = ' ', 1297 + .mods = .{ .shift = true }, 1298 + .text = " ", 1299 + }; 1300 + const expected_event: Event = .{ .key_press = expected_key }; 1301 + 1302 + try testing.expectEqual(7, result.n); 1303 + try testing.expectEqualDeep(expected_event, result.event); 1304 + }
+26 -31
src/Screen.zig
··· 5 5 const Shape = @import("Mouse.zig").Shape; 6 6 const Image = @import("Image.zig"); 7 7 const Winsize = @import("main.zig").Winsize; 8 - const Unicode = @import("Unicode.zig"); 9 8 const Method = @import("gwidth.zig").Method; 10 9 11 10 const Screen = @This(); 12 11 13 - width: usize = 0, 14 - height: usize = 0, 12 + width: u16 = 0, 13 + height: u16 = 0, 15 14 16 - width_pix: usize = 0, 17 - height_pix: usize = 0, 15 + width_pix: u16 = 0, 16 + height_pix: u16 = 0, 18 17 19 - buf: []Cell = undefined, 18 + buf: []Cell = &.{}, 20 19 21 - cursor_row: usize = 0, 22 - cursor_col: usize = 0, 20 + cursor_row: u16 = 0, 21 + cursor_col: u16 = 0, 23 22 cursor_vis: bool = false, 24 23 25 - unicode: *const Unicode = undefined, 26 - 27 24 width_method: Method = .wcwidth, 28 25 29 26 mouse_shape: Shape = .default, 30 27 cursor_shape: Cell.CursorShape = .default, 31 28 32 - pub fn init(alloc: std.mem.Allocator, winsize: Winsize, unicode: *const Unicode) !Screen { 29 + pub fn init(alloc: std.mem.Allocator, winsize: Winsize) std.mem.Allocator.Error!Screen { 33 30 const w = winsize.cols; 34 31 const h = winsize.rows; 35 32 const self = Screen{ 36 - .buf = try alloc.alloc(Cell, w * h), 33 + .buf = try alloc.alloc(Cell, @as(usize, @intCast(w)) * h), 37 34 .width = w, 38 35 .height = h, 39 36 .width_pix = winsize.x_pixel, 40 37 .height_pix = winsize.y_pixel, 41 - .unicode = unicode, 42 38 }; 43 39 const base_cell: Cell = .{}; 44 40 @memset(self.buf, base_cell); 45 41 return self; 46 42 } 43 + 47 44 pub fn deinit(self: *Screen, alloc: std.mem.Allocator) void { 48 45 alloc.free(self.buf); 49 46 } 50 47 51 48 /// writes a cell to a location. 0 indexed 52 - pub fn writeCell(self: *Screen, col: usize, row: usize, cell: Cell) void { 53 - if (self.width <= col) { 54 - // column out of bounds 49 + pub fn writeCell(self: *Screen, col: u16, row: u16, cell: Cell) void { 50 + if (col >= self.width or 51 + row >= self.height) 55 52 return; 56 - } 57 - if (self.height <= row) { 58 - // height out of bounds 59 - return; 60 - } 61 - const i = (row * self.width) + col; 53 + const i = (@as(usize, @intCast(row)) * self.width) + col; 62 54 assert(i < self.buf.len); 63 55 self.buf[i] = cell; 64 56 } 65 57 66 - pub fn readCell(self: *Screen, col: usize, row: usize) ?Cell { 67 - if (self.width <= col) { 68 - // column out of bounds 69 - return null; 70 - } 71 - if (self.height <= row) { 72 - // height out of bounds 58 + pub fn readCell(self: *const Screen, col: u16, row: u16) ?Cell { 59 + if (col >= self.width or 60 + row >= self.height) 73 61 return null; 74 - } 75 - const i = (row * self.width) + col; 62 + const i = (@as(usize, @intCast(row)) * self.width) + col; 76 63 assert(i < self.buf.len); 77 64 return self.buf[i]; 78 65 } 66 + 67 + pub fn clear(self: *Screen) void { 68 + @memset(self.buf, .{}); 69 + } 70 + 71 + test "refAllDecls" { 72 + std.testing.refAllDecls(@This()); 73 + }
-28
src/Unicode.zig
··· 1 - const std = @import("std"); 2 - const grapheme = @import("grapheme"); 3 - const DisplayWidth = @import("DisplayWidth"); 4 - 5 - /// A thin wrapper around zg data 6 - const Unicode = @This(); 7 - 8 - grapheme_data: grapheme.GraphemeData, 9 - width_data: DisplayWidth.DisplayWidthData, 10 - 11 - /// initialize all unicode data vaxis may possibly need 12 - pub fn init(alloc: std.mem.Allocator) !Unicode { 13 - return .{ 14 - .grapheme_data = try grapheme.GraphemeData.init(alloc), 15 - .width_data = try DisplayWidth.DisplayWidthData.init(alloc), 16 - }; 17 - } 18 - 19 - /// free all data 20 - pub fn deinit(self: *const Unicode) void { 21 - self.grapheme_data.deinit(); 22 - self.width_data.deinit(); 23 - } 24 - 25 - /// creates a grapheme iterator based on str 26 - pub fn graphemeIterator(self: *const Unicode, str: []const u8) grapheme.Iterator { 27 - return grapheme.Iterator.init(str, &self.grapheme_data); 28 - }
+686 -109
src/Vaxis.zig
··· 3 3 const atomic = std.atomic; 4 4 const base64Encoder = std.base64.standard.Encoder; 5 5 const zigimg = @import("zigimg"); 6 + const IoWriter = std.io.Writer; 6 7 7 8 const Cell = @import("Cell.zig"); 8 9 const Image = @import("Image.zig"); ··· 10 11 const Key = @import("Key.zig"); 11 12 const Mouse = @import("Mouse.zig"); 12 13 const Screen = @import("Screen.zig"); 13 - const Unicode = @import("Unicode.zig"); 14 + const unicode = @import("unicode.zig"); 14 15 const Window = @import("Window.zig"); 15 16 16 - const AnyWriter = std.io.AnyWriter; 17 17 const Hyperlink = Cell.Hyperlink; 18 18 const KittyFlags = Key.KittyFlags; 19 19 const Shape = Mouse.Shape; ··· 23 23 const ctlseqs = @import("ctlseqs.zig"); 24 24 const gwidth = @import("gwidth.zig"); 25 25 26 + const assert = std.debug.assert; 27 + 26 28 const Vaxis = @This(); 27 29 28 30 const log = std.log.scoped(.vaxis); ··· 34 36 unicode: gwidth.Method = .wcwidth, 35 37 sgr_pixels: bool = false, 36 38 color_scheme_updates: bool = false, 39 + explicit_width: bool = false, 40 + scaled_text: bool = false, 41 + multi_cursor: bool = false, 37 42 }; 38 43 39 44 pub const Options = struct { ··· 48 53 screen: Screen, 49 54 /// The last screen we drew. We keep this so we can efficiently update on 50 55 /// the next render 51 - screen_last: InternalScreen = undefined, 56 + screen_last: InternalScreen, 52 57 53 58 caps: Capabilities = .{}, 54 59 ··· 61 66 /// futex times out 62 67 query_futex: atomic.Value(u32) = atomic.Value(u32).init(0), 63 68 69 + /// If Queries were sent, we set this to false. We reset to true when all queries are complete. This 70 + /// is used because we do explicit cursor position reports in the queries, which interfere with F3 71 + /// key encoding. This can be used as a flag to determine how we should evaluate this sequence 72 + queries_done: atomic.Value(bool) = atomic.Value(bool).init(true), 73 + 64 74 // images 65 75 next_img_id: u32 = 1, 66 76 67 - unicode: Unicode, 68 - 69 - // statistics 70 - renders: usize = 0, 71 - render_dur: u64 = 0, 72 - render_timer: std.time.Timer, 73 - 74 77 sgr: enum { 75 78 standard, 76 79 legacy, 77 80 } = .standard, 78 81 82 + /// Enable workarounds for escape sequence handling issues/bugs in terminals 83 + /// So far this just enables a UL escape sequence workaround for conpty 84 + enable_workarounds: bool = true, 85 + 79 86 state: struct { 80 87 /// if we are in the alt screen 81 88 alt_screen: bool = false, ··· 86 93 pixel_mouse: bool = false, 87 94 color_scheme_updates: bool = false, 88 95 in_band_resize: bool = false, 96 + changed_default_fg: bool = false, 97 + changed_default_bg: bool = false, 98 + changed_cursor_color: bool = false, 89 99 cursor: struct { 90 - row: usize = 0, 91 - col: usize = 0, 100 + row: u16 = 0, 101 + col: u16 = 0, 92 102 } = .{}, 93 103 } = .{}, 94 104 ··· 97 107 return .{ 98 108 .opts = opts, 99 109 .screen = .{}, 100 - .screen_last = .{}, 101 - .render_timer = try std.time.Timer.start(), 102 - .unicode = try Unicode.init(alloc), 110 + .screen_last = try .init(alloc, 0, 0), 103 111 }; 104 112 } 105 113 ··· 107 115 /// passed, this will free resources associated with Vaxis. This is left as an 108 116 /// optional so applications can choose to not free resources when the 109 117 /// application will be exiting anyways 110 - pub fn deinit(self: *Vaxis, alloc: ?std.mem.Allocator, tty: AnyWriter) void { 118 + pub fn deinit(self: *Vaxis, alloc: ?std.mem.Allocator, tty: *IoWriter) void { 111 119 self.resetState(tty) catch {}; 112 120 113 - // always show the cursor on exit 114 - tty.writeAll(ctlseqs.show_cursor) catch {}; 115 121 if (alloc) |a| { 116 122 self.screen.deinit(a); 117 123 self.screen_last.deinit(a); 118 124 } 119 - if (self.renders > 0) { 120 - const tpr = @divTrunc(self.render_dur, self.renders); 121 - log.debug("total renders = {d}\r", .{self.renders}); 122 - log.debug("microseconds per render = {d}\r", .{tpr}); 123 - } 124 - self.unicode.deinit(); 125 125 } 126 126 127 127 /// resets enabled features, sends cursor to home and clears below cursor 128 - pub fn resetState(self: *Vaxis, tty: AnyWriter) !void { 128 + pub fn resetState(self: *Vaxis, tty: *IoWriter) !void { 129 + // always show the cursor on state reset 130 + tty.writeAll(ctlseqs.show_cursor) catch {}; 131 + tty.writeAll(ctlseqs.sgr_reset) catch {}; 132 + if (self.screen.cursor_shape != .default) { 133 + // In many terminals, `.default` will set to the configured cursor shape. Others, it will 134 + // change to a blinking block. 135 + tty.print(ctlseqs.cursor_shape, .{@intFromEnum(Cell.CursorShape.default)}) catch {}; 136 + } 129 137 if (self.state.kitty_keyboard) { 130 138 try tty.writeAll(ctlseqs.csi_u_pop); 131 139 self.state.kitty_keyboard = false; ··· 142 150 try self.exitAltScreen(tty); 143 151 } else { 144 152 try tty.writeByte('\r'); 145 - var i: usize = 0; 153 + var i: u16 = 0; 146 154 while (i < self.state.cursor.row) : (i += 1) { 147 155 try tty.writeAll(ctlseqs.ri); 148 156 } ··· 156 164 try tty.writeAll(ctlseqs.in_band_resize_reset); 157 165 self.state.in_band_resize = false; 158 166 } 167 + if (self.state.changed_default_fg) { 168 + try tty.writeAll(ctlseqs.osc10_reset); 169 + self.state.changed_default_fg = false; 170 + } 171 + if (self.state.changed_default_bg) { 172 + try tty.writeAll(ctlseqs.osc11_reset); 173 + self.state.changed_default_bg = false; 174 + } 175 + if (self.state.changed_cursor_color) { 176 + try tty.writeAll(ctlseqs.osc12_reset); 177 + self.state.changed_cursor_color = false; 178 + } 179 + 180 + try tty.flush(); 159 181 } 160 182 161 183 /// resize allocates a slice of cells equal to the number of cells ··· 165 187 pub fn resize( 166 188 self: *Vaxis, 167 189 alloc: std.mem.Allocator, 168 - tty: AnyWriter, 190 + tty: *IoWriter, 169 191 winsize: Winsize, 170 192 ) !void { 171 193 log.debug("resizing screen: width={d} height={d}", .{ winsize.cols, winsize.rows }); 172 194 self.screen.deinit(alloc); 173 - self.screen = try Screen.init(alloc, winsize, &self.unicode); 195 + self.screen = try Screen.init(alloc, winsize); 174 196 self.screen.width_method = self.caps.unicode; 175 197 // try self.screen.int(alloc, winsize.cols, winsize.rows); 176 198 // we only init our current screen. This has the effect of redrawing ··· 180 202 if (self.state.alt_screen) 181 203 try tty.writeAll(ctlseqs.home) 182 204 else { 183 - try tty.writeBytesNTimes(ctlseqs.ri, self.state.cursor.row); 205 + for (0..self.state.cursor.row) |_| { 206 + try tty.writeAll(ctlseqs.ri); 207 + } 184 208 try tty.writeByte('\r'); 185 209 } 186 210 self.state.cursor.row = 0; 187 211 self.state.cursor.col = 0; 188 212 try tty.writeAll(ctlseqs.sgr_reset ++ ctlseqs.erase_below_cursor); 213 + try tty.flush(); 189 214 } 190 215 191 216 /// returns a Window comprising of the entire terminal screen ··· 193 218 return .{ 194 219 .x_off = 0, 195 220 .y_off = 0, 221 + .parent_x_off = 0, 222 + .parent_y_off = 0, 196 223 .width = self.screen.width, 197 224 .height = self.screen.height, 198 225 .screen = &self.screen, ··· 200 227 } 201 228 202 229 /// enter the alternate screen. The alternate screen will automatically 203 - /// be exited if calling deinit while in the alt screen 204 - pub fn enterAltScreen(self: *Vaxis, tty: AnyWriter) !void { 230 + /// be exited if calling deinit while in the alt screen. 231 + pub fn enterAltScreen(self: *Vaxis, tty: *IoWriter) !void { 205 232 try tty.writeAll(ctlseqs.smcup); 233 + try tty.flush(); 206 234 self.state.alt_screen = true; 207 235 } 208 236 209 - /// exit the alternate screen 210 - pub fn exitAltScreen(self: *Vaxis, tty: AnyWriter) !void { 237 + /// exit the alternate screen. Does not flush the writer. 238 + pub fn exitAltScreen(self: *Vaxis, tty: *IoWriter) !void { 211 239 try tty.writeAll(ctlseqs.rmcup); 240 + try tty.flush(); 212 241 self.state.alt_screen = false; 213 242 } 214 243 ··· 218 247 /// 219 248 /// This call will block until Vaxis.query_futex is woken up, or the timeout. 220 249 /// Event loops can wake up this futex when cap_da1 is received 221 - pub fn queryTerminal(self: *Vaxis, tty: AnyWriter, timeout_ns: u64) !void { 250 + pub fn queryTerminal(self: *Vaxis, tty: *IoWriter, timeout_ns: u64) !void { 222 251 try self.queryTerminalSend(tty); 223 252 // 1 second timeout 224 253 std.Thread.Futex.timedWait(&self.query_futex, 0, timeout_ns) catch {}; 254 + self.queries_done.store(true, .unordered); 225 255 try self.enableDetectedFeatures(tty); 226 256 } 227 257 228 258 /// write queries to the terminal to determine capabilities. This function 229 259 /// is only for use with a custom main loop. Call Vaxis.queryTerminal() if 230 260 /// you are using Loop.run() 231 - pub fn queryTerminalSend(_: Vaxis, tty: AnyWriter) !void { 261 + pub fn queryTerminalSend(vx: *Vaxis, tty: *IoWriter) !void { 262 + vx.queries_done.store(false, .unordered); 232 263 233 264 // TODO: re-enable this 234 265 // const colorterm = std.posix.getenv("COLORTERM") orelse ""; ··· 249 280 ctlseqs.decrqm_unicode ++ 250 281 ctlseqs.decrqm_color_scheme ++ 251 282 ctlseqs.in_band_resize_set ++ 283 + 284 + // Explicit width query. We send the cursor home, then do an explicit width command, then 285 + // query the position. If the parsed value is an F3 with shift, we support explicit width. 286 + // The returned response will be something like \x1b[1;2R...which when parsed as a Key is a 287 + // shift + F3 (the row is ignored). We only care if the column has moved from 1->2, which is 288 + // why we see a Shift modifier 289 + ctlseqs.home ++ 290 + ctlseqs.explicit_width_query ++ 291 + ctlseqs.cursor_position_request ++ 292 + // Explicit width query. We send the cursor home, then do an scaled text command, then 293 + // query the position. If the parsed value is an F3 with al, we support scaled text. 294 + // The returned response will be something like \x1b[1;3R...which when parsed as a Key is a 295 + // alt + F3 (the row is ignored). We only care if the column has moved from 1->3, which is 296 + // why we see a Shift modifier 297 + ctlseqs.home ++ 298 + ctlseqs.scaled_text_query ++ 299 + ctlseqs.multi_cursor_query ++ 300 + ctlseqs.cursor_position_request ++ 252 301 ctlseqs.xtversion ++ 253 302 ctlseqs.csi_u_query ++ 254 303 ctlseqs.kitty_graphics_query ++ 255 304 ctlseqs.primary_device_attrs); 305 + 306 + try tty.flush(); 256 307 } 257 308 258 309 /// Enable features detected by responses to queryTerminal. This function 259 310 /// is only for use with a custom main loop. Call Vaxis.queryTerminal() if 260 311 /// you are using Loop.run() 261 - pub fn enableDetectedFeatures(self: *Vaxis, tty: AnyWriter) !void { 312 + pub fn enableDetectedFeatures(self: *Vaxis, tty: *IoWriter) !void { 262 313 switch (builtin.os.tag) { 263 314 .windows => { 264 315 // No feature detection on windows. We just hard enable some knowns for ConPTY ··· 273 324 self.caps.kitty_keyboard = false; 274 325 self.sgr = .legacy; 275 326 } 327 + if (std.posix.getenv("TERM_PROGRAM")) |prg| { 328 + if (std.mem.eql(u8, prg, "vscode")) 329 + self.sgr = .legacy; 330 + } 276 331 if (std.posix.getenv("VAXIS_FORCE_LEGACY_SGR")) |_| 277 332 self.sgr = .legacy; 278 333 if (std.posix.getenv("VAXIS_FORCE_WCWIDTH")) |_| ··· 284 339 if (self.caps.kitty_keyboard) { 285 340 try self.enableKittyKeyboard(tty, self.opts.kitty_keyboard_flags); 286 341 } 287 - if (self.caps.unicode == .unicode) { 342 + // Only enable mode 2027 if we don't have explicit width 343 + if (self.caps.unicode == .unicode and !self.caps.explicit_width) { 288 344 try tty.writeAll(ctlseqs.unicode_set); 289 345 } 290 346 }, 291 347 } 348 + 349 + try tty.flush(); 292 350 } 293 351 294 352 // the next render call will refresh the entire screen ··· 297 355 } 298 356 299 357 /// draws the screen to the terminal 300 - pub fn render(self: *Vaxis, tty: AnyWriter) !void { 301 - self.renders += 1; 302 - self.render_timer.reset(); 303 - defer { 304 - self.render_dur += self.render_timer.read() / std.time.ns_per_us; 305 - } 306 - 358 + pub fn render(self: *Vaxis, tty: *IoWriter) !void { 307 359 defer self.refresh = false; 360 + assert(self.screen.buf.len == @as(usize, @intCast(self.screen.width)) * self.screen.height); // correct size 361 + assert(self.screen.buf.len == self.screen_last.buf.len); // same size 308 362 309 - // Set up sync before we write anything 310 - // TODO: optimize sync so we only sync _when we have changes_. This 311 - // requires a smarter buffered writer, we'll probably have to write 312 - // our own 313 - try tty.writeAll(ctlseqs.sync_set); 314 - defer tty.writeAll(ctlseqs.sync_reset) catch {}; 363 + var started: bool = false; 364 + var sync_active: bool = false; 365 + errdefer if (sync_active) tty.writeAll(ctlseqs.sync_reset) catch {}; 315 366 316 - // Send the cursor to 0,0 317 - // TODO: this needs to move after we optimize writes. We only do 318 - // this if we have an update to make. We also need to hide cursor 319 - // and then reshow it if needed 320 - try tty.writeAll(ctlseqs.hide_cursor); 321 - if (self.state.alt_screen) 322 - try tty.writeAll(ctlseqs.home) 323 - else { 324 - try tty.writeByte('\r'); 325 - try tty.writeBytesNTimes(ctlseqs.ri, self.state.cursor.row); 326 - } 327 - try tty.writeAll(ctlseqs.sgr_reset); 367 + const cursor_vis_changed = self.screen.cursor_vis != self.screen_last.cursor_vis; 368 + const cursor_shape_changed = self.screen.cursor_shape != self.screen_last.cursor_shape; 369 + const mouse_shape_changed = self.screen.mouse_shape != self.screen_last.mouse_shape; 370 + const cursor_pos_changed = self.screen.cursor_vis and 371 + (self.screen.cursor_row != self.state.cursor.row or 372 + self.screen.cursor_col != self.state.cursor.col); 373 + const needs_render = self.refresh or cursor_vis_changed or cursor_shape_changed or mouse_shape_changed or cursor_pos_changed; 328 374 329 375 // initialize some variables 330 376 var reposition: bool = false; 331 - var row: usize = 0; 332 - var col: usize = 0; 377 + var row: u16 = 0; 378 + var col: u16 = 0; 333 379 var cursor: Style = .{}; 334 380 var link: Hyperlink = .{}; 335 - var cursor_pos: struct { 336 - row: usize = 0, 337 - col: usize = 0, 338 - } = .{}; 381 + const CursorPos = struct { 382 + row: u16 = 0, 383 + col: u16 = 0, 384 + }; 385 + var cursor_pos: CursorPos = .{}; 339 386 340 - // Clear all images 341 - if (self.caps.kitty_graphics) 342 - try tty.writeAll(ctlseqs.kitty_graphics_clear); 387 + const startRender = struct { 388 + fn run( 389 + vx: *Vaxis, 390 + io: *IoWriter, 391 + cursor_pos_ptr: *CursorPos, 392 + reposition_ptr: *bool, 393 + started_ptr: *bool, 394 + sync_active_ptr: *bool, 395 + ) !void { 396 + if (started_ptr.*) return; 397 + started_ptr.* = true; 398 + sync_active_ptr.* = true; 399 + // Set up sync before we write anything 400 + try io.writeAll(ctlseqs.sync_set); 401 + // Send the cursor to 0,0 402 + try io.writeAll(ctlseqs.hide_cursor); 403 + if (vx.state.alt_screen) 404 + try io.writeAll(ctlseqs.home) 405 + else { 406 + try io.writeByte('\r'); 407 + for (0..vx.state.cursor.row) |_| { 408 + try io.writeAll(ctlseqs.ri); 409 + } 410 + } 411 + try io.writeAll(ctlseqs.sgr_reset); 412 + cursor_pos_ptr.* = .{}; 413 + reposition_ptr.* = true; 414 + // Clear all images 415 + if (vx.caps.kitty_graphics) 416 + try io.writeAll(ctlseqs.kitty_graphics_clear); 417 + } 418 + }; 419 + 420 + // Reset skip flag on all last_screen cells 421 + for (self.screen_last.buf) |*last_cell| { 422 + last_cell.skip = false; 423 + } 424 + 425 + if (needs_render) { 426 + try startRender.run(self, tty, &cursor_pos, &reposition, &started, &sync_active); 427 + } 343 428 344 429 var i: usize = 0; 345 430 while (i < self.screen.buf.len) { 346 431 const cell = self.screen.buf[i]; 347 - const w = blk: { 432 + const w: u16 = blk: { 348 433 if (cell.char.width != 0) break :blk cell.char.width; 349 434 350 435 const method: gwidth.Method = self.caps.unicode; 351 - const width = gwidth.gwidth(cell.char.grapheme, method, &self.unicode.width_data) catch 1; 436 + const width: u16 = @intCast(gwidth.gwidth(cell.char.grapheme, method)); 352 437 break :blk @max(1, width); 353 438 }; 354 439 defer { ··· 372 457 // If cell is the same as our last frame, we don't need to do 373 458 // anything 374 459 const last = self.screen_last.buf[i]; 375 - if (!self.refresh and last.eql(cell) and !last.skipped and cell.image == null) { 460 + if ((!self.refresh and 461 + last.eql(cell) and 462 + !last.skipped and 463 + cell.image == null) or 464 + last.skip) 465 + { 376 466 reposition = true; 377 467 // Close any osc8 sequence we might be in before 378 468 // repositioning ··· 381 471 } 382 472 continue; 383 473 } 474 + if (!started) { 475 + try startRender.run(self, tty, &cursor_pos, &reposition, &started, &sync_active); 476 + } 384 477 self.screen_last.buf[i].skipped = false; 385 478 defer { 386 479 cursor = cell.style; ··· 389 482 // Set this cell in the last frame 390 483 self.screen_last.writeCell(col, row, cell); 391 484 485 + // If we support scaled text, we set the flags now 486 + if (self.caps.scaled_text and cell.scale.scale > 1) { 487 + // The cell is scaled. Set appropriate skips. We only need to do this if the scale factor is 488 + // > 1 489 + assert(cell.char.width > 0); 490 + const cols = cell.scale.scale * cell.char.width; 491 + const rows = cell.scale.scale; 492 + for (0..rows) |skipped_row| { 493 + for (0..cols) |skipped_col| { 494 + if (skipped_row == 0 and skipped_col == 0) { 495 + continue; 496 + } 497 + const skipped_i = (@as(usize, @intCast(skipped_row + row)) * self.screen_last.width) + (skipped_col + col); 498 + self.screen_last.buf[skipped_i].skip = true; 499 + } 500 + } 501 + } 502 + 392 503 // reposition the cursor, if needed 393 504 if (reposition) { 394 505 reposition = false; 506 + link = .{}; 395 507 if (self.state.alt_screen) 396 508 try tty.print(ctlseqs.cup, .{ row + 1, col + 1 }) 397 509 else { ··· 401 513 try tty.print(ctlseqs.cuf, .{n}); 402 514 } else { 403 515 const n = row - cursor_pos.row; 404 - try tty.writeByteNTimes('\n', n); 516 + for (0..n) |_| { 517 + try tty.writeByte('\n'); 518 + } 405 519 try tty.writeByte('\r'); 406 520 if (col > 0) 407 521 try tty.print(ctlseqs.cuf, .{col}); ··· 504 618 } 505 619 }, 506 620 .rgb => |rgb| { 507 - switch (self.sgr) { 621 + if (self.enable_workarounds) 622 + try tty.print(ctlseqs.ul_rgb_conpty, .{ rgb[0], rgb[1], rgb[2] }) 623 + else switch (self.sgr) { 508 624 .standard => try tty.print(ctlseqs.ul_rgb, .{ rgb[0], rgb[1], rgb[2] }), 509 625 .legacy => try tty.print(ctlseqs.ul_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }), 510 626 } ··· 596 712 } 597 713 try tty.print(ctlseqs.osc8, .{ ps, cell.link.uri }); 598 714 } 599 - try tty.writeAll(cell.char.grapheme); 715 + 716 + // scale 717 + if (self.caps.scaled_text and !cell.scale.eql(.{})) { 718 + const scale = cell.scale; 719 + // We have a scaled cell. 720 + switch (cell.scale.denominator) { 721 + // Denominator cannot be 0 722 + 0 => unreachable, 723 + 1 => { 724 + // no fractional scaling, just a straight scale factor 725 + try tty.print( 726 + ctlseqs.scaled_text, 727 + .{ scale.scale, w, cell.char.grapheme }, 728 + ); 729 + }, 730 + else => { 731 + // fractional scaling 732 + // no fractional scaling, just a straight scale factor 733 + try tty.print( 734 + ctlseqs.scaled_text_with_fractions, 735 + .{ 736 + scale.scale, 737 + w, 738 + scale.numerator, 739 + scale.denominator, 740 + @intFromEnum(scale.vertical_alignment), 741 + cell.char.grapheme, 742 + }, 743 + ); 744 + }, 745 + } 746 + cursor_pos.col = col + (w * scale.scale); 747 + cursor_pos.row = row; 748 + continue; 749 + } 750 + 751 + // If we have explicit width and our width is greater than 1, let's use it 752 + if (self.caps.explicit_width and w > 1) { 753 + try tty.print(ctlseqs.explicit_width, .{ w, cell.char.grapheme }); 754 + } else { 755 + try tty.writeAll(cell.char.grapheme); 756 + } 600 757 cursor_pos.col = col + w; 601 758 cursor_pos.row = row; 602 759 } 760 + if (!started) return; 603 761 if (self.screen.cursor_vis) { 604 762 if (self.state.alt_screen) { 605 763 try tty.print( ··· 612 770 } else { 613 771 // TODO: position cursor relative to current location 614 772 try tty.writeByte('\r'); 615 - if (self.screen.cursor_row >= cursor_pos.row) 616 - try tty.writeByteNTimes('\n', self.screen.cursor_row - cursor_pos.row) 617 - else 618 - try tty.writeBytesNTimes(ctlseqs.ri, cursor_pos.row - self.screen.cursor_row); 773 + if (self.screen.cursor_row >= cursor_pos.row) { 774 + for (0..(self.screen.cursor_row - cursor_pos.row)) |_| { 775 + try tty.writeByte('\n'); 776 + } 777 + } else { 778 + for (0..(cursor_pos.row - self.screen.cursor_row)) |_| { 779 + try tty.writeAll(ctlseqs.ri); 780 + } 781 + } 619 782 if (self.screen.cursor_col > 0) 620 783 try tty.print(ctlseqs.cuf, .{self.screen.cursor_col}); 621 784 } ··· 626 789 self.state.cursor.row = cursor_pos.row; 627 790 self.state.cursor.col = cursor_pos.col; 628 791 } 792 + self.screen_last.cursor_vis = self.screen.cursor_vis; 629 793 if (self.screen.mouse_shape != self.screen_last.mouse_shape) { 630 794 try tty.print( 631 795 ctlseqs.osc22_mouse_shape, ··· 640 804 ); 641 805 self.screen_last.cursor_shape = self.screen.cursor_shape; 642 806 } 807 + 808 + try tty.writeAll(ctlseqs.sync_reset); 809 + try tty.flush(); 643 810 } 644 811 645 - fn enableKittyKeyboard(self: *Vaxis, tty: AnyWriter, flags: Key.KittyFlags) !void { 812 + fn enableKittyKeyboard(self: *Vaxis, tty: *IoWriter, flags: Key.KittyFlags) !void { 646 813 const flag_int: u5 = @bitCast(flags); 647 814 try tty.print(ctlseqs.csi_u_push, .{flag_int}); 815 + try tty.flush(); 648 816 self.state.kitty_keyboard = true; 649 817 } 650 818 651 819 /// send a system notification 652 - pub fn notify(_: *Vaxis, tty: AnyWriter, title: ?[]const u8, body: []const u8) !void { 820 + pub fn notify(_: *Vaxis, tty: *IoWriter, title: ?[]const u8, body: []const u8) !void { 653 821 if (title) |t| 654 822 try tty.print(ctlseqs.osc777_notify, .{ t, body }) 655 823 else 656 824 try tty.print(ctlseqs.osc9_notify, .{body}); 825 + 826 + try tty.flush(); 657 827 } 658 828 659 829 /// sets the window title 660 - pub fn setTitle(_: *Vaxis, tty: AnyWriter, title: []const u8) !void { 830 + pub fn setTitle(_: *Vaxis, tty: *IoWriter, title: []const u8) !void { 661 831 try tty.print(ctlseqs.osc2_set_title, .{title}); 832 + try tty.flush(); 662 833 } 663 834 664 835 // turn bracketed paste on or off. An event will be sent at the 665 836 // beginning and end of a detected paste. All keystrokes between these 666 837 // events were pasted 667 - pub fn setBracketedPaste(self: *Vaxis, tty: AnyWriter, enable: bool) !void { 838 + pub fn setBracketedPaste(self: *Vaxis, tty: *IoWriter, enable: bool) !void { 668 839 const seq = if (enable) 669 840 ctlseqs.bp_set 670 841 else 671 842 ctlseqs.bp_reset; 672 843 try tty.writeAll(seq); 844 + try tty.flush(); 673 845 self.state.bracketed_paste = enable; 674 846 } 675 847 ··· 679 851 } 680 852 681 853 /// Change the mouse reporting mode 682 - pub fn setMouseMode(self: *Vaxis, tty: AnyWriter, enable: bool) !void { 854 + pub fn setMouseMode(self: *Vaxis, tty: *IoWriter, enable: bool) !void { 683 855 if (enable) { 684 856 self.state.mouse = true; 685 857 if (self.caps.sgr_pixels) { ··· 693 865 } else { 694 866 try tty.writeAll(ctlseqs.mouse_reset); 695 867 } 868 + 869 + try tty.flush(); 696 870 } 697 871 698 872 /// Translate pixel mouse coordinates to cell + offset ··· 706 880 const ypos = mouse.row; 707 881 const xextra = self.screen.width_pix % self.screen.width; 708 882 const yextra = self.screen.height_pix % self.screen.height; 709 - const xcell = (self.screen.width_pix - xextra) / self.screen.width; 710 - const ycell = (self.screen.height_pix - yextra) / self.screen.height; 711 - result.col = xpos / xcell; 712 - result.row = ypos / ycell; 713 - result.xoffset = xpos % xcell; 714 - result.yoffset = ypos % ycell; 883 + const xcell: i16 = @intCast((self.screen.width_pix - xextra) / self.screen.width); 884 + const ycell: i16 = @intCast((self.screen.height_pix - yextra) / self.screen.height); 885 + if (xcell == 0 or ycell == 0) return mouse; 886 + result.col = @divFloor(xpos, xcell); 887 + result.row = @divFloor(ypos, ycell); 888 + result.xoffset = @intCast(@mod(xpos, xcell)); 889 + result.yoffset = @intCast(@mod(ypos, ycell)); 715 890 } 716 891 return result; 717 892 } 718 893 894 + /// Transmit an image using the local filesystem. Allocates only for base64 encoding 895 + pub fn transmitLocalImagePath( 896 + self: *Vaxis, 897 + allocator: std.mem.Allocator, 898 + tty: *IoWriter, 899 + payload: []const u8, 900 + width: u16, 901 + height: u16, 902 + medium: Image.TransmitMedium, 903 + format: Image.TransmitFormat, 904 + ) !Image { 905 + if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; 906 + 907 + defer self.next_img_id += 1; 908 + 909 + const id = self.next_img_id; 910 + 911 + const size = base64Encoder.calcSize(payload.len); 912 + if (size >= 4096) return error.PathTooLong; 913 + 914 + const buf = try allocator.alloc(u8, size); 915 + const encoded = base64Encoder.encode(buf, payload); 916 + defer allocator.free(buf); 917 + 918 + const medium_char: u8 = switch (medium) { 919 + .file => 'f', 920 + .temp_file => 't', 921 + .shared_mem => 's', 922 + }; 923 + 924 + switch (format) { 925 + .rgb => { 926 + try tty.print( 927 + "\x1b_Gf=24,s={d},v={d},i={d},t={c};{s}\x1b\\", 928 + .{ width, height, id, medium_char, encoded }, 929 + ); 930 + }, 931 + .rgba => { 932 + try tty.print( 933 + "\x1b_Gf=32,s={d},v={d},i={d},t={c};{s}\x1b\\", 934 + .{ width, height, id, medium_char, encoded }, 935 + ); 936 + }, 937 + .png => { 938 + try tty.print( 939 + "\x1b_Gf=100,i={d},t={c};{s}\x1b\\", 940 + .{ id, medium_char, encoded }, 941 + ); 942 + }, 943 + } 944 + 945 + try tty.flush(); 946 + return .{ 947 + .id = id, 948 + .width = width, 949 + .height = height, 950 + }; 951 + } 952 + 719 953 /// Transmit an image which has been pre-base64 encoded 720 954 pub fn transmitPreEncodedImage( 721 955 self: *Vaxis, 722 - tty: AnyWriter, 956 + tty: *IoWriter, 723 957 bytes: []const u8, 724 - width: usize, 725 - height: usize, 958 + width: u16, 959 + height: u16, 726 960 format: Image.TransmitFormat, 727 961 ) !Image { 962 + if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; 963 + 728 964 defer self.next_img_id += 1; 729 965 const id = self.next_img_id; 730 966 ··· 764 1000 ); 765 1001 } 766 1002 } 1003 + 1004 + try tty.flush(); 767 1005 return .{ 768 1006 .id = id, 769 1007 .width = width, ··· 774 1012 pub fn transmitImage( 775 1013 self: *Vaxis, 776 1014 alloc: std.mem.Allocator, 777 - tty: AnyWriter, 1015 + tty: *IoWriter, 778 1016 img: *zigimg.Image, 779 1017 format: Image.TransmitFormat, 780 1018 ) !Image { ··· 786 1024 const buf = switch (format) { 787 1025 .png => png: { 788 1026 const png_buf = try arena.allocator().alloc(u8, img.imageByteSize()); 789 - const png = try img.writeToMemory(png_buf, .{ .png = .{} }); 1027 + const png = try img.writeToMemory(arena.allocator(), png_buf, .{ .png = .{} }); 790 1028 break :png png; 791 1029 }, 792 1030 .rgb => rgb: { 793 - try img.convert(.rgb24); 1031 + try img.convert(arena.allocator(), .rgb24); 794 1032 break :rgb img.rawBytes(); 795 1033 }, 796 1034 .rgba => rgba: { 797 - try img.convert(.rgba32); 1035 + try img.convert(arena.allocator(), .rgba32); 798 1036 break :rgba img.rawBytes(); 799 1037 }, 800 1038 }; ··· 802 1040 const b64_buf = try arena.allocator().alloc(u8, base64Encoder.calcSize(buf.len)); 803 1041 const encoded = base64Encoder.encode(b64_buf, buf); 804 1042 805 - return self.transmitPreEncodedImage(tty, encoded, img.width, img.height, format); 1043 + return self.transmitPreEncodedImage(tty, encoded, @intCast(img.width), @intCast(img.height), format); 806 1044 } 807 1045 808 1046 pub fn loadImage( 809 1047 self: *Vaxis, 810 1048 alloc: std.mem.Allocator, 811 - tty: AnyWriter, 1049 + tty: *IoWriter, 812 1050 src: Image.Source, 813 1051 ) !Image { 814 1052 if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; 815 1053 1054 + var read_buffer: [1024 * 1024]u8 = undefined; // 1MB buffer 816 1055 var img = switch (src) { 817 - .path => |path| try zigimg.Image.fromFilePath(alloc, path), 1056 + .path => |path| try zigimg.Image.fromFilePath(alloc, path, &read_buffer), 818 1057 .mem => |bytes| try zigimg.Image.fromMemory(alloc, bytes), 819 1058 }; 820 - defer img.deinit(); 1059 + defer img.deinit(alloc); 821 1060 return self.transmitImage(alloc, tty, &img, .png); 822 1061 } 823 1062 824 1063 /// deletes an image from the terminal's memory 825 - pub fn freeImage(_: Vaxis, tty: AnyWriter, id: u32) void { 1064 + pub fn freeImage(_: Vaxis, tty: *IoWriter, id: u32) void { 826 1065 tty.print("\x1b_Ga=d,d=I,i={d};\x1b\\", .{id}) catch |err| { 827 1066 log.err("couldn't delete image {d}: {}", .{ id, err }); 828 1067 return; 829 1068 }; 1069 + tty.flush() catch {}; 830 1070 } 831 1071 832 - pub fn copyToSystemClipboard(_: Vaxis, tty: AnyWriter, text: []const u8, encode_allocator: std.mem.Allocator) !void { 1072 + pub fn copyToSystemClipboard(_: Vaxis, tty: *IoWriter, text: []const u8, encode_allocator: std.mem.Allocator) !void { 833 1073 const encoder = std.base64.standard.Encoder; 834 1074 const size = encoder.calcSize(text.len); 835 1075 const buf = try encode_allocator.alloc(u8, size); ··· 839 1079 ctlseqs.osc52_clipboard_copy, 840 1080 .{b64}, 841 1081 ); 1082 + 1083 + try tty.flush(); 842 1084 } 843 1085 844 - pub fn requestSystemClipboard(self: Vaxis, tty: AnyWriter) !void { 1086 + pub fn requestSystemClipboard(self: Vaxis, tty: *IoWriter) !void { 845 1087 if (self.opts.system_clipboard_allocator == null) return error.NoClipboardAllocator; 846 1088 try tty.print( 847 1089 ctlseqs.osc52_clipboard_request, 848 1090 .{}, 849 1091 ); 1092 + try tty.flush(); 1093 + } 1094 + 1095 + /// Set the default terminal foreground color 1096 + pub fn setTerminalForegroundColor(self: *Vaxis, tty: *IoWriter, rgb: [3]u8) !void { 1097 + try tty.print(ctlseqs.osc10_set, .{ rgb[0], rgb[0], rgb[1], rgb[1], rgb[2], rgb[2] }); 1098 + try tty.flush(); 1099 + self.state.changed_default_fg = true; 1100 + } 1101 + 1102 + /// Set the default terminal background color 1103 + pub fn setTerminalBackgroundColor(self: *Vaxis, tty: *IoWriter, rgb: [3]u8) !void { 1104 + try tty.print(ctlseqs.osc11_set, .{ rgb[0], rgb[0], rgb[1], rgb[1], rgb[2], rgb[2] }); 1105 + try tty.flush(); 1106 + self.state.changed_default_bg = true; 1107 + } 1108 + 1109 + /// Set the terminal cursor color 1110 + pub fn setTerminalCursorColor(self: *Vaxis, tty: *IoWriter, rgb: [3]u8) !void { 1111 + try tty.print(ctlseqs.osc12_set, .{ rgb[0], rgb[0], rgb[1], rgb[1], rgb[2], rgb[2] }); 1112 + try tty.flush(); 1113 + self.state.changed_cursor_color = true; 850 1114 } 851 1115 852 1116 /// Request a color report from the terminal. Note: not all terminals support 853 1117 /// reporting colors. It is always safe to try, but you may not receive a 854 1118 /// response. 855 - pub fn queryColor(_: Vaxis, tty: AnyWriter, kind: Cell.Color.Kind) !void { 1119 + pub fn queryColor(_: Vaxis, tty: *IoWriter, kind: Cell.Color.Kind) !void { 856 1120 switch (kind) { 857 1121 .fg => try tty.writeAll(ctlseqs.osc10_query), 858 1122 .bg => try tty.writeAll(ctlseqs.osc11_query), 859 1123 .cursor => try tty.writeAll(ctlseqs.osc12_query), 860 1124 .index => |idx| try tty.print(ctlseqs.osc4_query, .{idx}), 861 1125 } 1126 + try tty.flush(); 862 1127 } 863 1128 864 1129 /// Subscribe to color theme updates. A `color_scheme: Color.Scheme` tag must ··· 866 1131 /// capability. Support can be detected by checking the value of 867 1132 /// vaxis.caps.color_scheme_updates. The initial scheme will be reported when 868 1133 /// subscribing. 869 - pub fn subscribeToColorSchemeUpdates(self: Vaxis, tty: AnyWriter) !void { 1134 + pub fn subscribeToColorSchemeUpdates(self: *Vaxis, tty: *IoWriter) !void { 870 1135 try tty.writeAll(ctlseqs.color_scheme_request); 871 1136 try tty.writeAll(ctlseqs.color_scheme_set); 1137 + try tty.flush(); 872 1138 self.state.color_scheme_updates = true; 873 1139 } 874 1140 875 - pub fn deviceStatusReport(_: Vaxis, tty: AnyWriter) !void { 1141 + pub fn deviceStatusReport(_: Vaxis, tty: *IoWriter) !void { 876 1142 try tty.writeAll(ctlseqs.device_status_report); 1143 + try tty.flush(); 1144 + } 1145 + 1146 + /// prettyPrint is used to print the contents of the Screen to the tty. The state is not stored, and 1147 + /// the cursor will be put on the next line after the last line is printed. This is useful to 1148 + /// sequentially print data in a styled format to eg. stdout. This function returns an error if you 1149 + /// are not in the alt screen. The cursor is always hidden, and mouse shapes are not available 1150 + pub fn prettyPrint(self: *Vaxis, tty: *IoWriter) !void { 1151 + if (self.state.alt_screen) return error.NotInPrimaryScreen; 1152 + 1153 + try tty.writeAll(ctlseqs.hide_cursor); 1154 + try tty.writeAll(ctlseqs.sync_set); 1155 + defer tty.writeAll(ctlseqs.sync_reset) catch {}; 1156 + try tty.writeAll(ctlseqs.sgr_reset); 1157 + defer tty.writeAll(ctlseqs.sgr_reset) catch {}; 1158 + 1159 + var reposition: bool = false; 1160 + var row: u16 = 0; 1161 + var col: u16 = 0; 1162 + var cursor: Style = .{}; 1163 + var link: Hyperlink = .{}; 1164 + var cursor_pos: struct { 1165 + row: u16 = 0, 1166 + col: u16 = 0, 1167 + } = .{}; 1168 + 1169 + var i: u16 = 0; 1170 + while (i < self.screen.buf.len) { 1171 + const cell = self.screen.buf[i]; 1172 + const w = blk: { 1173 + if (cell.char.width != 0) break :blk cell.char.width; 1174 + 1175 + const method: gwidth.Method = self.caps.unicode; 1176 + const width = gwidth.gwidth(cell.char.grapheme, method); 1177 + break :blk @max(1, width); 1178 + }; 1179 + defer { 1180 + // advance by the width of this char mod 1 1181 + std.debug.assert(w > 0); 1182 + var j = i + 1; 1183 + while (j < i + w) : (j += 1) { 1184 + if (j >= self.screen_last.buf.len) break; 1185 + self.screen_last.buf[j].skipped = true; 1186 + } 1187 + col += w; 1188 + i += w; 1189 + } 1190 + if (col >= self.screen.width) { 1191 + row += 1; 1192 + col = 0; 1193 + // Rely on terminal wrapping to reposition into next row instead of forcing it 1194 + if (!cell.wrapped) 1195 + reposition = true; 1196 + } 1197 + if (cell.default) { 1198 + reposition = true; 1199 + continue; 1200 + } 1201 + defer { 1202 + cursor = cell.style; 1203 + link = cell.link; 1204 + } 1205 + 1206 + // reposition the cursor, if needed 1207 + if (reposition) { 1208 + reposition = false; 1209 + link = .{}; 1210 + if (cursor_pos.row == row) { 1211 + const n = col - cursor_pos.col; 1212 + if (n > 0) 1213 + try tty.print(ctlseqs.cuf, .{n}); 1214 + } else { 1215 + const n = row - cursor_pos.row; 1216 + for (0..n) |_| { 1217 + try tty.writeByte('\n'); 1218 + } 1219 + try tty.writeByte('\r'); 1220 + if (col > 0) 1221 + try tty.print(ctlseqs.cuf, .{col}); 1222 + } 1223 + } 1224 + 1225 + if (cell.image) |img| { 1226 + try tty.print( 1227 + ctlseqs.kitty_graphics_preamble, 1228 + .{img.img_id}, 1229 + ); 1230 + if (img.options.pixel_offset) |offset| { 1231 + try tty.print( 1232 + ",X={d},Y={d}", 1233 + .{ offset.x, offset.y }, 1234 + ); 1235 + } 1236 + if (img.options.clip_region) |clip| { 1237 + if (clip.x) |x| 1238 + try tty.print(",x={d}", .{x}); 1239 + if (clip.y) |y| 1240 + try tty.print(",y={d}", .{y}); 1241 + if (clip.width) |width| 1242 + try tty.print(",w={d}", .{width}); 1243 + if (clip.height) |height| 1244 + try tty.print(",h={d}", .{height}); 1245 + } 1246 + if (img.options.size) |size| { 1247 + if (size.rows) |rows| 1248 + try tty.print(",r={d}", .{rows}); 1249 + if (size.cols) |cols| 1250 + try tty.print(",c={d}", .{cols}); 1251 + } 1252 + if (img.options.z_index) |z| { 1253 + try tty.print(",z={d}", .{z}); 1254 + } 1255 + try tty.writeAll(ctlseqs.kitty_graphics_closing); 1256 + } 1257 + 1258 + // something is different, so let's loop through everything and 1259 + // find out what 1260 + 1261 + // foreground 1262 + if (!Cell.Color.eql(cursor.fg, cell.style.fg)) { 1263 + switch (cell.style.fg) { 1264 + .default => try tty.writeAll(ctlseqs.fg_reset), 1265 + .index => |idx| { 1266 + switch (idx) { 1267 + 0...7 => try tty.print(ctlseqs.fg_base, .{idx}), 1268 + 8...15 => try tty.print(ctlseqs.fg_bright, .{idx - 8}), 1269 + else => { 1270 + switch (self.sgr) { 1271 + .standard => try tty.print(ctlseqs.fg_indexed, .{idx}), 1272 + .legacy => try tty.print(ctlseqs.fg_indexed_legacy, .{idx}), 1273 + } 1274 + }, 1275 + } 1276 + }, 1277 + .rgb => |rgb| { 1278 + switch (self.sgr) { 1279 + .standard => try tty.print(ctlseqs.fg_rgb, .{ rgb[0], rgb[1], rgb[2] }), 1280 + .legacy => try tty.print(ctlseqs.fg_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }), 1281 + } 1282 + }, 1283 + } 1284 + } 1285 + // background 1286 + if (!Cell.Color.eql(cursor.bg, cell.style.bg)) { 1287 + switch (cell.style.bg) { 1288 + .default => try tty.writeAll(ctlseqs.bg_reset), 1289 + .index => |idx| { 1290 + switch (idx) { 1291 + 0...7 => try tty.print(ctlseqs.bg_base, .{idx}), 1292 + 8...15 => try tty.print(ctlseqs.bg_bright, .{idx - 8}), 1293 + else => { 1294 + switch (self.sgr) { 1295 + .standard => try tty.print(ctlseqs.bg_indexed, .{idx}), 1296 + .legacy => try tty.print(ctlseqs.bg_indexed_legacy, .{idx}), 1297 + } 1298 + }, 1299 + } 1300 + }, 1301 + .rgb => |rgb| { 1302 + switch (self.sgr) { 1303 + .standard => try tty.print(ctlseqs.bg_rgb, .{ rgb[0], rgb[1], rgb[2] }), 1304 + .legacy => try tty.print(ctlseqs.bg_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }), 1305 + } 1306 + }, 1307 + } 1308 + } 1309 + // underline color 1310 + if (!Cell.Color.eql(cursor.ul, cell.style.ul)) { 1311 + switch (cell.style.ul) { 1312 + .default => try tty.writeAll(ctlseqs.ul_reset), 1313 + .index => |idx| { 1314 + switch (self.sgr) { 1315 + .standard => try tty.print(ctlseqs.ul_indexed, .{idx}), 1316 + .legacy => try tty.print(ctlseqs.ul_indexed_legacy, .{idx}), 1317 + } 1318 + }, 1319 + .rgb => |rgb| { 1320 + if (self.enable_workarounds) 1321 + try tty.print(ctlseqs.ul_rgb_conpty, .{ rgb[0], rgb[1], rgb[2] }) 1322 + else switch (self.sgr) { 1323 + .standard => try tty.print(ctlseqs.ul_rgb, .{ rgb[0], rgb[1], rgb[2] }), 1324 + .legacy => { 1325 + try tty.print(ctlseqs.ul_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }); 1326 + }, 1327 + } 1328 + }, 1329 + } 1330 + } 1331 + // underline style 1332 + if (cursor.ul_style != cell.style.ul_style) { 1333 + const seq = switch (cell.style.ul_style) { 1334 + .off => ctlseqs.ul_off, 1335 + .single => ctlseqs.ul_single, 1336 + .double => ctlseqs.ul_double, 1337 + .curly => ctlseqs.ul_curly, 1338 + .dotted => ctlseqs.ul_dotted, 1339 + .dashed => ctlseqs.ul_dashed, 1340 + }; 1341 + try tty.writeAll(seq); 1342 + } 1343 + // bold 1344 + if (cursor.bold != cell.style.bold) { 1345 + const seq = switch (cell.style.bold) { 1346 + true => ctlseqs.bold_set, 1347 + false => ctlseqs.bold_dim_reset, 1348 + }; 1349 + try tty.writeAll(seq); 1350 + if (cell.style.dim) { 1351 + try tty.writeAll(ctlseqs.dim_set); 1352 + } 1353 + } 1354 + // dim 1355 + if (cursor.dim != cell.style.dim) { 1356 + const seq = switch (cell.style.dim) { 1357 + true => ctlseqs.dim_set, 1358 + false => ctlseqs.bold_dim_reset, 1359 + }; 1360 + try tty.writeAll(seq); 1361 + if (cell.style.bold) { 1362 + try tty.writeAll(ctlseqs.bold_set); 1363 + } 1364 + } 1365 + // dim 1366 + if (cursor.italic != cell.style.italic) { 1367 + const seq = switch (cell.style.italic) { 1368 + true => ctlseqs.italic_set, 1369 + false => ctlseqs.italic_reset, 1370 + }; 1371 + try tty.writeAll(seq); 1372 + } 1373 + // dim 1374 + if (cursor.blink != cell.style.blink) { 1375 + const seq = switch (cell.style.blink) { 1376 + true => ctlseqs.blink_set, 1377 + false => ctlseqs.blink_reset, 1378 + }; 1379 + try tty.writeAll(seq); 1380 + } 1381 + // reverse 1382 + if (cursor.reverse != cell.style.reverse) { 1383 + const seq = switch (cell.style.reverse) { 1384 + true => ctlseqs.reverse_set, 1385 + false => ctlseqs.reverse_reset, 1386 + }; 1387 + try tty.writeAll(seq); 1388 + } 1389 + // invisible 1390 + if (cursor.invisible != cell.style.invisible) { 1391 + const seq = switch (cell.style.invisible) { 1392 + true => ctlseqs.invisible_set, 1393 + false => ctlseqs.invisible_reset, 1394 + }; 1395 + try tty.writeAll(seq); 1396 + } 1397 + // strikethrough 1398 + if (cursor.strikethrough != cell.style.strikethrough) { 1399 + const seq = switch (cell.style.strikethrough) { 1400 + true => ctlseqs.strikethrough_set, 1401 + false => ctlseqs.strikethrough_reset, 1402 + }; 1403 + try tty.writeAll(seq); 1404 + } 1405 + 1406 + // url 1407 + if (!std.mem.eql(u8, link.uri, cell.link.uri)) { 1408 + var ps = cell.link.params; 1409 + if (cell.link.uri.len == 0) { 1410 + // Empty out the params no matter what if we don't have 1411 + // a url 1412 + ps = ""; 1413 + } 1414 + try tty.print(ctlseqs.osc8, .{ ps, cell.link.uri }); 1415 + } 1416 + try tty.writeAll(cell.char.grapheme); 1417 + cursor_pos.col = col + w; 1418 + cursor_pos.row = row; 1419 + } 1420 + try tty.writeAll("\r\n"); 1421 + try tty.flush(); 1422 + } 1423 + 1424 + /// Set the terminal's current working directory 1425 + pub fn setTerminalWorkingDirectory(_: *Vaxis, tty: *IoWriter, path: []const u8) !void { 1426 + if (path.len == 0 or path[0] != '/') 1427 + return error.InvalidAbsolutePath; 1428 + const hostname = switch (builtin.os.tag) { 1429 + .windows => null, 1430 + else => std.posix.getenv("HOSTNAME"), 1431 + } orelse "localhost"; 1432 + 1433 + const uri: std.Uri = .{ 1434 + .scheme = "file", 1435 + .host = .{ .raw = hostname }, 1436 + .path = .{ .raw = path }, 1437 + }; 1438 + try tty.print(ctlseqs.osc7, .{uri.fmt(.{ .scheme = true, .authority = true, .path = true })}); 1439 + try tty.flush(); 1440 + } 1441 + 1442 + test "render: no output when no changes" { 1443 + var vx = try Vaxis.init(std.testing.allocator, .{}); 1444 + var deinit_writer = std.io.Writer.Allocating.init(std.testing.allocator); 1445 + defer deinit_writer.deinit(); 1446 + defer vx.deinit(std.testing.allocator, &deinit_writer.writer); 1447 + 1448 + var render_writer = std.io.Writer.Allocating.init(std.testing.allocator); 1449 + defer render_writer.deinit(); 1450 + try vx.render(&render_writer.writer); 1451 + const output = try render_writer.toOwnedSlice(); 1452 + defer std.testing.allocator.free(output); 1453 + try std.testing.expectEqual(@as(usize, 0), output.len); 877 1454 }
+167 -132
src/Window.zig
··· 4 4 const Cell = @import("Cell.zig"); 5 5 const Mouse = @import("Mouse.zig"); 6 6 const Segment = @import("Cell.zig").Segment; 7 - const Unicode = @import("Unicode.zig"); 7 + const unicode = @import("unicode.zig"); 8 8 const gw = @import("gwidth.zig"); 9 9 10 10 const Window = @This(); 11 11 12 - pub const Size = union(enum) { 13 - expand, 14 - limit: usize, 15 - }; 16 - 17 - /// horizontal offset from the screen 18 - x_off: usize, 19 - /// vertical offset from the screen 20 - y_off: usize, 12 + /// absolute horizontal offset from the screen 13 + x_off: i17, 14 + /// absolute vertical offset from the screen 15 + y_off: i17, 16 + /// relative horizontal offset, from parent window. This only accumulates if it is negative so that 17 + /// we can clip the window correctly 18 + parent_x_off: i17, 19 + /// relative vertical offset, from parent window. This only accumulates if it is negative so that 20 + /// we can clip the window correctly 21 + parent_y_off: i17, 21 22 /// width of the window. This can't be larger than the terminal screen 22 - width: usize, 23 + width: u16, 23 24 /// height of the window. This can't be larger than the terminal screen 24 - height: usize, 25 + height: u16, 25 26 26 27 screen: *Screen, 27 28 28 - /// Deprecated. Use `child` instead 29 - /// 30 29 /// Creates a new window with offset relative to parent and size clamped to the 31 30 /// parent's size. Windows do not retain a reference to their parent and are 32 31 /// unaware of resizes. 33 - pub fn initChild( 32 + fn initChild( 34 33 self: Window, 35 - x_off: usize, 36 - y_off: usize, 37 - width: Size, 38 - height: Size, 34 + x_off: i17, 35 + y_off: i17, 36 + maybe_width: ?u16, 37 + maybe_height: ?u16, 39 38 ) Window { 40 - const resolved_width = switch (width) { 41 - .expand => self.width -| x_off, 42 - .limit => |w| blk: { 43 - if (w + x_off > self.width) { 44 - break :blk self.width -| x_off; 45 - } 46 - break :blk w; 47 - }, 48 - }; 49 - const resolved_height = switch (height) { 50 - .expand => self.height -| y_off, 51 - .limit => |h| blk: { 52 - if (h + y_off > self.height) { 53 - break :blk self.height -| y_off; 54 - } 55 - break :blk h; 56 - }, 57 - }; 39 + const max_height = @max(self.height - y_off, 0); 40 + const max_width = @max(self.width - x_off, 0); 41 + const width: u16 = maybe_width orelse max_width; 42 + const height: u16 = maybe_height orelse max_height; 43 + 58 44 return Window{ 59 45 .x_off = x_off + self.x_off, 60 46 .y_off = y_off + self.y_off, 61 - .width = resolved_width, 62 - .height = resolved_height, 47 + .parent_x_off = @min(self.parent_x_off + x_off, 0), 48 + .parent_y_off = @min(self.parent_y_off + y_off, 0), 49 + .width = @min(width, max_width), 50 + .height = @min(height, max_height), 63 51 .screen = self.screen, 64 52 }; 65 53 } 66 54 67 55 pub const ChildOptions = struct { 68 - x_off: usize = 0, 69 - y_off: usize = 0, 56 + x_off: i17 = 0, 57 + y_off: i17 = 0, 70 58 /// the width of the resulting child, including any borders 71 - width: Size = .expand, 59 + width: ?u16 = null, 72 60 /// the height of the resulting child, including any borders 73 - height: Size = .expand, 61 + height: ?u16 = null, 74 62 border: BorderOptions = .{}, 75 63 }; 76 64 ··· 143 131 .other => |loc| loc, 144 132 }; 145 133 if (loc.top) { 146 - var i: usize = 0; 134 + var i: u16 = 0; 147 135 while (i < w) : (i += 1) { 148 136 result.writeCell(i, 0, .{ .char = horizontal, .style = style }); 149 137 } 150 138 } 151 139 if (loc.bottom) { 152 - var i: usize = 0; 140 + var i: u16 = 0; 153 141 while (i < w) : (i += 1) { 154 142 result.writeCell(i, h -| 1, .{ .char = horizontal, .style = style }); 155 143 } 156 144 } 157 145 if (loc.left) { 158 - var i: usize = 0; 146 + var i: u16 = 0; 159 147 while (i < h) : (i += 1) { 160 148 result.writeCell(0, i, .{ .char = vertical, .style = style }); 161 149 } 162 150 } 163 151 if (loc.right) { 164 - var i: usize = 0; 152 + var i: u16 = 0; 165 153 while (i < h) : (i += 1) { 166 154 result.writeCell(w -| 1, i, .{ .char = vertical, .style = style }); 167 155 } ··· 170 158 if (loc.top and loc.left) 171 159 result.writeCell(0, 0, .{ .char = top_left, .style = style }); 172 160 if (loc.top and loc.right) 173 - result.writeCell(w - 1, 0, .{ .char = top_right, .style = style }); 161 + result.writeCell(w -| 1, 0, .{ .char = top_right, .style = style }); 174 162 if (loc.bottom and loc.left) 175 163 result.writeCell(0, h -| 1, .{ .char = bottom_left, .style = style }); 176 164 if (loc.bottom and loc.right) 177 - result.writeCell(w - 1, h -| 1, .{ .char = bottom_right, .style = style }); 165 + result.writeCell(w -| 1, h -| 1, .{ .char = bottom_right, .style = style }); 178 166 179 - const x_off: usize = if (loc.left) 1 else 0; 180 - const y_off: usize = if (loc.top) 1 else 0; 181 - const h_delt: usize = if (loc.bottom) 1 else 0; 182 - const w_delt: usize = if (loc.right) 1 else 0; 183 - const h_ch: usize = h -| y_off -| h_delt; 184 - const w_ch: usize = w -| x_off -| w_delt; 185 - return result.initChild(x_off, y_off, .{ .limit = w_ch }, .{ .limit = h_ch }); 167 + const x_off: u16 = if (loc.left) 1 else 0; 168 + const y_off: u16 = if (loc.top) 1 else 0; 169 + const h_delt: u16 = if (loc.bottom) 1 else 0; 170 + const w_delt: u16 = if (loc.right) 1 else 0; 171 + const h_ch: u16 = h -| y_off -| h_delt; 172 + const w_ch: u16 = w -| x_off -| w_delt; 173 + return result.initChild(x_off, y_off, w_ch, h_ch); 186 174 } 187 175 188 176 /// writes a cell to the location in the window 189 - pub fn writeCell(self: Window, col: usize, row: usize, cell: Cell) void { 190 - if (self.height == 0 or self.width == 0) return; 191 - if (self.height <= row or self.width <= col) return; 192 - self.screen.writeCell(col + self.x_off, row + self.y_off, cell); 177 + pub fn writeCell(self: Window, col: u16, row: u16, cell: Cell) void { 178 + if (self.height <= row or 179 + self.width <= col or 180 + self.x_off + col < 0 or 181 + self.y_off + row < 0 or 182 + self.parent_x_off + col < 0 or 183 + self.parent_y_off + row < 0) 184 + return; 185 + 186 + self.screen.writeCell(@intCast(col + self.x_off), @intCast(row + self.y_off), cell); 193 187 } 194 188 195 189 /// reads a cell at the location in the window 196 - pub fn readCell(self: Window, col: usize, row: usize) ?Cell { 197 - if (self.height == 0 or self.width == 0) return null; 198 - if (self.height <= row or self.width <= col) return null; 199 - return self.screen.readCell(col + self.x_off, row + self.y_off); 190 + pub fn readCell(self: Window, col: u16, row: u16) ?Cell { 191 + if (self.height <= row or 192 + self.width <= col or 193 + self.x_off + col < 0 or 194 + self.y_off + row < 0 or 195 + self.parent_x_off + col < 0 or 196 + self.parent_y_off + row < 0) 197 + return null; 198 + return self.screen.readCell(@intCast(col + self.x_off), @intCast(row + self.y_off)); 200 199 } 201 200 202 201 /// fills the window with the default cell ··· 205 204 } 206 205 207 206 /// returns the width of the grapheme. This depends on the terminal capabilities 208 - pub fn gwidth(self: Window, str: []const u8) usize { 209 - return gw.gwidth(str, self.screen.width_method, &self.screen.unicode.width_data) catch 1; 207 + pub fn gwidth(self: Window, str: []const u8) u16 { 208 + return gw.gwidth(str, self.screen.width_method); 210 209 } 211 210 212 211 /// fills the window with the provided cell 213 212 pub fn fill(self: Window, cell: Cell) void { 214 - if (self.screen.width < self.x_off) 213 + if (self.x_off + self.width < 0 or 214 + self.y_off + self.height < 0 or 215 + self.screen.width < self.x_off or 216 + self.screen.height < self.y_off) 215 217 return; 216 - if (self.screen.height < self.y_off) 217 - return; 218 + const first_row: usize = @intCast(@max(self.y_off, 0)); 218 219 if (self.x_off == 0 and self.width == self.screen.width) { 219 220 // we have a full width window, therefore contiguous memory. 220 - const start = self.y_off * self.width; 221 - const end = start + (self.height * self.width); 221 + const start = @min(first_row * self.width, self.screen.buf.len); 222 + const end = @min(start + (@as(usize, @intCast(self.height)) * self.width), self.screen.buf.len); 222 223 @memset(self.screen.buf[start..end], cell); 223 224 } else { 224 225 // Non-contiguous. Iterate over rows an memset 225 - var row: usize = self.y_off; 226 + var row: usize = first_row; 227 + const first_col: usize = @max(self.x_off, 0); 226 228 const last_row = @min(self.height + self.y_off, self.screen.height); 227 229 while (row < last_row) : (row += 1) { 228 - const start = self.x_off + (row * self.screen.width); 229 - const end = @min(start + self.width, start + (self.screen.width - self.x_off)); 230 + const start = @min(first_col + (row * self.screen.width), self.screen.buf.len); 231 + var end = @min(start + self.width, start + (self.screen.width - first_col)); 232 + end = @min(end, self.screen.buf.len); 230 233 @memset(self.screen.buf[start..end], cell); 231 234 } 232 235 } ··· 238 241 } 239 242 240 243 /// show the cursor at the given coordinates, 0 indexed 241 - pub fn showCursor(self: Window, col: usize, row: usize) void { 242 - if (self.height == 0 or self.width == 0) return; 243 - if (self.height <= row or self.width <= col) return; 244 + pub fn showCursor(self: Window, col: u16, row: u16) void { 245 + if (self.x_off + col < 0 or 246 + self.y_off + row < 0 or 247 + row >= self.height or 248 + col >= self.width) 249 + return; 244 250 self.screen.cursor_vis = true; 245 - self.screen.cursor_row = row + self.y_off; 246 - self.screen.cursor_col = col + self.x_off; 251 + self.screen.cursor_row = @intCast(row + self.y_off); 252 + self.screen.cursor_col = @intCast(col + self.x_off); 247 253 } 248 254 249 255 pub fn setCursorShape(self: Window, shape: Cell.CursorShape) void { ··· 253 259 /// Options to use when printing Segments to a window 254 260 pub const PrintOptions = struct { 255 261 /// vertical offset to start printing at 256 - row_offset: usize = 0, 262 + row_offset: u16 = 0, 257 263 /// horizontal offset to start printing at 258 - col_offset: usize = 0, 264 + col_offset: u16 = 0, 259 265 260 266 /// wrap behavior for printing 261 267 wrap: enum { ··· 274 280 }; 275 281 276 282 pub const PrintResult = struct { 277 - col: usize, 278 - row: usize, 283 + col: u16, 284 + row: u16, 279 285 overflow: bool, 280 286 }; 281 287 282 288 /// prints segments to the window. returns true if the text overflowed with the 283 289 /// given wrap strategy and size. 284 - pub fn print(self: Window, segments: []const Segment, opts: PrintOptions) !PrintResult { 290 + pub fn print(self: Window, segments: []const Segment, opts: PrintOptions) PrintResult { 285 291 var row = opts.row_offset; 286 292 switch (opts.wrap) { 287 293 .grapheme => { 288 - var col: usize = opts.col_offset; 294 + var col: u16 = opts.col_offset; 289 295 const overflow: bool = blk: for (segments) |segment| { 290 - var iter = self.screen.unicode.graphemeIterator(segment.text); 296 + var iter = unicode.graphemeIterator(segment.text); 291 297 while (iter.next()) |grapheme| { 292 298 if (col >= self.width) { 293 299 row += 1; ··· 305 311 if (opts.commit) self.writeCell(col, row, .{ 306 312 .char = .{ 307 313 .grapheme = s, 308 - .width = w, 314 + .width = @intCast(w), 309 315 }, 310 316 .style = segment.style, 311 317 .link = segment.link, ··· 325 331 }; 326 332 }, 327 333 .word => { 328 - var col: usize = opts.col_offset; 334 + var col: u16 = opts.col_offset; 329 335 var overflow: bool = false; 330 336 var soft_wrapped: bool = false; 331 337 outer: for (segments) |segment| { ··· 370 376 col = 0; 371 377 } 372 378 373 - var grapheme_iterator = self.screen.unicode.graphemeIterator(word); 379 + var grapheme_iterator = unicode.graphemeIterator(word); 374 380 while (grapheme_iterator.next()) |grapheme| { 375 381 soft_wrapped = false; 376 382 if (row >= self.height) { ··· 382 388 if (opts.commit) self.writeCell(col, row, .{ 383 389 .char = .{ 384 390 .grapheme = s, 385 - .width = w, 391 + .width = @intCast(w), 386 392 }, 387 393 .style = segment.style, 388 394 .link = segment.link, ··· 407 413 }; 408 414 }, 409 415 .none => { 410 - var col: usize = opts.col_offset; 416 + var col: u16 = opts.col_offset; 411 417 const overflow: bool = blk: for (segments) |segment| { 412 - var iter = self.screen.unicode.graphemeIterator(segment.text); 418 + var iter = unicode.graphemeIterator(segment.text); 413 419 while (iter.next()) |grapheme| { 414 420 if (col >= self.width) break :blk true; 415 421 const s = grapheme.bytes(segment.text); ··· 419 425 if (opts.commit) self.writeCell(col, row, .{ 420 426 .char = .{ 421 427 .grapheme = s, 422 - .width = w, 428 + .width = @intCast(w), 423 429 }, 424 430 .style = segment.style, 425 431 .link = segment.link, ··· 438 444 } 439 445 440 446 /// print a single segment. This is just a shortcut for print(&.{segment}, opts) 441 - pub fn printSegment(self: Window, segment: Segment, opts: PrintOptions) !PrintResult { 447 + pub fn printSegment(self: Window, segment: Segment, opts: PrintOptions) PrintResult { 442 448 return self.print(&.{segment}, opts); 443 449 } 444 450 445 451 /// scrolls the window down one row (IE inserts a blank row at the bottom of the 446 452 /// screen and shifts all rows up one) 447 - pub fn scroll(self: Window, n: usize) void { 453 + pub fn scroll(self: Window, n: u16) void { 448 454 if (n > self.height) return; 449 - var row = self.y_off; 455 + var row: u16 = @max(self.y_off, 0); 456 + const first_col: u16 = @max(self.x_off, 0); 450 457 while (row < self.height - n) : (row += 1) { 451 - const dst_start = (row * self.width) + self.x_off; 458 + const dst_start = (row * self.screen.width) + first_col; 452 459 const dst_end = dst_start + self.width; 453 460 454 - const src_start = ((row + n) * self.width) + self.x_off; 461 + const src_start = ((row + n) * self.screen.width) + first_col; 455 462 const src_end = src_start + self.width; 456 463 @memcpy(self.screen.buf[dst_start..dst_end], self.screen.buf[src_start..src_end]); 457 464 } ··· 475 482 var parent = Window{ 476 483 .x_off = 0, 477 484 .y_off = 0, 485 + .parent_x_off = 0, 486 + .parent_y_off = 0, 478 487 .width = 20, 479 488 .height = 20, 480 489 .screen = undefined, 481 490 }; 482 491 483 - const ch = parent.initChild(1, 1, .expand, .expand); 492 + const ch = parent.initChild(1, 1, null, null); 484 493 try std.testing.expectEqual(19, ch.width); 485 494 try std.testing.expectEqual(19, ch.height); 486 495 } ··· 489 498 var parent = Window{ 490 499 .x_off = 0, 491 500 .y_off = 0, 501 + .parent_x_off = 0, 502 + .parent_y_off = 0, 492 503 .width = 20, 493 504 .height = 20, 494 505 .screen = undefined, 495 506 }; 496 507 497 - const ch = parent.initChild(0, 0, .{ .limit = 21 }, .{ .limit = 21 }); 508 + const ch = parent.initChild(0, 0, 21, 21); 498 509 try std.testing.expectEqual(20, ch.width); 499 510 try std.testing.expectEqual(20, ch.height); 500 511 } ··· 503 514 var parent = Window{ 504 515 .x_off = 0, 505 516 .y_off = 0, 517 + .parent_x_off = 0, 518 + .parent_y_off = 0, 506 519 .width = 20, 507 520 .height = 20, 508 521 .screen = undefined, 509 522 }; 510 523 511 - const ch = parent.initChild(10, 10, .{ .limit = 21 }, .{ .limit = 21 }); 524 + const ch = parent.initChild(10, 10, 21, 21); 512 525 try std.testing.expectEqual(10, ch.width); 513 526 try std.testing.expectEqual(10, ch.height); 514 527 } ··· 517 530 var parent = Window{ 518 531 .x_off = 1, 519 532 .y_off = 1, 533 + .parent_x_off = 0, 534 + .parent_y_off = 0, 520 535 .width = 20, 521 536 .height = 20, 522 537 .screen = undefined, 523 538 }; 524 539 525 - const ch = parent.initChild(10, 10, .{ .limit = 21 }, .{ .limit = 21 }); 540 + const ch = parent.initChild(10, 10, 21, 21); 526 541 try std.testing.expectEqual(11, ch.x_off); 527 542 try std.testing.expectEqual(11, ch.y_off); 528 543 } 529 544 545 + test "Window offsets" { 546 + var parent = Window{ 547 + .x_off = 0, 548 + .y_off = 0, 549 + .parent_x_off = 0, 550 + .parent_y_off = 0, 551 + .width = 20, 552 + .height = 20, 553 + .screen = undefined, 554 + }; 555 + 556 + const ch = parent.initChild(10, 10, 21, 21); 557 + const ch2 = ch.initChild(-4, -4, null, null); 558 + // Reading ch2 at row 0 should be null 559 + try std.testing.expect(ch2.readCell(0, 0) == null); 560 + // Should not panic us 561 + ch2.writeCell(0, 0, undefined); 562 + } 563 + 530 564 test "print: grapheme" { 531 - const alloc = std.testing.allocator_instance.allocator(); 532 - const unicode = try Unicode.init(alloc); 533 - defer unicode.deinit(); 534 - var screen: Screen = .{ .width_method = .unicode, .unicode = &unicode }; 565 + var screen: Screen = .{ .width_method = .unicode }; 535 566 const win: Window = .{ 536 567 .x_off = 0, 537 568 .y_off = 0, 569 + .parent_x_off = 0, 570 + .parent_y_off = 0, 538 571 .width = 4, 539 572 .height = 2, 540 573 .screen = &screen, ··· 548 581 var segments = [_]Segment{ 549 582 .{ .text = "a" }, 550 583 }; 551 - const result = try win.print(&segments, opts); 584 + const result = win.print(&segments, opts); 552 585 try std.testing.expectEqual(1, result.col); 553 586 try std.testing.expectEqual(0, result.row); 554 587 try std.testing.expectEqual(false, result.overflow); ··· 557 590 var segments = [_]Segment{ 558 591 .{ .text = "abcd" }, 559 592 }; 560 - const result = try win.print(&segments, opts); 593 + const result = win.print(&segments, opts); 561 594 try std.testing.expectEqual(0, result.col); 562 595 try std.testing.expectEqual(1, result.row); 563 596 try std.testing.expectEqual(false, result.overflow); ··· 566 599 var segments = [_]Segment{ 567 600 .{ .text = "abcde" }, 568 601 }; 569 - const result = try win.print(&segments, opts); 602 + const result = win.print(&segments, opts); 570 603 try std.testing.expectEqual(1, result.col); 571 604 try std.testing.expectEqual(1, result.row); 572 605 try std.testing.expectEqual(false, result.overflow); ··· 575 608 var segments = [_]Segment{ 576 609 .{ .text = "abcdefgh" }, 577 610 }; 578 - const result = try win.print(&segments, opts); 611 + const result = win.print(&segments, opts); 579 612 try std.testing.expectEqual(0, result.col); 580 613 try std.testing.expectEqual(2, result.row); 581 614 try std.testing.expectEqual(false, result.overflow); ··· 584 617 var segments = [_]Segment{ 585 618 .{ .text = "abcdefghi" }, 586 619 }; 587 - const result = try win.print(&segments, opts); 620 + const result = win.print(&segments, opts); 588 621 try std.testing.expectEqual(0, result.col); 589 622 try std.testing.expectEqual(2, result.row); 590 623 try std.testing.expectEqual(true, result.overflow); ··· 592 625 } 593 626 594 627 test "print: word" { 595 - const alloc = std.testing.allocator_instance.allocator(); 596 - const unicode = try Unicode.init(alloc); 597 - defer unicode.deinit(); 598 628 var screen: Screen = .{ 599 629 .width_method = .unicode, 600 - .unicode = &unicode, 601 630 }; 602 631 const win: Window = .{ 603 632 .x_off = 0, 604 633 .y_off = 0, 634 + .parent_x_off = 0, 635 + .parent_y_off = 0, 605 636 .width = 4, 606 637 .height = 2, 607 638 .screen = &screen, ··· 615 646 var segments = [_]Segment{ 616 647 .{ .text = "a" }, 617 648 }; 618 - const result = try win.print(&segments, opts); 649 + const result = win.print(&segments, opts); 619 650 try std.testing.expectEqual(1, result.col); 620 651 try std.testing.expectEqual(0, result.row); 621 652 try std.testing.expectEqual(false, result.overflow); ··· 624 655 var segments = [_]Segment{ 625 656 .{ .text = " " }, 626 657 }; 627 - const result = try win.print(&segments, opts); 658 + const result = win.print(&segments, opts); 628 659 try std.testing.expectEqual(1, result.col); 629 660 try std.testing.expectEqual(0, result.row); 630 661 try std.testing.expectEqual(false, result.overflow); ··· 633 664 var segments = [_]Segment{ 634 665 .{ .text = " a" }, 635 666 }; 636 - const result = try win.print(&segments, opts); 667 + const result = win.print(&segments, opts); 637 668 try std.testing.expectEqual(2, result.col); 638 669 try std.testing.expectEqual(0, result.row); 639 670 try std.testing.expectEqual(false, result.overflow); ··· 642 673 var segments = [_]Segment{ 643 674 .{ .text = "a b" }, 644 675 }; 645 - const result = try win.print(&segments, opts); 676 + const result = win.print(&segments, opts); 646 677 try std.testing.expectEqual(3, result.col); 647 678 try std.testing.expectEqual(0, result.row); 648 679 try std.testing.expectEqual(false, result.overflow); ··· 651 682 var segments = [_]Segment{ 652 683 .{ .text = "a b c" }, 653 684 }; 654 - const result = try win.print(&segments, opts); 685 + const result = win.print(&segments, opts); 655 686 try std.testing.expectEqual(1, result.col); 656 687 try std.testing.expectEqual(1, result.row); 657 688 try std.testing.expectEqual(false, result.overflow); ··· 660 691 var segments = [_]Segment{ 661 692 .{ .text = "hello" }, 662 693 }; 663 - const result = try win.print(&segments, opts); 694 + const result = win.print(&segments, opts); 664 695 try std.testing.expectEqual(1, result.col); 665 696 try std.testing.expectEqual(1, result.row); 666 697 try std.testing.expectEqual(false, result.overflow); ··· 669 700 var segments = [_]Segment{ 670 701 .{ .text = "hi tim" }, 671 702 }; 672 - const result = try win.print(&segments, opts); 703 + const result = win.print(&segments, opts); 673 704 try std.testing.expectEqual(3, result.col); 674 705 try std.testing.expectEqual(1, result.row); 675 706 try std.testing.expectEqual(false, result.overflow); ··· 678 709 var segments = [_]Segment{ 679 710 .{ .text = "hello tim" }, 680 711 }; 681 - const result = try win.print(&segments, opts); 712 + const result = win.print(&segments, opts); 682 713 try std.testing.expectEqual(0, result.col); 683 714 try std.testing.expectEqual(2, result.row); 684 715 try std.testing.expectEqual(true, result.overflow); ··· 687 718 var segments = [_]Segment{ 688 719 .{ .text = "hello ti" }, 689 720 }; 690 - const result = try win.print(&segments, opts); 721 + const result = win.print(&segments, opts); 691 722 try std.testing.expectEqual(0, result.col); 692 723 try std.testing.expectEqual(2, result.row); 693 724 try std.testing.expectEqual(false, result.overflow); ··· 697 728 .{ .text = "h" }, 698 729 .{ .text = "e" }, 699 730 }; 700 - const result = try win.print(&segments, opts); 731 + const result = win.print(&segments, opts); 701 732 try std.testing.expectEqual(2, result.col); 702 733 try std.testing.expectEqual(0, result.row); 703 734 try std.testing.expectEqual(false, result.overflow); ··· 710 741 .{ .text = "l" }, 711 742 .{ .text = "o" }, 712 743 }; 713 - const result = try win.print(&segments, opts); 744 + const result = win.print(&segments, opts); 714 745 try std.testing.expectEqual(1, result.col); 715 746 try std.testing.expectEqual(1, result.row); 716 747 try std.testing.expectEqual(false, result.overflow); ··· 719 750 var segments = [_]Segment{ 720 751 .{ .text = "he\n" }, 721 752 }; 722 - const result = try win.print(&segments, opts); 753 + const result = win.print(&segments, opts); 723 754 try std.testing.expectEqual(0, result.col); 724 755 try std.testing.expectEqual(1, result.row); 725 756 try std.testing.expectEqual(false, result.overflow); ··· 728 759 var segments = [_]Segment{ 729 760 .{ .text = "he\n\n" }, 730 761 }; 731 - const result = try win.print(&segments, opts); 762 + const result = win.print(&segments, opts); 732 763 try std.testing.expectEqual(0, result.col); 733 764 try std.testing.expectEqual(2, result.row); 734 765 try std.testing.expectEqual(false, result.overflow); ··· 737 768 var segments = [_]Segment{ 738 769 .{ .text = "not now" }, 739 770 }; 740 - const result = try win.print(&segments, opts); 771 + const result = win.print(&segments, opts); 741 772 try std.testing.expectEqual(3, result.col); 742 773 try std.testing.expectEqual(1, result.row); 743 774 try std.testing.expectEqual(false, result.overflow); ··· 746 777 var segments = [_]Segment{ 747 778 .{ .text = "note now" }, 748 779 }; 749 - const result = try win.print(&segments, opts); 780 + const result = win.print(&segments, opts); 750 781 try std.testing.expectEqual(3, result.col); 751 782 try std.testing.expectEqual(1, result.row); 752 783 try std.testing.expectEqual(false, result.overflow); ··· 756 787 .{ .text = "note" }, 757 788 .{ .text = " now" }, 758 789 }; 759 - const result = try win.print(&segments, opts); 790 + const result = win.print(&segments, opts); 760 791 try std.testing.expectEqual(3, result.col); 761 792 try std.testing.expectEqual(1, result.row); 762 793 try std.testing.expectEqual(false, result.overflow); ··· 766 797 .{ .text = "note " }, 767 798 .{ .text = "now" }, 768 799 }; 769 - const result = try win.print(&segments, opts); 800 + const result = win.print(&segments, opts); 770 801 try std.testing.expectEqual(3, result.col); 771 802 try std.testing.expectEqual(1, result.row); 772 803 try std.testing.expectEqual(false, result.overflow); ··· 852 883 } 853 884 } 854 885 }; 886 + 887 + test "refAllDecls" { 888 + std.testing.refAllDecls(@This()); 889 + }
-207
src/aio.zig
··· 1 - const build_options = @import("build_options"); 2 - const builtin = @import("builtin"); 3 - const std = @import("std"); 4 - const vaxis = @import("main.zig"); 5 - const handleEventGeneric = @import("Loop.zig").handleEventGeneric; 6 - const log = std.log.scoped(.vaxis_aio); 7 - 8 - const Yield = enum { no_state, took_event }; 9 - 10 - pub fn Loop(T: type) type { 11 - if (!build_options.aio) { 12 - @compileError( 13 - \\build_options.aio is not enabled. 14 - \\Use `LoopWithModules` instead to provide `aio` and `coro` modules from outside vaxis. 15 - ); 16 - } 17 - return LoopWithModules(T, @import("aio"), @import("coro")); 18 - } 19 - 20 - /// zig-aio based event loop 21 - /// <https://github.com/Cloudef/zig-aio> 22 - pub fn LoopWithModules(T: type, aio: type, coro: type) type { 23 - return struct { 24 - const Event = T; 25 - 26 - winsize_task: ?coro.Task.Generic2(winsizeTask) = null, 27 - reader_task: ?coro.Task.Generic2(ttyReaderTask) = null, 28 - queue: std.BoundedArray(T, 512) = .{}, 29 - source: aio.EventSource, 30 - fatal: bool = false, 31 - 32 - pub fn init() !@This() { 33 - return .{ .source = try aio.EventSource.init() }; 34 - } 35 - 36 - pub fn deinit(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { 37 - vx.deviceStatusReport(tty.anyWriter()) catch {}; 38 - if (self.winsize_task) |task| task.cancel(); 39 - if (self.reader_task) |task| task.cancel(); 40 - self.source.deinit(); 41 - self.* = undefined; 42 - } 43 - 44 - fn winsizeInner(self: *@This(), tty: *vaxis.Tty) !void { 45 - const Context = struct { 46 - loop: *@TypeOf(self.*), 47 - tty: *vaxis.Tty, 48 - winsize: ?vaxis.Winsize = null, 49 - fn cb(ptr: *anyopaque) void { 50 - std.debug.assert(coro.current() == null); 51 - const ctx: *@This() = @ptrCast(@alignCast(ptr)); 52 - ctx.winsize = vaxis.Tty.getWinsize(ctx.tty.fd) catch return; 53 - ctx.loop.source.notify(); 54 - } 55 - }; 56 - 57 - // keep on stack 58 - var ctx: Context = .{ .loop = self, .tty = tty }; 59 - if (builtin.target.os.tag != .windows) { 60 - if (@hasField(Event, "winsize")) { 61 - const handler: vaxis.Tty.SignalHandler = .{ .context = &ctx, .callback = Context.cb }; 62 - try vaxis.Tty.notifyWinsize(handler); 63 - } 64 - } 65 - 66 - while (true) { 67 - try coro.io.single(aio.WaitEventSource{ .source = &self.source }); 68 - if (ctx.winsize) |winsize| { 69 - if (!@hasField(Event, "winsize")) unreachable; 70 - ctx.loop.postEvent(.{ .winsize = winsize }) catch {}; 71 - ctx.winsize = null; 72 - } 73 - } 74 - } 75 - 76 - fn winsizeTask(self: *@This(), tty: *vaxis.Tty) void { 77 - self.winsizeInner(tty) catch |err| { 78 - if (err != error.Canceled) log.err("winsize: {}", .{err}); 79 - self.fatal = true; 80 - }; 81 - } 82 - 83 - fn windowsReadEvent(tty: *vaxis.Tty) !vaxis.Event { 84 - var state: vaxis.Tty.EventState = .{}; 85 - while (true) { 86 - var bytes_read: usize = 0; 87 - var input_record: vaxis.Tty.INPUT_RECORD = undefined; 88 - try coro.io.single(aio.ReadTty{ 89 - .tty = .{ .handle = tty.stdin }, 90 - .buffer = std.mem.asBytes(&input_record), 91 - .out_read = &bytes_read, 92 - }); 93 - 94 - if (try tty.eventFromRecord(&input_record, &state)) |ev| { 95 - return ev; 96 - } 97 - } 98 - } 99 - 100 - fn ttyReaderWindows(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty) !void { 101 - var cache: vaxis.GraphemeCache = .{}; 102 - while (true) { 103 - const event = try windowsReadEvent(tty); 104 - try handleEventGeneric(self, vx, &cache, Event, event, null); 105 - } 106 - } 107 - 108 - fn ttyReaderPosix(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_allocator: ?std.mem.Allocator) !void { 109 - // initialize a grapheme cache 110 - var cache: vaxis.GraphemeCache = .{}; 111 - 112 - // get our initial winsize 113 - const winsize = try vaxis.Tty.getWinsize(tty.fd); 114 - if (@hasField(Event, "winsize")) { 115 - try self.postEvent(.{ .winsize = winsize }); 116 - } 117 - 118 - var parser: vaxis.Parser = .{ 119 - .grapheme_data = &vx.unicode.grapheme_data, 120 - }; 121 - 122 - const file: std.fs.File = .{ .handle = tty.fd }; 123 - while (true) { 124 - var buf: [4096]u8 = undefined; 125 - var n: usize = undefined; 126 - var read_start: usize = 0; 127 - try coro.io.single(aio.ReadTty{ .tty = file, .buffer = buf[read_start..], .out_read = &n }); 128 - var seq_start: usize = 0; 129 - while (seq_start < n) { 130 - const result = try parser.parse(buf[seq_start..n], paste_allocator); 131 - if (result.n == 0) { 132 - // copy the read to the beginning. We don't use memcpy because 133 - // this could be overlapping, and it's also rare 134 - const initial_start = seq_start; 135 - while (seq_start < n) : (seq_start += 1) { 136 - buf[seq_start - initial_start] = buf[seq_start]; 137 - } 138 - read_start = seq_start - initial_start + 1; 139 - continue; 140 - } 141 - read_start = 0; 142 - seq_start += result.n; 143 - 144 - const event = result.event orelse continue; 145 - try handleEventGeneric(self, vx, &cache, Event, event, paste_allocator); 146 - } 147 - } 148 - } 149 - 150 - fn ttyReaderTask(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_allocator: ?std.mem.Allocator) void { 151 - return switch (builtin.target.os.tag) { 152 - .windows => self.ttyReaderWindows(vx, tty), 153 - else => self.ttyReaderPosix(vx, tty, paste_allocator), 154 - } catch |err| { 155 - if (err != error.Canceled) log.err("ttyReader: {}", .{err}); 156 - self.fatal = true; 157 - }; 158 - } 159 - 160 - /// Spawns tasks to handle winsize signal and tty 161 - pub fn spawn( 162 - self: *@This(), 163 - scheduler: *coro.Scheduler, 164 - vx: *vaxis.Vaxis, 165 - tty: *vaxis.Tty, 166 - paste_allocator: ?std.mem.Allocator, 167 - spawn_options: coro.Scheduler.SpawnOptions, 168 - ) coro.Scheduler.SpawnError!void { 169 - if (self.reader_task) |_| unreachable; // programming error 170 - // This is required even if app doesn't care about winsize 171 - // It is because it consumes the EventSource, so it can wakeup the scheduler 172 - // Without that custom `postEvent`'s wouldn't wake up the scheduler and UI wouldn't update 173 - self.winsize_task = try scheduler.spawn(winsizeTask, .{ self, tty }, spawn_options); 174 - self.reader_task = try scheduler.spawn(ttyReaderTask, .{ self, vx, tty, paste_allocator }, spawn_options); 175 - } 176 - 177 - pub const PopEventError = error{TtyCommunicationSevered}; 178 - 179 - /// Call this in a while loop in the main event handler until it returns null 180 - pub fn popEvent(self: *@This()) PopEventError!?T { 181 - if (self.fatal) return error.TtyCommunicationSevered; 182 - defer self.winsize_task.?.wakeupIf(Yield.took_event); 183 - defer self.reader_task.?.wakeupIf(Yield.took_event); 184 - return self.queue.popOrNull(); 185 - } 186 - 187 - pub const PostEventError = error{Overflow}; 188 - 189 - pub fn postEvent(self: *@This(), event: T) !void { 190 - if (coro.current()) |_| { 191 - while (true) { 192 - self.queue.insert(0, event) catch { 193 - // wait for the app to take event 194 - try coro.yield(Yield.took_event); 195 - continue; 196 - }; 197 - break; 198 - } 199 - } else { 200 - // queue can be full, app could handle this error by spinning the scheduler 201 - try self.queue.insert(0, event); 202 - } 203 - // wakes up the scheduler, so custom events update UI 204 - self.source.notify(); 205 - } 206 - }; 207 - }
+14
src/ctlseqs.zig
··· 11 11 pub const csi_u_query = "\x1b[?u"; 12 12 pub const kitty_graphics_query = "\x1b_Gi=1,a=q\x1b\\"; 13 13 pub const sixel_geometry_query = "\x1b[?2;1;0S"; 14 + pub const cursor_position_request = "\x1b[6n"; 15 + pub const explicit_width_query = "\x1b]66;w=1; \x1b\\"; 16 + pub const scaled_text_query = "\x1b]66;s=2; \x1b\\"; 17 + pub const multi_cursor_query = "\x1b[> q"; 14 18 15 19 // mouse. We try for button motion and any motion. terminals will enable the 16 20 // last one we tried (any motion). This was added because zellij doesn't ··· 31 35 // unicode 32 36 pub const unicode_set = "\x1b[?2027h"; 33 37 pub const unicode_reset = "\x1b[?2027l"; 38 + pub const explicit_width = "\x1b]66;w={d};{s}\x1b\\"; 39 + 40 + // text sizing 41 + pub const scaled_text = "\x1b]66;s={d}:w={d};{s}\x1b\\"; 42 + pub const scaled_text_with_fractions = "\x1b]66;s={d}:w={d}:n={d}:d={d}:v={d};{s}\x1b\\"; 34 43 35 44 // bracketed paste 36 45 pub const bp_set = "\x1b[?2004h"; ··· 87 96 pub const fg_rgb_legacy = "\x1b[38;2;{d};{d};{d}m"; 88 97 pub const bg_rgb_legacy = "\x1b[48;2;{d};{d};{d}m"; 89 98 pub const ul_rgb_legacy = "\x1b[58;2;{d};{d};{d}m"; 99 + pub const ul_rgb_conpty = "\x1b[58:2::{d}:{d}:{d}m"; 90 100 91 101 // Underlines 92 102 pub const ul_off = "\x1b[24m"; // NOTE: this could be \x1b[4:0m but is not as widely supported ··· 113 123 114 124 // OSC sequences 115 125 pub const osc2_set_title = "\x1b]2;{s}\x1b\\"; 126 + pub const osc7 = "\x1b]7;{f}\x1b\\"; 116 127 pub const osc8 = "\x1b]8;{s};{s}\x1b\\"; 117 128 pub const osc8_clear = "\x1b]8;;\x1b\\"; 118 129 pub const osc9_notify = "\x1b]9;{s}\x1b\\"; ··· 130 141 pub const osc4_query = "\x1b]4;{d};?\x1b\\"; // color index {d} 131 142 pub const osc4_reset = "\x1b]104\x1b\\"; // this resets _all_ color indexes 132 143 pub const osc10_query = "\x1b]10;?\x1b\\"; // fg 144 + pub const osc10_set = "\x1b]10;rgb:{x:0>2}{x:0>2}/{x:0>2}{x:0>2}/{x:0>2}{x:0>2}\x1b\\"; // set default terminal fg 133 145 pub const osc10_reset = "\x1b]110\x1b\\"; // reset fg to terminal default 134 146 pub const osc11_query = "\x1b]11;?\x1b\\"; // bg 147 + pub const osc11_set = "\x1b]11;rgb:{x:0>2}{x:0>2}/{x:0>2}{x:0>2}/{x:0>2}{x:0>2}\x1b\\"; // set default terminal bg 135 148 pub const osc11_reset = "\x1b]111\x1b\\"; // reset bg to terminal default 136 149 pub const osc12_query = "\x1b]12;?\x1b\\"; // cursor color 150 + pub const osc12_set = "\x1b]12;rgb:{x:0>2}{x:0>2}/{x:0>2}{x:0>2}/{x:0>2}{x:0>2}\x1b\\"; // set terminal cursor color 137 151 pub const osc12_reset = "\x1b]112\x1b\\"; // reset cursor to terminal default
+2
src/event.zig
··· 8 8 key_press: Key, 9 9 key_release: Key, 10 10 mouse: Mouse, 11 + mouse_leave, 11 12 focus_in, 12 13 focus_out, 13 14 paste_start, // bracketed paste start ··· 25 26 cap_unicode, 26 27 cap_da1, 27 28 cap_color_scheme_updates, 29 + cap_multi_cursor, 28 30 };
+178 -42
src/gwidth.zig
··· 1 1 const std = @import("std"); 2 2 const unicode = std.unicode; 3 3 const testing = std.testing; 4 - const DisplayWidth = @import("DisplayWidth"); 5 - const code_point = @import("code_point"); 4 + const uucode = @import("uucode"); 6 5 7 6 /// the method to use when calculating the width of a grapheme 8 7 pub const Method = enum { ··· 11 10 no_zwj, 12 11 }; 13 12 13 + /// Calculate width from east asian width property and Unicode properties 14 + fn eawToWidth(cp: u21, eaw: uucode.types.EastAsianWidth) i16 { 15 + // Based on wcwidth implementation 16 + // Control characters 17 + if (cp == 0) return 0; 18 + if (cp < 32 or (cp >= 0x7f and cp < 0xa0)) return -1; 19 + 20 + // Use general category for comprehensive zero-width detection 21 + const gc = uucode.get(.general_category, cp); 22 + switch (gc) { 23 + .mark_nonspacing, .mark_enclosing => return 0, 24 + else => {}, 25 + } 26 + 27 + // Additional zero-width characters not covered by general category 28 + if (cp == 0x00ad) return 0; // soft hyphen 29 + if (cp == 0x200b) return 0; // zero-width space 30 + if (cp == 0x200c) return 0; // zero-width non-joiner 31 + if (cp == 0x200d) return 0; // zero-width joiner 32 + if (cp == 0x2060) return 0; // word joiner 33 + if (cp == 0x034f) return 0; // combining grapheme joiner 34 + if (cp == 0xfeff) return 0; // zero-width no-break space (BOM) 35 + if (cp >= 0x180b and cp <= 0x180d) return 0; // Mongolian variation selectors 36 + if (cp >= 0xfe00 and cp <= 0xfe0f) return 0; // variation selectors 37 + if (cp >= 0xe0100 and cp <= 0xe01ef) return 0; // Plane-14 variation selectors 38 + 39 + // East Asian Width: fullwidth or wide = 2 40 + // ambiguous in East Asian context = 2, otherwise 1 41 + // halfwidth, narrow, or neutral = 1 42 + return switch (eaw) { 43 + .fullwidth, .wide => 2, 44 + else => 1, 45 + }; 46 + } 47 + 14 48 /// returns the width of the provided string, as measured by the method chosen 15 - pub fn gwidth(str: []const u8, method: Method, data: *const DisplayWidth.DisplayWidthData) !usize { 49 + pub fn gwidth(str: []const u8, method: Method) u16 { 16 50 switch (method) { 17 51 .unicode => { 18 - const dw: DisplayWidth = .{ .data = data }; 19 - return dw.strWidth(str); 52 + var total: u16 = 0; 53 + var grapheme_iter = uucode.grapheme.Iterator(uucode.utf8.Iterator).init(.init(str)); 54 + 55 + var grapheme_start: usize = 0; 56 + var prev_break: bool = true; 57 + 58 + while (grapheme_iter.next()) |result| { 59 + if (prev_break and !result.is_break) { 60 + // Start of a new grapheme 61 + const cp_len: usize = std.unicode.utf8CodepointSequenceLength(result.cp) catch 1; 62 + grapheme_start = grapheme_iter.i - cp_len; 63 + } 64 + 65 + if (result.is_break) { 66 + // End of a grapheme - calculate its width 67 + const grapheme_end = grapheme_iter.i; 68 + const grapheme_bytes = str[grapheme_start..grapheme_end]; 69 + 70 + // Calculate grapheme width 71 + var g_iter = uucode.utf8.Iterator.init(grapheme_bytes); 72 + var width: i16 = 0; 73 + var has_emoji_vs: bool = false; 74 + var has_text_vs: bool = false; 75 + var has_emoji_presentation: bool = false; 76 + var ri_count: u8 = 0; 77 + 78 + while (g_iter.next()) |cp| { 79 + // Check for emoji variation selector (U+FE0F) 80 + if (cp == 0xfe0f) { 81 + has_emoji_vs = true; 82 + continue; 83 + } 84 + 85 + // Check for text variation selector (U+FE0E) 86 + if (cp == 0xfe0e) { 87 + has_text_vs = true; 88 + continue; 89 + } 90 + 91 + // Check if this codepoint has emoji presentation 92 + if (uucode.get(.is_emoji_presentation, cp)) { 93 + has_emoji_presentation = true; 94 + } 95 + 96 + // Count regional indicators (for flag emojis) 97 + if (cp >= 0x1F1E6 and cp <= 0x1F1FF) { 98 + ri_count += 1; 99 + } 100 + 101 + const eaw = uucode.get(.east_asian_width, cp); 102 + const w = eawToWidth(cp, eaw); 103 + // Take max of non-zero widths 104 + if (w > 0 and w > width) width = w; 105 + } 106 + 107 + // Handle variation selectors and emoji presentation 108 + if (has_text_vs) { 109 + // Text presentation explicit - keep width as-is (usually 1) 110 + width = @max(1, width); 111 + } else if (has_emoji_vs or has_emoji_presentation or ri_count == 2) { 112 + // Emoji presentation or flag pair - force width 2 113 + width = @max(2, width); 114 + } 115 + 116 + total += @max(0, width); 117 + 118 + grapheme_start = grapheme_end; 119 + } 120 + prev_break = result.is_break; 121 + } 122 + 123 + return total; 20 124 }, 21 125 .wcwidth => { 22 - var total: usize = 0; 23 - var iter: code_point.Iterator = .{ .bytes = str }; 126 + var total: u16 = 0; 127 + var iter = uucode.utf8.Iterator.init(str); 24 128 while (iter.next()) |cp| { 25 - const w = switch (cp.code) { 129 + const w: i16 = switch (cp) { 26 130 // undo an override in zg for emoji skintone selectors 27 - 0x1f3fb...0x1f3ff, 28 - => 2, 29 - else => data.codePointWidth(cp.code), 131 + 0x1f3fb...0x1f3ff => 2, 132 + else => blk: { 133 + const eaw = uucode.get(.east_asian_width, cp); 134 + break :blk eawToWidth(cp, eaw); 135 + }, 30 136 }; 31 - if (w < 0) continue; 32 - total += @intCast(w); 137 + total += @intCast(@max(0, w)); 33 138 } 34 139 return total; 35 140 }, 36 141 .no_zwj => { 37 - var out: [256]u8 = undefined; 38 - if (str.len > out.len) return error.OutOfMemory; 39 - const n = std.mem.replacementSize(u8, str, "\u{200D}", ""); 40 - _ = std.mem.replace(u8, str, "\u{200D}", "", &out); 41 - return gwidth(out[0..n], .unicode, data); 142 + var iter = std.mem.splitSequence(u8, str, "\u{200D}"); 143 + var result: u16 = 0; 144 + while (iter.next()) |s| { 145 + result += gwidth(s, .unicode); 146 + } 147 + return result; 42 148 }, 43 149 } 44 150 } 45 151 46 152 test "gwidth: a" { 47 - const alloc = testing.allocator_instance.allocator(); 48 - const data = try DisplayWidth.DisplayWidthData.init(alloc); 49 - defer data.deinit(); 50 - try testing.expectEqual(1, try gwidth("a", .unicode, &data)); 51 - try testing.expectEqual(1, try gwidth("a", .wcwidth, &data)); 52 - try testing.expectEqual(1, try gwidth("a", .no_zwj, &data)); 153 + try testing.expectEqual(1, gwidth("a", .unicode)); 154 + try testing.expectEqual(1, gwidth("a", .wcwidth)); 155 + try testing.expectEqual(1, gwidth("a", .no_zwj)); 53 156 } 54 157 55 158 test "gwidth: emoji with ZWJ" { 56 - const alloc = testing.allocator_instance.allocator(); 57 - const data = try DisplayWidth.DisplayWidthData.init(alloc); 58 - defer data.deinit(); 59 - try testing.expectEqual(2, try gwidth("๐Ÿ‘ฉโ€๐Ÿš€", .unicode, &data)); 60 - try testing.expectEqual(4, try gwidth("๐Ÿ‘ฉโ€๐Ÿš€", .wcwidth, &data)); 61 - try testing.expectEqual(4, try gwidth("๐Ÿ‘ฉโ€๐Ÿš€", .no_zwj, &data)); 159 + try testing.expectEqual(2, gwidth("๐Ÿ‘ฉโ€๐Ÿš€", .unicode)); 160 + try testing.expectEqual(4, gwidth("๐Ÿ‘ฉโ€๐Ÿš€", .wcwidth)); 161 + try testing.expectEqual(4, gwidth("๐Ÿ‘ฉโ€๐Ÿš€", .no_zwj)); 62 162 } 63 163 64 164 test "gwidth: emoji with VS16 selector" { 65 - const alloc = testing.allocator_instance.allocator(); 66 - const data = try DisplayWidth.DisplayWidthData.init(alloc); 67 - defer data.deinit(); 68 - try testing.expectEqual(2, try gwidth("\xE2\x9D\xA4\xEF\xB8\x8F", .unicode, &data)); 69 - try testing.expectEqual(1, try gwidth("\xE2\x9D\xA4\xEF\xB8\x8F", .wcwidth, &data)); 70 - try testing.expectEqual(2, try gwidth("\xE2\x9D\xA4\xEF\xB8\x8F", .no_zwj, &data)); 165 + try testing.expectEqual(2, gwidth("\xE2\x9D\xA4\xEF\xB8\x8F", .unicode)); 166 + try testing.expectEqual(1, gwidth("\xE2\x9D\xA4\xEF\xB8\x8F", .wcwidth)); 167 + try testing.expectEqual(2, gwidth("\xE2\x9D\xA4\xEF\xB8\x8F", .no_zwj)); 71 168 } 72 169 73 170 test "gwidth: emoji with skin tone selector" { 74 - const alloc = testing.allocator_instance.allocator(); 75 - const data = try DisplayWidth.DisplayWidthData.init(alloc); 76 - defer data.deinit(); 77 - try testing.expectEqual(2, try gwidth("๐Ÿ‘‹๐Ÿฟ", .unicode, &data)); 78 - try testing.expectEqual(4, try gwidth("๐Ÿ‘‹๐Ÿฟ", .wcwidth, &data)); 79 - try testing.expectEqual(2, try gwidth("๐Ÿ‘‹๐Ÿฟ", .no_zwj, &data)); 171 + try testing.expectEqual(2, gwidth("๐Ÿ‘‹๐Ÿฟ", .unicode)); 172 + try testing.expectEqual(4, gwidth("๐Ÿ‘‹๐Ÿฟ", .wcwidth)); 173 + try testing.expectEqual(2, gwidth("๐Ÿ‘‹๐Ÿฟ", .no_zwj)); 174 + } 175 + 176 + test "gwidth: zero-width space" { 177 + try testing.expectEqual(0, gwidth("\u{200B}", .unicode)); 178 + try testing.expectEqual(0, gwidth("\u{200B}", .wcwidth)); 179 + } 180 + 181 + test "gwidth: zero-width non-joiner" { 182 + try testing.expectEqual(0, gwidth("\u{200C}", .unicode)); 183 + try testing.expectEqual(0, gwidth("\u{200C}", .wcwidth)); 184 + } 185 + 186 + test "gwidth: combining marks" { 187 + // Hebrew combining mark 188 + try testing.expectEqual(0, gwidth("\u{05B0}", .unicode)); 189 + // Devanagari combining mark 190 + try testing.expectEqual(0, gwidth("\u{093C}", .unicode)); 191 + } 192 + 193 + test "gwidth: flag emoji (regional indicators)" { 194 + // US flag ๐Ÿ‡บ๐Ÿ‡ธ 195 + try testing.expectEqual(2, gwidth("๐Ÿ‡บ๐Ÿ‡ธ", .unicode)); 196 + // UK flag ๐Ÿ‡ฌ๐Ÿ‡ง 197 + try testing.expectEqual(2, gwidth("๐Ÿ‡ฌ๐Ÿ‡ง", .unicode)); 198 + } 199 + 200 + test "gwidth: text variation selector" { 201 + // U+2764 (heavy black heart) + U+FE0E (text variation selector) 202 + // Should be width 1 with text presentation 203 + try testing.expectEqual(1, gwidth("โค๏ธŽ", .unicode)); 204 + } 205 + 206 + test "gwidth: keycap sequence" { 207 + // Digit 1 + U+FE0F + U+20E3 (combining enclosing keycap) 208 + // Should be width 2 209 + try testing.expectEqual(2, gwidth("1๏ธโƒฃ", .unicode)); 210 + } 211 + 212 + test "gwidth: base letter with combining mark" { 213 + // 'a' + combining acute accent (NFD form) 214 + // Should be width 1 (combining mark is zero-width) 215 + try testing.expectEqual(1, gwidth("รก", .unicode)); 80 216 }
+34 -32
src/main.zig
··· 1 1 const std = @import("std"); 2 2 const builtin = @import("builtin"); 3 - const build_options = @import("build_options"); 3 + 4 + pub const tty = @import("tty.zig"); 4 5 5 6 pub const Vaxis = @import("Vaxis.zig"); 6 7 7 - pub const Loop = @import("Loop.zig").Loop; 8 - pub const xev = @import("xev.zig"); 9 - pub const aio = @import("aio.zig"); 8 + pub const loop = @import("Loop.zig"); 9 + pub const Loop = loop.Loop; 10 10 11 11 pub const zigimg = @import("zigimg"); 12 12 ··· 27 27 pub const gwidth = @import("gwidth.zig"); 28 28 pub const ctlseqs = @import("ctlseqs.zig"); 29 29 pub const GraphemeCache = @import("GraphemeCache.zig"); 30 - pub const grapheme = @import("grapheme"); 31 30 pub const Event = @import("event.zig").Event; 32 - pub const Unicode = @import("Unicode.zig"); 31 + pub const unicode = @import("unicode.zig"); 32 + 33 + pub const vxfw = @import("vxfw/vxfw.zig"); 33 34 34 - /// The target TTY implementation 35 - pub const Tty = switch (builtin.os.tag) { 36 - .windows => @import("windows/Tty.zig"), 37 - else => @import("posix/Tty.zig"), 38 - }; 35 + pub const Tty = tty.Tty; 39 36 40 37 /// The size of the terminal screen 41 38 pub const Winsize = struct { 42 - rows: usize, 43 - cols: usize, 44 - x_pixel: usize, 45 - y_pixel: usize, 39 + rows: u16, 40 + cols: u16, 41 + x_pixel: u16, 42 + y_pixel: u16, 46 43 }; 47 44 48 45 /// Initialize a Vaxis application. ··· 50 47 return Vaxis.init(alloc, opts); 51 48 } 52 49 50 + pub const Panic = struct { 51 + pub const call = panic_handler; 52 + pub const sentinelMismatch = std.debug.FormattedPanic.sentinelMismatch; 53 + pub const unwrapError = std.debug.FormattedPanic.unwrapError; 54 + pub const outOfBounds = std.debug.FormattedPanic.outOfBounds; 55 + pub const startGreaterThanEnd = std.debug.FormattedPanic.startGreaterThanEnd; 56 + pub const inactiveUnionField = std.debug.FormattedPanic.inactiveUnionField; 57 + pub const messages = std.debug.FormattedPanic.messages; 58 + }; 59 + 53 60 /// Resets terminal state on a panic, then calls the default zig panic handler 54 - pub fn panic_handler(msg: []const u8, error_return_trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn { 55 - if (Tty.global_tty) |gty| { 61 + pub fn panic_handler(msg: []const u8, _: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn { 62 + recover(); 63 + std.debug.defaultPanic(msg, ret_addr); 64 + } 65 + 66 + /// Resets the terminal state using the global tty instance. Use this only to recover during a panic 67 + pub fn recover() void { 68 + if (tty.global_tty) |*gty| { 56 69 const reset: []const u8 = ctlseqs.csi_u_pop ++ 57 70 ctlseqs.mouse_reset ++ 58 71 ctlseqs.bp_reset ++ 59 72 ctlseqs.rmcup; 60 73 61 - gty.anyWriter().writeAll(reset) catch {}; 62 - 74 + gty.writer().writeAll(reset) catch {}; 75 + gty.writer().flush() catch {}; 63 76 gty.deinit(); 64 77 } 65 - 66 - std.builtin.default_panic(msg, error_return_trace, ret_addr); 67 78 } 68 79 69 80 pub const log_scopes = enum { ··· 78 89 \\ โ–€โ–„โ–€ โ–ˆ โ–ˆ โ–ˆ โ–ˆ โ–„โ–ˆโ–„ โ–€โ–„โ–„โ–„โ–€ 79 90 ; 80 91 81 - test { 82 - _ = @import("gwidth.zig"); 83 - _ = @import("Cell.zig"); 84 - _ = @import("Key.zig"); 85 - _ = @import("Parser.zig"); 86 - _ = @import("Window.zig"); 87 - 88 - _ = @import("gwidth.zig"); 89 - _ = @import("queue.zig"); 90 - if (build_options.text_input) 91 - _ = @import("widgets/TextInput.zig"); 92 + test "refAllDecls" { 93 + std.testing.refAllDecls(@This()); 92 94 }
-178
src/posix/Tty.zig
··· 1 - //! TTY implementation conforming to posix standards 2 - const Posix = @This(); 3 - 4 - const std = @import("std"); 5 - const builtin = @import("builtin"); 6 - 7 - const posix = std.posix; 8 - const Winsize = @import("../main.zig").Winsize; 9 - 10 - /// the original state of the terminal, prior to calling makeRaw 11 - termios: posix.termios, 12 - 13 - /// The file descriptor of the tty 14 - fd: posix.fd_t, 15 - 16 - pub const SignalHandler = struct { 17 - context: *anyopaque, 18 - callback: *const fn (context: *anyopaque) void, 19 - }; 20 - 21 - /// global signal handlers 22 - var handlers: [8]SignalHandler = undefined; 23 - var handler_mutex: std.Thread.Mutex = .{}; 24 - var handler_idx: usize = 0; 25 - 26 - /// global tty instance, used in case of a panic. Not guaranteed to work if 27 - /// for some reason there are multiple TTYs open under a single vaxis 28 - /// compilation unit - but this is better than nothing 29 - pub var global_tty: ?Posix = null; 30 - 31 - /// initializes a Tty instance by opening /dev/tty and "making it raw". A 32 - /// signal handler is installed for SIGWINCH. No callbacks are installed, be 33 - /// sure to register a callback when initializing the event loop 34 - pub fn init() !Posix { 35 - // Open our tty 36 - const fd = try posix.open("/dev/tty", .{ .ACCMODE = .RDWR }, 0); 37 - 38 - // Set the termios of the tty 39 - const termios = try makeRaw(fd); 40 - 41 - var act = posix.Sigaction{ 42 - .handler = .{ .handler = Posix.handleWinch }, 43 - .mask = switch (builtin.os.tag) { 44 - .macos => 0, 45 - .linux => posix.empty_sigset, 46 - .freebsd => posix.empty_sigset, 47 - else => @compileError("os not supported"), 48 - }, 49 - .flags = 0, 50 - }; 51 - try posix.sigaction(posix.SIG.WINCH, &act, null); 52 - 53 - const self: Posix = .{ 54 - .fd = fd, 55 - .termios = termios, 56 - }; 57 - 58 - global_tty = self; 59 - 60 - return self; 61 - } 62 - 63 - /// release resources associated with the Tty return it to its original state 64 - pub fn deinit(self: Posix) void { 65 - posix.tcsetattr(self.fd, .FLUSH, self.termios) catch |err| { 66 - std.log.err("couldn't restore terminal: {}", .{err}); 67 - }; 68 - if (builtin.os.tag != .macos) // closing /dev/tty may block indefinitely on macos 69 - posix.close(self.fd); 70 - } 71 - 72 - /// Write bytes to the tty 73 - pub fn write(self: *const Posix, bytes: []const u8) !usize { 74 - return posix.write(self.fd, bytes); 75 - } 76 - 77 - pub fn opaqueWrite(ptr: *const anyopaque, bytes: []const u8) !usize { 78 - const self: *const Posix = @ptrCast(@alignCast(ptr)); 79 - return posix.write(self.fd, bytes); 80 - } 81 - 82 - pub fn anyWriter(self: *const Posix) std.io.AnyWriter { 83 - return .{ 84 - .context = self, 85 - .writeFn = Posix.opaqueWrite, 86 - }; 87 - } 88 - 89 - pub fn read(self: *const Posix, buf: []u8) !usize { 90 - return posix.read(self.fd, buf); 91 - } 92 - 93 - pub fn opaqueRead(ptr: *const anyopaque, buf: []u8) !usize { 94 - const self: *const Posix = @ptrCast(@alignCast(ptr)); 95 - return posix.read(self.fd, buf); 96 - } 97 - 98 - pub fn anyReader(self: *const Posix) std.io.AnyReader { 99 - return .{ 100 - .context = self, 101 - .readFn = Posix.opaqueRead, 102 - }; 103 - } 104 - 105 - /// Install a signal handler for winsize. A maximum of 8 handlers may be 106 - /// installed 107 - pub fn notifyWinsize(handler: SignalHandler) !void { 108 - handler_mutex.lock(); 109 - defer handler_mutex.unlock(); 110 - if (handler_idx == handlers.len) return error.OutOfMemory; 111 - handlers[handler_idx] = handler; 112 - handler_idx += 1; 113 - } 114 - 115 - fn handleWinch(_: c_int) callconv(.C) void { 116 - handler_mutex.lock(); 117 - defer handler_mutex.unlock(); 118 - var i: usize = 0; 119 - while (i < handler_idx) : (i += 1) { 120 - const handler = handlers[i]; 121 - handler.callback(handler.context); 122 - } 123 - } 124 - 125 - /// makeRaw enters the raw state for the terminal. 126 - pub fn makeRaw(fd: posix.fd_t) !posix.termios { 127 - const state = try posix.tcgetattr(fd); 128 - var raw = state; 129 - // see termios(3) 130 - raw.iflag.IGNBRK = false; 131 - raw.iflag.BRKINT = false; 132 - raw.iflag.PARMRK = false; 133 - raw.iflag.ISTRIP = false; 134 - raw.iflag.INLCR = false; 135 - raw.iflag.IGNCR = false; 136 - raw.iflag.ICRNL = false; 137 - raw.iflag.IXON = false; 138 - 139 - raw.oflag.OPOST = false; 140 - 141 - raw.lflag.ECHO = false; 142 - raw.lflag.ECHONL = false; 143 - raw.lflag.ICANON = false; 144 - raw.lflag.ISIG = false; 145 - raw.lflag.IEXTEN = false; 146 - 147 - raw.cflag.CSIZE = .CS8; 148 - raw.cflag.PARENB = false; 149 - 150 - raw.cc[@intFromEnum(posix.V.MIN)] = 1; 151 - raw.cc[@intFromEnum(posix.V.TIME)] = 0; 152 - try posix.tcsetattr(fd, .FLUSH, raw); 153 - return state; 154 - } 155 - 156 - /// Get the window size from the kernel 157 - pub fn getWinsize(fd: posix.fd_t) !Winsize { 158 - var winsize = posix.winsize{ 159 - .ws_row = 0, 160 - .ws_col = 0, 161 - .ws_xpixel = 0, 162 - .ws_ypixel = 0, 163 - }; 164 - 165 - const err = posix.system.ioctl(fd, posix.T.IOCGWINSZ, @intFromPtr(&winsize)); 166 - if (posix.errno(err) == .SUCCESS) 167 - return Winsize{ 168 - .rows = winsize.ws_row, 169 - .cols = winsize.ws_col, 170 - .x_pixel = winsize.ws_xpixel, 171 - .y_pixel = winsize.ws_ypixel, 172 - }; 173 - return error.IoctlError; 174 - } 175 - 176 - pub fn bufferedWriter(self: *const Posix) std.io.BufferedWriter(4096, std.io.AnyWriter) { 177 - return std.io.bufferedWriter(self.anyWriter()); 178 - }
+68 -46
src/queue.zig
··· 30 30 self.not_empty.wait(&self.mutex); 31 31 } 32 32 std.debug.assert(!self.isEmptyLH()); 33 - if (self.isFullLH()) { 34 - // If we are full, wake up a push that might be 35 - // waiting here. 36 - self.not_full.signal(); 37 - } 38 - 39 - const result = self.buf[self.mask(self.read_index)]; 40 - self.read_index = self.mask2(self.read_index + 1); 41 - return result; 33 + return self.popAndSignalLH(); 42 34 } 43 35 44 36 /// Push an item into the queue. Blocks until an item has been ··· 49 41 while (self.isFullLH()) { 50 42 self.not_full.wait(&self.mutex); 51 43 } 52 - if (self.isEmptyLH()) { 53 - // If we were empty, wake up a pop if it was waiting. 54 - self.not_empty.signal(); 55 - } 56 44 std.debug.assert(!self.isFullLH()); 57 - 58 - self.buf[self.mask(self.write_index)] = item; 59 - self.write_index = self.mask2(self.write_index + 1); 45 + self.pushAndSignalLH(item); 60 46 } 61 47 62 48 /// Push an item into the queue. Returns true when the item ··· 64 50 /// was full. 65 51 pub fn tryPush(self: *Self, item: T) bool { 66 52 self.mutex.lock(); 67 - if (self.isFullLH()) { 68 - self.mutex.unlock(); 69 - return false; 70 - } 71 - self.mutex.unlock(); 72 - self.push(item); 53 + defer self.mutex.unlock(); 54 + if (self.isFullLH()) return false; 55 + self.pushAndSignalLH(item); 73 56 return true; 74 57 } 75 58 ··· 77 60 /// available. 78 61 pub fn tryPop(self: *Self) ?T { 79 62 self.mutex.lock(); 80 - if (self.isEmptyLH()) { 81 - self.mutex.unlock(); 82 - return null; 83 - } 84 - self.mutex.unlock(); 85 - return self.pop(); 63 + defer self.mutex.unlock(); 64 + if (self.isEmptyLH()) return null; 65 + return self.popAndSignalLH(); 86 66 } 87 67 88 68 /// Poll the queue. This call blocks until events are in the queue ··· 93 73 self.not_empty.wait(&self.mutex); 94 74 } 95 75 std.debug.assert(!self.isEmptyLH()); 76 + } 77 + 78 + pub fn lock(self: *Self) void { 79 + self.mutex.lock(); 80 + } 81 + 82 + pub fn unlock(self: *Self) void { 83 + self.mutex.unlock(); 84 + } 85 + 86 + /// Used to efficiently drain the queue while the lock is externally held 87 + pub fn drain(self: *Self) ?T { 88 + if (self.isEmptyLH()) return null; 89 + return self.popLH(); 96 90 } 97 91 98 92 fn isEmptyLH(self: Self) bool { ··· 135 129 fn mask2(self: Self, index: usize) usize { 136 130 return index % (2 * self.buf.len); 137 131 } 132 + 133 + fn pushAndSignalLH(self: *Self, item: T) void { 134 + const was_empty = self.isEmptyLH(); 135 + self.buf[self.mask(self.write_index)] = item; 136 + self.write_index = self.mask2(self.write_index + 1); 137 + if (was_empty) { 138 + self.not_empty.signal(); 139 + } 140 + } 141 + 142 + fn popAndSignalLH(self: *Self) T { 143 + const was_full = self.isFullLH(); 144 + const result = self.popLH(); 145 + if (was_full) { 146 + self.not_full.signal(); 147 + } 148 + return result; 149 + } 150 + 151 + fn popLH(self: *Self) T { 152 + const result = self.buf[self.mask(self.read_index)]; 153 + self.read_index = self.mask2(self.read_index + 1); 154 + return result; 155 + } 138 156 }; 139 157 } 140 158 ··· 184 202 thread.join(); 185 203 } 186 204 187 - fn sleepyPop(q: *Queue(u8, 2)) !void { 205 + fn sleepyPop(q: *Queue(u8, 2), state: *atomic.Value(u8)) !void { 188 206 // First we wait for the queue to be full. 189 - while (!q.isFull()) 207 + while (state.load(.acquire) < 1) 190 208 try Thread.yield(); 191 209 192 210 // Then we spuriously wake it up, because that's a thing that can ··· 200 218 // still full and the push in the other thread is still blocked 201 219 // waiting for space. 202 220 try Thread.yield(); 203 - std.time.sleep(std.time.ns_per_s); 221 + std.Thread.sleep(10 * std.time.ns_per_ms); 204 222 // Finally, let that other thread go. 205 223 try std.testing.expectEqual(1, q.pop()); 206 224 207 - // This won't continue until the other thread has had a chance to 208 - // put at least one item in the queue. 209 - while (!q.isFull()) 225 + // Wait for the other thread to signal it's ready for second push 226 + while (state.load(.acquire) < 2) 210 227 try Thread.yield(); 211 228 // But we want to ensure that there's a second push waiting, so 212 229 // here's another sleep. 213 - std.time.sleep(std.time.ns_per_s / 2); 230 + std.Thread.sleep(10 * std.time.ns_per_ms); 214 231 215 232 // Another spurious wake... 216 233 q.not_full.signal(); ··· 218 235 // And another chance for the other thread to see that it's 219 236 // spurious and go back to sleep. 220 237 try Thread.yield(); 221 - std.time.sleep(std.time.ns_per_s / 2); 238 + std.Thread.sleep(10 * std.time.ns_per_ms); 222 239 223 240 // Pop that thing and we're done. 224 241 try std.testing.expectEqual(2, q.pop()); ··· 232 249 // fails if the while loop in `push` is turned into an `if`. 233 250 234 251 var queue: Queue(u8, 2) = .{}; 235 - const thread = try Thread.spawn(cfg, sleepyPop, .{&queue}); 252 + var state = atomic.Value(u8).init(0); 253 + const thread = try Thread.spawn(cfg, sleepyPop, .{ &queue, &state }); 236 254 queue.push(1); 237 255 queue.push(2); 256 + state.store(1, .release); 238 257 const now = std.time.milliTimestamp(); 239 258 queue.push(3); // This one should block. 240 259 const then = std.time.milliTimestamp(); 241 260 242 261 // Just to make sure the sleeps are yielding to this thread, make 243 - // sure it took at least 900ms to do the push. 244 - try std.testing.expect(then - now > 900); 262 + // sure it took at least 5ms to do the push. 263 + try std.testing.expect(then - now > 5); 245 264 265 + state.store(2, .release); 246 266 // This should block again, waiting for the other thread. 247 267 queue.push(4); 248 268 ··· 252 272 try std.testing.expectEqual(4, queue.pop()); 253 273 } 254 274 255 - fn sleepyPush(q: *Queue(u8, 1)) !void { 275 + fn sleepyPush(q: *Queue(u8, 1), state: *atomic.Value(u8)) !void { 256 276 // Try to ensure the other thread has already started trying to pop. 257 277 try Thread.yield(); 258 - std.time.sleep(std.time.ns_per_s / 2); 278 + std.Thread.sleep(10 * std.time.ns_per_ms); 259 279 260 280 // Spurious wake 261 281 q.not_full.signal(); 262 282 q.not_empty.signal(); 263 283 264 284 try Thread.yield(); 265 - std.time.sleep(std.time.ns_per_s / 2); 285 + std.Thread.sleep(10 * std.time.ns_per_ms); 266 286 267 287 // Stick something in the queue so it can be popped. 268 288 q.push(1); 269 289 // Ensure it's been popped. 270 - while (!q.isEmpty()) 290 + while (state.load(.acquire) < 1) 271 291 try Thread.yield(); 272 292 // Give the other thread time to block again. 273 293 try Thread.yield(); 274 - std.time.sleep(std.time.ns_per_s / 2); 294 + std.Thread.sleep(10 * std.time.ns_per_ms); 275 295 276 296 // Spurious wake 277 297 q.not_full.signal(); ··· 286 306 // `if`. 287 307 288 308 var queue: Queue(u8, 1) = .{}; 289 - const thread = try Thread.spawn(cfg, sleepyPush, .{&queue}); 309 + var state = atomic.Value(u8).init(0); 310 + const thread = try Thread.spawn(cfg, sleepyPush, .{ &queue, &state }); 290 311 try std.testing.expectEqual(1, queue.pop()); 312 + state.store(1, .release); 291 313 try std.testing.expectEqual(2, queue.pop()); 292 314 thread.join(); 293 315 } ··· 302 324 const t1 = try Thread.spawn(cfg, readerThread, .{&queue}); 303 325 const t2 = try Thread.spawn(cfg, readerThread, .{&queue}); 304 326 try Thread.yield(); 305 - std.time.sleep(std.time.ns_per_s / 2); 327 + std.Thread.sleep(10 * std.time.ns_per_ms); 306 328 queue.push(1); 307 329 queue.push(1); 308 330 t1.join();
+750
src/tty.zig
··· 1 + const std = @import("std"); 2 + const builtin = @import("builtin"); 3 + 4 + const vaxis = @import("main.zig"); 5 + 6 + const ctlseqs = vaxis.ctlseqs; 7 + const posix = std.posix; 8 + const windows = std.os.windows; 9 + 10 + const Event = vaxis.Event; 11 + const Key = vaxis.Key; 12 + const Mouse = vaxis.Mouse; 13 + const Parser = vaxis.Parser; 14 + const Winsize = vaxis.Winsize; 15 + 16 + /// The target TTY implementation 17 + pub const Tty = if (builtin.is_test) 18 + TestTty 19 + else switch (builtin.os.tag) { 20 + .windows => WindowsTty, 21 + else => PosixTty, 22 + }; 23 + 24 + /// global tty instance, used in case of a panic. Not guaranteed to work if 25 + /// for some reason there are multiple TTYs open under a single vaxis 26 + /// compilation unit - but this is better than nothing 27 + pub var global_tty: ?Tty = null; 28 + 29 + pub const PosixTty = struct { 30 + /// the original state of the terminal, prior to calling makeRaw 31 + termios: posix.termios, 32 + 33 + /// The file descriptor of the tty 34 + fd: posix.fd_t, 35 + 36 + /// File.Writer for efficient buffered writing 37 + tty_writer: std.fs.File.Writer, 38 + 39 + pub const SignalHandler = struct { 40 + context: *anyopaque, 41 + callback: *const fn (context: *anyopaque) void, 42 + }; 43 + 44 + /// global signal handlers 45 + var handlers: [8]SignalHandler = undefined; 46 + var handler_mutex: std.Thread.Mutex = .{}; 47 + var handler_idx: usize = 0; 48 + 49 + var handler_installed: bool = false; 50 + 51 + /// initializes a Tty instance by opening /dev/tty and "making it raw". A 52 + /// signal handler is installed for SIGWINCH. No callbacks are installed, be 53 + /// sure to register a callback when initializing the event loop 54 + pub fn init(buffer: []u8) !PosixTty { 55 + // Open our tty 56 + const fd = try posix.open("/dev/tty", .{ .ACCMODE = .RDWR }, 0); 57 + 58 + // Set the termios of the tty 59 + const termios = try makeRaw(fd); 60 + 61 + var act = posix.Sigaction{ 62 + .handler = .{ .handler = PosixTty.handleWinch }, 63 + .mask = switch (builtin.os.tag) { 64 + .macos => 0, 65 + else => posix.sigemptyset(), 66 + }, 67 + .flags = 0, 68 + }; 69 + posix.sigaction(posix.SIG.WINCH, &act, null); 70 + handler_installed = true; 71 + 72 + const file = std.fs.File{ .handle = fd }; 73 + 74 + const self: PosixTty = .{ 75 + .fd = fd, 76 + .termios = termios, 77 + .tty_writer = .initStreaming(file, buffer), 78 + }; 79 + 80 + global_tty = self; 81 + 82 + return self; 83 + } 84 + 85 + /// release resources associated with the Tty return it to its original state 86 + pub fn deinit(self: PosixTty) void { 87 + posix.tcsetattr(self.fd, .FLUSH, self.termios) catch |err| { 88 + std.log.err("couldn't restore terminal: {}", .{err}); 89 + }; 90 + if (builtin.os.tag != .macos) // closing /dev/tty may block indefinitely on macos 91 + posix.close(self.fd); 92 + } 93 + 94 + /// Resets the signal handler to it's default 95 + pub fn resetSignalHandler() void { 96 + if (!handler_installed) return; 97 + handler_installed = false; 98 + var act = posix.Sigaction{ 99 + .handler = .{ .handler = posix.SIG.DFL }, 100 + .mask = switch (builtin.os.tag) { 101 + .macos => 0, 102 + else => posix.sigemptyset(), 103 + }, 104 + .flags = 0, 105 + }; 106 + posix.sigaction(posix.SIG.WINCH, &act, null); 107 + } 108 + 109 + pub fn writer(self: *PosixTty) *std.Io.Writer { 110 + return &self.tty_writer.interface; 111 + } 112 + 113 + pub fn read(self: *const PosixTty, buf: []u8) !usize { 114 + return posix.read(self.fd, buf); 115 + } 116 + 117 + /// Install a signal handler for winsize. A maximum of 8 handlers may be 118 + /// installed 119 + pub fn notifyWinsize(handler: SignalHandler) !void { 120 + handler_mutex.lock(); 121 + defer handler_mutex.unlock(); 122 + if (handler_idx == handlers.len) return error.OutOfMemory; 123 + handlers[handler_idx] = handler; 124 + handler_idx += 1; 125 + } 126 + 127 + fn handleWinch(_: c_int) callconv(.c) void { 128 + handler_mutex.lock(); 129 + defer handler_mutex.unlock(); 130 + var i: usize = 0; 131 + while (i < handler_idx) : (i += 1) { 132 + const handler = handlers[i]; 133 + handler.callback(handler.context); 134 + } 135 + } 136 + 137 + /// makeRaw enters the raw state for the terminal. 138 + pub fn makeRaw(fd: posix.fd_t) !posix.termios { 139 + const state = try posix.tcgetattr(fd); 140 + var raw = state; 141 + // see termios(3) 142 + raw.iflag.IGNBRK = false; 143 + raw.iflag.BRKINT = false; 144 + raw.iflag.PARMRK = false; 145 + raw.iflag.ISTRIP = false; 146 + raw.iflag.INLCR = false; 147 + raw.iflag.IGNCR = false; 148 + raw.iflag.ICRNL = false; 149 + raw.iflag.IXON = false; 150 + 151 + raw.oflag.OPOST = false; 152 + 153 + raw.lflag.ECHO = false; 154 + raw.lflag.ECHONL = false; 155 + raw.lflag.ICANON = false; 156 + raw.lflag.ISIG = false; 157 + raw.lflag.IEXTEN = false; 158 + 159 + raw.cflag.CSIZE = .CS8; 160 + raw.cflag.PARENB = false; 161 + 162 + raw.cc[@intFromEnum(posix.V.MIN)] = 1; 163 + raw.cc[@intFromEnum(posix.V.TIME)] = 0; 164 + try posix.tcsetattr(fd, .FLUSH, raw); 165 + return state; 166 + } 167 + 168 + /// Get the window size from the kernel 169 + pub fn getWinsize(fd: posix.fd_t) !Winsize { 170 + var winsize = posix.winsize{ 171 + .row = 0, 172 + .col = 0, 173 + .xpixel = 0, 174 + .ypixel = 0, 175 + }; 176 + 177 + const err = posix.system.ioctl(fd, posix.T.IOCGWINSZ, @intFromPtr(&winsize)); 178 + if (posix.errno(err) == .SUCCESS) 179 + return Winsize{ 180 + .rows = winsize.row, 181 + .cols = winsize.col, 182 + .x_pixel = winsize.xpixel, 183 + .y_pixel = winsize.ypixel, 184 + }; 185 + return error.IoctlError; 186 + } 187 + }; 188 + 189 + pub const WindowsTty = struct { 190 + stdin: windows.HANDLE, 191 + stdout: windows.HANDLE, 192 + 193 + initial_codepage: c_uint, 194 + initial_input_mode: CONSOLE_MODE_INPUT, 195 + initial_output_mode: CONSOLE_MODE_OUTPUT, 196 + 197 + // a buffer to write key text into 198 + buf: [4]u8 = undefined, 199 + 200 + /// File.Writer for efficient buffered writing 201 + tty_writer: std.fs.File.Writer, 202 + 203 + /// The last mouse button that was pressed. We store the previous state of button presses on each 204 + /// mouse event so we can detect which button was released 205 + last_mouse_button_press: u16 = 0, 206 + 207 + const utf8_codepage: c_uint = 65001; 208 + 209 + /// The input mode set by init 210 + pub const input_raw_mode: CONSOLE_MODE_INPUT = .{ 211 + .WINDOW_INPUT = 1, // resize events 212 + .MOUSE_INPUT = 1, 213 + .EXTENDED_FLAGS = 1, // allow mouse events 214 + }; 215 + 216 + /// The output mode set by init 217 + pub const output_raw_mode: CONSOLE_MODE_OUTPUT = .{ 218 + .PROCESSED_OUTPUT = 1, // handle control sequences 219 + .VIRTUAL_TERMINAL_PROCESSING = 1, // handle ANSI sequences 220 + .DISABLE_NEWLINE_AUTO_RETURN = 1, // disable inserting a new line when we write at the last column 221 + .ENABLE_LVB_GRID_WORLDWIDE = 1, // enables reverse video and underline 222 + }; 223 + 224 + pub fn init(buffer: []u8) !Tty { 225 + const stdin: std.fs.File = .stdin(); 226 + const stdout: std.fs.File = .stdout(); 227 + 228 + // get initial modes 229 + const initial_output_codepage = windows.kernel32.GetConsoleOutputCP(); 230 + const initial_input_mode = try getConsoleMode(CONSOLE_MODE_INPUT, stdin.handle); 231 + const initial_output_mode = try getConsoleMode(CONSOLE_MODE_OUTPUT, stdout.handle); 232 + 233 + // set new modes 234 + try setConsoleMode(stdin.handle, input_raw_mode); 235 + try setConsoleMode(stdout.handle, output_raw_mode); 236 + if (windows.kernel32.SetConsoleOutputCP(utf8_codepage) == 0) 237 + return windows.unexpectedError(windows.kernel32.GetLastError()); 238 + 239 + const self: Tty = .{ 240 + .stdin = stdin.handle, 241 + .stdout = stdout.handle, 242 + .initial_codepage = initial_output_codepage, 243 + .initial_input_mode = initial_input_mode, 244 + .initial_output_mode = initial_output_mode, 245 + .tty_writer = .initStreaming(stdout, buffer), 246 + }; 247 + 248 + // save a copy of this tty as the global_tty for panic handling 249 + global_tty = self; 250 + 251 + return self; 252 + } 253 + 254 + pub fn deinit(self: Tty) void { 255 + _ = windows.kernel32.SetConsoleOutputCP(self.initial_codepage); 256 + setConsoleMode(self.stdin, self.initial_input_mode) catch {}; 257 + setConsoleMode(self.stdout, self.initial_output_mode) catch {}; 258 + windows.CloseHandle(self.stdin); 259 + windows.CloseHandle(self.stdout); 260 + } 261 + 262 + pub const CONSOLE_MODE_INPUT = packed struct(u32) { 263 + PROCESSED_INPUT: u1 = 0, 264 + LINE_INPUT: u1 = 0, 265 + ECHO_INPUT: u1 = 0, 266 + WINDOW_INPUT: u1 = 0, 267 + MOUSE_INPUT: u1 = 0, 268 + INSERT_MODE: u1 = 0, 269 + QUICK_EDIT_MODE: u1 = 0, 270 + EXTENDED_FLAGS: u1 = 0, 271 + AUTO_POSITION: u1 = 0, 272 + VIRTUAL_TERMINAL_INPUT: u1 = 0, 273 + _: u22 = 0, 274 + }; 275 + pub const CONSOLE_MODE_OUTPUT = packed struct(u32) { 276 + PROCESSED_OUTPUT: u1 = 0, 277 + WRAP_AT_EOL_OUTPUT: u1 = 0, 278 + VIRTUAL_TERMINAL_PROCESSING: u1 = 0, 279 + DISABLE_NEWLINE_AUTO_RETURN: u1 = 0, 280 + ENABLE_LVB_GRID_WORLDWIDE: u1 = 0, 281 + _: u27 = 0, 282 + }; 283 + 284 + pub fn getConsoleMode(comptime T: type, handle: windows.HANDLE) !T { 285 + var mode: u32 = undefined; 286 + if (windows.kernel32.GetConsoleMode(handle, &mode) == 0) return switch (windows.kernel32.GetLastError()) { 287 + .INVALID_HANDLE => error.InvalidHandle, 288 + else => |e| windows.unexpectedError(e), 289 + }; 290 + return @bitCast(mode); 291 + } 292 + 293 + pub fn setConsoleMode(handle: windows.HANDLE, mode: anytype) !void { 294 + if (windows.kernel32.SetConsoleMode(handle, @bitCast(mode)) == 0) return switch (windows.kernel32.GetLastError()) { 295 + .INVALID_HANDLE => error.InvalidHandle, 296 + else => |e| windows.unexpectedError(e), 297 + }; 298 + } 299 + 300 + pub fn writer(self: *Tty) *std.Io.Writer { 301 + return &self.tty_writer.interface; 302 + } 303 + 304 + pub fn read(self: *const Tty, buf: []u8) !usize { 305 + return posix.read(self.fd, buf); 306 + } 307 + 308 + pub fn nextEvent(self: *Tty, parser: *Parser, paste_allocator: ?std.mem.Allocator) !Event { 309 + // We use a loop so we can ignore certain events 310 + var state: EventState = .{}; 311 + while (true) { 312 + var event_count: u32 = 0; 313 + var input_record: INPUT_RECORD = undefined; 314 + if (ReadConsoleInputW(self.stdin, &input_record, 1, &event_count) == 0) 315 + return windows.unexpectedError(windows.kernel32.GetLastError()); 316 + 317 + if (try self.eventFromRecord(&input_record, &state, parser, paste_allocator)) |ev| { 318 + return ev; 319 + } 320 + } 321 + } 322 + 323 + pub const EventState = struct { 324 + ansi_buf: [128]u8 = undefined, 325 + ansi_idx: usize = 0, 326 + utf16_buf: [2]u16 = undefined, 327 + utf16_half: bool = false, 328 + }; 329 + 330 + pub fn eventFromRecord(self: *Tty, record: *const INPUT_RECORD, state: *EventState, parser: *Parser, paste_allocator: ?std.mem.Allocator) !?Event { 331 + switch (record.EventType) { 332 + 0x0001 => { // Key event 333 + const event = record.Event.KeyEvent; 334 + 335 + if (state.utf16_half) half: { 336 + state.utf16_half = false; 337 + state.utf16_buf[1] = event.uChar.UnicodeChar; 338 + const codepoint: u21 = std.unicode.utf16DecodeSurrogatePair(&state.utf16_buf) catch break :half; 339 + const n = std.unicode.utf8Encode(codepoint, &self.buf) catch return null; 340 + 341 + const key: Key = .{ 342 + .codepoint = codepoint, 343 + .base_layout_codepoint = codepoint, 344 + .mods = translateMods(event.dwControlKeyState), 345 + .text = self.buf[0..n], 346 + }; 347 + 348 + switch (event.bKeyDown) { 349 + 0 => return .{ .key_release = key }, 350 + else => return .{ .key_press = key }, 351 + } 352 + } 353 + 354 + const base_layout: u16 = switch (event.wVirtualKeyCode) { 355 + 0x00 => blk: { // delivered when we get an escape sequence or a unicode codepoint 356 + if (state.ansi_idx == 0 and event.uChar.AsciiChar != 27) 357 + break :blk event.uChar.UnicodeChar; 358 + state.ansi_buf[state.ansi_idx] = event.uChar.AsciiChar; 359 + state.ansi_idx += 1; 360 + if (state.ansi_idx <= 2) return null; 361 + const result = try parser.parse(state.ansi_buf[0..state.ansi_idx], paste_allocator); 362 + return if (result.n == 0) null else evt: { 363 + state.ansi_idx = 0; 364 + break :evt result.event; 365 + }; 366 + }, 367 + 0x08 => Key.backspace, 368 + 0x09 => Key.tab, 369 + 0x0D => Key.enter, 370 + 0x13 => Key.pause, 371 + 0x14 => Key.caps_lock, 372 + 0x1B => Key.escape, 373 + 0x20 => Key.space, 374 + 0x21 => Key.page_up, 375 + 0x22 => Key.page_down, 376 + 0x23 => Key.end, 377 + 0x24 => Key.home, 378 + 0x25 => Key.left, 379 + 0x26 => Key.up, 380 + 0x27 => Key.right, 381 + 0x28 => Key.down, 382 + 0x2c => Key.print_screen, 383 + 0x2d => Key.insert, 384 + 0x2e => Key.delete, 385 + 0x30...0x39 => |k| k, 386 + 0x41...0x5a => |k| k + 0x20, // translate to lowercase 387 + 0x5b => Key.left_meta, 388 + 0x5c => Key.right_meta, 389 + 0x60 => Key.kp_0, 390 + 0x61 => Key.kp_1, 391 + 0x62 => Key.kp_2, 392 + 0x63 => Key.kp_3, 393 + 0x64 => Key.kp_4, 394 + 0x65 => Key.kp_5, 395 + 0x66 => Key.kp_6, 396 + 0x67 => Key.kp_7, 397 + 0x68 => Key.kp_8, 398 + 0x69 => Key.kp_9, 399 + 0x6a => Key.kp_multiply, 400 + 0x6b => Key.kp_add, 401 + 0x6c => Key.kp_separator, 402 + 0x6d => Key.kp_subtract, 403 + 0x6e => Key.kp_decimal, 404 + 0x6f => Key.kp_divide, 405 + 0x70 => Key.f1, 406 + 0x71 => Key.f2, 407 + 0x72 => Key.f3, 408 + 0x73 => Key.f4, 409 + 0x74 => Key.f5, 410 + 0x75 => Key.f6, 411 + 0x76 => Key.f8, 412 + 0x77 => Key.f8, 413 + 0x78 => Key.f9, 414 + 0x79 => Key.f10, 415 + 0x7a => Key.f11, 416 + 0x7b => Key.f12, 417 + 0x7c => Key.f13, 418 + 0x7d => Key.f14, 419 + 0x7e => Key.f15, 420 + 0x7f => Key.f16, 421 + 0x80 => Key.f17, 422 + 0x81 => Key.f18, 423 + 0x82 => Key.f19, 424 + 0x83 => Key.f20, 425 + 0x84 => Key.f21, 426 + 0x85 => Key.f22, 427 + 0x86 => Key.f23, 428 + 0x87 => Key.f24, 429 + 0x90 => Key.num_lock, 430 + 0x91 => Key.scroll_lock, 431 + 0xa0 => Key.left_shift, 432 + 0x10 => Key.left_shift, 433 + 0xa1 => Key.right_shift, 434 + 0xa2 => Key.left_control, 435 + 0x11 => Key.left_control, 436 + 0xa3 => Key.right_control, 437 + 0xa4 => Key.left_alt, 438 + 0x12 => Key.left_alt, 439 + 0xa5 => Key.right_alt, 440 + 0xad => Key.mute_volume, 441 + 0xae => Key.lower_volume, 442 + 0xaf => Key.raise_volume, 443 + 0xb0 => Key.media_track_next, 444 + 0xb1 => Key.media_track_previous, 445 + 0xb2 => Key.media_stop, 446 + 0xb3 => Key.media_play_pause, 447 + 0xba => ';', 448 + 0xbb => '+', 449 + 0xbc => ',', 450 + 0xbd => '-', 451 + 0xbe => '.', 452 + 0xbf => '/', 453 + 0xc0 => '`', 454 + 0xdb => '[', 455 + 0xdc => '\\', 456 + 0xdf => '\\', 457 + 0xe2 => '\\', 458 + 0xdd => ']', 459 + 0xde => '\'', 460 + else => { 461 + const log = std.log.scoped(.vaxis); 462 + log.warn("unknown wVirtualKeyCode: 0x{x}", .{event.wVirtualKeyCode}); 463 + return null; 464 + }, 465 + }; 466 + 467 + if (std.unicode.utf16IsHighSurrogate(base_layout)) { 468 + state.utf16_buf[0] = base_layout; 469 + state.utf16_half = true; 470 + return null; 471 + } 472 + if (std.unicode.utf16IsLowSurrogate(base_layout)) { 473 + return null; 474 + } 475 + 476 + var codepoint: u21 = base_layout; 477 + var text: ?[]const u8 = null; 478 + switch (event.uChar.UnicodeChar) { 479 + 0x00...0x1F => {}, 480 + else => |cp| { 481 + codepoint = cp; 482 + const n = try std.unicode.utf8Encode(codepoint, &self.buf); 483 + text = self.buf[0..n]; 484 + }, 485 + } 486 + 487 + const key: Key = .{ 488 + .codepoint = codepoint, 489 + .base_layout_codepoint = base_layout, 490 + .mods = translateMods(event.dwControlKeyState), 491 + .text = text, 492 + }; 493 + 494 + switch (event.bKeyDown) { 495 + 0 => return .{ .key_release = key }, 496 + else => return .{ .key_press = key }, 497 + } 498 + }, 499 + 0x0002 => { // Mouse event 500 + // see https://learn.microsoft.com/en-us/windows/console/mouse-event-record-str 501 + 502 + const event = record.Event.MouseEvent; 503 + 504 + // High word of dwButtonState represents mouse wheel. Positive is wheel_up, negative 505 + // is wheel_down 506 + // Low word represents button state 507 + const mouse_wheel_direction: i16 = blk: { 508 + const wheelu32: u32 = event.dwButtonState >> 16; 509 + const wheelu16: u16 = @truncate(wheelu32); 510 + break :blk @bitCast(wheelu16); 511 + }; 512 + 513 + const buttons: u16 = @truncate(event.dwButtonState); 514 + // save the current state when we are done 515 + defer self.last_mouse_button_press = buttons; 516 + const button_xor = self.last_mouse_button_press ^ buttons; 517 + 518 + var event_type: Mouse.Type = .press; 519 + const btn: Mouse.Button = switch (button_xor) { 520 + 0x0000 => blk: { 521 + // Check wheel event 522 + if (event.dwEventFlags & 0x0004 > 0) { 523 + if (mouse_wheel_direction > 0) 524 + break :blk .wheel_up 525 + else 526 + break :blk .wheel_down; 527 + } 528 + 529 + // If we have no change but one of the buttons is still pressed we have a 530 + // drag event. Find out which button is held down 531 + if (buttons > 0 and event.dwEventFlags & 0x0001 > 0) { 532 + event_type = .drag; 533 + if (buttons & 0x0001 > 0) break :blk .left; 534 + if (buttons & 0x0002 > 0) break :blk .right; 535 + if (buttons & 0x0004 > 0) break :blk .middle; 536 + if (buttons & 0x0008 > 0) break :blk .button_8; 537 + if (buttons & 0x0010 > 0) break :blk .button_9; 538 + } 539 + 540 + if (event.dwEventFlags & 0x0001 > 0) event_type = .motion; 541 + break :blk .none; 542 + }, 543 + 0x0001 => blk: { 544 + if (buttons & 0x0001 == 0) event_type = .release; 545 + break :blk .left; 546 + }, 547 + 0x0002 => blk: { 548 + if (buttons & 0x0002 == 0) event_type = .release; 549 + break :blk .right; 550 + }, 551 + 0x0004 => blk: { 552 + if (buttons & 0x0004 == 0) event_type = .release; 553 + break :blk .middle; 554 + }, 555 + 0x0008 => blk: { 556 + if (buttons & 0x0008 == 0) event_type = .release; 557 + break :blk .button_8; 558 + }, 559 + 0x0010 => blk: { 560 + if (buttons & 0x0010 == 0) event_type = .release; 561 + break :blk .button_9; 562 + }, 563 + else => { 564 + std.log.warn("unknown mouse event: {}", .{event}); 565 + return null; 566 + }, 567 + }; 568 + 569 + const shift: u32 = 0x0010; 570 + const alt: u32 = 0x0001 | 0x0002; 571 + const ctrl: u32 = 0x0004 | 0x0008; 572 + const mods: Mouse.Modifiers = .{ 573 + .shift = event.dwControlKeyState & shift > 0, 574 + .alt = event.dwControlKeyState & alt > 0, 575 + .ctrl = event.dwControlKeyState & ctrl > 0, 576 + }; 577 + 578 + const mouse: Mouse = .{ 579 + .col = @as(i16, @bitCast(event.dwMousePosition.X)), // Windows reports with 0 index 580 + .row = @as(i16, @bitCast(event.dwMousePosition.Y)), // Windows reports with 0 index 581 + .mods = mods, 582 + .type = event_type, 583 + .button = btn, 584 + }; 585 + return .{ .mouse = mouse }; 586 + }, 587 + 0x0004 => { // Screen resize events 588 + // NOTE: Even though the event comes with a size, it may not be accurate. We ask for 589 + // the size directly when we get this event 590 + var console_info: windows.CONSOLE_SCREEN_BUFFER_INFO = undefined; 591 + if (windows.kernel32.GetConsoleScreenBufferInfo(self.stdout, &console_info) == 0) { 592 + return windows.unexpectedError(windows.kernel32.GetLastError()); 593 + } 594 + const window_rect = console_info.srWindow; 595 + const width = window_rect.Right - window_rect.Left + 1; 596 + const height = window_rect.Bottom - window_rect.Top + 1; 597 + return .{ 598 + .winsize = .{ 599 + .cols = @intCast(width), 600 + .rows = @intCast(height), 601 + .x_pixel = 0, 602 + .y_pixel = 0, 603 + }, 604 + }; 605 + }, 606 + 0x0010 => { // Focus events 607 + switch (record.Event.FocusEvent.bSetFocus) { 608 + 0 => return .focus_out, 609 + else => return .focus_in, 610 + } 611 + }, 612 + else => {}, 613 + } 614 + return null; 615 + } 616 + 617 + fn translateMods(mods: u32) Key.Modifiers { 618 + const left_alt: u32 = 0x0002; 619 + const right_alt: u32 = 0x0001; 620 + const left_ctrl: u32 = 0x0008; 621 + const right_ctrl: u32 = 0x0004; 622 + 623 + const caps: u32 = 0x0080; 624 + const num_lock: u32 = 0x0020; 625 + const shift: u32 = 0x0010; 626 + const alt: u32 = left_alt | right_alt; 627 + const ctrl: u32 = left_ctrl | right_ctrl; 628 + 629 + return .{ 630 + .shift = mods & shift > 0, 631 + .alt = mods & alt > 0, 632 + .ctrl = mods & ctrl > 0, 633 + .caps_lock = mods & caps > 0, 634 + .num_lock = mods & num_lock > 0, 635 + }; 636 + } 637 + 638 + // From gitub.com/ziglibs/zig-windows-console. Thanks :) 639 + // 640 + // Events 641 + const union_unnamed_248 = extern union { 642 + UnicodeChar: windows.WCHAR, 643 + AsciiChar: windows.CHAR, 644 + }; 645 + pub const KEY_EVENT_RECORD = extern struct { 646 + bKeyDown: windows.BOOL, 647 + wRepeatCount: windows.WORD, 648 + wVirtualKeyCode: windows.WORD, 649 + wVirtualScanCode: windows.WORD, 650 + uChar: union_unnamed_248, 651 + dwControlKeyState: windows.DWORD, 652 + }; 653 + pub const PKEY_EVENT_RECORD = *KEY_EVENT_RECORD; 654 + 655 + pub const MOUSE_EVENT_RECORD = extern struct { 656 + dwMousePosition: windows.COORD, 657 + dwButtonState: windows.DWORD, 658 + dwControlKeyState: windows.DWORD, 659 + dwEventFlags: windows.DWORD, 660 + }; 661 + pub const PMOUSE_EVENT_RECORD = *MOUSE_EVENT_RECORD; 662 + 663 + pub const WINDOW_BUFFER_SIZE_RECORD = extern struct { 664 + dwSize: windows.COORD, 665 + }; 666 + pub const PWINDOW_BUFFER_SIZE_RECORD = *WINDOW_BUFFER_SIZE_RECORD; 667 + 668 + pub const MENU_EVENT_RECORD = extern struct { 669 + dwCommandId: windows.UINT, 670 + }; 671 + pub const PMENU_EVENT_RECORD = *MENU_EVENT_RECORD; 672 + 673 + pub const FOCUS_EVENT_RECORD = extern struct { 674 + bSetFocus: windows.BOOL, 675 + }; 676 + pub const PFOCUS_EVENT_RECORD = *FOCUS_EVENT_RECORD; 677 + 678 + const union_unnamed_249 = extern union { 679 + KeyEvent: KEY_EVENT_RECORD, 680 + MouseEvent: MOUSE_EVENT_RECORD, 681 + WindowBufferSizeEvent: WINDOW_BUFFER_SIZE_RECORD, 682 + MenuEvent: MENU_EVENT_RECORD, 683 + FocusEvent: FOCUS_EVENT_RECORD, 684 + }; 685 + pub const INPUT_RECORD = extern struct { 686 + EventType: windows.WORD, 687 + Event: union_unnamed_249, 688 + }; 689 + pub const PINPUT_RECORD = *INPUT_RECORD; 690 + 691 + pub extern "kernel32" fn ReadConsoleInputW(hConsoleInput: windows.HANDLE, lpBuffer: PINPUT_RECORD, nLength: windows.DWORD, lpNumberOfEventsRead: *windows.DWORD) callconv(.winapi) windows.BOOL; 692 + }; 693 + 694 + pub const TestTty = struct { 695 + /// Used for API compat 696 + fd: posix.fd_t, 697 + pipe_read: posix.fd_t, 698 + pipe_write: posix.fd_t, 699 + tty_writer: *std.Io.Writer.Allocating, 700 + 701 + /// Initializes a TestTty. 702 + pub fn init(buffer: []u8) !TestTty { 703 + _ = buffer; 704 + 705 + if (builtin.os.tag == .windows) return error.SkipZigTest; 706 + const list = try std.testing.allocator.create(std.Io.Writer.Allocating); 707 + list.* = .init(std.testing.allocator); 708 + const r, const w = try posix.pipe(); 709 + return .{ 710 + .fd = r, 711 + .pipe_read = r, 712 + .pipe_write = w, 713 + .tty_writer = list, 714 + }; 715 + } 716 + 717 + pub fn deinit(self: TestTty) void { 718 + std.posix.close(self.pipe_read); 719 + std.posix.close(self.pipe_write); 720 + self.tty_writer.deinit(); 721 + std.testing.allocator.destroy(self.tty_writer); 722 + } 723 + 724 + pub fn writer(self: *TestTty) *std.Io.Writer { 725 + return &self.tty_writer.writer; 726 + } 727 + 728 + pub fn read(self: *const TestTty, buf: []u8) !usize { 729 + return posix.read(self.fd, buf); 730 + } 731 + 732 + /// Get the window size from the kernel 733 + pub fn getWinsize(_: posix.fd_t) !Winsize { 734 + return .{ 735 + .rows = 40, 736 + .cols = 80, 737 + .x_pixel = 40 * 8, 738 + .y_pixel = 40 * 8 * 2, 739 + }; 740 + } 741 + 742 + /// Implemented for the Windows API 743 + pub fn nextEvent(_: *Tty, _: *Parser, _: ?std.mem.Allocator) !Event { 744 + return error.SkipZigTest; 745 + } 746 + 747 + pub fn resetSignalHandler() void { 748 + return; 749 + } 750 + };
+64
src/unicode.zig
··· 1 + const std = @import("std"); 2 + const uucode = @import("uucode"); 3 + 4 + // Old API-compatible Grapheme value 5 + pub const Grapheme = struct { 6 + start: usize, 7 + len: usize, 8 + 9 + pub fn bytes(self: Grapheme, str: []const u8) []const u8 { 10 + return str[self.start .. self.start + self.len]; 11 + } 12 + }; 13 + 14 + // Old API-compatible iterator that yields Grapheme with .len and .bytes() 15 + pub const GraphemeIterator = struct { 16 + str: []const u8, 17 + inner: uucode.grapheme.Iterator(uucode.utf8.Iterator), 18 + start: usize = 0, 19 + prev_break: bool = true, 20 + 21 + pub fn init(str: []const u8) GraphemeIterator { 22 + return .{ 23 + .str = str, 24 + .inner = uucode.grapheme.Iterator(uucode.utf8.Iterator).init(.init(str)), 25 + }; 26 + } 27 + 28 + pub fn next(self: *GraphemeIterator) ?Grapheme { 29 + while (self.inner.next()) |res| { 30 + // When leaving a break and entering a non-break, set the start of a cluster 31 + if (self.prev_break and !res.is_break) { 32 + const cp_len: usize = std.unicode.utf8CodepointSequenceLength(res.cp) catch 1; 33 + self.start = self.inner.i - cp_len; 34 + } 35 + 36 + // A break marks the end of the current grapheme 37 + if (res.is_break) { 38 + const end = self.inner.i; 39 + const s = self.start; 40 + self.start = end; 41 + self.prev_break = true; 42 + return .{ .start = s, .len = end - s }; 43 + } 44 + 45 + self.prev_break = false; 46 + } 47 + 48 + // Flush the last grapheme if we ended mid-cluster 49 + if (!self.prev_break and self.start < self.str.len) { 50 + const s = self.start; 51 + const len = self.str.len - s; 52 + self.start = self.str.len; 53 + self.prev_break = true; 54 + return .{ .start = s, .len = len }; 55 + } 56 + 57 + return null; 58 + } 59 + }; 60 + 61 + /// creates a grapheme iterator based on str 62 + pub fn graphemeIterator(str: []const u8) GraphemeIterator { 63 + return GraphemeIterator.init(str); 64 + }
+604
src/vxfw/App.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + const vxfw = @import("vxfw.zig"); 4 + 5 + const assert = std.debug.assert; 6 + 7 + const Allocator = std.mem.Allocator; 8 + 9 + const EventLoop = vaxis.Loop(vxfw.Event); 10 + const Widget = vxfw.Widget; 11 + 12 + const App = @This(); 13 + 14 + allocator: Allocator, 15 + tty: vaxis.Tty, 16 + vx: vaxis.Vaxis, 17 + timers: std.ArrayList(vxfw.Tick), 18 + wants_focus: ?vxfw.Widget, 19 + buffer: [1024]u8, 20 + 21 + /// Runtime options 22 + pub const Options = struct { 23 + /// Frames per second 24 + framerate: u8 = 60, 25 + }; 26 + 27 + /// Create an application. We require stable pointers to do the set up, so this will create an App 28 + /// object on the heap. Call destroy when the app is complete to reset terminal state and release 29 + /// resources 30 + pub fn init(allocator: Allocator) !App { 31 + var app: App = .{ 32 + .allocator = allocator, 33 + .tty = undefined, 34 + .vx = try vaxis.init(allocator, .{ 35 + .system_clipboard_allocator = allocator, 36 + .kitty_keyboard_flags = .{ 37 + .report_events = true, 38 + }, 39 + }), 40 + .timers = std.ArrayList(vxfw.Tick){}, 41 + .wants_focus = null, 42 + .buffer = undefined, 43 + }; 44 + app.tty = try vaxis.Tty.init(&app.buffer); 45 + return app; 46 + } 47 + 48 + pub fn deinit(self: *App) void { 49 + self.timers.deinit(self.allocator); 50 + self.vx.deinit(self.allocator, self.tty.writer()); 51 + self.tty.deinit(); 52 + } 53 + 54 + pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void { 55 + const tty = &self.tty; 56 + const vx = &self.vx; 57 + 58 + var loop: EventLoop = .{ .tty = tty, .vaxis = vx }; 59 + try loop.start(); 60 + defer loop.stop(); 61 + 62 + // Send the init event 63 + loop.postEvent(.init); 64 + // Also always initialize the app with a focus event 65 + loop.postEvent(.focus_in); 66 + 67 + try vx.enterAltScreen(tty.writer()); 68 + try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s); 69 + try vx.setBracketedPaste(tty.writer(), true); 70 + try vx.subscribeToColorSchemeUpdates(tty.writer()); 71 + 72 + { 73 + // This part deserves a comment. loop.init installs a signal handler for the tty. We wait to 74 + // init the loop until we know if we need this handler. We don't need it if the terminal 75 + // supports in-band-resize 76 + if (!vx.state.in_band_resize) try loop.init(); 77 + } 78 + 79 + // NOTE: We don't use pixel mouse anywhere 80 + vx.caps.sgr_pixels = false; 81 + try vx.setMouseMode(tty.writer(), true); 82 + 83 + vxfw.DrawContext.init(vx.screen.width_method); 84 + 85 + const framerate: u64 = if (opts.framerate > 0) opts.framerate else 60; 86 + // Calculate tick rate 87 + const tick_ms: u64 = @divFloor(std.time.ms_per_s, framerate); 88 + 89 + // Set up arena and context 90 + var arena = std.heap.ArenaAllocator.init(self.allocator); 91 + defer arena.deinit(); 92 + 93 + var mouse_handler = MouseHandler.init(widget); 94 + defer mouse_handler.deinit(self.allocator); 95 + var focus_handler = FocusHandler.init(self.allocator, widget); 96 + try focus_handler.path_to_focused.append(self.allocator, widget); 97 + defer focus_handler.deinit(self.allocator); 98 + 99 + // Timestamp of our next frame 100 + var next_frame_ms: u64 = @intCast(std.time.milliTimestamp()); 101 + 102 + // Create our event context 103 + var ctx: vxfw.EventContext = .{ 104 + .alloc = self.allocator, 105 + .phase = .capturing, 106 + .cmds = vxfw.CommandList{}, 107 + .consume_event = false, 108 + .redraw = false, 109 + .quit = false, 110 + }; 111 + defer ctx.cmds.deinit(self.allocator); 112 + 113 + while (true) { 114 + const now_ms: u64 = @intCast(std.time.milliTimestamp()); 115 + if (now_ms >= next_frame_ms) { 116 + // Deadline exceeded. Schedule the next frame 117 + next_frame_ms = now_ms + tick_ms; 118 + } else { 119 + // Sleep until the deadline 120 + std.Thread.sleep((next_frame_ms - now_ms) * std.time.ns_per_ms); 121 + next_frame_ms += tick_ms; 122 + } 123 + 124 + try self.checkTimers(&ctx); 125 + 126 + { 127 + loop.queue.lock(); 128 + defer loop.queue.unlock(); 129 + while (loop.queue.drain()) |event| { 130 + defer { 131 + // Reset our context 132 + ctx.consume_event = false; 133 + ctx.phase = .capturing; 134 + } 135 + switch (event) { 136 + .key_press => { 137 + try focus_handler.handleEvent(&ctx, event); 138 + try self.handleCommand(&ctx.cmds); 139 + }, 140 + .focus_out => { 141 + try mouse_handler.mouseExit(self, &ctx); 142 + try focus_handler.handleEvent(&ctx, .focus_out); 143 + try self.handleCommand(&ctx.cmds); 144 + }, 145 + .focus_in => { 146 + try focus_handler.handleEvent(&ctx, .focus_in); 147 + try self.handleCommand(&ctx.cmds); 148 + }, 149 + .mouse => |mouse| try mouse_handler.handleMouse(self, &ctx, mouse), 150 + .winsize => |ws| { 151 + try vx.resize(self.allocator, tty.writer(), ws); 152 + ctx.redraw = true; 153 + }, 154 + else => { 155 + try focus_handler.handleEvent(&ctx, event); 156 + try self.handleCommand(&ctx.cmds); 157 + }, 158 + } 159 + } 160 + } 161 + 162 + // If we have a focus change, handle that event before we layout 163 + if (self.wants_focus) |wants_focus| { 164 + try focus_handler.focusWidget(&ctx, wants_focus); 165 + try self.handleCommand(&ctx.cmds); 166 + self.wants_focus = null; 167 + } 168 + 169 + // Check if we should quit 170 + if (ctx.quit) return; 171 + 172 + // Check if we need a redraw 173 + if (!ctx.redraw) continue; 174 + ctx.redraw = false; 175 + // Clear the arena. 176 + _ = arena.reset(.free_all); 177 + // Assert that we have handled all commands 178 + assert(ctx.cmds.items.len == 0); 179 + 180 + const surface: vxfw.Surface = blk: { 181 + // Draw the root widget 182 + const surface = try self.doLayout(widget, &arena); 183 + 184 + // Check if any hover or mouse effects changed 185 + try mouse_handler.updateMouse(self, surface, &ctx); 186 + // Our focus may have changed. Handle that here 187 + if (self.wants_focus) |wants_focus| { 188 + try focus_handler.focusWidget(&ctx, wants_focus); 189 + try self.handleCommand(&ctx.cmds); 190 + self.wants_focus = null; 191 + } 192 + 193 + assert(ctx.cmds.items.len == 0); 194 + if (!ctx.redraw) break :blk surface; 195 + // If updating the mouse required a redraw, we do the layout again 196 + break :blk try self.doLayout(widget, &arena); 197 + }; 198 + 199 + // Store the last frame 200 + mouse_handler.last_frame = surface; 201 + // Update the focus handler list 202 + try focus_handler.update(self.allocator, surface); 203 + try self.render(surface, focus_handler.focused_widget); 204 + } 205 + } 206 + 207 + fn doLayout( 208 + self: *App, 209 + widget: vxfw.Widget, 210 + arena: *std.heap.ArenaAllocator, 211 + ) !vxfw.Surface { 212 + const vx = &self.vx; 213 + 214 + const draw_context: vxfw.DrawContext = .{ 215 + .arena = arena.allocator(), 216 + .min = .{ .width = 0, .height = 0 }, 217 + .max = .{ 218 + .width = @intCast(vx.screen.width), 219 + .height = @intCast(vx.screen.height), 220 + }, 221 + .cell_size = .{ 222 + .width = vx.screen.width_pix / vx.screen.width, 223 + .height = vx.screen.height_pix / vx.screen.height, 224 + }, 225 + }; 226 + return widget.draw(draw_context); 227 + } 228 + 229 + fn render( 230 + self: *App, 231 + surface: vxfw.Surface, 232 + focused_widget: vxfw.Widget, 233 + ) !void { 234 + const vx = &self.vx; 235 + const tty = &self.tty; 236 + 237 + const win = vx.window(); 238 + win.clear(); 239 + win.hideCursor(); 240 + win.setCursorShape(.default); 241 + 242 + const root_win = win.child(.{ 243 + .width = surface.size.width, 244 + .height = surface.size.height, 245 + }); 246 + surface.render(root_win, focused_widget); 247 + 248 + try vx.render(tty.writer()); 249 + } 250 + 251 + fn addTick(self: *App, tick: vxfw.Tick) Allocator.Error!void { 252 + try self.timers.append(self.allocator, tick); 253 + std.sort.insertion(vxfw.Tick, self.timers.items, {}, vxfw.Tick.lessThan); 254 + } 255 + 256 + fn handleCommand(self: *App, cmds: *vxfw.CommandList) Allocator.Error!void { 257 + defer cmds.clearRetainingCapacity(); 258 + for (cmds.items) |cmd| { 259 + switch (cmd) { 260 + .tick => |tick| try self.addTick(tick), 261 + .set_mouse_shape => |shape| self.vx.setMouseShape(shape), 262 + .request_focus => |widget| self.wants_focus = widget, 263 + .copy_to_clipboard => |content| { 264 + defer self.allocator.free(content); 265 + self.vx.copyToSystemClipboard(self.tty.writer(), content, self.allocator) catch |err| { 266 + switch (err) { 267 + error.OutOfMemory => return Allocator.Error.OutOfMemory, 268 + else => std.log.err("copy error: {}", .{err}), 269 + } 270 + }; 271 + }, 272 + .set_title => |title| { 273 + defer self.allocator.free(title); 274 + self.vx.setTitle(self.tty.writer(), title) catch |err| { 275 + std.log.err("set_title error: {}", .{err}); 276 + }; 277 + }, 278 + .queue_refresh => self.vx.queueRefresh(), 279 + .notify => |notification| { 280 + self.vx.notify(self.tty.writer(), notification.title, notification.body) catch |err| { 281 + std.log.err("notify error: {}", .{err}); 282 + }; 283 + const alloc = self.allocator; 284 + if (notification.title) |title| { 285 + alloc.free(title); 286 + } 287 + alloc.free(notification.body); 288 + }, 289 + .query_color => |kind| { 290 + self.vx.queryColor(self.tty.writer(), kind) catch |err| { 291 + std.log.err("queryColor error: {}", .{err}); 292 + }; 293 + }, 294 + } 295 + } 296 + } 297 + 298 + fn checkTimers(self: *App, ctx: *vxfw.EventContext) anyerror!void { 299 + const now_ms = std.time.milliTimestamp(); 300 + 301 + // timers are always sorted descending 302 + while (self.timers.pop()) |tick| { 303 + if (now_ms < tick.deadline_ms) { 304 + // re-add the timer 305 + try self.timers.append(self.allocator, tick); 306 + break; 307 + } 308 + try tick.widget.handleEvent(ctx, .tick); 309 + } 310 + try self.handleCommand(&ctx.cmds); 311 + } 312 + 313 + const MouseHandler = struct { 314 + last_frame: vxfw.Surface, 315 + last_hit_list: []vxfw.HitResult, 316 + mouse: ?vaxis.Mouse, 317 + 318 + fn init(root: Widget) MouseHandler { 319 + return .{ 320 + .last_frame = .{ 321 + .size = .{ .width = 0, .height = 0 }, 322 + .widget = root, 323 + .buffer = &.{}, 324 + .children = &.{}, 325 + }, 326 + .last_hit_list = &.{}, 327 + .mouse = null, 328 + }; 329 + } 330 + 331 + fn deinit(self: MouseHandler, gpa: Allocator) void { 332 + gpa.free(self.last_hit_list); 333 + } 334 + 335 + fn updateMouse( 336 + self: *MouseHandler, 337 + app: *App, 338 + surface: vxfw.Surface, 339 + ctx: *vxfw.EventContext, 340 + ) anyerror!void { 341 + const mouse = self.mouse orelse return; 342 + // For mouse events we store the last frame and use that for hit testing 343 + const last_frame = surface; 344 + 345 + var hits = std.ArrayList(vxfw.HitResult){}; 346 + defer hits.deinit(app.allocator); 347 + const sub: vxfw.SubSurface = .{ 348 + .origin = .{ .row = 0, .col = 0 }, 349 + .surface = last_frame, 350 + .z_index = 0, 351 + }; 352 + const mouse_point: vxfw.Point = .{ 353 + .row = @intCast(mouse.row), 354 + .col = @intCast(mouse.col), 355 + }; 356 + if (sub.containsPoint(mouse_point)) { 357 + try last_frame.hitTest(app.allocator, &hits, mouse_point); 358 + } 359 + 360 + // We store the hit list from the last mouse event to determine mouse_enter and mouse_leave 361 + // events. If list a is the previous hit list, and list b is the current hit list: 362 + // - Widgets in a but not in b get a mouse_leave event 363 + // - Widgets in b but not in a get a mouse_enter event 364 + // - Widgets in both receive nothing 365 + const a = self.last_hit_list; 366 + const b = hits.items; 367 + 368 + // Find widgets in a but not b 369 + for (a) |a_item| { 370 + const a_widget = a_item.widget; 371 + for (b) |b_item| { 372 + const b_widget = b_item.widget; 373 + if (a_widget.eql(b_widget)) break; 374 + } else { 375 + // a_item is not in b 376 + try a_widget.handleEvent(ctx, .mouse_leave); 377 + try app.handleCommand(&ctx.cmds); 378 + } 379 + } 380 + 381 + // Widgets in b but not in a 382 + for (b) |b_item| { 383 + const b_widget = b_item.widget; 384 + for (a) |a_item| { 385 + const a_widget = a_item.widget; 386 + if (b_widget.eql(a_widget)) break; 387 + } else { 388 + // b_item is not in a. 389 + try b_widget.handleEvent(ctx, .mouse_enter); 390 + try app.handleCommand(&ctx.cmds); 391 + } 392 + } 393 + 394 + // Store a copy of this hit list for next frame 395 + app.allocator.free(self.last_hit_list); 396 + self.last_hit_list = try app.allocator.dupe(vxfw.HitResult, hits.items); 397 + } 398 + 399 + fn handleMouse(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext, mouse: vaxis.Mouse) anyerror!void { 400 + // For mouse events we store the last frame and use that for hit testing 401 + const last_frame = self.last_frame; 402 + self.mouse = mouse; 403 + 404 + var hits = std.ArrayList(vxfw.HitResult){}; 405 + defer hits.deinit(app.allocator); 406 + const sub: vxfw.SubSurface = .{ 407 + .origin = .{ .row = 0, .col = 0 }, 408 + .surface = last_frame, 409 + .z_index = 0, 410 + }; 411 + const mouse_point: vxfw.Point = .{ 412 + .row = @intCast(mouse.row), 413 + .col = @intCast(mouse.col), 414 + }; 415 + if (sub.containsPoint(mouse_point)) { 416 + try last_frame.hitTest(app.allocator, &hits, mouse_point); 417 + } 418 + 419 + // Handle mouse_enter and mouse_leave events 420 + { 421 + // We store the hit list from the last mouse event to determine mouse_enter and mouse_leave 422 + // events. If list a is the previous hit list, and list b is the current hit list: 423 + // - Widgets in a but not in b get a mouse_leave event 424 + // - Widgets in b but not in a get a mouse_enter event 425 + // - Widgets in both receive nothing 426 + const a = self.last_hit_list; 427 + const b = hits.items; 428 + 429 + // Find widgets in a but not b 430 + for (a) |a_item| { 431 + const a_widget = a_item.widget; 432 + for (b) |b_item| { 433 + const b_widget = b_item.widget; 434 + if (a_widget.eql(b_widget)) break; 435 + } else { 436 + // a_item is not in b 437 + try a_widget.handleEvent(ctx, .mouse_leave); 438 + try app.handleCommand(&ctx.cmds); 439 + } 440 + } 441 + 442 + // Widgets in b but not in a 443 + for (b) |b_item| { 444 + const b_widget = b_item.widget; 445 + for (a) |a_item| { 446 + const a_widget = a_item.widget; 447 + if (b_widget.eql(a_widget)) break; 448 + } else { 449 + // b_item is not in a. 450 + try b_widget.handleEvent(ctx, .mouse_enter); 451 + try app.handleCommand(&ctx.cmds); 452 + } 453 + } 454 + 455 + // Store a copy of this hit list for next frame 456 + app.allocator.free(self.last_hit_list); 457 + self.last_hit_list = try app.allocator.dupe(vxfw.HitResult, hits.items); 458 + } 459 + 460 + const target = hits.pop() orelse return; 461 + 462 + // capturing phase 463 + ctx.phase = .capturing; 464 + for (hits.items) |item| { 465 + var m_local = mouse; 466 + m_local.col = item.local.col; 467 + m_local.row = item.local.row; 468 + try item.widget.captureEvent(ctx, .{ .mouse = m_local }); 469 + try app.handleCommand(&ctx.cmds); 470 + 471 + if (ctx.consume_event) return; 472 + } 473 + 474 + // target phase 475 + ctx.phase = .at_target; 476 + { 477 + var m_local = mouse; 478 + m_local.col = target.local.col; 479 + m_local.row = target.local.row; 480 + try target.widget.handleEvent(ctx, .{ .mouse = m_local }); 481 + try app.handleCommand(&ctx.cmds); 482 + 483 + if (ctx.consume_event) return; 484 + } 485 + 486 + // Bubbling phase 487 + ctx.phase = .bubbling; 488 + while (hits.pop()) |item| { 489 + var m_local = mouse; 490 + m_local.col = item.local.col; 491 + m_local.row = item.local.row; 492 + try item.widget.handleEvent(ctx, .{ .mouse = m_local }); 493 + try app.handleCommand(&ctx.cmds); 494 + 495 + if (ctx.consume_event) return; 496 + } 497 + } 498 + 499 + /// sends .mouse_leave to all of the widgets from the last_hit_list 500 + fn mouseExit(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext) anyerror!void { 501 + for (self.last_hit_list) |item| { 502 + try item.widget.handleEvent(ctx, .mouse_leave); 503 + try app.handleCommand(&ctx.cmds); 504 + } 505 + } 506 + }; 507 + 508 + /// Maintains a tree of focusable nodes. Delivers events to the currently focused node, walking up 509 + /// the tree until the event is handled 510 + const FocusHandler = struct { 511 + root: Widget, 512 + focused_widget: vxfw.Widget, 513 + path_to_focused: std.ArrayList(Widget), 514 + 515 + fn init(_: Allocator, root: Widget) FocusHandler { 516 + return .{ 517 + .root = root, 518 + .focused_widget = root, 519 + .path_to_focused = std.ArrayList(Widget){}, 520 + }; 521 + } 522 + 523 + fn deinit(self: *FocusHandler, allocator: Allocator) void { 524 + self.path_to_focused.deinit(allocator); 525 + } 526 + 527 + /// Update the focus list 528 + fn update(self: *FocusHandler, allocator: Allocator, surface: vxfw.Surface) Allocator.Error!void { 529 + // clear path 530 + self.path_to_focused.clearAndFree(allocator); 531 + 532 + // Find the path to the focused widget. This builds a list that has the first element as the 533 + // focused widget, and walks backward to the root. It's possible our focused widget is *not* 534 + // in this tree. If this is the case, we refocus to the root widget 535 + _ = try self.childHasFocus(allocator, surface); 536 + 537 + if (!self.root.eql(surface.widget)) { 538 + // If the root of surface is not the initial widget, we append the initial widget 539 + try self.path_to_focused.append(allocator, self.root); 540 + } 541 + 542 + // reverse path_to_focused so that it is root first 543 + std.mem.reverse(Widget, self.path_to_focused.items); 544 + } 545 + 546 + /// Returns true if a child of surface is the focused widget 547 + fn childHasFocus( 548 + self: *FocusHandler, 549 + allocator: Allocator, 550 + surface: vxfw.Surface, 551 + ) Allocator.Error!bool { 552 + // Check if we are the focused widget 553 + if (self.focused_widget.eql(surface.widget)) { 554 + try self.path_to_focused.append(allocator, surface.widget); 555 + return true; 556 + } 557 + for (surface.children) |child| { 558 + // Add child to list if it is the focused widget or one of it's own children is 559 + if (try self.childHasFocus(allocator, child.surface)) { 560 + try self.path_to_focused.append(allocator, surface.widget); 561 + return true; 562 + } 563 + } 564 + return false; 565 + } 566 + 567 + fn focusWidget(self: *FocusHandler, ctx: *vxfw.EventContext, widget: vxfw.Widget) anyerror!void { 568 + // Focusing a widget requires it to have an event handler 569 + assert(widget.eventHandler != null); 570 + if (self.focused_widget.eql(widget)) return; 571 + 572 + ctx.phase = .at_target; 573 + try self.focused_widget.handleEvent(ctx, .focus_out); 574 + self.focused_widget = widget; 575 + try self.focused_widget.handleEvent(ctx, .focus_in); 576 + } 577 + 578 + fn handleEvent(self: *FocusHandler, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 579 + const path = self.path_to_focused.items; 580 + assert(path.len > 0); 581 + 582 + // Capturing phase. We send capture events from the root to the target (inclusive of target) 583 + ctx.phase = .capturing; 584 + for (path) |widget| { 585 + try widget.captureEvent(ctx, event); 586 + if (ctx.consume_event) return; 587 + } 588 + 589 + // Target phase. This is only sent to the target 590 + ctx.phase = .at_target; 591 + const target = self.path_to_focused.getLast(); 592 + try target.handleEvent(ctx, event); 593 + if (ctx.consume_event) return; 594 + 595 + // Bubbling phase. Bubbling phase moves from target (exclusive) to the root 596 + ctx.phase = .bubbling; 597 + const target_idx = path.len - 1; 598 + var iter = std.mem.reverseIterator(path[0..target_idx]); 599 + while (iter.next()) |widget| { 600 + try widget.handleEvent(ctx, event); 601 + if (ctx.consume_event) return; 602 + } 603 + } 604 + };
+146
src/vxfw/Border.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const Allocator = std.mem.Allocator; 5 + 6 + const vxfw = @import("vxfw.zig"); 7 + 8 + pub const BorderLabel = struct { 9 + text: []const u8, 10 + alignment: enum { 11 + top_left, 12 + top_center, 13 + top_right, 14 + bottom_left, 15 + bottom_center, 16 + bottom_right, 17 + }, 18 + }; 19 + 20 + const Border = @This(); 21 + 22 + child: vxfw.Widget, 23 + style: vaxis.Style = .{}, 24 + labels: []const BorderLabel = &[_]BorderLabel{}, 25 + 26 + pub fn widget(self: *const Border) vxfw.Widget { 27 + return .{ 28 + .userdata = @constCast(self), 29 + .drawFn = typeErasedDrawFn, 30 + }; 31 + } 32 + 33 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 34 + const self: *const Border = @ptrCast(@alignCast(ptr)); 35 + return self.draw(ctx); 36 + } 37 + 38 + /// If Border has a bounded maximum size, it will shrink the maximum size to account for the border 39 + /// before drawing the child. If the size is unbounded, border will draw the child and then itself 40 + /// around the childs size 41 + pub fn draw(self: *const Border, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 42 + const max_width: ?u16 = if (ctx.max.width) |width| width -| 2 else null; 43 + const max_height: ?u16 = if (ctx.max.height) |height| height -| 2 else null; 44 + 45 + const child_ctx = ctx.withConstraints(ctx.min, .{ 46 + .width = max_width, 47 + .height = max_height, 48 + }); 49 + const child = try self.child.draw(child_ctx); 50 + 51 + const children = try ctx.arena.alloc(vxfw.SubSurface, 1); 52 + children[0] = .{ 53 + .origin = .{ .col = 1, .row = 1 }, 54 + .z_index = 0, 55 + .surface = child, 56 + }; 57 + 58 + const size: vxfw.Size = .{ .width = child.size.width + 2, .height = child.size.height + 2 }; 59 + 60 + var surf = try vxfw.Surface.initWithChildren(ctx.arena, self.widget(), size, children); 61 + 62 + // Draw the border 63 + const right_edge = size.width -| 1; 64 + const bottom_edge = size.height -| 1; 65 + surf.writeCell(0, 0, .{ .char = .{ .grapheme = "โ•ญ", .width = 1 }, .style = self.style }); 66 + surf.writeCell(right_edge, 0, .{ .char = .{ .grapheme = "โ•ฎ", .width = 1 }, .style = self.style }); 67 + surf.writeCell(right_edge, bottom_edge, .{ .char = .{ .grapheme = "โ•ฏ", .width = 1 }, .style = self.style }); 68 + surf.writeCell(0, bottom_edge, .{ .char = .{ .grapheme = "โ•ฐ", .width = 1 }, .style = self.style }); 69 + 70 + var col: u16 = 1; 71 + while (col < right_edge) : (col += 1) { 72 + surf.writeCell(col, 0, .{ .char = .{ .grapheme = "โ”€", .width = 1 }, .style = self.style }); 73 + surf.writeCell(col, bottom_edge, .{ .char = .{ .grapheme = "โ”€", .width = 1 }, .style = self.style }); 74 + } 75 + 76 + var row: u16 = 1; 77 + while (row < bottom_edge) : (row += 1) { 78 + surf.writeCell(0, row, .{ .char = .{ .grapheme = "โ”‚", .width = 1 }, .style = self.style }); 79 + surf.writeCell(right_edge, row, .{ .char = .{ .grapheme = "โ”‚", .width = 1 }, .style = self.style }); 80 + } 81 + 82 + // Add border labels 83 + for (self.labels) |label| { 84 + const text_len: u16 = @intCast(ctx.stringWidth(label.text)); 85 + if (text_len == 0) continue; 86 + 87 + const text_row: u16 = switch (label.alignment) { 88 + .top_left, .top_center, .top_right => 0, 89 + .bottom_left, .bottom_center, .bottom_right => bottom_edge, 90 + }; 91 + 92 + var text_col: u16 = switch (label.alignment) { 93 + .top_left, .bottom_left => 1, 94 + .top_center, .bottom_center => @max((size.width - text_len) / 2, 1), 95 + .top_right, .bottom_right => @max(size.width - 1 - text_len, 1), 96 + }; 97 + 98 + var iter = ctx.graphemeIterator(label.text); 99 + while (iter.next()) |grapheme| { 100 + const text = grapheme.bytes(label.text); 101 + const width: u16 = @intCast(ctx.stringWidth(text)); 102 + surf.writeCell(text_col, text_row, .{ 103 + .char = .{ .grapheme = text, .width = @intCast(width) }, 104 + .style = self.style, 105 + }); 106 + text_col += width; 107 + } 108 + } 109 + 110 + return surf; 111 + } 112 + 113 + test Border { 114 + const Text = @import("Text.zig"); 115 + // Will be height=1, width=3 116 + const text: Text = .{ .text = "abc" }; 117 + 118 + const border: Border = .{ .child = text.widget() }; 119 + 120 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 121 + defer arena.deinit(); 122 + vxfw.DrawContext.init(.unicode); 123 + 124 + // Border will draw itself tightly around the child 125 + const ctx: vxfw.DrawContext = .{ 126 + .arena = arena.allocator(), 127 + .min = .{}, 128 + .max = .{ .width = 10, .height = 10 }, 129 + .cell_size = .{ .width = 10, .height = 20 }, 130 + }; 131 + 132 + const surface = try border.draw(ctx); 133 + // Border should be the size of Text + 2 134 + try std.testing.expectEqual(5, surface.size.width); 135 + try std.testing.expectEqual(3, surface.size.height); 136 + // Border has 1 child 137 + try std.testing.expectEqual(1, surface.children.len); 138 + const child = surface.children[0]; 139 + // The child is 1x3 140 + try std.testing.expectEqual(3, child.surface.size.width); 141 + try std.testing.expectEqual(1, child.surface.size.height); 142 + } 143 + 144 + test "refAllDecls" { 145 + std.testing.refAllDecls(@This()); 146 + }
+214
src/vxfw/Button.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const vxfw = @import("vxfw.zig"); 5 + 6 + const Allocator = std.mem.Allocator; 7 + 8 + const Center = @import("Center.zig"); 9 + const Text = @import("Text.zig"); 10 + 11 + const Button = @This(); 12 + 13 + // User supplied values 14 + label: []const u8, 15 + onClick: *const fn (?*anyopaque, ctx: *vxfw.EventContext) anyerror!void, 16 + userdata: ?*anyopaque = null, 17 + 18 + // Styles 19 + style: struct { 20 + default: vaxis.Style = .{ .reverse = true }, 21 + mouse_down: vaxis.Style = .{ .fg = .{ .index = 4 }, .reverse = true }, 22 + hover: vaxis.Style = .{ .fg = .{ .index = 3 }, .reverse = true }, 23 + focus: vaxis.Style = .{ .fg = .{ .index = 5 }, .reverse = true }, 24 + } = .{}, 25 + 26 + // State 27 + mouse_down: bool = false, 28 + has_mouse: bool = false, 29 + focused: bool = false, 30 + 31 + pub fn widget(self: *Button) vxfw.Widget { 32 + return .{ 33 + .userdata = self, 34 + .eventHandler = typeErasedEventHandler, 35 + .drawFn = typeErasedDrawFn, 36 + }; 37 + } 38 + 39 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 40 + const self: *Button = @ptrCast(@alignCast(ptr)); 41 + return self.handleEvent(ctx, event); 42 + } 43 + 44 + pub fn handleEvent(self: *Button, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 45 + switch (event) { 46 + .key_press => |key| { 47 + if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) { 48 + return self.doClick(ctx); 49 + } 50 + }, 51 + .mouse => |mouse| { 52 + if (self.mouse_down and mouse.type == .release) { 53 + self.mouse_down = false; 54 + return self.doClick(ctx); 55 + } 56 + if (mouse.type == .press and mouse.button == .left) { 57 + self.mouse_down = true; 58 + return ctx.consumeAndRedraw(); 59 + } 60 + return ctx.consumeEvent(); 61 + }, 62 + .mouse_enter => { 63 + // implicit redraw 64 + self.has_mouse = true; 65 + try ctx.setMouseShape(.pointer); 66 + return ctx.consumeAndRedraw(); 67 + }, 68 + .mouse_leave => { 69 + self.has_mouse = false; 70 + self.mouse_down = false; 71 + // implicit redraw 72 + try ctx.setMouseShape(.default); 73 + }, 74 + .focus_in => { 75 + self.focused = true; 76 + ctx.redraw = true; 77 + }, 78 + .focus_out => { 79 + self.focused = false; 80 + ctx.redraw = true; 81 + }, 82 + else => {}, 83 + } 84 + } 85 + 86 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 87 + const self: *Button = @ptrCast(@alignCast(ptr)); 88 + return self.draw(ctx); 89 + } 90 + 91 + pub fn draw(self: *Button, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 92 + const style: vaxis.Style = if (self.mouse_down) 93 + self.style.mouse_down 94 + else if (self.has_mouse) 95 + self.style.hover 96 + else if (self.focused) 97 + self.style.focus 98 + else 99 + self.style.default; 100 + 101 + const text: Text = .{ 102 + .style = style, 103 + .text = self.label, 104 + .text_align = .center, 105 + }; 106 + 107 + const center: Center = .{ .child = text.widget() }; 108 + const surf = try center.draw(ctx); 109 + 110 + const button_surf = try vxfw.Surface.initWithChildren(ctx.arena, self.widget(), surf.size, surf.children); 111 + @memset(button_surf.buffer, .{ .style = style }); 112 + return button_surf; 113 + } 114 + 115 + fn doClick(self: *Button, ctx: *vxfw.EventContext) anyerror!void { 116 + try self.onClick(self.userdata, ctx); 117 + ctx.consume_event = true; 118 + } 119 + 120 + test Button { 121 + // Create some object which reacts to a button press 122 + const Foo = struct { 123 + count: u8, 124 + 125 + fn onClick(ptr: ?*anyopaque, ctx: *vxfw.EventContext) anyerror!void { 126 + const foo: *@This() = @ptrCast(@alignCast(ptr)); 127 + foo.count +|= 1; 128 + ctx.consumeAndRedraw(); 129 + } 130 + }; 131 + var foo: Foo = .{ .count = 0 }; 132 + 133 + var button: Button = .{ 134 + .label = "Test Button", 135 + .onClick = Foo.onClick, 136 + .userdata = &foo, 137 + }; 138 + 139 + // Event handlers need a context 140 + var ctx: vxfw.EventContext = .{ 141 + .alloc = std.testing.allocator, 142 + .cmds = .empty, 143 + }; 144 + defer ctx.cmds.deinit(ctx.alloc); 145 + 146 + // Get the widget interface 147 + const b_widget = button.widget(); 148 + 149 + // Create a synthetic mouse event 150 + var mouse_event: vaxis.Mouse = .{ 151 + .col = 0, 152 + .row = 0, 153 + .mods = .{}, 154 + .button = .left, 155 + .type = .press, 156 + }; 157 + // Send the button a mouse press event 158 + try b_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 159 + 160 + // A press alone doesn't trigger onClick 161 + try std.testing.expectEqual(0, foo.count); 162 + 163 + // Send the button a mouse release event. The onClick handler is called 164 + mouse_event.type = .release; 165 + try b_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 166 + try std.testing.expectEqual(1, foo.count); 167 + 168 + // Send it another press 169 + mouse_event.type = .press; 170 + try b_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 171 + 172 + // Now the mouse leaves 173 + try b_widget.handleEvent(&ctx, .mouse_leave); 174 + 175 + // Then it comes back. We don't know it but the button was pressed outside of our widget. We 176 + // receie the release event 177 + mouse_event.type = .release; 178 + try b_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 179 + 180 + // But we didn't have the press registered, so we don't call onClick 181 + try std.testing.expectEqual(1, foo.count); 182 + 183 + // Now we receive an enter keypress. This also triggers the onClick handler 184 + try b_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.enter } }); 185 + try std.testing.expectEqual(2, foo.count); 186 + 187 + // Now we draw the button. Set up our context with some unicode data 188 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 189 + defer arena.deinit(); 190 + vxfw.DrawContext.init(.unicode); 191 + 192 + const draw_ctx: vxfw.DrawContext = .{ 193 + .arena = arena.allocator(), 194 + .min = .{}, 195 + .max = .{ .width = 13, .height = 3 }, 196 + .cell_size = .{ .width = 10, .height = 20 }, 197 + }; 198 + const surface = try b_widget.draw(draw_ctx); 199 + 200 + // The button should fill the available space. 201 + try std.testing.expectEqual(surface.size.width, draw_ctx.max.width.?); 202 + try std.testing.expectEqual(surface.size.height, draw_ctx.max.height.?); 203 + 204 + // It should have one child, the label 205 + try std.testing.expectEqual(1, surface.children.len); 206 + 207 + // The label should be centered 208 + try std.testing.expectEqual(1, surface.children[0].origin.row); 209 + try std.testing.expectEqual(1, surface.children[0].origin.col); 210 + } 211 + 212 + test "refAllDecls" { 213 + std.testing.refAllDecls(@This()); 214 + }
+113
src/vxfw/Center.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const Allocator = std.mem.Allocator; 5 + 6 + const vxfw = @import("vxfw.zig"); 7 + 8 + const Center = @This(); 9 + 10 + child: vxfw.Widget, 11 + 12 + pub fn widget(self: *const Center) vxfw.Widget { 13 + return .{ 14 + .userdata = @constCast(self), 15 + .drawFn = typeErasedDrawFn, 16 + }; 17 + } 18 + 19 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 20 + const self: *const Center = @ptrCast(@alignCast(ptr)); 21 + return self.draw(ctx); 22 + } 23 + 24 + /// Cannot have unbounded constraints 25 + pub fn draw(self: *const Center, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 26 + const child_ctx = ctx.withConstraints(.{ .width = 0, .height = 0 }, ctx.max); 27 + const max_size = ctx.max.size(); 28 + const child = try self.child.draw(child_ctx); 29 + 30 + const x = (max_size.width - child.size.width) / 2; 31 + const y = (max_size.height - child.size.height) / 2; 32 + 33 + const children = try ctx.arena.alloc(vxfw.SubSurface, 1); 34 + children[0] = .{ 35 + .origin = .{ .col = x, .row = y }, 36 + .z_index = 0, 37 + .surface = child, 38 + }; 39 + 40 + return .{ 41 + .size = max_size, 42 + .widget = self.widget(), 43 + .buffer = &.{}, 44 + .children = children, 45 + }; 46 + } 47 + 48 + test Center { 49 + const Text = @import("Text.zig"); 50 + // Will be height=1, width=3 51 + const text: Text = .{ .text = "abc" }; 52 + 53 + const center: Center = .{ .child = text.widget() }; 54 + 55 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 56 + defer arena.deinit(); 57 + vxfw.DrawContext.init(.unicode); 58 + 59 + { 60 + // Center expands to the max size. It must therefore have non-null max width and max height. 61 + // These values are asserted in draw 62 + const ctx: vxfw.DrawContext = .{ 63 + .arena = arena.allocator(), 64 + .min = .{}, 65 + .max = .{ .width = 10, .height = 10 }, 66 + .cell_size = .{ .width = 10, .height = 20 }, 67 + }; 68 + 69 + const surface = try center.draw(ctx); 70 + // Center does not produce any drawable cells 71 + try std.testing.expectEqual(0, surface.buffer.len); 72 + // Center has 1 child 73 + try std.testing.expectEqual(1, surface.children.len); 74 + // Center is the max size 75 + try std.testing.expectEqual(surface.size, ctx.max.size()); 76 + const child = surface.children[0]; 77 + // The child is 1x3 78 + try std.testing.expectEqual(3, child.surface.size.width); 79 + try std.testing.expectEqual(1, child.surface.size.height); 80 + // A centered 1x3 in 10x10 should be at origin 3, 4. The bias is toward the top left corner 81 + try std.testing.expectEqual(4, child.origin.row); 82 + try std.testing.expectEqual(3, child.origin.col); 83 + } 84 + { 85 + // Center expands to the max size. It must therefore have non-null max width and max height. 86 + // These values are asserted in draw 87 + const ctx: vxfw.DrawContext = .{ 88 + .arena = arena.allocator(), 89 + .min = .{}, 90 + .max = .{ .width = 5, .height = 3 }, 91 + .cell_size = .{ .width = 10, .height = 20 }, 92 + }; 93 + 94 + const surface = try center.draw(ctx); 95 + // Center does not produce any drawable cells 96 + try std.testing.expectEqual(0, surface.buffer.len); 97 + // Center has 1 child 98 + try std.testing.expectEqual(1, surface.children.len); 99 + // Center is the max size 100 + try std.testing.expectEqual(surface.size, ctx.max.size()); 101 + const child = surface.children[0]; 102 + // The child is 1x3 103 + try std.testing.expectEqual(3, child.surface.size.width); 104 + try std.testing.expectEqual(1, child.surface.size.height); 105 + // A centered 1x3 in 3x5 should be at origin 1, 1. This is a perfectly centered child 106 + try std.testing.expectEqual(1, child.origin.row); 107 + try std.testing.expectEqual(1, child.origin.col); 108 + } 109 + } 110 + 111 + test "refAllDecls" { 112 + std.testing.refAllDecls(@This()); 113 + }
+162
src/vxfw/FlexColumn.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const Allocator = std.mem.Allocator; 5 + 6 + const vxfw = @import("vxfw.zig"); 7 + 8 + const FlexColumn = @This(); 9 + 10 + children: []const vxfw.FlexItem, 11 + 12 + pub fn widget(self: *const FlexColumn) vxfw.Widget { 13 + return .{ 14 + .userdata = @constCast(self), 15 + .drawFn = typeErasedDrawFn, 16 + }; 17 + } 18 + 19 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 20 + const self: *const FlexColumn = @ptrCast(@alignCast(ptr)); 21 + return self.draw(ctx); 22 + } 23 + 24 + pub fn draw(self: *const FlexColumn, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 25 + std.debug.assert(ctx.max.height != null); 26 + std.debug.assert(ctx.max.width != null); 27 + if (self.children.len == 0) return vxfw.Surface.init(ctx.arena, self.widget(), ctx.min); 28 + 29 + // Store the inherent size of each widget 30 + const size_list = try ctx.arena.alloc(u16, self.children.len); 31 + 32 + var layout_arena = std.heap.ArenaAllocator.init(ctx.arena); 33 + 34 + const layout_ctx: vxfw.DrawContext = .{ 35 + .min = .{ .width = 0, .height = 0 }, 36 + .max = .{ .width = ctx.max.width, .height = null }, 37 + .arena = layout_arena.allocator(), 38 + .cell_size = ctx.cell_size, 39 + }; 40 + 41 + // Store the inherent size of each widget 42 + var first_pass_height: u16 = 0; 43 + var total_flex: u16 = 0; 44 + for (self.children, 0..) |child, i| { 45 + const surf = try child.widget.draw(layout_ctx); 46 + first_pass_height += surf.size.height; 47 + total_flex += child.flex; 48 + size_list[i] = surf.size.height; 49 + } 50 + 51 + // We are done with the layout arena 52 + layout_arena.deinit(); 53 + 54 + // make our children list 55 + var children: std.ArrayList(vxfw.SubSurface) = .empty; 56 + 57 + // Draw again, but with distributed heights 58 + var second_pass_height: u16 = 0; 59 + var max_width: u16 = 0; 60 + const remaining_space = ctx.max.height.? - first_pass_height; 61 + for (self.children, 1..) |child, i| { 62 + const inherent_height = size_list[i - 1]; 63 + const child_height = if (child.flex == 0) 64 + inherent_height 65 + else if (i == self.children.len) 66 + // If we are the last one, we just get the remainder 67 + ctx.max.height.? - second_pass_height 68 + else 69 + inherent_height + (remaining_space * child.flex) / total_flex; 70 + 71 + // Create a context for the child 72 + const child_ctx = ctx.withConstraints( 73 + .{ .width = 0, .height = child_height }, 74 + .{ .width = ctx.max.width.?, .height = child_height }, 75 + ); 76 + const surf = try child.widget.draw(child_ctx); 77 + 78 + try children.append(ctx.arena, .{ 79 + .origin = .{ .col = 0, .row = second_pass_height }, 80 + .surface = surf, 81 + .z_index = 0, 82 + }); 83 + max_width = @max(max_width, surf.size.width); 84 + second_pass_height += surf.size.height; 85 + } 86 + 87 + const size: vxfw.Size = .{ .width = max_width, .height = second_pass_height }; 88 + return .{ 89 + .size = size, 90 + .widget = self.widget(), 91 + .buffer = &.{}, 92 + .children = children.items, 93 + }; 94 + } 95 + 96 + test FlexColumn { 97 + // Create child widgets 98 + const Text = @import("Text.zig"); 99 + // Will be height=1, width=3 100 + const abc: Text = .{ .text = "abc" }; 101 + const def: Text = .{ .text = "def" }; 102 + const ghi: Text = .{ .text = "ghi" }; 103 + const jklmno: Text = .{ .text = "jkl\nmno" }; 104 + 105 + // Create the flex column 106 + const flex_column: FlexColumn = .{ 107 + .children = &.{ 108 + .{ .widget = abc.widget(), .flex = 0 }, // flex=0 means we are our inherent size 109 + .{ .widget = def.widget(), .flex = 1 }, 110 + .{ .widget = ghi.widget(), .flex = 1 }, 111 + .{ .widget = jklmno.widget(), .flex = 1 }, 112 + }, 113 + }; 114 + 115 + // Boiler plate draw context 116 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 117 + defer arena.deinit(); 118 + vxfw.DrawContext.init(.unicode); 119 + 120 + const flex_widget = flex_column.widget(); 121 + const ctx: vxfw.DrawContext = .{ 122 + .arena = arena.allocator(), 123 + .min = .{}, 124 + .max = .{ .width = 16, .height = 16 }, 125 + .cell_size = .{ .width = 10, .height = 20 }, 126 + }; 127 + 128 + const surface = try flex_widget.draw(ctx); 129 + // FlexColumn expands to max height and widest child 130 + try std.testing.expectEqual(16, surface.size.height); 131 + try std.testing.expectEqual(3, surface.size.width); 132 + // We have four children 133 + try std.testing.expectEqual(4, surface.children.len); 134 + 135 + // We will track the row we are on to confirm the origins 136 + var row: u16 = 0; 137 + // First child has flex=0, it should be it's inherent height 138 + try std.testing.expectEqual(1, surface.children[0].surface.size.height); 139 + try std.testing.expectEqual(row, surface.children[0].origin.row); 140 + // Add the child height each time 141 + row += surface.children[0].surface.size.height; 142 + // Let's do some math 143 + // - We have 4 children to fit into 16 rows. 3 children will be 1 row tall, one will be 2 rows 144 + // tall for a total height of 5 rows. 145 + // - The first child is 1 row and no flex. The rest of the height gets distributed evenly among 146 + // the remaining 3 children. The remainder height is 16 - 5 = 11, so each child should get 11 / 147 + // 3 = 3 extra rows, and the last will receive the remainder 148 + try std.testing.expectEqual(1 + 3, surface.children[1].surface.size.height); 149 + try std.testing.expectEqual(row, surface.children[1].origin.row); 150 + row += surface.children[1].surface.size.height; 151 + 152 + try std.testing.expectEqual(1 + 3, surface.children[2].surface.size.height); 153 + try std.testing.expectEqual(row, surface.children[2].origin.row); 154 + row += surface.children[2].surface.size.height; 155 + 156 + try std.testing.expectEqual(2 + 3 + 2, surface.children[3].surface.size.height); 157 + try std.testing.expectEqual(row, surface.children[3].origin.row); 158 + } 159 + 160 + test "refAllDecls" { 161 + std.testing.refAllDecls(@This()); 162 + }
+160
src/vxfw/FlexRow.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const vxfw = @import("vxfw.zig"); 5 + 6 + const Allocator = std.mem.Allocator; 7 + 8 + const FlexRow = @This(); 9 + 10 + children: []const vxfw.FlexItem, 11 + 12 + pub fn widget(self: *const FlexRow) vxfw.Widget { 13 + return .{ 14 + .userdata = @constCast(self), 15 + .drawFn = typeErasedDrawFn, 16 + }; 17 + } 18 + 19 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 20 + const self: *const FlexRow = @ptrCast(@alignCast(ptr)); 21 + return self.draw(ctx); 22 + } 23 + 24 + pub fn draw(self: *const FlexRow, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 25 + std.debug.assert(ctx.max.height != null); 26 + std.debug.assert(ctx.max.width != null); 27 + if (self.children.len == 0) return vxfw.Surface.init(ctx.arena, self.widget(), ctx.min); 28 + 29 + // Store the inherent size of each widget 30 + const size_list = try ctx.arena.alloc(u16, self.children.len); 31 + 32 + var layout_arena = std.heap.ArenaAllocator.init(ctx.arena); 33 + 34 + const layout_ctx: vxfw.DrawContext = .{ 35 + .min = .{ .width = 0, .height = 0 }, 36 + .max = .{ .width = null, .height = ctx.max.height }, 37 + .arena = layout_arena.allocator(), 38 + .cell_size = ctx.cell_size, 39 + }; 40 + 41 + var first_pass_width: u16 = 0; 42 + var total_flex: u16 = 0; 43 + for (self.children, 0..) |child, i| { 44 + if (child.flex == 0) { 45 + const surf = try child.widget.draw(layout_ctx); 46 + first_pass_width += surf.size.width; 47 + size_list[i] = surf.size.width; 48 + } 49 + total_flex += child.flex; 50 + } 51 + 52 + // We are done with the layout arena 53 + layout_arena.deinit(); 54 + 55 + // make our children list 56 + var children: std.ArrayList(vxfw.SubSurface) = .empty; 57 + 58 + // Draw again, but with distributed widths 59 + var second_pass_width: u16 = 0; 60 + var max_height: u16 = 0; 61 + const remaining_space = ctx.max.width.? -| first_pass_width; 62 + for (self.children, 0..) |child, i| { 63 + const child_width = if (child.flex == 0) 64 + size_list[i] 65 + else if (i == self.children.len - 1) 66 + // If we are the last one, we just get the remainder 67 + ctx.max.width.? -| second_pass_width 68 + else 69 + (remaining_space * child.flex) / total_flex; 70 + 71 + // Create a context for the child 72 + const child_ctx = ctx.withConstraints( 73 + .{ .width = child_width, .height = 0 }, 74 + .{ .width = child_width, .height = ctx.max.height.? }, 75 + ); 76 + const surf = try child.widget.draw(child_ctx); 77 + 78 + try children.append(ctx.arena, .{ 79 + .origin = .{ .col = second_pass_width, .row = 0 }, 80 + .surface = surf, 81 + .z_index = 0, 82 + }); 83 + max_height = @max(max_height, surf.size.height); 84 + second_pass_width += surf.size.width; 85 + } 86 + const size: vxfw.Size = .{ .width = second_pass_width, .height = max_height }; 87 + return .{ 88 + .size = size, 89 + .widget = self.widget(), 90 + .buffer = &.{}, 91 + .children = children.items, 92 + }; 93 + } 94 + 95 + test FlexRow { 96 + // Create child widgets 97 + const Text = @import("Text.zig"); 98 + // Will be height=1, width=3 99 + const abc: Text = .{ .text = "abc" }; 100 + const def: Text = .{ .text = "def" }; 101 + const ghi: Text = .{ .text = "ghi" }; 102 + const jklmno: Text = .{ .text = "jkl\nmno" }; 103 + 104 + // Create the flex row 105 + const flex_row: FlexRow = .{ 106 + .children = &.{ 107 + .{ .widget = abc.widget(), .flex = 0 }, // flex=0 means we are our inherent size 108 + .{ .widget = def.widget(), .flex = 1 }, 109 + .{ .widget = ghi.widget(), .flex = 1 }, 110 + .{ .widget = jklmno.widget(), .flex = 1 }, 111 + }, 112 + }; 113 + 114 + // Boiler plate draw context 115 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 116 + defer arena.deinit(); 117 + vxfw.DrawContext.init(.unicode); 118 + 119 + const flex_widget = flex_row.widget(); 120 + const ctx: vxfw.DrawContext = .{ 121 + .arena = arena.allocator(), 122 + .min = .{}, 123 + .max = .{ .width = 16, .height = 16 }, 124 + .cell_size = .{ .width = 10, .height = 20 }, 125 + }; 126 + 127 + const surface = try flex_widget.draw(ctx); 128 + // FlexRow expands to max width and tallest child 129 + try std.testing.expectEqual(16, surface.size.width); 130 + try std.testing.expectEqual(2, surface.size.height); 131 + // We have four children 132 + try std.testing.expectEqual(4, surface.children.len); 133 + 134 + // We will track the column we are on to confirm the origins 135 + var col: u16 = 0; 136 + // First child has flex=0, it should be it's inherent width 137 + try std.testing.expectEqual(3, surface.children[0].surface.size.width); 138 + try std.testing.expectEqual(col, surface.children[0].origin.col); 139 + // Add the child height each time 140 + col += surface.children[0].surface.size.width; 141 + // Let's do some math 142 + // - We have 4 children to fit into 16 cols. All children will be 3 wide for a total width of 12 143 + // - The first child is 3 cols and no flex. The rest of the width gets distributed evenly among 144 + // the remaining 3 children. The remainder width is 16 - 12 = 4, so each child should get 4 / 145 + // 3 = 1 extra cols, and the last will receive the remainder 146 + try std.testing.expectEqual(1 + 3, surface.children[1].surface.size.width); 147 + try std.testing.expectEqual(col, surface.children[1].origin.col); 148 + col += surface.children[1].surface.size.width; 149 + 150 + try std.testing.expectEqual(1 + 3, surface.children[2].surface.size.width); 151 + try std.testing.expectEqual(col, surface.children[2].origin.col); 152 + col += surface.children[2].surface.size.width; 153 + 154 + try std.testing.expectEqual(1 + 3 + 1, surface.children[3].surface.size.width); 155 + try std.testing.expectEqual(col, surface.children[3].origin.col); 156 + } 157 + 158 + test "refAllDecls" { 159 + std.testing.refAllDecls(@This()); 160 + }
+767
src/vxfw/ListView.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const assert = std.debug.assert; 5 + 6 + const Allocator = std.mem.Allocator; 7 + 8 + const vxfw = @import("vxfw.zig"); 9 + 10 + const ListView = @This(); 11 + 12 + pub const Builder = struct { 13 + userdata: *const anyopaque, 14 + buildFn: *const fn (*const anyopaque, idx: usize, cursor: usize) ?vxfw.Widget, 15 + 16 + inline fn itemAtIdx(self: Builder, idx: usize, cursor: usize) ?vxfw.Widget { 17 + return self.buildFn(self.userdata, idx, cursor); 18 + } 19 + }; 20 + 21 + pub const Source = union(enum) { 22 + slice: []const vxfw.Widget, 23 + builder: Builder, 24 + }; 25 + 26 + const Scroll = struct { 27 + /// Index of the first fully-in-view widget 28 + top: u32 = 0, 29 + /// Line offset within the top widget. 30 + offset: i17 = 0, 31 + /// Pending scroll amount 32 + pending_lines: i17 = 0, 33 + /// If there is more room to scroll down 34 + has_more: bool = true, 35 + /// The cursor must be in the viewport 36 + wants_cursor: bool = false, 37 + 38 + fn linesDown(self: *Scroll, n: u8) bool { 39 + if (!self.has_more) return false; 40 + self.pending_lines += n; 41 + return true; 42 + } 43 + 44 + fn linesUp(self: *Scroll, n: u8) bool { 45 + if (self.top == 0 and self.offset == 0) return false; 46 + self.pending_lines -= @intCast(n); 47 + return true; 48 + } 49 + }; 50 + 51 + const cursor_indicator: vaxis.Cell = .{ .char = .{ .grapheme = "โ–", .width = 1 } }; 52 + 53 + children: Source, 54 + cursor: u32 = 0, 55 + /// When true, the widget will draw a cursor next to the widget which has the cursor 56 + draw_cursor: bool = true, 57 + /// Lines to scroll for a mouse wheel 58 + wheel_scroll: u8 = 3, 59 + /// Set this if the exact item count is known. 60 + item_count: ?u32 = null, 61 + 62 + /// scroll position 63 + scroll: Scroll = .{}, 64 + 65 + pub fn widget(self: *const ListView) vxfw.Widget { 66 + return .{ 67 + .userdata = @constCast(self), 68 + .eventHandler = typeErasedEventHandler, 69 + .drawFn = typeErasedDrawFn, 70 + }; 71 + } 72 + 73 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 74 + const self: *ListView = @ptrCast(@alignCast(ptr)); 75 + return self.handleEvent(ctx, event); 76 + } 77 + 78 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 79 + const self: *ListView = @ptrCast(@alignCast(ptr)); 80 + return self.draw(ctx); 81 + } 82 + 83 + pub fn handleEvent(self: *ListView, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 84 + switch (event) { 85 + .mouse => |mouse| { 86 + if (mouse.button == .wheel_up) { 87 + if (self.scroll.linesUp(self.wheel_scroll)) 88 + ctx.consumeAndRedraw(); 89 + } 90 + if (mouse.button == .wheel_down) { 91 + if (self.scroll.linesDown(self.wheel_scroll)) 92 + ctx.consumeAndRedraw(); 93 + } 94 + }, 95 + .key_press => |key| { 96 + if (key.matches('j', .{}) or 97 + key.matches('n', .{ .ctrl = true }) or 98 + key.matches(vaxis.Key.down, .{})) 99 + { 100 + return self.nextItem(ctx); 101 + } 102 + if (key.matches('k', .{}) or 103 + key.matches('p', .{ .ctrl = true }) or 104 + key.matches(vaxis.Key.up, .{})) 105 + { 106 + return self.prevItem(ctx); 107 + } 108 + if (key.matches(vaxis.Key.escape, .{})) { 109 + self.ensureScroll(); 110 + return ctx.consumeAndRedraw(); 111 + } 112 + }, 113 + else => {}, 114 + } 115 + } 116 + 117 + pub fn draw(self: *ListView, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 118 + std.debug.assert(ctx.max.width != null); 119 + std.debug.assert(ctx.max.height != null); 120 + switch (self.children) { 121 + .slice => |slice| { 122 + self.item_count = @intCast(slice.len); 123 + const builder: SliceBuilder = .{ .slice = slice }; 124 + return self.drawBuilder(ctx, .{ .userdata = &builder, .buildFn = SliceBuilder.build }); 125 + }, 126 + .builder => |b| return self.drawBuilder(ctx, b), 127 + } 128 + } 129 + 130 + pub fn nextItem(self: *ListView, ctx: *vxfw.EventContext) void { 131 + // If we have a count, we can handle this directly 132 + if (self.item_count) |count| { 133 + if (self.cursor >= count -| 1) { 134 + return ctx.consumeEvent(); 135 + } 136 + self.cursor += 1; 137 + } else { 138 + switch (self.children) { 139 + .slice => |slice| { 140 + self.item_count = @intCast(slice.len); 141 + // If we are already at the end, don't do anything 142 + if (self.cursor == slice.len - 1) { 143 + return ctx.consumeEvent(); 144 + } 145 + // Advance the cursor 146 + self.cursor += 1; 147 + }, 148 + .builder => |builder| { 149 + // Save our current state 150 + const prev = self.cursor; 151 + // Advance the cursor 152 + self.cursor += 1; 153 + // Check the bounds, reversing until we get the last item 154 + while (builder.itemAtIdx(self.cursor, self.cursor) == null) { 155 + self.cursor -|= 1; 156 + } 157 + // If we didn't change state, we don't redraw 158 + if (self.cursor == prev) { 159 + return ctx.consumeEvent(); 160 + } 161 + }, 162 + } 163 + } 164 + // Reset scroll 165 + self.ensureScroll(); 166 + ctx.consumeAndRedraw(); 167 + } 168 + 169 + pub fn prevItem(self: *ListView, ctx: *vxfw.EventContext) void { 170 + if (self.cursor == 0) { 171 + return ctx.consumeEvent(); 172 + } 173 + 174 + if (self.item_count) |count| { 175 + // If for some reason our count changed, we handle it here 176 + self.cursor = @min(self.cursor - 1, count - 1); 177 + } else { 178 + switch (self.children) { 179 + .slice => |slice| { 180 + self.item_count = @intCast(slice.len); 181 + self.cursor = @min(self.cursor - 1, slice.len - 1); 182 + }, 183 + .builder => |builder| { 184 + // Save our current state 185 + const prev = self.cursor; 186 + // Decrement the cursor 187 + self.cursor -= 1; 188 + // Check the bounds, reversing until we get the last item 189 + while (builder.itemAtIdx(self.cursor, self.cursor) == null) { 190 + self.cursor -|= 1; 191 + } 192 + // If we didn't change state, we don't redraw 193 + if (self.cursor == prev) { 194 + return ctx.consumeEvent(); 195 + } 196 + }, 197 + } 198 + } 199 + 200 + // Reset scroll 201 + self.ensureScroll(); 202 + return ctx.consumeAndRedraw(); 203 + } 204 + 205 + // Only call when cursor state has changed, or we want to ensure the cursored item is in view 206 + pub fn ensureScroll(self: *ListView) void { 207 + if (self.cursor <= self.scroll.top) { 208 + self.scroll.top = @intCast(self.cursor); 209 + self.scroll.offset = 0; 210 + } else { 211 + self.scroll.wants_cursor = true; 212 + } 213 + } 214 + 215 + /// Inserts children until add_height is < 0 216 + fn insertChildren( 217 + self: *ListView, 218 + ctx: vxfw.DrawContext, 219 + builder: Builder, 220 + child_list: *std.ArrayList(vxfw.SubSurface), 221 + add_height: i17, 222 + ) Allocator.Error!void { 223 + assert(self.scroll.top > 0); 224 + self.scroll.top -= 1; 225 + var upheight = add_height; 226 + while (self.scroll.top >= 0) : (self.scroll.top -= 1) { 227 + // Get the child 228 + const child = builder.itemAtIdx(self.scroll.top, self.cursor) orelse break; 229 + 230 + const child_offset: u16 = if (self.draw_cursor) 2 else 0; 231 + const max_size = ctx.max.size(); 232 + 233 + // Set up constraints. We let the child be the entire height if it wants 234 + const child_ctx = ctx.withConstraints( 235 + .{ .width = max_size.width - child_offset, .height = 0 }, 236 + .{ .width = max_size.width - child_offset, .height = null }, 237 + ); 238 + 239 + // Draw the child 240 + const surf = try child.draw(child_ctx); 241 + 242 + // Accumulate the height. Traversing backward so do this before setting origin 243 + upheight -= surf.size.height; 244 + 245 + // Insert the child to the beginning of the list 246 + try child_list.insert(ctx.arena, 0, .{ 247 + .origin = .{ .col = if (self.draw_cursor) 2 else 0, .row = upheight }, 248 + .surface = surf, 249 + .z_index = 0, 250 + }); 251 + 252 + // Break if we went past the top edge, or are the top item 253 + if (upheight <= 0 or self.scroll.top == 0) break; 254 + } 255 + 256 + // Our new offset is the "upheight" 257 + self.scroll.offset = upheight; 258 + 259 + // Reset origins if we overshot and put the top item too low 260 + if (self.scroll.top == 0 and upheight > 0) { 261 + self.scroll.offset = 0; 262 + var row: i17 = 0; 263 + for (child_list.items) |*child| { 264 + child.origin.row = row; 265 + row += child.surface.size.height; 266 + } 267 + } 268 + // Our new offset is the "upheight" 269 + self.scroll.offset = upheight; 270 + } 271 + 272 + fn totalHeight(list: *const std.ArrayList(vxfw.SubSurface)) usize { 273 + var result: usize = 0; 274 + for (list.items) |child| { 275 + result += child.surface.size.height; 276 + } 277 + return result; 278 + } 279 + 280 + fn drawBuilder(self: *ListView, ctx: vxfw.DrawContext, builder: Builder) Allocator.Error!vxfw.Surface { 281 + defer self.scroll.wants_cursor = false; 282 + 283 + // Get the size. asserts neither constraint is null 284 + const max_size = ctx.max.size(); 285 + // Set up surface. 286 + var surface: vxfw.Surface = .{ 287 + .size = max_size, 288 + .widget = self.widget(), 289 + .buffer = &.{}, 290 + .children = &.{}, 291 + }; 292 + if (self.draw_cursor) { 293 + // If we are drawing the cursor, we need to allocate a buffer so that we obscure anything 294 + // underneath us 295 + surface.buffer = try vxfw.Surface.createBuffer(ctx.arena, max_size); 296 + } 297 + 298 + // Set state 299 + { 300 + // Assume we have more. We only know we don't after drawing 301 + self.scroll.has_more = true; 302 + } 303 + 304 + var child_list: std.ArrayList(vxfw.SubSurface) = .empty; 305 + 306 + // Accumulated height tracks how much height we have drawn. It's initial state is 307 + // (scroll.offset + scroll.pending_lines) lines _above_ the surface top edge. 308 + // Example: 309 + // 1. Scroll up 3 lines: 310 + // pending_lines = -3 311 + // offset = 0 312 + // accumulated_height = -(0 + -3) = 3; 313 + // Our first widget is placed at row 3, we will need to fill this in after the draw 314 + // 2. Scroll up 3 lines, with an offset of 4 315 + // pending_lines = -3 316 + // offset = 4 317 + // accumulated_height = -(4 + -3) = -1; 318 + // Our first widget is placed at row -1 319 + // 3. Scroll down 3 lines: 320 + // pending_lines = 3 321 + // offset = 0 322 + // accumulated_height = -(0 + 3) = -3; 323 + // Our first widget is placed at row -3. It's possible it consumes the entire widget. We 324 + // will check for this at the end and only include visible children 325 + var accumulated_height: i17 = -(self.scroll.offset + self.scroll.pending_lines); 326 + 327 + // We handled the pending scroll by assigning accumulated_height. Reset it's state 328 + self.scroll.pending_lines = 0; 329 + 330 + // Set the initial index for our downard loop. We do this here because we might modify 331 + // scroll.top before we traverse downward 332 + var i: usize = self.scroll.top; 333 + 334 + // If we are on the first item, and we have an upward scroll that consumed our offset, eg 335 + // accumulated_height > 0, we reset state here. We can't scroll up anymore so we set 336 + // accumulated_height to 0. 337 + if (accumulated_height > 0 and self.scroll.top == 0) { 338 + self.scroll.offset = 0; 339 + accumulated_height = 0; 340 + } 341 + 342 + // If we are offset downward, insert widgets to the front of the list before traversing downard 343 + if (accumulated_height > 0) { 344 + try self.insertChildren(ctx, builder, &child_list, accumulated_height); 345 + const last_child = child_list.items[child_list.items.len - 1]; 346 + accumulated_height = last_child.origin.row + last_child.surface.size.height; 347 + } 348 + 349 + const child_offset: u16 = if (self.draw_cursor) 2 else 0; 350 + 351 + while (builder.itemAtIdx(i, self.cursor)) |child| { 352 + // Defer the increment 353 + defer i += 1; 354 + 355 + // Set up constraints. We let the child be the entire height if it wants 356 + const child_ctx = ctx.withConstraints( 357 + .{ .width = max_size.width -| child_offset, .height = 0 }, 358 + .{ .width = max_size.width -| child_offset, .height = null }, 359 + ); 360 + 361 + // Draw the child 362 + const surf = try child.draw(child_ctx); 363 + 364 + // Add the child surface to our list. It's offset from parent is the accumulated height 365 + try child_list.append(ctx.arena, .{ 366 + .origin = .{ .col = child_offset, .row = accumulated_height }, 367 + .surface = surf, 368 + .z_index = 0, 369 + }); 370 + 371 + // Accumulate the height 372 + accumulated_height += surf.size.height; 373 + 374 + if (self.scroll.wants_cursor and i < self.cursor) 375 + continue // continue if we want the cursor and haven't gotten there yet 376 + else if (accumulated_height >= max_size.height) 377 + break; // Break if we drew enough 378 + } else { 379 + // This branch runs if we ran out of items. Set our state accordingly 380 + self.scroll.has_more = false; 381 + } 382 + 383 + var total_height: usize = totalHeight(&child_list); 384 + 385 + // If we reached the bottom, don't have enough height to fill the screen, and have room to add 386 + // more, then we add more until out of items or filled the space. This can happen on a resize 387 + if (!self.scroll.has_more and total_height < max_size.height and self.scroll.top > 0) { 388 + try self.insertChildren(ctx, builder, &child_list, @intCast(max_size.height - total_height)); 389 + // Set the new total height 390 + total_height = totalHeight(&child_list); 391 + } 392 + 393 + if (self.draw_cursor and self.cursor >= self.scroll.top) blk: { 394 + // The index of the cursored widget in our child_list 395 + const cursored_idx: u32 = self.cursor - self.scroll.top; 396 + // Nothing to draw if our cursor is below our viewport 397 + if (cursored_idx >= child_list.items.len) break :blk; 398 + 399 + const sub = try ctx.arena.alloc(vxfw.SubSurface, 1); 400 + const child = child_list.items[cursored_idx]; 401 + sub[0] = .{ 402 + .origin = .{ .col = child_offset, .row = 0 }, 403 + .surface = child.surface, 404 + .z_index = 0, 405 + }; 406 + const size = child.surface.size; 407 + const cursor_surf = try vxfw.Surface.initWithChildren( 408 + ctx.arena, 409 + self.widget(), 410 + .{ .width = child_offset + size.width, .height = size.height }, 411 + sub, 412 + ); 413 + for (0..cursor_surf.size.height) |row| { 414 + cursor_surf.writeCell(0, @intCast(row), cursor_indicator); 415 + } 416 + child_list.items[cursored_idx] = .{ 417 + .origin = .{ .col = 0, .row = child.origin.row }, 418 + .surface = cursor_surf, 419 + .z_index = 0, 420 + }; 421 + } 422 + 423 + // If we want the cursor, we check that the cursored widget is fully in view. If it is too 424 + // large, we position it so that it is the top item with a 0 offset 425 + if (self.scroll.wants_cursor) { 426 + const cursored_idx: u32 = self.cursor - self.scroll.top; 427 + const sub = child_list.items[cursored_idx]; 428 + // The bottom row of the cursored widget 429 + const bottom = sub.origin.row + sub.surface.size.height; 430 + if (bottom > max_size.height) { 431 + // Adjust the origin by the difference 432 + // anchor bottom 433 + var origin: i17 = max_size.height; 434 + var idx: usize = cursored_idx + 1; 435 + while (idx > 0) : (idx -= 1) { 436 + var child = child_list.items[idx - 1]; 437 + origin -= child.surface.size.height; 438 + child.origin.row = origin; 439 + child_list.items[idx - 1] = child; 440 + } 441 + } else if (sub.surface.size.height >= max_size.height) { 442 + // TODO: handle when the child is larger than our height. 443 + // We need to change the max constraint to be optional sizes so that we can support 444 + // unbounded drawing in scrollable areas 445 + self.scroll.top = self.cursor; 446 + self.scroll.offset = 0; 447 + child_list.deinit(ctx.arena); 448 + try child_list.append(ctx.arena, .{ 449 + .origin = .{ .col = 0, .row = 0 }, 450 + .surface = sub.surface, 451 + .z_index = 0, 452 + }); 453 + total_height = sub.surface.size.height; 454 + } 455 + } 456 + 457 + // If we reached the bottom, we need to reset origins 458 + if (!self.scroll.has_more and total_height < max_size.height) { 459 + // anchor top 460 + assert(self.scroll.top == 0); 461 + self.scroll.offset = 0; 462 + var origin: i17 = 0; 463 + for (0..child_list.items.len) |idx| { 464 + var child = child_list.items[idx]; 465 + child.origin.row = origin; 466 + origin += child.surface.size.height; 467 + child_list.items[idx] = child; 468 + } 469 + } else if (!self.scroll.has_more) { 470 + // anchor bottom 471 + var origin: i17 = max_size.height; 472 + var idx: usize = child_list.items.len; 473 + while (idx > 0) : (idx -= 1) { 474 + var child = child_list.items[idx - 1]; 475 + origin -= child.surface.size.height; 476 + child.origin.row = origin; 477 + child_list.items[idx - 1] = child; 478 + } 479 + } 480 + 481 + var start: usize = 0; 482 + var end: usize = child_list.items.len; 483 + 484 + for (child_list.items, 0..) |child, idx| { 485 + if (child.origin.row <= 0 and child.origin.row + child.surface.size.height > 0) { 486 + start = idx; 487 + self.scroll.offset = -child.origin.row; 488 + self.scroll.top += @intCast(idx); 489 + } 490 + if (child.origin.row > max_size.height) { 491 + end = idx; 492 + break; 493 + } 494 + } 495 + 496 + surface.children = child_list.items[start..end]; 497 + return surface; 498 + } 499 + 500 + const SliceBuilder = struct { 501 + slice: []const vxfw.Widget, 502 + 503 + fn build(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget { 504 + const self: *const SliceBuilder = @ptrCast(@alignCast(ptr)); 505 + if (idx >= self.slice.len) return null; 506 + return self.slice[idx]; 507 + } 508 + }; 509 + 510 + test ListView { 511 + // Create child widgets 512 + const Text = @import("Text.zig"); 513 + const abc: Text = .{ .text = "abc\n def\n ghi" }; 514 + const def: Text = .{ .text = "def" }; 515 + const ghi: Text = .{ .text = "ghi" }; 516 + const jklmno: Text = .{ .text = "jkl\n mno" }; 517 + // 0 |*abc 518 + // 1 | def 519 + // 2 | ghi 520 + // 3 | def 521 + // 4 ghi 522 + // 5 jkl 523 + // 6 mno 524 + 525 + // Create the list view 526 + const list_view: ListView = .{ 527 + .wheel_scroll = 1, // Set wheel scroll to one 528 + .children = .{ .slice = &.{ 529 + abc.widget(), 530 + def.widget(), 531 + ghi.widget(), 532 + jklmno.widget(), 533 + } }, 534 + }; 535 + 536 + // Boiler plate draw context 537 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 538 + defer arena.deinit(); 539 + vxfw.DrawContext.init(.unicode); 540 + 541 + const list_widget = list_view.widget(); 542 + const draw_ctx: vxfw.DrawContext = .{ 543 + .arena = arena.allocator(), 544 + .min = .{}, 545 + .max = .{ .width = 16, .height = 4 }, 546 + .cell_size = .{ .width = 10, .height = 20 }, 547 + }; 548 + 549 + var surface = try list_widget.draw(draw_ctx); 550 + // ListView expands to max height and max width 551 + try std.testing.expectEqual(4, surface.size.height); 552 + try std.testing.expectEqual(16, surface.size.width); 553 + // We have 2 children, because only visible children appear as a surface 554 + try std.testing.expectEqual(2, surface.children.len); 555 + 556 + var mouse_event: vaxis.Mouse = .{ 557 + .col = 0, 558 + .row = 0, 559 + .button = .wheel_up, 560 + .mods = .{}, 561 + .type = .press, 562 + }; 563 + // Event handlers need a context 564 + var ctx: vxfw.EventContext = .{ 565 + .alloc = std.testing.allocator, 566 + .cmds = .empty, 567 + }; 568 + defer ctx.cmds.deinit(ctx.alloc); 569 + 570 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 571 + // Wheel up doesn't adjust the scroll 572 + try std.testing.expectEqual(0, list_view.scroll.top); 573 + try std.testing.expectEqual(0, list_view.scroll.offset); 574 + 575 + // Send a wheel down 576 + mouse_event.button = .wheel_down; 577 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 578 + // We have to draw the widget for scrolls to take effect 579 + surface = try list_widget.draw(draw_ctx); 580 + // 0 *abc 581 + // 1 | def 582 + // 2 | ghi 583 + // 3 | def 584 + // 4 | ghi 585 + // 5 jkl 586 + // 6 mno 587 + // We should have gone down 1 line, and not changed our top widget 588 + try std.testing.expectEqual(0, list_view.scroll.top); 589 + try std.testing.expectEqual(1, list_view.scroll.offset); 590 + // One more widget has scrolled into view 591 + try std.testing.expectEqual(3, surface.children.len); 592 + 593 + // Scroll down two more lines 594 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 595 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 596 + surface = try list_widget.draw(draw_ctx); 597 + // 0 *abc 598 + // 1 def 599 + // 2 ghi 600 + // 3 | def 601 + // 4 | ghi 602 + // 5 | jkl 603 + // 6 | mno 604 + // We should have gone down 2 lines, which scrolls our top widget out of view 605 + try std.testing.expectEqual(1, list_view.scroll.top); 606 + try std.testing.expectEqual(0, list_view.scroll.offset); 607 + try std.testing.expectEqual(3, surface.children.len); 608 + 609 + // Scroll down again. We shouldn't advance anymore since we are at the bottom 610 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 611 + surface = try list_widget.draw(draw_ctx); 612 + try std.testing.expectEqual(1, list_view.scroll.top); 613 + try std.testing.expectEqual(0, list_view.scroll.offset); 614 + try std.testing.expectEqual(3, surface.children.len); 615 + 616 + // Mouse wheel events don't change the cursor position. Let's press "escape" to reset the 617 + // viewport and bring our cursor into view 618 + try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.escape } }); 619 + surface = try list_widget.draw(draw_ctx); 620 + try std.testing.expectEqual(0, list_view.scroll.top); 621 + try std.testing.expectEqual(0, list_view.scroll.offset); 622 + try std.testing.expectEqual(2, surface.children.len); 623 + 624 + // Cursor down 625 + try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 626 + surface = try list_widget.draw(draw_ctx); 627 + // 0 | abc 628 + // 1 | def 629 + // 2 | ghi 630 + // 3 |*def 631 + // 4 ghi 632 + // 5 jkl 633 + // 6 mno 634 + // Scroll doesn't change 635 + try std.testing.expectEqual(0, list_view.scroll.top); 636 + try std.testing.expectEqual(0, list_view.scroll.offset); 637 + try std.testing.expectEqual(2, surface.children.len); 638 + try std.testing.expectEqual(1, list_view.cursor); 639 + 640 + // Cursor down 641 + try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 642 + surface = try list_widget.draw(draw_ctx); 643 + // 0 abc 644 + // 1 | def 645 + // 2 | ghi 646 + // 3 | def 647 + // 4 |*ghi 648 + // 5 jkl 649 + // 6 mno 650 + // Scroll advances one row 651 + try std.testing.expectEqual(0, list_view.scroll.top); 652 + try std.testing.expectEqual(1, list_view.scroll.offset); 653 + try std.testing.expectEqual(3, surface.children.len); 654 + try std.testing.expectEqual(2, list_view.cursor); 655 + 656 + // Cursor down 657 + try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 658 + surface = try list_widget.draw(draw_ctx); 659 + // 0 abc 660 + // 1 def 661 + // 2 ghi 662 + // 3 | def 663 + // 4 | ghi 664 + // 5 |*jkl 665 + // 6 | mno 666 + // We are cursored onto the last item. The entire last item comes into view, effectively 667 + // advancing the scroll by 2 668 + try std.testing.expectEqual(1, list_view.scroll.top); 669 + try std.testing.expectEqual(0, list_view.scroll.offset); 670 + try std.testing.expectEqual(3, surface.children.len); 671 + try std.testing.expectEqual(3, list_view.cursor); 672 + } 673 + 674 + // @reykjalin found an issue on mac with ghostty where the scroll up and scroll down were uneven. 675 + // Ghostty has high precision scrolling and sends a lot of wheel events for each tick 676 + test "ListView: uneven scroll" { 677 + // Create child widgets 678 + const Text = @import("Text.zig"); 679 + const zero: Text = .{ .text = "0" }; 680 + const one: Text = .{ .text = "1" }; 681 + const two: Text = .{ .text = "2" }; 682 + const three: Text = .{ .text = "3" }; 683 + const four: Text = .{ .text = "4" }; 684 + const five: Text = .{ .text = "5" }; 685 + const six: Text = .{ .text = "6" }; 686 + // 0 | 687 + // 1 | 688 + // 2 | 689 + // 3 | 690 + // 4 691 + // 5 692 + // 6 693 + 694 + // Create the list view 695 + const list_view: ListView = .{ 696 + .wheel_scroll = 1, // Set wheel scroll to one 697 + .children = .{ .slice = &.{ 698 + zero.widget(), 699 + one.widget(), 700 + two.widget(), 701 + three.widget(), 702 + four.widget(), 703 + five.widget(), 704 + six.widget(), 705 + } }, 706 + }; 707 + 708 + // Boiler plate draw context 709 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 710 + defer arena.deinit(); 711 + vxfw.DrawContext.init(.unicode); 712 + 713 + const list_widget = list_view.widget(); 714 + const draw_ctx: vxfw.DrawContext = .{ 715 + .arena = arena.allocator(), 716 + .min = .{}, 717 + .max = .{ .width = 16, .height = 4 }, 718 + .cell_size = .{ .width = 10, .height = 20 }, 719 + }; 720 + 721 + var surface = try list_widget.draw(draw_ctx); 722 + 723 + var mouse_event: vaxis.Mouse = .{ 724 + .col = 0, 725 + .row = 0, 726 + .button = .wheel_up, 727 + .mods = .{}, 728 + .type = .press, 729 + }; 730 + // Event handlers need a context 731 + var ctx: vxfw.EventContext = .{ 732 + .alloc = std.testing.allocator, 733 + .cmds = .empty, 734 + }; 735 + defer ctx.cmds.deinit(ctx.alloc); 736 + 737 + // Send a wheel down x 3 738 + mouse_event.button = .wheel_down; 739 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 740 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 741 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 742 + // We have to draw the widget for scrolls to take effect 743 + surface = try list_widget.draw(draw_ctx); 744 + // 0 745 + // 1 746 + // 2 747 + // 3 | 748 + // 4 | 749 + // 5 | 750 + // 6 | 751 + try std.testing.expectEqual(3, list_view.scroll.top); 752 + try std.testing.expectEqual(0, list_view.scroll.offset); 753 + try std.testing.expectEqual(4, surface.children.len); 754 + 755 + // Now wheel_up two times should move us two lines up 756 + mouse_event.button = .wheel_up; 757 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 758 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 759 + surface = try list_widget.draw(draw_ctx); 760 + try std.testing.expectEqual(1, list_view.scroll.top); 761 + try std.testing.expectEqual(0, list_view.scroll.offset); 762 + try std.testing.expectEqual(4, surface.children.len); 763 + } 764 + 765 + test "refAllDecls" { 766 + std.testing.refAllDecls(@This()); 767 + }
+142
src/vxfw/Padding.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const Allocator = std.mem.Allocator; 5 + 6 + const vxfw = @import("vxfw.zig"); 7 + 8 + const Padding = @This(); 9 + const PadValues = struct { 10 + left: u16 = 0, 11 + right: u16 = 0, 12 + top: u16 = 0, 13 + bottom: u16 = 0, 14 + }; 15 + 16 + child: vxfw.Widget, 17 + padding: PadValues = .{}, 18 + 19 + /// Vertical padding will be divided by 2 to approximate equal padding 20 + pub fn all(padding: u16) PadValues { 21 + return .{ 22 + .left = padding, 23 + .right = padding, 24 + .top = padding / 2, 25 + .bottom = padding / 2, 26 + }; 27 + } 28 + 29 + pub fn horizontal(padding: u16) PadValues { 30 + return .{ 31 + .left = padding, 32 + .right = padding, 33 + }; 34 + } 35 + 36 + pub fn vertical(padding: u16) PadValues { 37 + return .{ 38 + .top = padding, 39 + .bottom = padding, 40 + }; 41 + } 42 + 43 + pub fn widget(self: *const Padding) vxfw.Widget { 44 + return .{ 45 + .userdata = @constCast(self), 46 + .drawFn = typeErasedDrawFn, 47 + }; 48 + } 49 + 50 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 51 + const self: *const Padding = @ptrCast(@alignCast(ptr)); 52 + return self.draw(ctx); 53 + } 54 + 55 + pub fn draw(self: *const Padding, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 56 + const pad = self.padding; 57 + if (pad.left > 0 or pad.right > 0) 58 + std.debug.assert(ctx.max.width != null); 59 + if (pad.top > 0 or pad.bottom > 0) 60 + std.debug.assert(ctx.max.height != null); 61 + const inner_min: vxfw.Size = .{ 62 + .width = ctx.min.width -| (pad.right + pad.left), 63 + .height = ctx.min.height -| (pad.top + pad.bottom), 64 + }; 65 + 66 + const max_width: ?u16 = if (ctx.max.width) |max| 67 + max -| (pad.right + pad.left) 68 + else 69 + null; 70 + const max_height: ?u16 = if (ctx.max.height) |max| 71 + max -| (pad.top + pad.bottom) 72 + else 73 + null; 74 + 75 + const inner_max: vxfw.MaxSize = .{ 76 + .width = max_width, 77 + .height = max_height, 78 + }; 79 + 80 + const child_surface = try self.child.draw(ctx.withConstraints(inner_min, inner_max)); 81 + 82 + const children = try ctx.arena.alloc(vxfw.SubSurface, 1); 83 + children[0] = .{ 84 + .surface = child_surface, 85 + .z_index = 0, 86 + .origin = .{ .row = pad.top, .col = pad.left }, 87 + }; 88 + 89 + const size: vxfw.Size = .{ 90 + .width = child_surface.size.width + (pad.right + pad.left), 91 + .height = child_surface.size.height + (pad.top + pad.bottom), 92 + }; 93 + 94 + // Create the padding surface 95 + return .{ 96 + .size = size, 97 + .widget = self.widget(), 98 + .buffer = &.{}, 99 + .children = children, 100 + }; 101 + } 102 + 103 + test Padding { 104 + const Text = @import("Text.zig"); 105 + // Will be height=1, width=3 106 + const text: Text = .{ .text = "abc" }; 107 + 108 + const padding: Padding = .{ 109 + .child = text.widget(), 110 + .padding = horizontal(1), 111 + }; 112 + 113 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 114 + defer arena.deinit(); 115 + vxfw.DrawContext.init(.unicode); 116 + 117 + // Center expands to the max size. It must therefore have non-null max width and max height. 118 + // These values are asserted in draw 119 + const ctx: vxfw.DrawContext = .{ 120 + .arena = arena.allocator(), 121 + .min = .{}, 122 + .max = .{ .width = 10, .height = 10 }, 123 + .cell_size = .{ .width = 10, .height = 20 }, 124 + }; 125 + 126 + const pad_widget = padding.widget(); 127 + 128 + const surface = try pad_widget.draw(ctx); 129 + // Padding does not produce any drawable cells 130 + try std.testing.expectEqual(0, surface.buffer.len); 131 + // Padding has 1 child 132 + try std.testing.expectEqual(1, surface.children.len); 133 + const child = surface.children[0]; 134 + // Padding is the child size + padding 135 + try std.testing.expectEqual(child.surface.size.width + 2, surface.size.width); 136 + try std.testing.expectEqual(0, child.origin.row); 137 + try std.testing.expectEqual(1, child.origin.col); 138 + } 139 + 140 + test "refAllDecls" { 141 + std.testing.refAllDecls(@This()); 142 + }
+425
src/vxfw/RichText.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const vxfw = @import("vxfw.zig"); 5 + 6 + const Allocator = std.mem.Allocator; 7 + 8 + const RichText = @This(); 9 + 10 + pub const TextSpan = vaxis.Segment; 11 + 12 + text: []const TextSpan, 13 + text_align: enum { left, center, right } = .left, 14 + base_style: vaxis.Style = .{}, 15 + softwrap: bool = true, 16 + overflow: enum { ellipsis, clip } = .ellipsis, 17 + width_basis: enum { parent, longest_line } = .longest_line, 18 + 19 + pub fn widget(self: *const RichText) vxfw.Widget { 20 + return .{ 21 + .userdata = @constCast(self), 22 + .drawFn = typeErasedDrawFn, 23 + }; 24 + } 25 + 26 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 27 + const self: *const RichText = @ptrCast(@alignCast(ptr)); 28 + return self.draw(ctx); 29 + } 30 + 31 + pub fn draw(self: *const RichText, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 32 + if (ctx.max.width != null and ctx.max.width.? == 0) { 33 + return .{ 34 + .size = ctx.min, 35 + .widget = self.widget(), 36 + .buffer = &.{}, 37 + .children = &.{}, 38 + }; 39 + } 40 + var iter = try SoftwrapIterator.init(self.text, ctx); 41 + const container_size = self.findContainerSize(&iter); 42 + 43 + // Create a surface of target width and max height. We'll trim the result after drawing 44 + const surface = try vxfw.Surface.init( 45 + ctx.arena, 46 + self.widget(), 47 + container_size, 48 + ); 49 + const base: vaxis.Cell = .{ .style = self.base_style }; 50 + @memset(surface.buffer, base); 51 + 52 + var row: u16 = 0; 53 + if (self.softwrap) { 54 + while (iter.next()) |line| { 55 + if (ctx.max.outsideHeight(row)) break; 56 + defer row += 1; 57 + var col: u16 = switch (self.text_align) { 58 + .left => 0, 59 + .center => (container_size.width - line.width) / 2, 60 + .right => container_size.width - line.width, 61 + }; 62 + for (line.cells) |cell| { 63 + surface.writeCell(col, row, cell); 64 + col += cell.char.width; 65 + } 66 + } 67 + } else { 68 + while (iter.nextHardBreak()) |line| { 69 + if (ctx.max.outsideHeight(row)) break; 70 + const line_width = blk: { 71 + var w: u16 = 0; 72 + for (line) |cell| { 73 + w +|= cell.char.width; 74 + } 75 + break :blk w; 76 + }; 77 + defer row += 1; 78 + var col: u16 = switch (self.text_align) { 79 + .left => 0, 80 + .center => (container_size.width -| line_width) / 2, 81 + .right => container_size.width -| line_width, 82 + }; 83 + for (line) |cell| { 84 + if (col + cell.char.width >= container_size.width and 85 + line_width > container_size.width and 86 + self.overflow == .ellipsis) 87 + { 88 + surface.writeCell(col, row, .{ 89 + .char = .{ .grapheme = "โ€ฆ", .width = 1 }, 90 + .style = cell.style, 91 + }); 92 + col = container_size.width; 93 + continue; 94 + } else { 95 + surface.writeCell(col, row, cell); 96 + col += @intCast(cell.char.width); 97 + } 98 + } 99 + } 100 + } 101 + return surface.trimHeight(@max(row, ctx.min.height)); 102 + } 103 + 104 + /// Finds the widest line within the viewable portion of ctx 105 + fn findContainerSize(self: RichText, iter: *SoftwrapIterator) vxfw.Size { 106 + defer iter.reset(); 107 + var row: u16 = 0; 108 + var max_width: u16 = iter.ctx.min.width; 109 + if (self.softwrap) { 110 + while (iter.next()) |line| { 111 + if (iter.ctx.max.outsideHeight(row)) break; 112 + defer row += 1; 113 + max_width = @max(max_width, line.width); 114 + } 115 + } else { 116 + while (iter.nextHardBreak()) |line| { 117 + if (iter.ctx.max.outsideHeight(row)) break; 118 + defer row += 1; 119 + var w: u16 = 0; 120 + for (line) |cell| { 121 + w +|= cell.char.width; 122 + } 123 + max_width = @max(max_width, w); 124 + } 125 + } 126 + const result_width = switch (self.width_basis) { 127 + .longest_line => blk: { 128 + if (iter.ctx.max.width) |max| 129 + break :blk @min(max, max_width) 130 + else 131 + break :blk max_width; 132 + }, 133 + .parent => blk: { 134 + std.debug.assert(iter.ctx.max.width != null); 135 + break :blk iter.ctx.max.width.?; 136 + }, 137 + }; 138 + return .{ .width = result_width, .height = @max(row, iter.ctx.min.height) }; 139 + } 140 + 141 + pub const SoftwrapIterator = struct { 142 + arena: std.heap.ArenaAllocator, 143 + ctx: vxfw.DrawContext, 144 + text: []const vaxis.Cell, 145 + line: []const vaxis.Cell, 146 + index: usize = 0, 147 + // Index of the hard iterator 148 + hard_index: usize = 0, 149 + 150 + const soft_breaks = " \t"; 151 + 152 + pub const Line = struct { 153 + width: u16, 154 + cells: []const vaxis.Cell, 155 + }; 156 + 157 + fn init(spans: []const TextSpan, ctx: vxfw.DrawContext) Allocator.Error!SoftwrapIterator { 158 + // Estimate the number of cells we need 159 + var len: usize = 0; 160 + for (spans) |span| { 161 + len += span.text.len; 162 + } 163 + var arena = std.heap.ArenaAllocator.init(ctx.arena); 164 + const alloc = arena.allocator(); 165 + var list: std.ArrayList(vaxis.Cell) = try .initCapacity(alloc, len); 166 + 167 + for (spans) |span| { 168 + var iter = ctx.graphemeIterator(span.text); 169 + while (iter.next()) |grapheme| { 170 + const char = grapheme.bytes(span.text); 171 + if (std.mem.eql(u8, char, "\t")) { 172 + const cell: vaxis.Cell = .{ 173 + .char = .{ .grapheme = " ", .width = 1 }, 174 + .style = span.style, 175 + .link = span.link, 176 + }; 177 + for (0..8) |_| { 178 + try list.append(alloc, cell); 179 + } 180 + continue; 181 + } 182 + const width = ctx.stringWidth(char); 183 + const cell: vaxis.Cell = .{ 184 + .char = .{ .grapheme = char, .width = @intCast(width) }, 185 + .style = span.style, 186 + .link = span.link, 187 + }; 188 + try list.append(alloc, cell); 189 + } 190 + } 191 + return .{ 192 + .arena = arena, 193 + .ctx = ctx, 194 + .text = list.items, 195 + .line = &.{}, 196 + }; 197 + } 198 + 199 + fn reset(self: *SoftwrapIterator) void { 200 + self.index = 0; 201 + self.hard_index = 0; 202 + self.line = &.{}; 203 + } 204 + 205 + fn deinit(self: *SoftwrapIterator) void { 206 + self.arena.deinit(); 207 + } 208 + 209 + fn nextHardBreak(self: *SoftwrapIterator) ?[]const vaxis.Cell { 210 + if (self.hard_index >= self.text.len) return null; 211 + const start = self.hard_index; 212 + var saw_cr: bool = false; 213 + while (self.hard_index < self.text.len) : (self.hard_index += 1) { 214 + const cell = self.text[self.hard_index]; 215 + if (std.mem.eql(u8, cell.char.grapheme, "\r")) { 216 + saw_cr = true; 217 + } 218 + if (std.mem.eql(u8, cell.char.grapheme, "\n")) { 219 + self.hard_index += 1; 220 + if (saw_cr) { 221 + return self.text[start .. self.hard_index - 2]; 222 + } 223 + return self.text[start .. self.hard_index - 1]; 224 + } 225 + if (saw_cr) { 226 + // back up one 227 + self.hard_index -= 1; 228 + return self.text[start .. self.hard_index - 1]; 229 + } 230 + } else return self.text[start..]; 231 + } 232 + 233 + fn trimWSPRight(text: []const vaxis.Cell) []const vaxis.Cell { 234 + // trim linear whitespace 235 + var i: usize = text.len; 236 + while (i > 0) : (i -= 1) { 237 + if (std.mem.eql(u8, text[i - 1].char.grapheme, " ") or 238 + std.mem.eql(u8, text[i - 1].char.grapheme, "\t")) 239 + { 240 + continue; 241 + } 242 + break; 243 + } 244 + return text[0..i]; 245 + } 246 + 247 + fn trimWSPLeft(text: []const vaxis.Cell) []const vaxis.Cell { 248 + // trim linear whitespace 249 + var i: usize = 0; 250 + while (i < text.len) : (i += 1) { 251 + if (std.mem.eql(u8, text[i].char.grapheme, " ") or 252 + std.mem.eql(u8, text[i].char.grapheme, "\t")) 253 + { 254 + continue; 255 + } 256 + break; 257 + } 258 + return text[i..]; 259 + } 260 + 261 + fn next(self: *SoftwrapIterator) ?Line { 262 + // Advance the hard iterator 263 + if (self.index == self.line.len) { 264 + self.line = self.nextHardBreak() orelse return null; 265 + // trim linear whitespace 266 + self.line = trimWSPRight(self.line); 267 + self.index = 0; 268 + } 269 + 270 + const max_width = self.ctx.max.width orelse { 271 + var width: u16 = 0; 272 + for (self.line) |cell| { 273 + width += cell.char.width; 274 + } 275 + self.index = self.line.len; 276 + return .{ 277 + .width = width, 278 + .cells = self.line, 279 + }; 280 + }; 281 + 282 + const start = self.index; 283 + var cur_width: u16 = 0; 284 + while (self.index < self.line.len) { 285 + // Find the width from current position to next word break 286 + const idx = self.nextWrap(); 287 + const word = self.line[self.index..idx]; 288 + const next_width = blk: { 289 + var w: usize = 0; 290 + for (word) |ch| { 291 + w += ch.char.width; 292 + } 293 + break :blk w; 294 + }; 295 + 296 + if (cur_width + next_width > max_width) { 297 + // Trim the word to see if it can fit on a line by itself 298 + const trimmed = trimWSPLeft(word); 299 + // New width is the previous width minus the number of cells we trimmed because we 300 + // are only trimming cells that would have been 1 wide (' ' and '\t' both measure as 301 + // 1 wide) 302 + const trimmed_width = next_width -| (word.len - trimmed.len); 303 + if (trimmed_width > max_width) { 304 + // Won't fit on line by itself, so fit as much on this line as we can 305 + for (word) |cell| { 306 + if (cur_width + cell.char.width > max_width) { 307 + const end = self.index; 308 + return .{ .width = cur_width, .cells = self.line[start..end] }; 309 + } 310 + cur_width += @intCast(cell.char.width); 311 + self.index += 1; 312 + } 313 + } 314 + const end = self.index; 315 + // We are softwrapping, advance index to the start of the next word. This is equal 316 + // to the difference in our word length and trimmed word length 317 + self.index += (word.len - trimmed.len); 318 + return .{ .width = cur_width, .cells = self.line[start..end] }; 319 + } 320 + 321 + self.index = idx; 322 + cur_width += @intCast(next_width); 323 + } 324 + return .{ .width = cur_width, .cells = self.line[start..] }; 325 + } 326 + 327 + fn nextWrap(self: *SoftwrapIterator) usize { 328 + var i: usize = self.index; 329 + 330 + // Find the first non-whitespace character 331 + while (i < self.line.len) : (i += 1) { 332 + if (std.mem.eql(u8, self.line[i].char.grapheme, " ") or 333 + std.mem.eql(u8, self.line[i].char.grapheme, "\t")) 334 + { 335 + continue; 336 + } 337 + break; 338 + } 339 + 340 + // Now find the first whitespace 341 + while (i < self.line.len) : (i += 1) { 342 + if (std.mem.eql(u8, self.line[i].char.grapheme, " ") or 343 + std.mem.eql(u8, self.line[i].char.grapheme, "\t")) 344 + { 345 + return i; 346 + } 347 + continue; 348 + } 349 + 350 + return self.line.len; 351 + } 352 + }; 353 + 354 + test RichText { 355 + var rich_text: RichText = .{ 356 + .text = &.{ 357 + .{ .text = "Hello, " }, 358 + .{ .text = "World", .style = .{ .bold = true } }, 359 + }, 360 + }; 361 + 362 + const rich_widget = rich_text.widget(); 363 + 364 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 365 + defer arena.deinit(); 366 + 367 + vxfw.DrawContext.init(.unicode); 368 + 369 + // Center expands to the max size. It must therefore have non-null max width and max height. 370 + // These values are asserted in draw 371 + const ctx: vxfw.DrawContext = .{ 372 + .arena = arena.allocator(), 373 + .min = .{}, 374 + .max = .{ .width = 7, .height = 2 }, 375 + .cell_size = .{ .width = 10, .height = 20 }, 376 + }; 377 + 378 + { 379 + // RichText softwraps by default 380 + const surface = try rich_widget.draw(ctx); 381 + try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 6, .height = 2 }), surface.size); 382 + } 383 + 384 + { 385 + rich_text.softwrap = false; 386 + rich_text.overflow = .ellipsis; 387 + const surface = try rich_widget.draw(ctx); 388 + try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 7, .height = 1 }), surface.size); 389 + // The last character will be an ellipsis 390 + try std.testing.expectEqualStrings("โ€ฆ", surface.buffer[surface.buffer.len - 1].char.grapheme); 391 + } 392 + } 393 + 394 + test "long word wrapping" { 395 + var rich_text: RichText = .{ 396 + .text = &.{ 397 + .{ .text = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, 398 + }, 399 + }; 400 + 401 + const rich_widget = rich_text.widget(); 402 + 403 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 404 + defer arena.deinit(); 405 + 406 + vxfw.DrawContext.init(.unicode); 407 + 408 + const len = rich_text.text[0].text.len; 409 + const width: u16 = 8; 410 + 411 + const ctx: vxfw.DrawContext = .{ 412 + .arena = arena.allocator(), 413 + .min = .{}, 414 + .max = .{ .width = width, .height = null }, 415 + .cell_size = .{ .width = 10, .height = 20 }, 416 + }; 417 + 418 + const surface = try rich_widget.draw(ctx); 419 + // Height should be length / width 420 + try std.testing.expectEqual(len / width, surface.size.height); 421 + } 422 + 423 + test "refAllDecls" { 424 + std.testing.refAllDecls(@This()); 425 + }
+632
src/vxfw/ScrollBars.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + const vxfw = @import("vxfw.zig"); 4 + 5 + const Allocator = std.mem.Allocator; 6 + 7 + const ScrollBars = @This(); 8 + 9 + /// The ScrollBars widget must contain a ScrollView widget. The scroll bars drawn will be for the 10 + /// scroll view contained in the ScrollBars widget. 11 + scroll_view: vxfw.ScrollView, 12 + /// If `true` a horizontal scroll bar will be drawn. Set to `false` to hide the horizontal scroll 13 + /// bar. Defaults to `true`. 14 + draw_horizontal_scrollbar: bool = true, 15 + /// If `true` a vertical scroll bar will be drawn. Set to `false` to hide the vertical scroll bar. 16 + /// Defaults to `true`. 17 + draw_vertical_scrollbar: bool = true, 18 + /// The estimated height of all the content in the ScrollView. When provided this height will be 19 + /// used to calculate the size of the scrollbar's thumb. If this is not provided the widget will 20 + /// make a best effort estimate of the size of the thumb using the number of elements rendered at 21 + /// any given time. This will cause inconsistent thumb sizes - and possibly inconsistent 22 + /// positioning - if different elements in the ScrollView have different heights. For the best user 23 + /// experience, providing this estimate is strongly recommended. 24 + /// 25 + /// Note that this doesn't necessarily have to be an accurate estimate and the tolerance for larger 26 + /// views is quite forgiving, especially if you overshoot the estimate. 27 + estimated_content_height: ?u32 = null, 28 + /// The estimated width of all the content in the ScrollView. When provided this width will be used 29 + /// to calculate the size of the scrollbar's thumb. If this is not provided the widget will make a 30 + /// best effort estimate of the size of the thumb using the width of the elements rendered at any 31 + /// given time. This will cause inconsistent thumb sizes - and possibly inconsistent positioning - 32 + /// if different elements in the ScrollView have different widths. For the best user experience, 33 + /// providing this estimate is strongly recommended. 34 + /// 35 + /// Note that this doesn't necessarily have to be 36 + /// an accurate estimate and the tolerance for larger views is quite forgiving, especially if you 37 + /// overshoot the estimate. 38 + estimated_content_width: ?u32 = null, 39 + /// The cell drawn for the vertical scroll thumb. Replace this to customize the scroll thumb. Must 40 + /// have a 1 column width. 41 + vertical_scrollbar_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "โ–", .width = 1 } }, 42 + /// The cell drawn for the vertical scroll thumb while it's being hovered. Replace this to customize 43 + /// the scroll thumb. Must have a 1 column width. 44 + vertical_scrollbar_hover_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "โ–ˆ", .width = 1 } }, 45 + /// The cell drawn for the vertical scroll thumb while it's being dragged by the mouse. Replace this 46 + /// to customize the scroll thumb. Must have a 1 column width. 47 + vertical_scrollbar_drag_thumb: vaxis.Cell = .{ 48 + .char = .{ .grapheme = "โ–ˆ", .width = 1 }, 49 + .style = .{ .fg = .{ .index = 4 } }, 50 + }, 51 + /// The cell drawn for the vertical scroll thumb. Replace this to customize the scroll thumb. Must 52 + /// have a 1 column width. 53 + horizontal_scrollbar_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "โ–ƒ", .width = 1 } }, 54 + /// The cell drawn for the horizontal scroll thumb while it's being hovered. Replace this to 55 + /// customize the scroll thumb. Must have a 1 column width. 56 + horizontal_scrollbar_hover_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "โ–ˆ", .width = 1 } }, 57 + /// The cell drawn for the horizontal scroll thumb while it's being dragged by the mouse. Replace 58 + /// this to customize the scroll thumb. Must have a 1 column width. 59 + horizontal_scrollbar_drag_thumb: vaxis.Cell = .{ 60 + .char = .{ .grapheme = "โ–ˆ", .width = 1 }, 61 + .style = .{ .fg = .{ .index = 4 } }, 62 + }, 63 + 64 + /// You should not change this variable, treat it as private to the implementation. Used to track 65 + /// the size of the widget so we can locate scroll bars for mouse interaction. 66 + last_frame_size: vxfw.Size = .{ .width = 0, .height = 0 }, 67 + /// You should not change this variable, treat it as private to the implementation. Used to track 68 + /// the width of the content so we map horizontal scroll thumb position to view position. 69 + last_frame_max_content_width: u32 = 0, 70 + /// You should not change this variable, treat it as private to the implementation. Used to track 71 + /// the position of the mouse relative to the scroll thumb for mouse interaction. 72 + mouse_offset_into_thumb: u8 = 0, 73 + 74 + /// You should not change this variable, treat it as private to the implementation. Used to track 75 + /// the position of the scroll thumb for mouse interaction. 76 + vertical_thumb_top_row: u32 = 0, 77 + /// You should not change this variable, treat it as private to the implementation. Used to track 78 + /// the position of the scroll thumb for mouse interaction. 79 + vertical_thumb_bottom_row: u32 = 0, 80 + /// You should not change this variable, treat it as private to the implementation. Used to track 81 + /// whether the scroll thumb is hovered or not so we can set the right hover style for the thumb. 82 + is_hovering_vertical_thumb: bool = false, 83 + /// You should not change this variable, treat it as private to the implementation. Used to track 84 + /// whether the thumb is currently being dragged, which is important to allowing the mouse to leave 85 + /// the scroll thumb while it's being dragged. 86 + is_dragging_vertical_thumb: bool = false, 87 + 88 + /// You should not change this variable, treat it as private to the implementation. Used to track 89 + /// the position of the scroll thumb for mouse interaction. 90 + horizontal_thumb_start_col: u32 = 0, 91 + /// You should not change this variable, treat it as private to the implementation. Used to track 92 + /// the position of the scroll thumb for mouse interaction. 93 + horizontal_thumb_end_col: u32 = 0, 94 + /// You should not change this variable, treat it as private to the implementation. Used to track 95 + /// whether the scroll thumb is hovered or not so we can set the right hover style for the thumb. 96 + is_hovering_horizontal_thumb: bool = false, 97 + /// You should not change this variable, treat it as private to the implementation. Used to track 98 + /// whether the thumb is currently being dragged, which is important to allowing the mouse to leave 99 + /// the scroll thumb while it's being dragged. 100 + is_dragging_horizontal_thumb: bool = false, 101 + 102 + pub fn widget(self: *const ScrollBars) vxfw.Widget { 103 + return .{ 104 + .userdata = @constCast(self), 105 + .eventHandler = typeErasedEventHandler, 106 + .captureHandler = typeErasedCaptureHandler, 107 + .drawFn = typeErasedDrawFn, 108 + }; 109 + } 110 + 111 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 112 + const self: *ScrollBars = @ptrCast(@alignCast(ptr)); 113 + return self.handleEvent(ctx, event); 114 + } 115 + fn typeErasedCaptureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 116 + const self: *ScrollBars = @ptrCast(@alignCast(ptr)); 117 + return self.handleCapture(ctx, event); 118 + } 119 + 120 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 121 + const self: *ScrollBars = @ptrCast(@alignCast(ptr)); 122 + return self.draw(ctx); 123 + } 124 + 125 + pub fn handleCapture(self: *ScrollBars, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 126 + switch (event) { 127 + .mouse => |mouse| { 128 + if (self.is_dragging_vertical_thumb) { 129 + // Stop dragging the thumb when the mouse is released. 130 + if (mouse.type == .release and 131 + mouse.button == .left and 132 + self.is_dragging_vertical_thumb) 133 + { 134 + // If we just let the scroll thumb go after dragging we need to make sure we 135 + // redraw so the right style is immediately applied to the thumb. 136 + if (self.is_dragging_vertical_thumb) { 137 + self.is_dragging_vertical_thumb = false; 138 + ctx.redraw = true; 139 + } 140 + 141 + const is_mouse_over_vertical_thumb = 142 + mouse.col == self.last_frame_size.width -| 1 and 143 + mouse.row >= self.vertical_thumb_top_row and 144 + mouse.row < self.vertical_thumb_bottom_row; 145 + 146 + // If we're not hovering the scroll bar after letting it go, we should trigger a 147 + // redraw so it goes back to its narrow, non-active, state immediately. 148 + if (!is_mouse_over_vertical_thumb) { 149 + self.is_hovering_vertical_thumb = false; 150 + ctx.redraw = true; 151 + } 152 + 153 + // No need to redraw yet, but we must consume the event so ending the drag 154 + // action doesn't trigger some other event handler. 155 + return ctx.consumeEvent(); 156 + } 157 + 158 + // Process dragging the vertical thumb. 159 + if (mouse.type == .drag) { 160 + // Make sure we consume the event if we're currently dragging the mouse so other 161 + // events aren't sent in the mean time. 162 + ctx.consumeEvent(); 163 + 164 + // New scroll thumb position. 165 + const new_thumb_top = mouse.row -| self.mouse_offset_into_thumb; 166 + 167 + // If the new thumb position is at the top we know we've scrolled to the top of 168 + // the scroll view. 169 + if (new_thumb_top == 0) { 170 + self.scroll_view.scroll.top = 0; 171 + return ctx.consumeAndRedraw(); 172 + } 173 + 174 + const new_thumb_top_f: f32 = @floatFromInt(new_thumb_top); 175 + const widget_height_f: f32 = @floatFromInt(self.last_frame_size.height); 176 + const total_num_children_f: f32 = count: { 177 + if (self.scroll_view.item_count) |c| break :count @floatFromInt(c); 178 + 179 + switch (self.scroll_view.children) { 180 + .slice => |slice| break :count @floatFromInt(slice.len), 181 + .builder => |builder| { 182 + var counter: usize = 0; 183 + while (builder.itemAtIdx(counter, self.scroll_view.cursor)) |_| 184 + counter += 1; 185 + 186 + break :count @floatFromInt(counter); 187 + }, 188 + } 189 + }; 190 + 191 + const new_top_child_idx_f = 192 + new_thumb_top_f * 193 + total_num_children_f / widget_height_f; 194 + self.scroll_view.scroll.top = @intFromFloat(new_top_child_idx_f); 195 + 196 + return ctx.consumeAndRedraw(); 197 + } 198 + } 199 + 200 + if (self.is_dragging_horizontal_thumb) { 201 + // Stop dragging the thumb when the mouse is released. 202 + if (mouse.type == .release and 203 + mouse.button == .left and 204 + self.is_dragging_horizontal_thumb) 205 + { 206 + // If we just let the scroll thumb go after dragging we need to make sure we 207 + // redraw so the right style is immediately applied to the thumb. 208 + if (self.is_dragging_horizontal_thumb) { 209 + self.is_dragging_horizontal_thumb = false; 210 + ctx.redraw = true; 211 + } 212 + 213 + const is_mouse_over_horizontal_thumb = 214 + mouse.row == self.last_frame_size.height -| 1 and 215 + mouse.col >= self.horizontal_thumb_start_col and 216 + mouse.col < self.horizontal_thumb_end_col; 217 + 218 + // If we're not hovering the scroll bar after letting it go, we should trigger a 219 + // redraw so it goes back to its narrow, non-active, state immediately. 220 + if (!is_mouse_over_horizontal_thumb) { 221 + self.is_hovering_horizontal_thumb = false; 222 + ctx.redraw = true; 223 + } 224 + 225 + // No need to redraw yet, but we must consume the event so ending the drag 226 + // action doesn't trigger some other event handler. 227 + return ctx.consumeEvent(); 228 + } 229 + 230 + // Process dragging the horizontal thumb. 231 + if (mouse.type == .drag) { 232 + // Make sure we consume the event if we're currently dragging the mouse so other 233 + // events aren't sent in the mean time. 234 + ctx.consumeEvent(); 235 + 236 + // New scroll thumb position. 237 + const new_thumb_col_start = mouse.col -| self.mouse_offset_into_thumb; 238 + 239 + // If the new thumb position is at the horizontal beginning of the current view 240 + // we know we've scrolled to the beginning of the scroll view. 241 + if (new_thumb_col_start == 0) { 242 + self.scroll_view.scroll.left = 0; 243 + return ctx.consumeAndRedraw(); 244 + } 245 + 246 + const new_thumb_col_start_f: f32 = @floatFromInt(new_thumb_col_start); 247 + const widget_width_f: f32 = @floatFromInt(self.last_frame_size.width); 248 + 249 + const max_content_width_f: f32 = 250 + @floatFromInt(self.last_frame_max_content_width); 251 + 252 + const new_view_col_start_f = 253 + new_thumb_col_start_f * max_content_width_f / widget_width_f; 254 + const new_view_col_start: u32 = @intFromFloat(@ceil(new_view_col_start_f)); 255 + 256 + self.scroll_view.scroll.left = 257 + @min(new_view_col_start, self.last_frame_max_content_width); 258 + 259 + return ctx.consumeAndRedraw(); 260 + } 261 + } 262 + }, 263 + else => {}, 264 + } 265 + } 266 + 267 + pub fn handleEvent(self: *ScrollBars, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 268 + switch (event) { 269 + .mouse => |mouse| { 270 + // 1. Process vertical scroll thumb hover. 271 + const mouse_col: u16 = if (mouse.col < 0) 0 else @intCast(mouse.col); 272 + const mouse_row: u16 = if (mouse.row < 0) 0 else @intCast(mouse.row); 273 + const is_mouse_over_vertical_thumb = 274 + mouse_col == self.last_frame_size.width -| 1 and 275 + mouse_row >= self.vertical_thumb_top_row and 276 + mouse_row < self.vertical_thumb_bottom_row; 277 + 278 + // Make sure we only update the state and redraw when it's necessary. 279 + if (!self.is_hovering_vertical_thumb and is_mouse_over_vertical_thumb) { 280 + self.is_hovering_vertical_thumb = true; 281 + ctx.redraw = true; 282 + } else if (self.is_hovering_vertical_thumb and !is_mouse_over_vertical_thumb) { 283 + self.is_hovering_vertical_thumb = false; 284 + ctx.redraw = true; 285 + } 286 + 287 + const did_start_dragging_vertical_thumb = is_mouse_over_vertical_thumb and 288 + mouse.type == .press and mouse.button == .left; 289 + 290 + if (did_start_dragging_vertical_thumb) { 291 + self.is_dragging_vertical_thumb = true; 292 + self.mouse_offset_into_thumb = @intCast(mouse_row -| self.vertical_thumb_top_row); 293 + 294 + // No need to redraw yet, but we must consume the event. 295 + return ctx.consumeEvent(); 296 + } 297 + 298 + // 2. Process horizontal scroll thumb hover. 299 + 300 + const is_mouse_over_horizontal_thumb = 301 + mouse_row == self.last_frame_size.height -| 1 and 302 + mouse_col >= self.horizontal_thumb_start_col and 303 + mouse_col < self.horizontal_thumb_end_col; 304 + 305 + // Make sure we only update the state and redraw when it's necessary. 306 + if (!self.is_hovering_horizontal_thumb and is_mouse_over_horizontal_thumb) { 307 + self.is_hovering_horizontal_thumb = true; 308 + ctx.redraw = true; 309 + } else if (self.is_hovering_horizontal_thumb and !is_mouse_over_horizontal_thumb) { 310 + self.is_hovering_horizontal_thumb = false; 311 + ctx.redraw = true; 312 + } 313 + 314 + const did_start_dragging_horizontal_thumb = is_mouse_over_horizontal_thumb and 315 + mouse.type == .press and mouse.button == .left; 316 + 317 + if (did_start_dragging_horizontal_thumb) { 318 + self.is_dragging_horizontal_thumb = true; 319 + self.mouse_offset_into_thumb = @intCast( 320 + mouse_col -| self.horizontal_thumb_start_col, 321 + ); 322 + 323 + // No need to redraw yet, but we must consume the event. 324 + return ctx.consumeEvent(); 325 + } 326 + }, 327 + .mouse_leave => self.is_dragging_vertical_thumb = false, 328 + else => {}, 329 + } 330 + } 331 + 332 + pub fn draw(self: *ScrollBars, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 333 + var children: std.ArrayList(vxfw.SubSurface) = .empty; 334 + 335 + // 1. If we're not drawing the scrollbars we can just draw the ScrollView directly. 336 + 337 + if (!self.draw_vertical_scrollbar and !self.draw_horizontal_scrollbar) { 338 + try children.append(ctx.arena, .{ 339 + .origin = .{ .row = 0, .col = 0 }, 340 + .surface = try self.scroll_view.draw(ctx), 341 + }); 342 + 343 + return .{ 344 + .size = ctx.max.size(), 345 + .widget = self.widget(), 346 + .buffer = &.{}, 347 + .children = children.items, 348 + }; 349 + } 350 + 351 + // 2. Otherwise we can draw the scrollbars. 352 + 353 + const max = ctx.max.size(); 354 + self.last_frame_size = max; 355 + 356 + // 3. Draw the scroll view itself. 357 + 358 + const scroll_view_surface = try self.scroll_view.draw(ctx.withConstraints( 359 + ctx.min, 360 + .{ 361 + // We make sure to make room for the scrollbars if required. 362 + .width = max.width -| @intFromBool(self.draw_vertical_scrollbar), 363 + .height = max.height -| @intFromBool(self.draw_horizontal_scrollbar), 364 + }, 365 + )); 366 + 367 + try children.append(ctx.arena, .{ 368 + .origin = .{ .row = 0, .col = 0 }, 369 + .surface = scroll_view_surface, 370 + }); 371 + 372 + // 4. Draw the vertical scroll bar. 373 + 374 + if (self.draw_vertical_scrollbar) vertical: { 375 + // If we can't scroll, then there's no need to draw the scroll bar. 376 + if (self.scroll_view.scroll.top == 0 and !self.scroll_view.scroll.has_more_vertical) 377 + break :vertical; 378 + 379 + // To draw the vertical scrollbar we need to know how big the scroll bar thumb should be. 380 + // If we've been provided with an estimated height we use that to figure out how big the 381 + // thumb should be, otherwise we estimate the size based on how many of the children were 382 + // actually drawn in the ScrollView. 383 + 384 + const widget_height_f: f32 = @floatFromInt(scroll_view_surface.size.height); 385 + const total_num_children_f: f32 = count: { 386 + if (self.scroll_view.item_count) |c| break :count @floatFromInt(c); 387 + 388 + switch (self.scroll_view.children) { 389 + .slice => |slice| break :count @floatFromInt(slice.len), 390 + .builder => |builder| { 391 + var counter: usize = 0; 392 + while (builder.itemAtIdx(counter, self.scroll_view.cursor)) |_| 393 + counter += 1; 394 + 395 + break :count @floatFromInt(counter); 396 + }, 397 + } 398 + }; 399 + 400 + const thumb_height: u16 = height: { 401 + // If we know the height, we can use the height of the current view to determine the 402 + // size of the thumb. 403 + if (self.estimated_content_height) |h| { 404 + const content_height_f: f32 = @floatFromInt(h); 405 + 406 + const thumb_height_f = widget_height_f * widget_height_f / content_height_f; 407 + break :height @intFromFloat(@max(thumb_height_f, 1)); 408 + } 409 + 410 + // Otherwise we estimate the size of the thumb based on the number of child elements 411 + // drawn in the scroll view, and the number of total child elements. 412 + 413 + const num_children_rendered_f: f32 = @floatFromInt(scroll_view_surface.children.len); 414 + 415 + const thumb_height_f = widget_height_f * num_children_rendered_f / total_num_children_f; 416 + break :height @intFromFloat(@max(thumb_height_f, 1)); 417 + }; 418 + 419 + // We also need to know the position of the thumb in the scroll bar. To find that we use the 420 + // index of the top-most child widget rendered in the ScrollView. 421 + 422 + const thumb_top: u32 = if (self.scroll_view.scroll.top == 0) 423 + 0 424 + else if (self.scroll_view.scroll.has_more_vertical) pos: { 425 + const top_child_idx_f: f32 = @floatFromInt(self.scroll_view.scroll.top); 426 + const thumb_top_f = widget_height_f * top_child_idx_f / total_num_children_f; 427 + 428 + break :pos @intFromFloat(thumb_top_f); 429 + } else max.height -| thumb_height; 430 + 431 + // Once we know the thumb height and its position we can draw the scroll bar. 432 + 433 + const scroll_bar = try vxfw.Surface.init( 434 + ctx.arena, 435 + self.widget(), 436 + .{ 437 + .width = 1, 438 + // We make sure to make room for the horizontal scroll bar if it's being drawn. 439 + .height = max.height -| @intFromBool(self.draw_horizontal_scrollbar), 440 + }, 441 + ); 442 + 443 + const thumb_end_row = thumb_top + thumb_height; 444 + for (thumb_top..thumb_end_row) |row| { 445 + scroll_bar.writeCell( 446 + 0, 447 + @intCast(row), 448 + if (self.is_dragging_vertical_thumb) 449 + self.vertical_scrollbar_drag_thumb 450 + else if (self.is_hovering_vertical_thumb) 451 + self.vertical_scrollbar_hover_thumb 452 + else 453 + self.vertical_scrollbar_thumb, 454 + ); 455 + } 456 + 457 + self.vertical_thumb_top_row = thumb_top; 458 + self.vertical_thumb_bottom_row = thumb_end_row; 459 + 460 + try children.append(ctx.arena, .{ 461 + .origin = .{ .row = 0, .col = max.width -| 1 }, 462 + .surface = scroll_bar, 463 + }); 464 + } 465 + 466 + // 5. Draw the horizontal scroll bar. 467 + 468 + const is_horizontally_scrolled = self.scroll_view.scroll.left > 0; 469 + const has_more_horizontal_content = self.scroll_view.scroll.has_more_horizontal; 470 + 471 + const should_draw_scrollbar = is_horizontally_scrolled or has_more_horizontal_content; 472 + 473 + if (self.draw_horizontal_scrollbar and should_draw_scrollbar) { 474 + const scroll_bar = try vxfw.Surface.init( 475 + ctx.arena, 476 + self.widget(), 477 + .{ .width = max.width, .height = 1 }, 478 + ); 479 + 480 + const widget_width_f: f32 = @floatFromInt(max.width); 481 + 482 + const max_content_width: u32 = width: { 483 + if (self.estimated_content_width) |w| break :width w; 484 + 485 + var max_content_width: u32 = 0; 486 + for (scroll_view_surface.children) |child| { 487 + max_content_width = @max(max_content_width, child.surface.size.width); 488 + } 489 + break :width max_content_width; 490 + }; 491 + const max_content_width_f: f32 = 492 + if (self.scroll_view.scroll.left + max.width > max_content_width) 493 + // If we've managed to overscroll horizontally for whatever reason - for example if the 494 + // content changes - we make sure the scroll thumb doesn't disappear by increasing the 495 + // max content width to match the current overscrolled position. 496 + @floatFromInt(self.scroll_view.scroll.left + max.width) 497 + else 498 + @floatFromInt(max_content_width); 499 + 500 + self.last_frame_max_content_width = max_content_width; 501 + 502 + const thumb_width_f: f32 = widget_width_f * widget_width_f / max_content_width_f; 503 + const thumb_width: u32 = @intFromFloat(@max(thumb_width_f, 1)); 504 + 505 + const view_start_col_f: f32 = @floatFromInt(self.scroll_view.scroll.left); 506 + const thumb_start_f = view_start_col_f * widget_width_f / max_content_width_f; 507 + 508 + const thumb_start: u32 = @intFromFloat(thumb_start_f); 509 + const thumb_end = thumb_start + thumb_width; 510 + for (thumb_start..thumb_end) |col| { 511 + scroll_bar.writeCell( 512 + @intCast(col), 513 + 0, 514 + if (self.is_dragging_horizontal_thumb) 515 + self.horizontal_scrollbar_drag_thumb 516 + else if (self.is_hovering_horizontal_thumb) 517 + self.horizontal_scrollbar_hover_thumb 518 + else 519 + self.horizontal_scrollbar_thumb, 520 + ); 521 + } 522 + self.horizontal_thumb_start_col = thumb_start; 523 + self.horizontal_thumb_end_col = thumb_end; 524 + try children.append(ctx.arena, .{ 525 + .origin = .{ .row = max.height -| 1, .col = 0 }, 526 + .surface = scroll_bar, 527 + }); 528 + } 529 + 530 + return .{ 531 + .size = ctx.max.size(), 532 + .widget = self.widget(), 533 + .buffer = &.{}, 534 + .children = children.items, 535 + }; 536 + } 537 + 538 + test ScrollBars { 539 + // Create child widgets 540 + const Text = @import("Text.zig"); 541 + const abc: Text = .{ .text = "abc\n def\n ghi" }; 542 + const def: Text = .{ .text = "def" }; 543 + const ghi: Text = .{ .text = "ghi" }; 544 + const jklmno: Text = .{ .text = "jkl\n mno" }; 545 + // 546 + // 0 |abc| 547 + // 1 | d|ef 548 + // 2 | g|hi 549 + // 3 |def| 550 + // 4 ghi 551 + // 5 jkl 552 + // 6 mno 553 + 554 + // Create the scroll view 555 + const ScrollView = @import("ScrollView.zig"); 556 + const scroll_view: ScrollView = .{ 557 + .wheel_scroll = 1, // Set wheel scroll to one 558 + .children = .{ .slice = &.{ 559 + abc.widget(), 560 + def.widget(), 561 + ghi.widget(), 562 + jklmno.widget(), 563 + } }, 564 + }; 565 + 566 + // Create the scroll bars. 567 + var scroll_bars: ScrollBars = .{ 568 + .scroll_view = scroll_view, 569 + .estimated_content_height = 7, 570 + .estimated_content_width = 5, 571 + }; 572 + 573 + // Boiler plate draw context 574 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 575 + defer arena.deinit(); 576 + vxfw.DrawContext.init(.unicode); 577 + 578 + const scroll_widget = scroll_bars.widget(); 579 + const draw_ctx: vxfw.DrawContext = .{ 580 + .arena = arena.allocator(), 581 + .min = .{}, 582 + .max = .{ .width = 3, .height = 4 }, 583 + .cell_size = .{ .width = 10, .height = 20 }, 584 + }; 585 + 586 + var surface = try scroll_widget.draw(draw_ctx); 587 + // Scroll bars should have 3 children: both scrollbars and the scroll view. 588 + try std.testing.expectEqual(3, surface.children.len); 589 + 590 + // Hide only the horizontal scroll bar. 591 + scroll_bars.draw_horizontal_scrollbar = false; 592 + surface = try scroll_widget.draw(draw_ctx); 593 + // Scroll bars should have 2 children: vertical scroll bar and the scroll view. 594 + try std.testing.expectEqual(2, surface.children.len); 595 + 596 + // Hide only the vertical scroll bar. 597 + scroll_bars.draw_horizontal_scrollbar = true; 598 + scroll_bars.draw_vertical_scrollbar = false; 599 + surface = try scroll_widget.draw(draw_ctx); 600 + // Scroll bars should have 2 children: vertical scroll bar and the scroll view. 601 + try std.testing.expectEqual(2, surface.children.len); 602 + 603 + // Hide both scroll bars. 604 + scroll_bars.draw_horizontal_scrollbar = false; 605 + surface = try scroll_widget.draw(draw_ctx); 606 + // Scroll bars should have 1 child: the scroll view. 607 + try std.testing.expectEqual(1, surface.children.len); 608 + 609 + // Re-enable scroll bars. 610 + scroll_bars.draw_horizontal_scrollbar = true; 611 + scroll_bars.draw_vertical_scrollbar = true; 612 + 613 + // Even though the estimated size is smaller than the draw area, we still render the scroll 614 + // bars if the scroll view knows we haven't rendered everything. 615 + scroll_bars.estimated_content_height = 2; 616 + scroll_bars.estimated_content_width = 1; 617 + surface = try scroll_widget.draw(draw_ctx); 618 + // Scroll bars should have 3 children: both scrollbars and the scroll view. 619 + try std.testing.expectEqual(3, surface.children.len); 620 + 621 + // The scroll view should be able to tell whether the scroll bars need to be rendered or not 622 + // even if estimated content sizes aren't provided. 623 + scroll_bars.estimated_content_height = null; 624 + scroll_bars.estimated_content_width = null; 625 + surface = try scroll_widget.draw(draw_ctx); 626 + // Scroll bars should have 3 children: both scrollbars and the scroll view. 627 + try std.testing.expectEqual(3, surface.children.len); 628 + } 629 + 630 + test "refAllDecls" { 631 + std.testing.refAllDecls(@This()); 632 + }
+1087
src/vxfw/ScrollView.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const assert = std.debug.assert; 5 + 6 + const Allocator = std.mem.Allocator; 7 + 8 + const vxfw = @import("vxfw.zig"); 9 + 10 + const ScrollView = @This(); 11 + 12 + pub const Builder = struct { 13 + userdata: *const anyopaque, 14 + buildFn: *const fn (*const anyopaque, idx: usize, cursor: usize) ?vxfw.Widget, 15 + 16 + pub inline fn itemAtIdx(self: Builder, idx: usize, cursor: usize) ?vxfw.Widget { 17 + return self.buildFn(self.userdata, idx, cursor); 18 + } 19 + }; 20 + 21 + pub const Source = union(enum) { 22 + slice: []const vxfw.Widget, 23 + builder: Builder, 24 + }; 25 + 26 + const Scroll = struct { 27 + /// Index of the first fully-in-view widget. 28 + top: u32 = 0, 29 + /// Line offset within the top widget. 30 + vertical_offset: i17 = 0, 31 + /// Pending vertical scroll amount. 32 + pending_lines: i17 = 0, 33 + /// If there is more room to scroll down. 34 + has_more_vertical: bool = true, 35 + /// The column of the first in-view column. 36 + left: u32 = 0, 37 + /// If there is more room to scroll right. 38 + has_more_horizontal: bool = true, 39 + /// The cursor must be in the viewport. 40 + wants_cursor: bool = false, 41 + 42 + pub fn linesDown(self: *Scroll, n: u8) bool { 43 + if (!self.has_more_vertical) return false; 44 + self.pending_lines += n; 45 + return true; 46 + } 47 + 48 + pub fn linesUp(self: *Scroll, n: u8) bool { 49 + if (self.top == 0 and self.vertical_offset == 0) return false; 50 + self.pending_lines -= @intCast(n); 51 + return true; 52 + } 53 + 54 + pub fn colsLeft(self: *Scroll, n: u8) bool { 55 + if (self.left == 0) return false; 56 + self.left -|= n; 57 + return true; 58 + } 59 + pub fn colsRight(self: *Scroll, n: u8) bool { 60 + if (!self.has_more_horizontal) return false; 61 + self.left +|= n; 62 + return true; 63 + } 64 + }; 65 + 66 + children: Source, 67 + cursor: u32 = 0, 68 + last_height: u8 = 0, 69 + /// When true, the widget will draw a cursor next to the widget which has the cursor 70 + draw_cursor: bool = false, 71 + /// The cell that will be drawn to represent the scroll view's cursor. Replace this to customize the 72 + /// cursor indicator. Must have a 1 column width. 73 + cursor_indicator: vaxis.Cell = .{ .char = .{ .grapheme = "โ–", .width = 1 } }, 74 + /// Lines to scroll for a mouse wheel 75 + wheel_scroll: u8 = 3, 76 + /// Set this if the exact item count is known. 77 + item_count: ?u32 = null, 78 + 79 + /// scroll position 80 + scroll: Scroll = .{}, 81 + 82 + pub fn widget(self: *const ScrollView) vxfw.Widget { 83 + return .{ 84 + .userdata = @constCast(self), 85 + .eventHandler = typeErasedEventHandler, 86 + .drawFn = typeErasedDrawFn, 87 + }; 88 + } 89 + 90 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 91 + const self: *ScrollView = @ptrCast(@alignCast(ptr)); 92 + return self.handleEvent(ctx, event); 93 + } 94 + 95 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 96 + const self: *ScrollView = @ptrCast(@alignCast(ptr)); 97 + return self.draw(ctx); 98 + } 99 + 100 + pub fn handleEvent(self: *ScrollView, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 101 + switch (event) { 102 + .mouse => |mouse| { 103 + if (mouse.button == .wheel_up) { 104 + if (self.scroll.linesUp(self.wheel_scroll)) 105 + return ctx.consumeAndRedraw(); 106 + } 107 + if (mouse.button == .wheel_down) { 108 + if (self.scroll.linesDown(self.wheel_scroll)) 109 + return ctx.consumeAndRedraw(); 110 + } 111 + if (mouse.button == .wheel_left) { 112 + if (self.scroll.colsRight(self.wheel_scroll)) 113 + return ctx.consumeAndRedraw(); 114 + } 115 + if (mouse.button == .wheel_right) { 116 + if (self.scroll.colsLeft(self.wheel_scroll)) 117 + return ctx.consumeAndRedraw(); 118 + } 119 + }, 120 + .key_press => |key| { 121 + if (key.matches(vaxis.Key.down, .{}) or 122 + key.matches('j', .{}) or 123 + key.matches('n', .{ .ctrl = true })) 124 + { 125 + // If we're drawing the cursor, move it to the next item. 126 + if (self.draw_cursor) return self.nextItem(ctx); 127 + 128 + // Otherwise scroll the view down. 129 + if (self.scroll.linesDown(1)) ctx.consumeAndRedraw(); 130 + } 131 + if (key.matches(vaxis.Key.up, .{}) or 132 + key.matches('k', .{}) or 133 + key.matches('p', .{ .ctrl = true })) 134 + { 135 + // If we're drawing the cursor, move it to the previous item. 136 + if (self.draw_cursor) return self.prevItem(ctx); 137 + 138 + // Otherwise scroll the view up. 139 + if (self.scroll.linesUp(1)) ctx.consumeAndRedraw(); 140 + } 141 + if (key.matches(vaxis.Key.right, .{}) or 142 + key.matches('l', .{}) or 143 + key.matches('f', .{ .ctrl = true })) 144 + { 145 + if (self.scroll.colsRight(1)) ctx.consumeAndRedraw(); 146 + } 147 + if (key.matches(vaxis.Key.left, .{}) or 148 + key.matches('h', .{}) or 149 + key.matches('b', .{ .ctrl = true })) 150 + { 151 + if (self.scroll.colsLeft(1)) ctx.consumeAndRedraw(); 152 + } 153 + if (key.matches('d', .{ .ctrl = true })) { 154 + const scroll_lines = @max(self.last_height / 2, 1); 155 + if (self.scroll.linesDown(scroll_lines)) 156 + ctx.consumeAndRedraw(); 157 + } 158 + if (key.matches('u', .{ .ctrl = true })) { 159 + const scroll_lines = @max(self.last_height / 2, 1); 160 + if (self.scroll.linesUp(scroll_lines)) 161 + ctx.consumeAndRedraw(); 162 + } 163 + if (key.matches(vaxis.Key.escape, .{})) { 164 + self.ensureScroll(); 165 + return ctx.consumeAndRedraw(); 166 + } 167 + }, 168 + else => {}, 169 + } 170 + } 171 + 172 + pub fn draw(self: *ScrollView, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 173 + std.debug.assert(ctx.max.width != null); 174 + std.debug.assert(ctx.max.height != null); 175 + switch (self.children) { 176 + .slice => |slice| { 177 + self.item_count = @intCast(slice.len); 178 + const builder: SliceBuilder = .{ .slice = slice }; 179 + return self.drawBuilder(ctx, .{ .userdata = &builder, .buildFn = SliceBuilder.build }); 180 + }, 181 + .builder => |b| return self.drawBuilder(ctx, b), 182 + } 183 + } 184 + 185 + pub fn nextItem(self: *ScrollView, ctx: *vxfw.EventContext) void { 186 + // If we have a count, we can handle this directly 187 + if (self.item_count) |count| { 188 + if (self.cursor >= count - 1) { 189 + return ctx.consumeEvent(); 190 + } 191 + self.cursor += 1; 192 + } else { 193 + switch (self.children) { 194 + .slice => |slice| { 195 + self.item_count = @intCast(slice.len); 196 + // If we are already at the end, don't do anything 197 + if (self.cursor == slice.len - 1) { 198 + return ctx.consumeEvent(); 199 + } 200 + // Advance the cursor 201 + self.cursor += 1; 202 + }, 203 + .builder => |builder| { 204 + // Save our current state 205 + const prev = self.cursor; 206 + // Advance the cursor 207 + self.cursor += 1; 208 + // Check the bounds, reversing until we get the last item 209 + while (builder.itemAtIdx(self.cursor, self.cursor) == null) { 210 + self.cursor -|= 1; 211 + } 212 + // If we didn't change state, we don't redraw 213 + if (self.cursor == prev) { 214 + return ctx.consumeEvent(); 215 + } 216 + }, 217 + } 218 + } 219 + // Reset scroll 220 + self.ensureScroll(); 221 + ctx.consumeAndRedraw(); 222 + } 223 + 224 + pub fn prevItem(self: *ScrollView, ctx: *vxfw.EventContext) void { 225 + if (self.cursor == 0) { 226 + return ctx.consumeEvent(); 227 + } 228 + 229 + if (self.item_count) |count| { 230 + // If for some reason our count changed, we handle it here 231 + self.cursor = @min(self.cursor - 1, count - 1); 232 + } else { 233 + switch (self.children) { 234 + .slice => |slice| { 235 + self.item_count = @intCast(slice.len); 236 + self.cursor = @min(self.cursor - 1, slice.len - 1); 237 + }, 238 + .builder => |builder| { 239 + // Save our current state 240 + const prev = self.cursor; 241 + // Decrement the cursor 242 + self.cursor -= 1; 243 + // Check the bounds, reversing until we get the last item 244 + while (builder.itemAtIdx(self.cursor, self.cursor) == null) { 245 + self.cursor -|= 1; 246 + } 247 + // If we didn't change state, we don't redraw 248 + if (self.cursor == prev) { 249 + return ctx.consumeEvent(); 250 + } 251 + }, 252 + } 253 + } 254 + 255 + // Reset scroll 256 + self.ensureScroll(); 257 + return ctx.consumeAndRedraw(); 258 + } 259 + 260 + // Only call when cursor state has changed, or we want to ensure the cursored item is in view 261 + pub fn ensureScroll(self: *ScrollView) void { 262 + if (self.cursor <= self.scroll.top) { 263 + self.scroll.top = @intCast(self.cursor); 264 + self.scroll.vertical_offset = 0; 265 + } else { 266 + self.scroll.wants_cursor = true; 267 + } 268 + } 269 + 270 + /// Inserts children until add_height is < 0 271 + fn insertChildren( 272 + self: *ScrollView, 273 + ctx: vxfw.DrawContext, 274 + builder: Builder, 275 + child_list: *std.ArrayList(vxfw.SubSurface), 276 + add_height: i17, 277 + ) Allocator.Error!void { 278 + assert(self.scroll.top > 0); 279 + self.scroll.top -= 1; 280 + var upheight = add_height; 281 + while (self.scroll.top >= 0) : (self.scroll.top -= 1) { 282 + // Get the child 283 + const child = builder.itemAtIdx(self.scroll.top, self.cursor) orelse break; 284 + 285 + const child_offset: u16 = if (self.draw_cursor) 2 else 0; 286 + const max_size = ctx.max.size(); 287 + 288 + // Set up constraints. We let the child be the entire height if it wants 289 + const child_ctx = ctx.withConstraints( 290 + .{ .width = max_size.width - child_offset, .height = 0 }, 291 + .{ .width = null, .height = null }, 292 + ); 293 + 294 + // Draw the child 295 + const surf = try child.draw(child_ctx); 296 + 297 + // Accumulate the height. Traversing backward so do this before setting origin 298 + upheight -= surf.size.height; 299 + 300 + // Insert the child to the beginning of the list 301 + const col_offset: i17 = if (self.draw_cursor) 2 else 0; 302 + try child_list.insert(ctx.arena, 0, .{ 303 + .origin = .{ .col = col_offset - @as(i17, @intCast(self.scroll.left)), .row = upheight }, 304 + .surface = surf, 305 + .z_index = 0, 306 + }); 307 + 308 + // Break if we went past the top edge, or are the top item 309 + if (upheight <= 0 or self.scroll.top == 0) break; 310 + } 311 + 312 + // Our new offset is the "upheight" 313 + self.scroll.vertical_offset = upheight; 314 + 315 + // Reset origins if we overshot and put the top item too low 316 + if (self.scroll.top == 0 and upheight > 0) { 317 + self.scroll.vertical_offset = 0; 318 + var row: i17 = 0; 319 + for (child_list.items) |*child| { 320 + child.origin.row = row; 321 + row += child.surface.size.height; 322 + } 323 + } 324 + // Our new offset is the "upheight" 325 + self.scroll.vertical_offset = upheight; 326 + } 327 + 328 + fn totalHeight(list: *const std.ArrayList(vxfw.SubSurface)) usize { 329 + var result: usize = 0; 330 + for (list.items) |child| { 331 + result += child.surface.size.height; 332 + } 333 + return result; 334 + } 335 + 336 + fn drawBuilder(self: *ScrollView, ctx: vxfw.DrawContext, builder: Builder) Allocator.Error!vxfw.Surface { 337 + defer self.scroll.wants_cursor = false; 338 + 339 + // Get the size. asserts neither constraint is null 340 + const max_size = ctx.max.size(); 341 + // Set up surface. 342 + var surface: vxfw.Surface = .{ 343 + .size = max_size, 344 + .widget = self.widget(), 345 + .buffer = &.{}, 346 + .children = &.{}, 347 + }; 348 + 349 + // Set state 350 + { 351 + // Assume we have more. We only know we don't after drawing 352 + self.scroll.has_more_vertical = true; 353 + } 354 + 355 + var child_list: std.ArrayList(vxfw.SubSurface) = .empty; 356 + 357 + // Accumulated height tracks how much height we have drawn. It's initial state is 358 + // -(scroll.vertical_offset + scroll.pending_lines) lines _above_ the surface top edge. 359 + // Example: 360 + // 1. Scroll up 3 lines: 361 + // pending_lines = -3 362 + // offset = 0 363 + // accumulated_height = -(0 + -3) = 3; 364 + // Our first widget is placed at row 3, we will need to fill this in after the draw 365 + // 2. Scroll up 3 lines, with an offset of 4 366 + // pending_lines = -3 367 + // offset = 4 368 + // accumulated_height = -(4 + -3) = -1; 369 + // Our first widget is placed at row -1 370 + // 3. Scroll down 3 lines: 371 + // pending_lines = 3 372 + // offset = 0 373 + // accumulated_height = -(0 + 3) = -3; 374 + // Our first widget is placed at row -3. It's possible it consumes the entire widget. We 375 + // will check for this at the end and only include visible children 376 + var accumulated_height: i17 = -(self.scroll.vertical_offset + self.scroll.pending_lines); 377 + 378 + // We handled the pending scroll by assigning accumulated_height. Reset it's state 379 + self.scroll.pending_lines = 0; 380 + 381 + // Set the initial index for our downard loop. We do this here because we might modify 382 + // scroll.top before we traverse downward 383 + var i: usize = self.scroll.top; 384 + 385 + // If we are on the first item, and we have an upward scroll that consumed our offset, eg 386 + // accumulated_height > 0, we reset state here. We can't scroll up anymore so we set 387 + // accumulated_height to 0. 388 + if (accumulated_height > 0 and self.scroll.top == 0) { 389 + self.scroll.vertical_offset = 0; 390 + accumulated_height = 0; 391 + } 392 + 393 + // If we are offset downward, insert widgets to the front of the list before traversing downard 394 + if (accumulated_height > 0) { 395 + try self.insertChildren(ctx, builder, &child_list, accumulated_height); 396 + const last_child = child_list.items[child_list.items.len - 1]; 397 + accumulated_height = last_child.origin.row + last_child.surface.size.height; 398 + } 399 + 400 + const child_offset: u16 = if (self.draw_cursor) 2 else 0; 401 + 402 + while (builder.itemAtIdx(i, self.cursor)) |child| { 403 + // Defer the increment 404 + defer i += 1; 405 + 406 + // Set up constraints. We let the child be the entire height if it wants 407 + const child_ctx = ctx.withConstraints( 408 + .{ .width = max_size.width - child_offset, .height = 0 }, 409 + .{ .width = null, .height = null }, 410 + ); 411 + 412 + // Draw the child 413 + const surf = try child.draw(child_ctx); 414 + 415 + // Add the child surface to our list. It's offset from parent is the accumulated height 416 + try child_list.append(ctx.arena, .{ 417 + .origin = .{ .col = child_offset - @as(i17, @intCast(self.scroll.left)), .row = accumulated_height }, 418 + .surface = surf, 419 + .z_index = 0, 420 + }); 421 + 422 + // Accumulate the height 423 + accumulated_height += surf.size.height; 424 + 425 + if (self.scroll.wants_cursor and i < self.cursor) 426 + continue // continue if we want the cursor and haven't gotten there yet 427 + else if (accumulated_height >= max_size.height) 428 + break; // Break if we drew enough 429 + } else { 430 + // This branch runs if we ran out of items. Set our state accordingly 431 + self.scroll.has_more_vertical = false; 432 + } 433 + 434 + // If we've looped through all the items without hitting the end we check for one more item to 435 + // see if we just drew the last item on the bottom of the screen. If we just drew the last item 436 + // we can set `scroll.has_more` to false. 437 + if (self.scroll.has_more_vertical and accumulated_height <= max_size.height) { 438 + if (builder.itemAtIdx(i, self.cursor) == null) self.scroll.has_more_vertical = false; 439 + } 440 + 441 + var total_height: usize = totalHeight(&child_list); 442 + 443 + // If we reached the bottom, don't have enough height to fill the screen, and have room to add 444 + // more, then we add more until out of items or filled the space. This can happen on a resize 445 + if (!self.scroll.has_more_vertical and total_height < max_size.height and self.scroll.top > 0) { 446 + try self.insertChildren(ctx, builder, &child_list, @intCast(max_size.height - total_height)); 447 + // Set the new total height 448 + total_height = totalHeight(&child_list); 449 + } 450 + 451 + if (self.draw_cursor and self.cursor >= self.scroll.top) blk: { 452 + // The index of the cursored widget in our child_list 453 + const cursored_idx: u32 = self.cursor - self.scroll.top; 454 + // Nothing to draw if our cursor is below our viewport 455 + if (cursored_idx >= child_list.items.len) break :blk; 456 + 457 + const sub = try ctx.arena.alloc(vxfw.SubSurface, 1); 458 + const child = child_list.items[cursored_idx]; 459 + sub[0] = .{ 460 + .origin = .{ .col = child_offset - @as(i17, @intCast(self.scroll.left)), .row = 0 }, 461 + .surface = child.surface, 462 + .z_index = 0, 463 + }; 464 + const cursor_surf = try vxfw.Surface.initWithChildren( 465 + ctx.arena, 466 + self.widget(), 467 + .{ .width = child_offset, .height = child.surface.size.height }, 468 + sub, 469 + ); 470 + for (0..cursor_surf.size.height) |row| { 471 + cursor_surf.writeCell(0, @intCast(row), self.cursor_indicator); 472 + } 473 + child_list.items[cursored_idx] = .{ 474 + .origin = .{ .col = 0, .row = child.origin.row }, 475 + .surface = cursor_surf, 476 + .z_index = 0, 477 + }; 478 + } 479 + 480 + // If we want the cursor, we check that the cursored widget is fully in view. If it is too 481 + // large, we position it so that it is the top item with a 0 offset 482 + if (self.scroll.wants_cursor) { 483 + const cursored_idx: u32 = self.cursor - self.scroll.top; 484 + const sub = child_list.items[cursored_idx]; 485 + // The bottom row of the cursored widget 486 + const bottom = sub.origin.row + sub.surface.size.height; 487 + if (bottom > max_size.height) { 488 + // Adjust the origin by the difference 489 + // anchor bottom 490 + var origin: i17 = max_size.height; 491 + var idx: usize = cursored_idx + 1; 492 + while (idx > 0) : (idx -= 1) { 493 + var child = child_list.items[idx - 1]; 494 + origin -= child.surface.size.height; 495 + child.origin.row = origin; 496 + child_list.items[idx - 1] = child; 497 + } 498 + } else if (sub.surface.size.height >= max_size.height) { 499 + // TODO: handle when the child is larger than our height. 500 + // We need to change the max constraint to be optional sizes so that we can support 501 + // unbounded drawing in scrollable areas 502 + self.scroll.top = self.cursor; 503 + self.scroll.vertical_offset = 0; 504 + child_list.deinit(ctx.arena); 505 + try child_list.append(ctx.arena, .{ 506 + .origin = .{ .col = 0 - @as(i17, @intCast(self.scroll.left)), .row = 0 }, 507 + .surface = sub.surface, 508 + .z_index = 0, 509 + }); 510 + total_height = sub.surface.size.height; 511 + } 512 + } 513 + 514 + // If we reached the bottom, we need to reset origins 515 + if (!self.scroll.has_more_vertical and total_height < max_size.height) { 516 + // anchor top 517 + assert(self.scroll.top == 0); 518 + self.scroll.vertical_offset = 0; 519 + var origin: i17 = 0; 520 + for (0..child_list.items.len) |idx| { 521 + var child = child_list.items[idx]; 522 + child.origin.row = origin; 523 + origin += child.surface.size.height; 524 + child_list.items[idx] = child; 525 + } 526 + } else if (!self.scroll.has_more_vertical) { 527 + // anchor bottom 528 + var origin: i17 = max_size.height; 529 + var idx: usize = child_list.items.len; 530 + while (idx > 0) : (idx -= 1) { 531 + var child = child_list.items[idx - 1]; 532 + origin -= child.surface.size.height; 533 + child.origin.row = origin; 534 + child_list.items[idx - 1] = child; 535 + } 536 + } 537 + 538 + // Reset horizontal scroll info. 539 + self.scroll.has_more_horizontal = false; 540 + for (child_list.items) |child| { 541 + if (child.surface.size.width -| self.scroll.left > max_size.width) { 542 + self.scroll.has_more_horizontal = true; 543 + break; 544 + } 545 + } 546 + 547 + var start: usize = 0; 548 + var end: usize = child_list.items.len; 549 + 550 + for (child_list.items, 0..) |child, idx| { 551 + if (child.origin.row <= 0 and child.origin.row + child.surface.size.height > 0) { 552 + start = idx; 553 + self.scroll.vertical_offset = -child.origin.row; 554 + self.scroll.top += @intCast(idx); 555 + } 556 + if (child.origin.row > max_size.height) { 557 + end = idx; 558 + break; 559 + } 560 + } 561 + 562 + surface.children = child_list.items; 563 + 564 + // Update last known height. 565 + // If the bits from total_height don't fit u8 we won't get the right value from @intCast or 566 + // @truncate so we check manually. 567 + self.last_height = if (total_height > 255) 255 else @intCast(total_height); 568 + 569 + return surface; 570 + } 571 + 572 + const SliceBuilder = struct { 573 + slice: []const vxfw.Widget, 574 + 575 + fn build(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget { 576 + const self: *const SliceBuilder = @ptrCast(@alignCast(ptr)); 577 + if (idx >= self.slice.len) return null; 578 + return self.slice[idx]; 579 + } 580 + }; 581 + 582 + test ScrollView { 583 + // Create child widgets 584 + const Text = @import("Text.zig"); 585 + const abc: Text = .{ .text = "abc\n def\n ghi" }; 586 + const def: Text = .{ .text = "def" }; 587 + const ghi: Text = .{ .text = "ghi" }; 588 + const jklmno: Text = .{ .text = "jkl\n mno" }; 589 + // 590 + // 0 |abc| 591 + // 1 | d|ef 592 + // 2 | g|hi 593 + // 3 |def| 594 + // 4 ghi 595 + // 5 jkl 596 + // 6 mno 597 + 598 + // Create the scroll view 599 + const scroll_view: ScrollView = .{ 600 + .wheel_scroll = 1, // Set wheel scroll to one 601 + .children = .{ .slice = &.{ 602 + abc.widget(), 603 + def.widget(), 604 + ghi.widget(), 605 + jklmno.widget(), 606 + } }, 607 + }; 608 + 609 + // Boiler plate draw context 610 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 611 + defer arena.deinit(); 612 + vxfw.DrawContext.init(.unicode); 613 + 614 + const scroll_widget = scroll_view.widget(); 615 + const draw_ctx: vxfw.DrawContext = .{ 616 + .arena = arena.allocator(), 617 + .min = .{}, 618 + .max = .{ .width = 3, .height = 4 }, 619 + .cell_size = .{ .width = 10, .height = 20 }, 620 + }; 621 + 622 + var surface = try scroll_widget.draw(draw_ctx); 623 + // ScrollView expands to max height and max width 624 + try std.testing.expectEqual(4, surface.size.height); 625 + try std.testing.expectEqual(3, surface.size.width); 626 + // We have 2 children, because only visible children appear as a surface 627 + try std.testing.expectEqual(2, surface.children.len); 628 + 629 + // ScrollView starts at the top and left. 630 + try std.testing.expectEqual(0, scroll_view.scroll.top); 631 + try std.testing.expectEqual(0, scroll_view.scroll.left); 632 + 633 + // With the widgets provided the scroll view should have both more content to scroll vertically 634 + // and horizontally. 635 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_vertical); 636 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 637 + 638 + var mouse_event: vaxis.Mouse = .{ 639 + .col = 0, 640 + .row = 0, 641 + .button = .wheel_up, 642 + .mods = .{}, 643 + .type = .press, 644 + }; 645 + // Event handlers need a context 646 + var ctx: vxfw.EventContext = .{ 647 + .alloc = std.testing.allocator, 648 + .cmds = .empty, 649 + }; 650 + defer ctx.cmds.deinit(ctx.alloc); 651 + 652 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 653 + // Wheel up doesn't adjust the scroll 654 + try std.testing.expectEqual(0, scroll_view.scroll.top); 655 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 656 + 657 + // Wheel right doesn't adjust the horizontal scroll 658 + mouse_event.button = .wheel_right; 659 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 660 + try std.testing.expectEqual(0, scroll_view.scroll.left); 661 + 662 + // Scroll right with 'h' doesn't adjust the horizontal scroll 663 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'h' } }); 664 + try std.testing.expectEqual(0, scroll_view.scroll.left); 665 + 666 + // Scroll right with '<c-b>' doesn't adjust the horizontal scroll 667 + try scroll_widget.handleEvent( 668 + &ctx, 669 + .{ .key_press = .{ .codepoint = 'c', .mods = .{ .ctrl = true } } }, 670 + ); 671 + try std.testing.expectEqual(0, scroll_view.scroll.left); 672 + 673 + // === TEST SCROLL DOWN === // 674 + 675 + // Send a wheel down to scroll down one line 676 + mouse_event.button = .wheel_down; 677 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 678 + // We have to draw the widget for scrolls to take effect 679 + surface = try scroll_widget.draw(draw_ctx); 680 + // 0 abc 681 + // 1 | d|ef 682 + // 2 | g|hi 683 + // 3 |def| 684 + // 4 |ghi| 685 + // 5 jkl 686 + // 6 mno 687 + // We should have gone down 1 line, and not changed our top widget 688 + try std.testing.expectEqual(0, scroll_view.scroll.top); 689 + try std.testing.expectEqual(1, scroll_view.scroll.vertical_offset); 690 + // One more widget has scrolled into view 691 + try std.testing.expectEqual(3, surface.children.len); 692 + 693 + // Send a 'j' to scroll down one more line. 694 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 695 + surface = try scroll_widget.draw(draw_ctx); 696 + // 0 abc 697 + // 1 def 698 + // 2 | g|hi 699 + // 3 |def| 700 + // 4 |ghi| 701 + // 5 |jkl| 702 + // 6 mno 703 + // We should have gone down 1 line, and not changed our top widget 704 + try std.testing.expectEqual(0, scroll_view.scroll.top); 705 + try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset); 706 + // One more widget has scrolled into view 707 + try std.testing.expectEqual(4, surface.children.len); 708 + 709 + // Send `<c-n> to scroll down one more line 710 + try scroll_widget.handleEvent( 711 + &ctx, 712 + .{ .key_press = .{ .codepoint = 'n', .mods = .{ .ctrl = true } } }, 713 + ); 714 + surface = try scroll_widget.draw(draw_ctx); 715 + // 0 abc 716 + // 1 def 717 + // 2 ghi 718 + // 3 |def| 719 + // 4 |ghi| 720 + // 5 |jkl| 721 + // 6 | m|no 722 + // We should have gone down 1 line, which scrolls our top widget out of view 723 + try std.testing.expectEqual(1, scroll_view.scroll.top); 724 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 725 + // The top widget has now scrolled out of view, but is still rendered out of view because of 726 + // how pending scroll events are handled. 727 + try std.testing.expectEqual(4, surface.children.len); 728 + 729 + // We've scrolled to the bottom. 730 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_vertical); 731 + 732 + // Scroll down one more line, this shouldn't do anything. 733 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 734 + surface = try scroll_widget.draw(draw_ctx); 735 + // 0 abc 736 + // 1 def 737 + // 2 ghi 738 + // 3 |def| 739 + // 4 |ghi| 740 + // 5 |jkl| 741 + // 6 | m|no 742 + try std.testing.expectEqual(1, scroll_view.scroll.top); 743 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 744 + // The top widget was scrolled out of view on the last render, so we should no longer be 745 + // drawing it right above the current view. 746 + try std.testing.expectEqual(3, surface.children.len); 747 + 748 + // We've scrolled to the bottom. 749 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_vertical); 750 + 751 + // === TEST SCROLL UP === // 752 + 753 + mouse_event.button = .wheel_up; 754 + 755 + // Send mouse up, now the top widget is in view. 756 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 757 + surface = try scroll_widget.draw(draw_ctx); 758 + // 0 abc 759 + // 1 def 760 + // 2 | g|hi 761 + // 3 |def| 762 + // 4 |ghi| 763 + // 5 |jkl| 764 + // 6 mno 765 + try std.testing.expectEqual(0, scroll_view.scroll.top); 766 + try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset); 767 + // The top widget was scrolled out of view on the last render, so we should no longer be 768 + // drawing it right above the current view. 769 + try std.testing.expectEqual(4, surface.children.len); 770 + 771 + // We've scrolled away from the bottom. 772 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_vertical); 773 + 774 + // Send 'k' to scroll up, now the bottom widget should be out of view. 775 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'k' } }); 776 + surface = try scroll_widget.draw(draw_ctx); 777 + // 0 abc 778 + // 1 | d|ef 779 + // 2 | g|hi 780 + // 3 |def| 781 + // 4 |ghi| 782 + // 5 jkl 783 + // 6 mno 784 + try std.testing.expectEqual(0, scroll_view.scroll.top); 785 + try std.testing.expectEqual(1, scroll_view.scroll.vertical_offset); 786 + // The top widget was scrolled out of view on the last render, so we should no longer be 787 + // drawing it right above the current view. 788 + try std.testing.expectEqual(3, surface.children.len); 789 + 790 + // Send '<c-p>' to scroll up, now we should be at the top. 791 + try scroll_widget.handleEvent( 792 + &ctx, 793 + .{ .key_press = .{ .codepoint = 'p', .mods = .{ .ctrl = true } } }, 794 + ); 795 + surface = try scroll_widget.draw(draw_ctx); 796 + // 0 |abc| 797 + // 1 | d|ef 798 + // 2 | g|hi 799 + // 3 |def| 800 + // 4 ghi 801 + // 5 jkl 802 + // 6 mno 803 + try std.testing.expectEqual(0, scroll_view.scroll.top); 804 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 805 + // The top widget was scrolled out of view on the last render, so we should no longer be 806 + // drawing it right above the current view. 807 + try std.testing.expectEqual(2, surface.children.len); 808 + 809 + // We should be at the top. 810 + try std.testing.expectEqual(0, scroll_view.scroll.top); 811 + // We should still have no horizontal scroll. 812 + try std.testing.expectEqual(0, scroll_view.scroll.left); 813 + 814 + // === TEST SCROLL LEFT - MOVES VIEW TO THE RIGHT === // 815 + 816 + mouse_event.button = .wheel_left; 817 + 818 + // Send `.wheel_left` to scroll the view to the right. 819 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 820 + surface = try scroll_widget.draw(draw_ctx); 821 + // 0 a|bc | 822 + // 1 | de|f 823 + // 2 | gh|i 824 + // 3 d|ef | 825 + // 4 ghi 826 + // 5 jkl 827 + // 6 mno 828 + try std.testing.expectEqual(1, scroll_view.scroll.left); 829 + // The number of children should be just the top 2 widgets. 830 + try std.testing.expectEqual(2, surface.children.len); 831 + // There is still more to draw horizontally. 832 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 833 + 834 + // Send `l` to scroll the view to the right. 835 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l' } }); 836 + surface = try scroll_widget.draw(draw_ctx); 837 + // 0 ab|c | 838 + // 1 |def| 839 + // 2 |ghi| 840 + // 3 de|f | 841 + // 4 ghi 842 + // 5 jkl 843 + // 6 mno 844 + try std.testing.expectEqual(2, scroll_view.scroll.left); 845 + // The number of children should be just the top 2 widgets. 846 + try std.testing.expectEqual(2, surface.children.len); 847 + // There is nothing more to draw horizontally. 848 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 849 + 850 + // Send `<c-f>` to scroll the view to the right, this should do nothing. 851 + try scroll_widget.handleEvent( 852 + &ctx, 853 + .{ .key_press = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } }, 854 + ); 855 + surface = try scroll_widget.draw(draw_ctx); 856 + // 0 ab|c | 857 + // 1 |def| 858 + // 2 |ghi| 859 + // 3 de|f | 860 + // 4 ghi 861 + // 5 jkl 862 + // 6 mno 863 + try std.testing.expectEqual(2, scroll_view.scroll.left); 864 + // The number of children should be just the top 2 widgets. 865 + try std.testing.expectEqual(2, surface.children.len); 866 + // There is nothing more to draw horizontally. 867 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 868 + 869 + // Send `.wheel_right` to scroll the view to the left. 870 + mouse_event.button = .wheel_right; 871 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 872 + surface = try scroll_widget.draw(draw_ctx); 873 + // 0 a|bc | 874 + // 1 | de|f 875 + // 2 | gh|i 876 + // 3 d|ef | 877 + // 4 ghi 878 + // 5 jkl 879 + // 6 mno 880 + try std.testing.expectEqual(1, scroll_view.scroll.left); 881 + // The number of children should be just the top 2 widgets. 882 + try std.testing.expectEqual(2, surface.children.len); 883 + // There is still more to draw horizontally. 884 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 885 + 886 + // Processing 2 or more events before drawing may produce overscroll, because we need to draw 887 + // the children to determine whether there's more horizontal scrolling available. 888 + try scroll_widget.handleEvent( 889 + &ctx, 890 + .{ .key_press = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } }, 891 + ); 892 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l' } }); 893 + surface = try scroll_widget.draw(draw_ctx); 894 + // 0 abc| | 895 + // 1 d|ef | 896 + // 2 g|hi | 897 + // 3 def| | 898 + // 4 ghi 899 + // 5 jkl 900 + // 6 mno 901 + try std.testing.expectEqual(3, scroll_view.scroll.left); 902 + // The number of children should be just the top 2 widgets. 903 + try std.testing.expectEqual(2, surface.children.len); 904 + // There is nothing more to draw horizontally. 905 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 906 + 907 + // === TEST SCROLL RIGHT - MOVES VIEW TO THE LEFT === // 908 + 909 + // Send `.wheel_right` to scroll the view to the left. 910 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 911 + surface = try scroll_widget.draw(draw_ctx); 912 + // 0 ab|c | 913 + // 1 |def| 914 + // 2 |ghi| 915 + // 3 de|f | 916 + // 4 ghi 917 + // 5 jkl 918 + // 6 mno 919 + try std.testing.expectEqual(2, scroll_view.scroll.left); 920 + // The number of children should be just the top 2 widgets. 921 + try std.testing.expectEqual(2, surface.children.len); 922 + // There is nothing more to draw horizontally. 923 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 924 + 925 + // Send `h` to scroll the view to the left. 926 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'h' } }); 927 + surface = try scroll_widget.draw(draw_ctx); 928 + // 0 a|bc | 929 + // 1 | de|f 930 + // 2 | gh|i 931 + // 3 d|ef | 932 + // 4 ghi 933 + // 5 jkl 934 + // 6 mno 935 + try std.testing.expectEqual(1, scroll_view.scroll.left); 936 + // The number of children should be just the top 2 widgets. 937 + try std.testing.expectEqual(2, surface.children.len); 938 + // There is now more to draw horizontally. 939 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 940 + 941 + // Send `<c-b>` to scroll the view to the left. 942 + try scroll_widget.handleEvent( 943 + &ctx, 944 + .{ .key_press = .{ .codepoint = 'b', .mods = .{ .ctrl = true } } }, 945 + ); 946 + surface = try scroll_widget.draw(draw_ctx); 947 + // 0 |abc| 948 + // 1 | d|ef 949 + // 2 | g|hi 950 + // 3 |def| 951 + // 4 ghi 952 + // 5 jkl 953 + // 6 mno 954 + try std.testing.expectEqual(0, scroll_view.scroll.left); 955 + // The number of children should be just the top 2 widgets. 956 + try std.testing.expectEqual(2, surface.children.len); 957 + // There is now more to draw horizontally. 958 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 959 + 960 + // === TEST COMBINED HORIZONTAL AND VERTICAL SCROLL === // 961 + 962 + // Scroll 3 columns to the right and 2 rows down. 963 + mouse_event.button = .wheel_left; 964 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 965 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 966 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 967 + mouse_event.button = .wheel_down; 968 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 969 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 970 + surface = try scroll_widget.draw(draw_ctx); 971 + // 0 abc 972 + // 1 def 973 + // 2 g|hi | 974 + // 3 def| | 975 + // 4 ghi| | 976 + // 5 jkl| | 977 + // 6 mno 978 + try std.testing.expectEqual(3, scroll_view.scroll.left); 979 + try std.testing.expectEqual(0, scroll_view.scroll.top); 980 + try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset); 981 + // Even though only 1 child is visible, we still draw all 4 children in the view. 982 + try std.testing.expectEqual(4, surface.children.len); 983 + // There is nothing more to draw horizontally. 984 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 985 + } 986 + 987 + // @reykjalin found an issue on mac with ghostty where the scroll up and scroll down were uneven. 988 + // Ghostty has high precision scrolling and sends a lot of wheel events for each tick 989 + test "ScrollView: uneven scroll" { 990 + // Create child widgets 991 + const Text = @import("Text.zig"); 992 + const zero: Text = .{ .text = "0" }; 993 + const one: Text = .{ .text = "1" }; 994 + const two: Text = .{ .text = "2" }; 995 + const three: Text = .{ .text = "3" }; 996 + const four: Text = .{ .text = "4" }; 997 + const five: Text = .{ .text = "5" }; 998 + const six: Text = .{ .text = "6" }; 999 + // 0 | 1000 + // 1 | 1001 + // 2 | 1002 + // 3 | 1003 + // 4 1004 + // 5 1005 + // 6 1006 + 1007 + // Create the list view 1008 + const scroll_view: ScrollView = .{ 1009 + .wheel_scroll = 1, // Set wheel scroll to one 1010 + .children = .{ .slice = &.{ 1011 + zero.widget(), 1012 + one.widget(), 1013 + two.widget(), 1014 + three.widget(), 1015 + four.widget(), 1016 + five.widget(), 1017 + six.widget(), 1018 + } }, 1019 + }; 1020 + 1021 + // Boiler plate draw context 1022 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1023 + defer arena.deinit(); 1024 + vxfw.DrawContext.init(.unicode); 1025 + 1026 + const scroll_widget = scroll_view.widget(); 1027 + const draw_ctx: vxfw.DrawContext = .{ 1028 + .arena = arena.allocator(), 1029 + .min = .{}, 1030 + .max = .{ .width = 16, .height = 4 }, 1031 + .cell_size = .{ .width = 10, .height = 20 }, 1032 + }; 1033 + 1034 + var surface = try scroll_widget.draw(draw_ctx); 1035 + 1036 + var mouse_event: vaxis.Mouse = .{ 1037 + .col = 0, 1038 + .row = 0, 1039 + .button = .wheel_up, 1040 + .mods = .{}, 1041 + .type = .press, 1042 + }; 1043 + // Event handlers need a context 1044 + var ctx: vxfw.EventContext = .{ 1045 + .alloc = std.testing.allocator, 1046 + .cmds = .empty, 1047 + }; 1048 + defer ctx.cmds.deinit(ctx.alloc); 1049 + 1050 + // Send a wheel down x 3 1051 + mouse_event.button = .wheel_down; 1052 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1053 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1054 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1055 + // We have to draw the widget for scrolls to take effect 1056 + surface = try scroll_widget.draw(draw_ctx); 1057 + // 0 1058 + // 1 1059 + // 2 1060 + // 3 | 1061 + // 4 | 1062 + // 5 | 1063 + // 6 | 1064 + try std.testing.expectEqual(3, scroll_view.scroll.top); 1065 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 1066 + // The first time we draw again we still draw all 7 children due to how pending scroll events 1067 + // work. 1068 + try std.testing.expectEqual(7, surface.children.len); 1069 + 1070 + surface = try scroll_widget.draw(draw_ctx); 1071 + // By drawing again without any pending events there are now only the 4 visible elements 1072 + // rendered. 1073 + try std.testing.expectEqual(4, surface.children.len); 1074 + 1075 + // Now wheel_up two times should move us two lines up 1076 + mouse_event.button = .wheel_up; 1077 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1078 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1079 + surface = try scroll_widget.draw(draw_ctx); 1080 + try std.testing.expectEqual(1, scroll_view.scroll.top); 1081 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 1082 + try std.testing.expectEqual(4, surface.children.len); 1083 + } 1084 + 1085 + test "refAllDecls" { 1086 + std.testing.refAllDecls(@This()); 1087 + }
+103
src/vxfw/SizedBox.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const Allocator = std.mem.Allocator; 5 + 6 + const vxfw = @import("vxfw.zig"); 7 + 8 + const SizedBox = @This(); 9 + 10 + child: vxfw.Widget, 11 + size: vxfw.Size, 12 + 13 + pub fn widget(self: *const SizedBox) vxfw.Widget { 14 + return .{ 15 + .userdata = @constCast(self), 16 + .drawFn = typeErasedDrawFn, 17 + }; 18 + } 19 + 20 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 21 + const self: *const SizedBox = @ptrCast(@alignCast(ptr)); 22 + const max: vxfw.MaxSize = .{ 23 + .width = if (ctx.max.width) |max_w| max_w else self.size.width, 24 + .height = if (ctx.max.height) |max_h| max_h else self.size.height, 25 + }; 26 + const min: vxfw.Size = .{ 27 + .width = @min(@max(ctx.min.width, self.size.width), max.width.?), 28 + .height = @min(@max(ctx.min.height, self.size.height), max.height.?), 29 + }; 30 + return self.child.draw(ctx.withConstraints(min, max)); 31 + } 32 + 33 + test SizedBox { 34 + // Create a test widget that saves the constraints it was given 35 + const TestWidget = struct { 36 + min: vxfw.Size, 37 + max: vxfw.MaxSize, 38 + 39 + pub fn widget(self: *@This()) vxfw.Widget { 40 + return .{ 41 + .userdata = self, 42 + .drawFn = @This().typeErasedDrawFn, 43 + }; 44 + } 45 + 46 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface { 47 + const self: *@This() = @ptrCast(@alignCast(ptr)); 48 + self.min = ctx.min; 49 + self.max = ctx.max; 50 + return .{ 51 + .size = ctx.min, 52 + .widget = self.widget(), 53 + .buffer = &.{}, 54 + .children = &.{}, 55 + }; 56 + } 57 + }; 58 + 59 + // Boiler plate draw context 60 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 61 + defer arena.deinit(); 62 + vxfw.DrawContext.init(.unicode); 63 + 64 + var draw_ctx: vxfw.DrawContext = .{ 65 + .arena = arena.allocator(), 66 + .min = .{}, 67 + .max = .{ .width = 16, .height = 16 }, 68 + .cell_size = .{ .width = 10, .height = 20 }, 69 + }; 70 + 71 + var test_widget: TestWidget = .{ .min = .{}, .max = .{} }; 72 + 73 + // SizedBox tries to draw the child widget at the specified size. It will shrink to fit within 74 + // constraints 75 + const sized_box: SizedBox = .{ 76 + .child = test_widget.widget(), 77 + .size = .{ .width = 10, .height = 10 }, 78 + }; 79 + 80 + const box_widget = sized_box.widget(); 81 + { 82 + const result = try box_widget.draw(draw_ctx); 83 + // The sized box is smaller than the constraints, so we should be the desired size 84 + try std.testing.expectEqual(sized_box.size, result.size); 85 + } 86 + 87 + { 88 + draw_ctx.max.height = 8; 89 + const result = try box_widget.draw(draw_ctx); 90 + // The sized box is smaller than the constraints, so we should be that size 91 + try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 10, .height = 8 }), result.size); 92 + } 93 + 94 + draw_ctx.max.width = 8; 95 + _ = try box_widget.draw(draw_ctx); 96 + // The sized box is smaller than the constraints, so we should be that size 97 + try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 8, .height = 8 }), test_widget.min); 98 + try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 8, .height = 8 }), test_widget.max.size()); 99 + } 100 + 101 + test "refAllDecls" { 102 + std.testing.refAllDecls(@This()); 103 + }
+159
src/vxfw/Spinner.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const vxfw = @import("vxfw.zig"); 5 + 6 + const Allocator = std.mem.Allocator; 7 + 8 + const Spinner = @This(); 9 + 10 + const frames: []const []const u8 = &.{ "โฃถ", "โฃง", "โฃ", "โกŸ", "โ ฟ", "โขป", "โฃน", "โฃผ" }; 11 + const time_lapse: u32 = std.time.ms_per_s / 12; // 12 fps 12 + 13 + count: std.atomic.Value(u16) = .{ .raw = 0 }, 14 + style: vaxis.Style = .{}, 15 + /// The frame index 16 + frame: u4 = 0, 17 + 18 + /// Turns true when we start the spinner. Only turns false during a draw if the count == 0. This 19 + /// ensures we draw one more time past the spinner stopping to clear the state 20 + was_spinning: std.atomic.Value(bool) = .{ .raw = false }, 21 + 22 + /// Start, or add one, to the spinner counter. Thread safe. 23 + pub fn start(self: *Spinner) ?vxfw.Command { 24 + self.was_spinning.store(true, .unordered); 25 + const count = self.count.fetchAdd(1, .monotonic); 26 + if (count == 0) { 27 + return vxfw.Tick.in(time_lapse, self.widget()); 28 + } 29 + return null; 30 + } 31 + 32 + /// Reduce one from the spinner counter. The spinner will stop when it reaches 0. Thread safe 33 + pub fn stop(self: *Spinner) void { 34 + self.count.store(self.count.load(.unordered) -| 1, .unordered); 35 + } 36 + 37 + pub fn wasSpinning(self: *Spinner) bool { 38 + return self.was_spinning.load(.unordered); 39 + } 40 + 41 + pub fn widget(self: *Spinner) vxfw.Widget { 42 + return .{ 43 + .userdata = self, 44 + .eventHandler = typeErasedEventHandler, 45 + .drawFn = typeErasedDrawFn, 46 + }; 47 + } 48 + 49 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 50 + const self: *Spinner = @ptrCast(@alignCast(ptr)); 51 + return self.handleEvent(ctx, event); 52 + } 53 + 54 + pub fn handleEvent(self: *Spinner, ctx: *vxfw.EventContext, event: vxfw.Event) Allocator.Error!void { 55 + switch (event) { 56 + .tick => { 57 + const count = self.count.load(.unordered); 58 + 59 + if (count == 0) { 60 + if (self.wasSpinning()) { 61 + ctx.redraw = true; 62 + self.was_spinning.store(false, .unordered); 63 + } 64 + return; 65 + } 66 + // Update frame 67 + self.frame += 1; 68 + if (self.frame >= frames.len) self.frame = 0; 69 + 70 + // Update rearm 71 + try ctx.tick(time_lapse, self.widget()); 72 + }, 73 + else => {}, 74 + } 75 + } 76 + 77 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 78 + const self: *Spinner = @ptrCast(@alignCast(ptr)); 79 + return self.draw(ctx); 80 + } 81 + 82 + pub fn draw(self: *Spinner, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 83 + const size: vxfw.Size = .{ 84 + .width = @max(1, ctx.min.width), 85 + .height = @max(1, ctx.min.height), 86 + }; 87 + 88 + const surface = try vxfw.Surface.init(ctx.arena, self.widget(), size); 89 + @memset(surface.buffer, .{ .style = self.style }); 90 + 91 + if (self.count.load(.unordered) == 0) return surface; 92 + 93 + surface.writeCell(0, 0, .{ 94 + .char = .{ 95 + .grapheme = frames[self.frame], 96 + .width = 1, 97 + }, 98 + .style = self.style, 99 + }); 100 + return surface; 101 + } 102 + 103 + test Spinner { 104 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 105 + defer arena.deinit(); 106 + // Create a spinner 107 + var spinner: Spinner = .{}; 108 + // Get our widget interface 109 + const spinner_widget = spinner.widget(); 110 + 111 + // Start the spinner. This (maybe) returns a Tick command to schedule the next frame. If the 112 + // spinner is already running, no command is returned. Calling start is thread safe. The 113 + // returned command can be added to an EventContext to schedule the frame 114 + const maybe_cmd = spinner.start(); 115 + try std.testing.expect(maybe_cmd != null); 116 + try std.testing.expect(maybe_cmd.? == .tick); 117 + try std.testing.expectEqual(1, spinner.count.load(.unordered)); 118 + 119 + // If we call start again, we won't get another command but our counter will go up 120 + const maybe_cmd2 = spinner.start(); 121 + try std.testing.expect(maybe_cmd2 == null); 122 + try std.testing.expectEqual(2, spinner.count.load(.unordered)); 123 + 124 + // We are about to deliver the tick to the widget. We need an EventContext (the engine will 125 + // provide this) 126 + var ctx: vxfw.EventContext = .{ 127 + .alloc = arena.allocator(), 128 + .cmds = .empty, 129 + }; 130 + 131 + // The event loop handles the tick event and calls us back with a .tick event. If we should keep 132 + // running, we will add a new tick event 133 + try spinner_widget.handleEvent(&ctx, .tick); 134 + 135 + // Receiving a .tick advances the frame 136 + try std.testing.expectEqual(1, spinner.frame); 137 + 138 + // Simulate a draw 139 + const surface = try spinner_widget.draw(.{ 140 + .arena = arena.allocator(), 141 + .min = .{}, 142 + .max = .{}, 143 + .cell_size = .{ .width = 10, .height = 20 }, 144 + }); 145 + 146 + // Spinner will try to be 1x1 147 + try std.testing.expectEqual(1, surface.size.width); 148 + try std.testing.expectEqual(1, surface.size.height); 149 + 150 + // Stopping the spinner decrements our counter 151 + spinner.stop(); 152 + try std.testing.expectEqual(1, spinner.count.load(.unordered)); 153 + spinner.stop(); 154 + try std.testing.expectEqual(0, spinner.count.load(.unordered)); 155 + } 156 + 157 + test "refAllDecls" { 158 + std.testing.refAllDecls(@This()); 159 + }
+251
src/vxfw/SplitView.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const Allocator = std.mem.Allocator; 5 + 6 + const vxfw = @import("vxfw.zig"); 7 + 8 + const SplitView = @This(); 9 + 10 + lhs: vxfw.Widget, 11 + rhs: vxfw.Widget, 12 + constrain: enum { lhs, rhs } = .lhs, 13 + style: vaxis.Style = .{}, 14 + /// min width for the constrained side 15 + min_width: u16 = 0, 16 + /// max width for the constrained side 17 + max_width: ?u16 = null, 18 + /// Target width to draw at 19 + width: u16, 20 + 21 + /// Used to calculate mouse events when our constraint is rhs 22 + last_max_width: ?u16 = null, 23 + 24 + // State 25 + pressed: bool = false, 26 + mouse_set: bool = false, 27 + 28 + pub fn widget(self: *const SplitView) vxfw.Widget { 29 + return .{ 30 + .userdata = @constCast(self), 31 + .eventHandler = typeErasedEventHandler, 32 + .drawFn = typeErasedDrawFn, 33 + }; 34 + } 35 + 36 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 37 + const self: *SplitView = @ptrCast(@alignCast(ptr)); 38 + switch (event) { 39 + .mouse_leave => { 40 + self.pressed = false; 41 + return; 42 + }, 43 + .mouse => {}, 44 + else => return, 45 + } 46 + const mouse = event.mouse; 47 + 48 + const separator_col: u16 = switch (self.constrain) { 49 + .lhs => self.width, 50 + .rhs => if (self.last_max_width) |max| 51 + max -| self.width -| 1 52 + else { 53 + ctx.redraw = true; 54 + return; 55 + }, 56 + }; 57 + 58 + // If we are on the separator, we always set the mouse shape 59 + if (mouse.col == separator_col) { 60 + try ctx.setMouseShape(.@"ew-resize"); 61 + self.mouse_set = true; 62 + // Set pressed state if we are a left click 63 + if (mouse.type == .press and mouse.button == .left) { 64 + self.pressed = true; 65 + } 66 + } else if (self.mouse_set) { 67 + // If we have set the mouse state and *aren't* over the separator, default the mouse state 68 + try ctx.setMouseShape(.default); 69 + self.mouse_set = false; 70 + } 71 + 72 + // On release, we reset state 73 + if (mouse.type == .release) { 74 + self.pressed = false; 75 + self.mouse_set = false; 76 + try ctx.setMouseShape(.default); 77 + } 78 + 79 + // If pressed, we always keep the mouse shape and we update the width 80 + if (self.pressed) { 81 + try ctx.setMouseShape(.@"ew-resize"); 82 + switch (self.constrain) { 83 + .lhs => { 84 + self.width = @max(self.min_width, mouse.col); 85 + if (self.max_width) |max| { 86 + self.width = @min(self.width, max); 87 + } 88 + }, 89 + .rhs => { 90 + const last_max = self.last_max_width orelse return; 91 + const mouse_col: u16 = if (mouse.col < 0) 0 else @intCast(mouse.col); 92 + self.width = @min(last_max -| self.min_width, last_max -| mouse_col -| 1); 93 + if (self.max_width) |max| { 94 + self.width = @max(self.width, max); 95 + } 96 + }, 97 + } 98 + ctx.consume_event = true; 99 + } 100 + } 101 + 102 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 103 + const self: *SplitView = @ptrCast(@alignCast(ptr)); 104 + // Fills entire space 105 + const max = ctx.max.size(); 106 + // Constrain width to the max 107 + self.width = @min(self.width, max.width); 108 + self.last_max_width = max.width; 109 + 110 + // The constrained side is equal to the width 111 + const constrained_min: vxfw.Size = .{ .width = self.width, .height = max.height }; 112 + const constrained_max = vxfw.MaxSize.fromSize(constrained_min); 113 + 114 + const unconstrained_min: vxfw.Size = .{ .width = max.width -| self.width -| 1, .height = max.height }; 115 + const unconstrained_max = vxfw.MaxSize.fromSize(unconstrained_min); 116 + 117 + var children = try std.ArrayList(vxfw.SubSurface).initCapacity(ctx.arena, 2); 118 + 119 + switch (self.constrain) { 120 + .lhs => { 121 + if (constrained_max.width.? > 0 and constrained_max.height.? > 0) { 122 + const lhs_ctx = ctx.withConstraints(constrained_min, constrained_max); 123 + const lhs_surface = try self.lhs.draw(lhs_ctx); 124 + children.appendAssumeCapacity(.{ 125 + .surface = lhs_surface, 126 + .origin = .{ .row = 0, .col = 0 }, 127 + }); 128 + } 129 + if (unconstrained_max.width.? > 0 and unconstrained_max.height.? > 0) { 130 + const rhs_ctx = ctx.withConstraints(unconstrained_min, unconstrained_max); 131 + const rhs_surface = try self.rhs.draw(rhs_ctx); 132 + children.appendAssumeCapacity(.{ 133 + .surface = rhs_surface, 134 + .origin = .{ .row = 0, .col = self.width + 1 }, 135 + }); 136 + } 137 + var surface = try vxfw.Surface.initWithChildren( 138 + ctx.arena, 139 + self.widget(), 140 + max, 141 + children.items, 142 + ); 143 + for (0..max.height) |row| { 144 + surface.writeCell(self.width, @intCast(row), .{ 145 + .char = .{ .grapheme = "โ”‚", .width = 1 }, 146 + .style = self.style, 147 + }); 148 + } 149 + return surface; 150 + }, 151 + .rhs => { 152 + if (unconstrained_max.width.? > 0 and unconstrained_max.height.? > 0) { 153 + const lhs_ctx = ctx.withConstraints(unconstrained_min, unconstrained_max); 154 + const lhs_surface = try self.lhs.draw(lhs_ctx); 155 + children.appendAssumeCapacity(.{ 156 + .surface = lhs_surface, 157 + .origin = .{ .row = 0, .col = 0 }, 158 + }); 159 + } 160 + if (constrained_max.width.? > 0 and constrained_max.height.? > 0) { 161 + const rhs_ctx = ctx.withConstraints(constrained_min, constrained_max); 162 + const rhs_surface = try self.rhs.draw(rhs_ctx); 163 + children.appendAssumeCapacity(.{ 164 + .surface = rhs_surface, 165 + .origin = .{ .row = 0, .col = unconstrained_max.width.? + 1 }, 166 + }); 167 + } 168 + var surface = try vxfw.Surface.initWithChildren( 169 + ctx.arena, 170 + self.widget(), 171 + max, 172 + children.items, 173 + ); 174 + for (0..max.height) |row| { 175 + surface.writeCell(max.width -| self.width -| 1, @intCast(row), .{ 176 + .char = .{ .grapheme = "โ”‚", .width = 1 }, 177 + .style = self.style, 178 + }); 179 + } 180 + return surface; 181 + }, 182 + } 183 + } 184 + 185 + test SplitView { 186 + // Boiler plate draw context 187 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 188 + defer arena.deinit(); 189 + vxfw.DrawContext.init(.unicode); 190 + 191 + const draw_ctx: vxfw.DrawContext = .{ 192 + .arena = arena.allocator(), 193 + .min = .{}, 194 + .max = .{ .width = 16, .height = 16 }, 195 + .cell_size = .{ .width = 10, .height = 20 }, 196 + }; 197 + 198 + // Create LHS and RHS widgets 199 + const lhs: vxfw.Text = .{ .text = "Left hand side" }; 200 + const rhs: vxfw.Text = .{ .text = "Right hand side" }; 201 + 202 + var split_view: SplitView = .{ 203 + .lhs = lhs.widget(), 204 + .rhs = rhs.widget(), 205 + .width = 8, 206 + }; 207 + 208 + const split_widget = split_view.widget(); 209 + { 210 + const surface = try split_widget.draw(draw_ctx); 211 + // SplitView expands to fill the space 212 + try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 16, .height = 16 }), surface.size); 213 + // It has two children 214 + try std.testing.expectEqual(2, surface.children.len); 215 + // The left child should have a width = SplitView.width 216 + try std.testing.expectEqual(split_view.width, surface.children[0].surface.size.width); 217 + } 218 + 219 + // Send the widget a mouse press on the separator 220 + var mouse: vaxis.Mouse = .{ 221 + // The separator is at width 222 + .col = @intCast(split_view.width), 223 + .row = 0, 224 + .type = .press, 225 + .button = .left, 226 + .mods = .{}, 227 + }; 228 + 229 + var ctx: vxfw.EventContext = .{ 230 + .alloc = arena.allocator(), 231 + .cmds = .empty, 232 + }; 233 + try split_widget.handleEvent(&ctx, .{ .mouse = mouse }); 234 + // We should get a command to change the mouse shape 235 + try std.testing.expect(ctx.cmds.items[0] == .set_mouse_shape); 236 + try std.testing.expect(ctx.redraw); 237 + try std.testing.expect(split_view.pressed); 238 + 239 + // If we move the mouse, we should update the width 240 + mouse.col = 2; 241 + mouse.type = .drag; 242 + try split_widget.handleEvent(&ctx, .{ .mouse = mouse }); 243 + try std.testing.expect(ctx.redraw); 244 + try std.testing.expect(split_view.pressed); 245 + const mouse_col: u16 = if (mouse.col < 0) 0 else @intCast(mouse.col); 246 + try std.testing.expectEqual(mouse_col, split_view.width); 247 + } 248 + 249 + test "refAllDecls" { 250 + std.testing.refAllDecls(@This()); 251 + }
+508
src/vxfw/Text.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const Allocator = std.mem.Allocator; 5 + 6 + const vxfw = @import("vxfw.zig"); 7 + 8 + const Text = @This(); 9 + 10 + text: []const u8, 11 + style: vaxis.Style = .{}, 12 + text_align: enum { left, center, right } = .left, 13 + softwrap: bool = true, 14 + overflow: enum { ellipsis, clip } = .ellipsis, 15 + width_basis: enum { parent, longest_line } = .longest_line, 16 + 17 + pub fn widget(self: *const Text) vxfw.Widget { 18 + return .{ 19 + .userdata = @constCast(self), 20 + .drawFn = typeErasedDrawFn, 21 + }; 22 + } 23 + 24 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 25 + const self: *const Text = @ptrCast(@alignCast(ptr)); 26 + return self.draw(ctx); 27 + } 28 + 29 + pub fn draw(self: *const Text, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 30 + if (ctx.max.width != null and ctx.max.width.? == 0) { 31 + return .{ 32 + .size = ctx.min, 33 + .widget = self.widget(), 34 + .buffer = &.{}, 35 + .children = &.{}, 36 + }; 37 + } 38 + const container_size = self.findContainerSize(ctx); 39 + 40 + // Create a surface of target width and max height. We'll trim the result after drawing 41 + const surface = try vxfw.Surface.init( 42 + ctx.arena, 43 + self.widget(), 44 + container_size, 45 + ); 46 + const base_style: vaxis.Style = .{ 47 + .fg = self.style.fg, 48 + .bg = self.style.bg, 49 + .reverse = self.style.reverse, 50 + }; 51 + const base: vaxis.Cell = .{ .style = base_style }; 52 + @memset(surface.buffer, base); 53 + 54 + var row: u16 = 0; 55 + if (self.softwrap) { 56 + var iter = SoftwrapIterator.init(self.text, ctx); 57 + while (iter.next()) |line| { 58 + if (row >= container_size.height) break; 59 + defer row += 1; 60 + var col: u16 = switch (self.text_align) { 61 + .left => 0, 62 + .center => (container_size.width - line.width) / 2, 63 + .right => container_size.width - line.width, 64 + }; 65 + var char_iter = ctx.graphemeIterator(line.bytes); 66 + while (char_iter.next()) |char| { 67 + const grapheme = char.bytes(line.bytes); 68 + if (std.mem.eql(u8, grapheme, "\t")) { 69 + for (0..8) |i| { 70 + surface.writeCell(@intCast(col + i), row, .{ 71 + .char = .{ .grapheme = " ", .width = 1 }, 72 + .style = self.style, 73 + }); 74 + } 75 + col += 8; 76 + continue; 77 + } 78 + const grapheme_width: u8 = @intCast(ctx.stringWidth(grapheme)); 79 + surface.writeCell(col, row, .{ 80 + .char = .{ .grapheme = grapheme, .width = grapheme_width }, 81 + .style = self.style, 82 + }); 83 + col += grapheme_width; 84 + } 85 + } 86 + } else { 87 + var line_iter: LineIterator = .{ .buf = self.text }; 88 + while (line_iter.next()) |line| { 89 + if (row >= container_size.height) break; 90 + // \t is default 1 wide. We add 7x the count of tab characters to get the full width 91 + const line_width = ctx.stringWidth(line) + 7 * std.mem.count(u8, line, "\t"); 92 + defer row += 1; 93 + const resolved_line_width = @min(container_size.width, line_width); 94 + var col: u16 = switch (self.text_align) { 95 + .left => 0, 96 + .center => (container_size.width - resolved_line_width) / 2, 97 + .right => container_size.width - resolved_line_width, 98 + }; 99 + var char_iter = ctx.graphemeIterator(line); 100 + while (char_iter.next()) |char| { 101 + if (col >= container_size.width) break; 102 + const grapheme = char.bytes(line); 103 + const grapheme_width: u8 = @intCast(ctx.stringWidth(grapheme)); 104 + 105 + if (col + grapheme_width >= container_size.width and 106 + line_width > container_size.width and 107 + self.overflow == .ellipsis) 108 + { 109 + surface.writeCell(col, row, .{ 110 + .char = .{ .grapheme = "โ€ฆ", .width = 1 }, 111 + .style = self.style, 112 + }); 113 + col = container_size.width; 114 + } else { 115 + surface.writeCell(col, row, .{ 116 + .char = .{ .grapheme = grapheme, .width = grapheme_width }, 117 + .style = self.style, 118 + }); 119 + col += @intCast(grapheme_width); 120 + } 121 + } 122 + } 123 + } 124 + return surface.trimHeight(@max(row, ctx.min.height)); 125 + } 126 + 127 + /// Determines the container size by finding the widest line in the viewable area 128 + fn findContainerSize(self: Text, ctx: vxfw.DrawContext) vxfw.Size { 129 + var row: u16 = 0; 130 + var max_width: u16 = ctx.min.width; 131 + if (self.softwrap) { 132 + var iter = SoftwrapIterator.init(self.text, ctx); 133 + while (iter.next()) |line| { 134 + if (ctx.max.outsideHeight(row)) 135 + break; 136 + 137 + defer row += 1; 138 + max_width = @max(max_width, line.width); 139 + } 140 + } else { 141 + var line_iter: LineIterator = .{ .buf = self.text }; 142 + while (line_iter.next()) |line| { 143 + if (ctx.max.outsideHeight(row)) 144 + break; 145 + const line_width: u16 = @truncate(ctx.stringWidth(line)); 146 + defer row += 1; 147 + const resolved_line_width = if (ctx.max.width) |max| 148 + @min(max, line_width) 149 + else 150 + line_width; 151 + max_width = @max(max_width, resolved_line_width); 152 + } 153 + } 154 + const result_width = switch (self.width_basis) { 155 + .longest_line => blk: { 156 + if (ctx.max.width) |max| 157 + break :blk @min(max, max_width) 158 + else 159 + break :blk max_width; 160 + }, 161 + .parent => blk: { 162 + std.debug.assert(ctx.max.width != null); 163 + break :blk ctx.max.width.?; 164 + }, 165 + }; 166 + return .{ .width = result_width, .height = @max(row, ctx.min.height) }; 167 + } 168 + 169 + /// Iterates a slice of bytes by linebreaks. Lines are split by '\r', '\n', or '\r\n' 170 + pub const LineIterator = struct { 171 + buf: []const u8, 172 + index: usize = 0, 173 + 174 + fn next(self: *LineIterator) ?[]const u8 { 175 + if (self.index >= self.buf.len) return null; 176 + 177 + const start = self.index; 178 + const end = std.mem.indexOfAnyPos(u8, self.buf, self.index, "\r\n") orelse { 179 + self.index = self.buf.len; 180 + return self.buf[start..]; 181 + }; 182 + 183 + self.index = end; 184 + self.consumeCR(); 185 + self.consumeLF(); 186 + return self.buf[start..end]; 187 + } 188 + 189 + // consumes a \n byte 190 + fn consumeLF(self: *LineIterator) void { 191 + if (self.index >= self.buf.len) return; 192 + if (self.buf[self.index] == '\n') self.index += 1; 193 + } 194 + 195 + // consumes a \r byte 196 + fn consumeCR(self: *LineIterator) void { 197 + if (self.index >= self.buf.len) return; 198 + if (self.buf[self.index] == '\r') self.index += 1; 199 + } 200 + }; 201 + 202 + pub const SoftwrapIterator = struct { 203 + ctx: vxfw.DrawContext, 204 + line: []const u8 = "", 205 + index: usize = 0, 206 + hard_iter: LineIterator, 207 + 208 + pub const Line = struct { 209 + width: u16, 210 + bytes: []const u8, 211 + }; 212 + 213 + const soft_breaks = " \t"; 214 + 215 + fn init(buf: []const u8, ctx: vxfw.DrawContext) SoftwrapIterator { 216 + return .{ 217 + .ctx = ctx, 218 + .hard_iter = .{ .buf = buf }, 219 + }; 220 + } 221 + 222 + fn next(self: *SoftwrapIterator) ?Line { 223 + // Advance the hard iterator 224 + if (self.index == self.line.len) { 225 + self.line = self.hard_iter.next() orelse return null; 226 + self.line = std.mem.trimRight(u8, self.line, " \t"); 227 + self.index = 0; 228 + } 229 + 230 + const start = self.index; 231 + var cur_width: u16 = 0; 232 + while (self.index < self.line.len) { 233 + const idx = self.nextWrap(); 234 + const word = self.line[self.index..idx]; 235 + const next_width = self.ctx.stringWidth(word); 236 + 237 + if (self.ctx.max.width) |max| { 238 + if (cur_width + next_width > max) { 239 + // Trim the word to see if it can fit on a line by itself 240 + const trimmed = std.mem.trimLeft(u8, word, " \t"); 241 + const trimmed_bytes = word.len - trimmed.len; 242 + // The number of bytes we trimmed is equal to the reduction in length 243 + const trimmed_width = next_width - trimmed_bytes; 244 + if (trimmed_width > max) { 245 + // Won't fit on line by itself, so fit as much on this line as we can 246 + var iter = self.ctx.graphemeIterator(word); 247 + while (iter.next()) |item| { 248 + const grapheme = item.bytes(word); 249 + const w = self.ctx.stringWidth(grapheme); 250 + if (cur_width + w > max) { 251 + const end = self.index; 252 + return .{ .width = cur_width, .bytes = self.line[start..end] }; 253 + } 254 + cur_width += @intCast(w); 255 + self.index += grapheme.len; 256 + } 257 + } 258 + // We are softwrapping, advance index to the start of the next word 259 + const end = self.index; 260 + self.index = std.mem.indexOfNonePos(u8, self.line, self.index, soft_breaks) orelse self.line.len; 261 + return .{ .width = cur_width, .bytes = self.line[start..end] }; 262 + } 263 + } 264 + 265 + self.index = idx; 266 + cur_width += @intCast(next_width); 267 + } 268 + return .{ .width = cur_width, .bytes = self.line[start..] }; 269 + } 270 + 271 + /// Determines the index of the end of the next word 272 + fn nextWrap(self: *SoftwrapIterator) usize { 273 + // Find the first linear whitespace char 274 + const start_pos = std.mem.indexOfNonePos(u8, self.line, self.index, soft_breaks) orelse 275 + return self.line.len; 276 + if (std.mem.indexOfAnyPos(u8, self.line, start_pos, soft_breaks)) |idx| { 277 + return idx; 278 + } 279 + return self.line.len; 280 + } 281 + 282 + // consumes a \n byte 283 + fn consumeLF(self: *SoftwrapIterator) void { 284 + if (self.index >= self.buf.len) return; 285 + if (self.buf[self.index] == '\n') self.index += 1; 286 + } 287 + 288 + // consumes a \r byte 289 + fn consumeCR(self: *SoftwrapIterator) void { 290 + if (self.index >= self.buf.len) return; 291 + if (self.buf[self.index] == '\r') self.index += 1; 292 + } 293 + }; 294 + 295 + test "SoftwrapIterator: LF breaks" { 296 + vxfw.DrawContext.init(.unicode); 297 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 298 + defer arena.deinit(); 299 + 300 + const ctx: vxfw.DrawContext = .{ 301 + .min = .{ .width = 0, .height = 0 }, 302 + .max = .{ .width = 20, .height = 10 }, 303 + .arena = arena.allocator(), 304 + .cell_size = .{ .width = 10, .height = 20 }, 305 + }; 306 + var iter = SoftwrapIterator.init("Hello, \n world", ctx); 307 + const first = iter.next(); 308 + try std.testing.expect(first != null); 309 + try std.testing.expectEqualStrings("Hello,", first.?.bytes); 310 + try std.testing.expectEqual(6, first.?.width); 311 + 312 + const second = iter.next(); 313 + try std.testing.expect(second != null); 314 + try std.testing.expectEqualStrings(" world", second.?.bytes); 315 + try std.testing.expectEqual(6, second.?.width); 316 + 317 + const end = iter.next(); 318 + try std.testing.expect(end == null); 319 + } 320 + 321 + test "SoftwrapIterator: soft breaks that fit" { 322 + vxfw.DrawContext.init(.unicode); 323 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 324 + defer arena.deinit(); 325 + 326 + const ctx: vxfw.DrawContext = .{ 327 + .min = .{ .width = 0, .height = 0 }, 328 + .max = .{ .width = 6, .height = 10 }, 329 + .arena = arena.allocator(), 330 + .cell_size = .{ .width = 10, .height = 20 }, 331 + }; 332 + var iter = SoftwrapIterator.init("Hello, \nworld", ctx); 333 + const first = iter.next(); 334 + try std.testing.expect(first != null); 335 + try std.testing.expectEqualStrings("Hello,", first.?.bytes); 336 + try std.testing.expectEqual(6, first.?.width); 337 + 338 + const second = iter.next(); 339 + try std.testing.expect(second != null); 340 + try std.testing.expectEqualStrings("world", second.?.bytes); 341 + try std.testing.expectEqual(5, second.?.width); 342 + 343 + const end = iter.next(); 344 + try std.testing.expect(end == null); 345 + } 346 + 347 + test "SoftwrapIterator: soft breaks that are longer than width" { 348 + vxfw.DrawContext.init(.unicode); 349 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 350 + defer arena.deinit(); 351 + 352 + const ctx: vxfw.DrawContext = .{ 353 + .min = .{ .width = 0, .height = 0 }, 354 + .max = .{ .width = 6, .height = 10 }, 355 + .arena = arena.allocator(), 356 + .cell_size = .{ .width = 10, .height = 20 }, 357 + }; 358 + var iter = SoftwrapIterator.init("very-long-word \nworld", ctx); 359 + const first = iter.next(); 360 + try std.testing.expect(first != null); 361 + try std.testing.expectEqualStrings("very-l", first.?.bytes); 362 + try std.testing.expectEqual(6, first.?.width); 363 + 364 + const second = iter.next(); 365 + try std.testing.expect(second != null); 366 + try std.testing.expectEqualStrings("ong-wo", second.?.bytes); 367 + try std.testing.expectEqual(6, second.?.width); 368 + 369 + const third = iter.next(); 370 + try std.testing.expect(third != null); 371 + try std.testing.expectEqualStrings("rd", third.?.bytes); 372 + try std.testing.expectEqual(2, third.?.width); 373 + 374 + const fourth = iter.next(); 375 + try std.testing.expect(fourth != null); 376 + try std.testing.expectEqualStrings("world", fourth.?.bytes); 377 + try std.testing.expectEqual(5, fourth.?.width); 378 + 379 + const end = iter.next(); 380 + try std.testing.expect(end == null); 381 + } 382 + 383 + test "SoftwrapIterator: soft breaks with leading spaces" { 384 + vxfw.DrawContext.init(.unicode); 385 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 386 + defer arena.deinit(); 387 + 388 + const ctx: vxfw.DrawContext = .{ 389 + .min = .{ .width = 0, .height = 0 }, 390 + .max = .{ .width = 6, .height = 10 }, 391 + .arena = arena.allocator(), 392 + .cell_size = .{ .width = 10, .height = 20 }, 393 + }; 394 + var iter = SoftwrapIterator.init("Hello, \n world", ctx); 395 + const first = iter.next(); 396 + try std.testing.expect(first != null); 397 + try std.testing.expectEqualStrings("Hello,", first.?.bytes); 398 + try std.testing.expectEqual(6, first.?.width); 399 + 400 + const second = iter.next(); 401 + try std.testing.expect(second != null); 402 + try std.testing.expectEqualStrings(" world", second.?.bytes); 403 + try std.testing.expectEqual(6, second.?.width); 404 + 405 + const end = iter.next(); 406 + try std.testing.expect(end == null); 407 + } 408 + 409 + test "LineIterator: LF breaks" { 410 + const input = "Hello, \n world"; 411 + var iter: LineIterator = .{ .buf = input }; 412 + const first = iter.next(); 413 + try std.testing.expect(first != null); 414 + try std.testing.expectEqualStrings("Hello, ", first.?); 415 + 416 + const second = iter.next(); 417 + try std.testing.expect(second != null); 418 + try std.testing.expectEqualStrings(" world", second.?); 419 + 420 + const end = iter.next(); 421 + try std.testing.expect(end == null); 422 + } 423 + 424 + test "LineIterator: CR breaks" { 425 + const input = "Hello, \r world"; 426 + var iter: LineIterator = .{ .buf = input }; 427 + const first = iter.next(); 428 + try std.testing.expect(first != null); 429 + try std.testing.expectEqualStrings("Hello, ", first.?); 430 + 431 + const second = iter.next(); 432 + try std.testing.expect(second != null); 433 + try std.testing.expectEqualStrings(" world", second.?); 434 + 435 + const end = iter.next(); 436 + try std.testing.expect(end == null); 437 + } 438 + 439 + test "LineIterator: CRLF breaks" { 440 + const input = "Hello, \r\n world"; 441 + var iter: LineIterator = .{ .buf = input }; 442 + const first = iter.next(); 443 + try std.testing.expect(first != null); 444 + try std.testing.expectEqualStrings("Hello, ", first.?); 445 + 446 + const second = iter.next(); 447 + try std.testing.expect(second != null); 448 + try std.testing.expectEqualStrings(" world", second.?); 449 + 450 + const end = iter.next(); 451 + try std.testing.expect(end == null); 452 + } 453 + 454 + test "LineIterator: CRLF breaks with empty line" { 455 + const input = "Hello, \r\n\r\n world"; 456 + var iter: LineIterator = .{ .buf = input }; 457 + const first = iter.next(); 458 + try std.testing.expect(first != null); 459 + try std.testing.expectEqualStrings("Hello, ", first.?); 460 + 461 + const second = iter.next(); 462 + try std.testing.expect(second != null); 463 + try std.testing.expectEqualStrings("", second.?); 464 + 465 + const third = iter.next(); 466 + try std.testing.expect(third != null); 467 + try std.testing.expectEqualStrings(" world", third.?); 468 + 469 + const end = iter.next(); 470 + try std.testing.expect(end == null); 471 + } 472 + 473 + test Text { 474 + var text: Text = .{ .text = "Hello, world" }; 475 + const text_widget = text.widget(); 476 + 477 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 478 + defer arena.deinit(); 479 + vxfw.DrawContext.init(.unicode); 480 + 481 + // Center expands to the max size. It must therefore have non-null max width and max height. 482 + // These values are asserted in draw 483 + const ctx: vxfw.DrawContext = .{ 484 + .arena = arena.allocator(), 485 + .min = .{}, 486 + .max = .{ .width = 7, .height = 2 }, 487 + .cell_size = .{ .width = 10, .height = 20 }, 488 + }; 489 + 490 + { 491 + // Text softwraps by default 492 + const surface = try text_widget.draw(ctx); 493 + try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 6, .height = 2 }), surface.size); 494 + } 495 + 496 + { 497 + text.softwrap = false; 498 + text.overflow = .ellipsis; 499 + const surface = try text_widget.draw(ctx); 500 + try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 7, .height = 1 }), surface.size); 501 + // The last character will be an ellipsis 502 + try std.testing.expectEqualStrings("โ€ฆ", surface.buffer[surface.buffer.len - 1].char.grapheme); 503 + } 504 + } 505 + 506 + test "refAllDecls" { 507 + std.testing.refAllDecls(@This()); 508 + }
+598
src/vxfw/TextField.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const vxfw = @import("vxfw.zig"); 5 + 6 + const assert = std.debug.assert; 7 + 8 + const Allocator = std.mem.Allocator; 9 + const Key = vaxis.Key; 10 + const Cell = vaxis.Cell; 11 + const Window = vaxis.Window; 12 + const unicode = vaxis.unicode; 13 + 14 + const TextField = @This(); 15 + 16 + const ellipsis: Cell.Character = .{ .grapheme = "โ€ฆ", .width = 1 }; 17 + 18 + // Index of our cursor 19 + buf: Buffer, 20 + 21 + /// Style to draw the TextField with 22 + style: vaxis.Style = .{}, 23 + 24 + /// the number of graphemes to skip when drawing. Used for horizontal scrolling 25 + draw_offset: u16 = 0, 26 + /// the column we placed the cursor the last time we drew 27 + prev_cursor_col: u16 = 0, 28 + /// the grapheme index of the cursor the last time we drew 29 + prev_cursor_idx: u16 = 0, 30 + /// approximate distance from an edge before we scroll 31 + scroll_offset: u4 = 4, 32 + /// Previous width we drew at 33 + prev_width: u16 = 0, 34 + 35 + previous_val: []const u8 = "", 36 + 37 + userdata: ?*anyopaque = null, 38 + onChange: ?*const fn (?*anyopaque, *vxfw.EventContext, []const u8) anyerror!void = null, 39 + onSubmit: ?*const fn (?*anyopaque, *vxfw.EventContext, []const u8) anyerror!void = null, 40 + 41 + pub fn init(alloc: std.mem.Allocator) TextField { 42 + return TextField{ 43 + .buf = Buffer.init(alloc), 44 + }; 45 + } 46 + 47 + pub fn deinit(self: *TextField) void { 48 + self.buf.allocator.free(self.previous_val); 49 + self.buf.deinit(); 50 + } 51 + 52 + pub fn widget(self: *TextField) vxfw.Widget { 53 + return .{ 54 + .userdata = self, 55 + .eventHandler = typeErasedEventHandler, 56 + .drawFn = typeErasedDrawFn, 57 + }; 58 + } 59 + 60 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 61 + const self: *TextField = @ptrCast(@alignCast(ptr)); 62 + return self.handleEvent(ctx, event); 63 + } 64 + 65 + pub fn handleEvent(self: *TextField, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 66 + switch (event) { 67 + .focus_out, .focus_in => ctx.redraw = true, 68 + .key_press => |key| { 69 + if (key.matches(Key.backspace, .{})) { 70 + self.deleteBeforeCursor(); 71 + return self.checkChanged(ctx); 72 + } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) { 73 + self.deleteAfterCursor(); 74 + return self.checkChanged(ctx); 75 + } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) { 76 + self.cursorLeft(); 77 + return ctx.consumeAndRedraw(); 78 + } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) { 79 + self.cursorRight(); 80 + return ctx.consumeAndRedraw(); 81 + } else if (key.matches('a', .{ .ctrl = true }) or key.matches(Key.home, .{})) { 82 + self.buf.moveGapLeft(self.buf.firstHalf().len); 83 + return ctx.consumeAndRedraw(); 84 + } else if (key.matches('e', .{ .ctrl = true }) or key.matches(Key.end, .{})) { 85 + self.buf.moveGapRight(self.buf.secondHalf().len); 86 + return ctx.consumeAndRedraw(); 87 + } else if (key.matches('k', .{ .ctrl = true })) { 88 + self.deleteToEnd(); 89 + return self.checkChanged(ctx); 90 + } else if (key.matches('u', .{ .ctrl = true })) { 91 + self.deleteToStart(); 92 + return self.checkChanged(ctx); 93 + } else if (key.matches('b', .{ .alt = true }) or key.matches(Key.left, .{ .alt = true })) { 94 + self.moveBackwardWordwise(); 95 + return ctx.consumeAndRedraw(); 96 + } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) { 97 + self.moveForwardWordwise(); 98 + return ctx.consumeAndRedraw(); 99 + } else if (key.matches('w', .{ .ctrl = true }) or key.matches(Key.backspace, .{ .alt = true })) { 100 + self.deleteWordBefore(); 101 + return self.checkChanged(ctx); 102 + } else if (key.matches('d', .{ .alt = true })) { 103 + self.deleteWordAfter(); 104 + return self.checkChanged(ctx); 105 + } else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) { 106 + if (self.onSubmit) |onSubmit| { 107 + const value = try self.toOwnedSlice(); 108 + // Get a ref to the allocator in case onSubmit deinits the TextField 109 + const allocator = self.buf.allocator; 110 + defer allocator.free(value); 111 + try onSubmit(self.userdata, ctx, value); 112 + return ctx.consumeAndRedraw(); 113 + } 114 + } else if (key.text) |text| { 115 + try self.insertSliceAtCursor(text); 116 + return self.checkChanged(ctx); 117 + } 118 + }, 119 + else => {}, 120 + } 121 + } 122 + 123 + fn checkChanged(self: *TextField, ctx: *vxfw.EventContext) anyerror!void { 124 + ctx.consumeAndRedraw(); 125 + const onChange = self.onChange orelse return; 126 + const new = try self.buf.dupe(); 127 + defer { 128 + self.buf.allocator.free(self.previous_val); 129 + self.previous_val = new; 130 + } 131 + if (std.mem.eql(u8, new, self.previous_val)) return; 132 + try onChange(self.userdata, ctx, new); 133 + } 134 + 135 + /// insert text at the cursor position 136 + pub fn insertSliceAtCursor(self: *TextField, data: []const u8) std.mem.Allocator.Error!void { 137 + var iter = unicode.graphemeIterator(data); 138 + while (iter.next()) |text| { 139 + try self.buf.insertSliceAtCursor(text.bytes(data)); 140 + } 141 + } 142 + 143 + pub fn sliceToCursor(self: *TextField, buf: []u8) []const u8 { 144 + assert(buf.len >= self.buf.cursor); 145 + @memcpy(buf[0..self.buf.cursor], self.buf.firstHalf()); 146 + return buf[0..self.buf.cursor]; 147 + } 148 + 149 + /// calculates the display width from the draw_offset to the cursor 150 + pub fn widthToCursor(self: *TextField, ctx: vxfw.DrawContext) u16 { 151 + var width: u16 = 0; 152 + const first_half = self.buf.firstHalf(); 153 + var first_iter = unicode.graphemeIterator(first_half); 154 + var i: usize = 0; 155 + while (first_iter.next()) |grapheme| { 156 + defer i += 1; 157 + if (i < self.draw_offset) { 158 + continue; 159 + } 160 + const g = grapheme.bytes(first_half); 161 + width += @intCast(ctx.stringWidth(g)); 162 + } 163 + return width; 164 + } 165 + 166 + pub fn cursorLeft(self: *TextField) void { 167 + // We need to find the size of the last grapheme in the first half 168 + var iter = unicode.graphemeIterator(self.buf.firstHalf()); 169 + var len: usize = 0; 170 + while (iter.next()) |grapheme| { 171 + len = grapheme.len; 172 + } 173 + self.buf.moveGapLeft(len); 174 + } 175 + 176 + pub fn cursorRight(self: *TextField) void { 177 + var iter = unicode.graphemeIterator(self.buf.secondHalf()); 178 + const grapheme = iter.next() orelse return; 179 + self.buf.moveGapRight(grapheme.len); 180 + } 181 + 182 + pub fn graphemesBeforeCursor(self: *const TextField) u16 { 183 + const first_half = self.buf.firstHalf(); 184 + var first_iter = unicode.graphemeIterator(first_half); 185 + var i: u16 = 0; 186 + while (first_iter.next()) |_| { 187 + i += 1; 188 + } 189 + return i; 190 + } 191 + 192 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 193 + const self: *TextField = @ptrCast(@alignCast(ptr)); 194 + return self.draw(ctx); 195 + } 196 + 197 + pub fn draw(self: *TextField, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 198 + std.debug.assert(ctx.max.width != null); 199 + const max_width = ctx.max.width.?; 200 + if (max_width != self.prev_width) { 201 + self.prev_width = max_width; 202 + self.draw_offset = 0; 203 + self.prev_cursor_col = 0; 204 + } 205 + // Create a surface with max width and a minimum height of 1. 206 + var surface = try vxfw.Surface.init( 207 + ctx.arena, 208 + self.widget(), 209 + .{ .width = max_width, .height = @max(ctx.min.height, 1) }, 210 + ); 211 + 212 + const base: vaxis.Cell = .{ .style = self.style }; 213 + @memset(surface.buffer, base); 214 + const style = self.style; 215 + const cursor_idx = self.graphemesBeforeCursor(); 216 + if (cursor_idx < self.draw_offset) self.draw_offset = cursor_idx; 217 + if (max_width == 0) return surface; 218 + while (true) { 219 + const width = self.widthToCursor(ctx); 220 + if (width >= max_width) { 221 + self.draw_offset +|= width - max_width + 1; 222 + continue; 223 + } else break; 224 + } 225 + 226 + self.prev_cursor_idx = cursor_idx; 227 + self.prev_cursor_col = 0; 228 + 229 + const first_half = self.buf.firstHalf(); 230 + var first_iter = unicode.graphemeIterator(first_half); 231 + var col: u16 = 0; 232 + var i: u16 = 0; 233 + while (first_iter.next()) |grapheme| { 234 + if (i < self.draw_offset) { 235 + i += 1; 236 + continue; 237 + } 238 + const g = grapheme.bytes(first_half); 239 + const w: u8 = @intCast(ctx.stringWidth(g)); 240 + if (col + w >= max_width) { 241 + surface.writeCell(max_width - 1, 0, .{ 242 + .char = ellipsis, 243 + .style = style, 244 + }); 245 + break; 246 + } 247 + surface.writeCell(@intCast(col), 0, .{ 248 + .char = .{ 249 + .grapheme = g, 250 + .width = w, 251 + }, 252 + .style = style, 253 + }); 254 + col += w; 255 + i += 1; 256 + if (i == cursor_idx) self.prev_cursor_col = col; 257 + } 258 + const second_half = self.buf.secondHalf(); 259 + var second_iter = unicode.graphemeIterator(second_half); 260 + while (second_iter.next()) |grapheme| { 261 + if (i < self.draw_offset) { 262 + i += 1; 263 + continue; 264 + } 265 + const g = grapheme.bytes(second_half); 266 + const w: u8 = @intCast(ctx.stringWidth(g)); 267 + if (col + w > max_width) { 268 + surface.writeCell(max_width - 1, 0, .{ 269 + .char = ellipsis, 270 + .style = style, 271 + }); 272 + break; 273 + } 274 + surface.writeCell(@intCast(col), 0, .{ 275 + .char = .{ 276 + .grapheme = g, 277 + .width = w, 278 + }, 279 + .style = style, 280 + }); 281 + col += w; 282 + i += 1; 283 + if (i == cursor_idx) self.prev_cursor_col = col; 284 + } 285 + if (self.draw_offset > 0) { 286 + surface.writeCell(0, 0, .{ 287 + .char = ellipsis, 288 + .style = style, 289 + }); 290 + } 291 + surface.cursor = .{ .col = @intCast(self.prev_cursor_col), .row = 0 }; 292 + return surface; 293 + // win.showCursor(self.prev_cursor_col, 0); 294 + } 295 + 296 + pub fn clearAndFree(self: *TextField) void { 297 + self.buf.clearAndFree(); 298 + self.reset(); 299 + } 300 + 301 + pub fn clearRetainingCapacity(self: *TextField) void { 302 + self.buf.clearRetainingCapacity(); 303 + self.reset(); 304 + } 305 + 306 + pub fn toOwnedSlice(self: *TextField) ![]const u8 { 307 + defer self.reset(); 308 + return self.buf.toOwnedSlice(); 309 + } 310 + 311 + pub fn reset(self: *TextField) void { 312 + self.draw_offset = 0; 313 + self.prev_cursor_col = 0; 314 + self.prev_cursor_idx = 0; 315 + } 316 + 317 + // returns the number of bytes before the cursor 318 + pub fn byteOffsetToCursor(self: TextField) usize { 319 + return self.buf.cursor; 320 + } 321 + 322 + pub fn deleteToEnd(self: *TextField) void { 323 + self.buf.growGapRight(self.buf.secondHalf().len); 324 + } 325 + 326 + pub fn deleteToStart(self: *TextField) void { 327 + self.buf.growGapLeft(self.buf.cursor); 328 + } 329 + 330 + pub fn deleteBeforeCursor(self: *TextField) void { 331 + // We need to find the size of the last grapheme in the first half 332 + var iter = unicode.graphemeIterator(self.buf.firstHalf()); 333 + var len: usize = 0; 334 + while (iter.next()) |grapheme| { 335 + len = grapheme.len; 336 + } 337 + self.buf.growGapLeft(len); 338 + } 339 + 340 + pub fn deleteAfterCursor(self: *TextField) void { 341 + var iter = unicode.graphemeIterator(self.buf.secondHalf()); 342 + const grapheme = iter.next() orelse return; 343 + self.buf.growGapRight(grapheme.len); 344 + } 345 + 346 + /// Moves the cursor backward by words. If the character before the cursor is a space, the cursor is 347 + /// positioned just after the next previous space 348 + pub fn moveBackwardWordwise(self: *TextField) void { 349 + const trimmed = std.mem.trimRight(u8, self.buf.firstHalf(), " "); 350 + const idx = if (std.mem.lastIndexOfScalar(u8, trimmed, ' ')) |last| 351 + last + 1 352 + else 353 + 0; 354 + self.buf.moveGapLeft(self.buf.cursor - idx); 355 + } 356 + 357 + pub fn moveForwardWordwise(self: *TextField) void { 358 + const second_half = self.buf.secondHalf(); 359 + var i: usize = 0; 360 + while (i < second_half.len and second_half[i] == ' ') : (i += 1) {} 361 + const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len; 362 + self.buf.moveGapRight(idx); 363 + } 364 + 365 + pub fn deleteWordBefore(self: *TextField) void { 366 + // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 367 + // moved 368 + const pre = self.buf.cursor; 369 + self.moveBackwardWordwise(); 370 + self.buf.growGapRight(pre - self.buf.cursor); 371 + } 372 + 373 + pub fn deleteWordAfter(self: *TextField) void { 374 + // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 375 + // moved 376 + const second_half = self.buf.secondHalf(); 377 + var i: usize = 0; 378 + while (i < second_half.len and second_half[i] == ' ') : (i += 1) {} 379 + const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len; 380 + self.buf.growGapRight(idx); 381 + } 382 + 383 + test "sliceToCursor" { 384 + var input = init(std.testing.allocator); 385 + defer input.deinit(); 386 + try input.insertSliceAtCursor("hello, world"); 387 + input.cursorLeft(); 388 + input.cursorLeft(); 389 + input.cursorLeft(); 390 + var buf: [32]u8 = undefined; 391 + try std.testing.expectEqualStrings("hello, wo", input.sliceToCursor(&buf)); 392 + input.cursorRight(); 393 + try std.testing.expectEqualStrings("hello, wor", input.sliceToCursor(&buf)); 394 + } 395 + 396 + pub const Buffer = struct { 397 + allocator: std.mem.Allocator, 398 + buffer: []u8, 399 + cursor: usize, 400 + gap_size: usize, 401 + 402 + pub fn init(allocator: std.mem.Allocator) Buffer { 403 + return .{ 404 + .allocator = allocator, 405 + .buffer = &.{}, 406 + .cursor = 0, 407 + .gap_size = 0, 408 + }; 409 + } 410 + 411 + pub fn deinit(self: *Buffer) void { 412 + self.allocator.free(self.buffer); 413 + } 414 + 415 + pub fn firstHalf(self: Buffer) []const u8 { 416 + return self.buffer[0..self.cursor]; 417 + } 418 + 419 + pub fn secondHalf(self: Buffer) []const u8 { 420 + return self.buffer[self.cursor + self.gap_size ..]; 421 + } 422 + 423 + pub fn grow(self: *Buffer, n: usize) std.mem.Allocator.Error!void { 424 + // Always grow by 512 bytes 425 + const new_size = self.buffer.len + n + 512; 426 + // Allocate the new memory 427 + const new_memory = try self.allocator.alloc(u8, new_size); 428 + // Copy the first half 429 + @memcpy(new_memory[0..self.cursor], self.firstHalf()); 430 + // Copy the second half 431 + const second_half = self.secondHalf(); 432 + @memcpy(new_memory[new_size - second_half.len ..], second_half); 433 + self.allocator.free(self.buffer); 434 + self.buffer = new_memory; 435 + self.gap_size = new_size - second_half.len - self.cursor; 436 + } 437 + 438 + pub fn insertSliceAtCursor(self: *Buffer, slice: []const u8) std.mem.Allocator.Error!void { 439 + if (slice.len == 0) return; 440 + if (self.gap_size <= slice.len) try self.grow(slice.len); 441 + @memcpy(self.buffer[self.cursor .. self.cursor + slice.len], slice); 442 + self.cursor += slice.len; 443 + self.gap_size -= slice.len; 444 + } 445 + 446 + /// Move the gap n bytes to the left 447 + pub fn moveGapLeft(self: *Buffer, n: usize) void { 448 + const new_idx = self.cursor -| n; 449 + const dst = self.buffer[new_idx + self.gap_size ..]; 450 + const src = self.buffer[new_idx..self.cursor]; 451 + std.mem.copyForwards(u8, dst, src); 452 + self.cursor = new_idx; 453 + } 454 + 455 + pub fn moveGapRight(self: *Buffer, n: usize) void { 456 + const new_idx = self.cursor + n; 457 + const dst = self.buffer[self.cursor..]; 458 + const src = self.buffer[self.cursor + self.gap_size .. new_idx + self.gap_size]; 459 + std.mem.copyForwards(u8, dst, src); 460 + self.cursor = new_idx; 461 + } 462 + 463 + /// grow the gap by moving the cursor n bytes to the left 464 + pub fn growGapLeft(self: *Buffer, n: usize) void { 465 + // gap grows by the delta 466 + self.gap_size += n; 467 + self.cursor -|= n; 468 + } 469 + 470 + /// grow the gap by removing n bytes after the cursor 471 + pub fn growGapRight(self: *Buffer, n: usize) void { 472 + self.gap_size = @min(self.gap_size + n, self.buffer.len - self.cursor); 473 + } 474 + 475 + pub fn clearAndFree(self: *Buffer) void { 476 + self.cursor = 0; 477 + self.allocator.free(self.buffer); 478 + self.buffer = &.{}; 479 + self.gap_size = 0; 480 + } 481 + 482 + pub fn clearRetainingCapacity(self: *Buffer) void { 483 + self.cursor = 0; 484 + self.gap_size = self.buffer.len; 485 + } 486 + 487 + pub fn toOwnedSlice(self: *Buffer) std.mem.Allocator.Error![]const u8 { 488 + const slice = try self.dupe(); 489 + self.clearAndFree(); 490 + return slice; 491 + } 492 + 493 + pub fn realLength(self: *const Buffer) usize { 494 + return self.firstHalf().len + self.secondHalf().len; 495 + } 496 + 497 + pub fn dupe(self: *const Buffer) std.mem.Allocator.Error![]const u8 { 498 + const first_half = self.firstHalf(); 499 + const second_half = self.secondHalf(); 500 + const buf = try self.allocator.alloc(u8, first_half.len + second_half.len); 501 + @memcpy(buf[0..first_half.len], first_half); 502 + @memcpy(buf[first_half.len..], second_half); 503 + return buf; 504 + } 505 + }; 506 + 507 + test "TextField.zig: Buffer" { 508 + var gap_buf = Buffer.init(std.testing.allocator); 509 + defer gap_buf.deinit(); 510 + 511 + try gap_buf.insertSliceAtCursor("abc"); 512 + try std.testing.expectEqualStrings("abc", gap_buf.firstHalf()); 513 + try std.testing.expectEqualStrings("", gap_buf.secondHalf()); 514 + 515 + gap_buf.moveGapLeft(1); 516 + try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 517 + try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 518 + 519 + try gap_buf.insertSliceAtCursor(" "); 520 + try std.testing.expectEqualStrings("ab ", gap_buf.firstHalf()); 521 + try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 522 + 523 + gap_buf.growGapLeft(1); 524 + try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 525 + try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 526 + try std.testing.expectEqual(2, gap_buf.cursor); 527 + 528 + gap_buf.growGapRight(1); 529 + try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 530 + try std.testing.expectEqualStrings("", gap_buf.secondHalf()); 531 + try std.testing.expectEqual(2, gap_buf.cursor); 532 + } 533 + 534 + test TextField { 535 + // Boiler plate draw context init 536 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 537 + defer arena.deinit(); 538 + vxfw.DrawContext.init(.unicode); 539 + 540 + // Create some object which reacts to text field changes 541 + const Foo = struct { 542 + allocator: std.mem.Allocator, 543 + text: []const u8, 544 + 545 + fn onChange(ptr: ?*anyopaque, ctx: *vxfw.EventContext, str: []const u8) anyerror!void { 546 + const foo: *@This() = @ptrCast(@alignCast(ptr)); 547 + foo.text = try foo.allocator.dupe(u8, str); 548 + ctx.consumeAndRedraw(); 549 + } 550 + }; 551 + var foo: Foo = .{ .text = "", .allocator = arena.allocator() }; 552 + 553 + // Text field expands to the width, so it can't be null. It is always 1 line tall 554 + const draw_ctx: vxfw.DrawContext = .{ 555 + .arena = arena.allocator(), 556 + .min = .{}, 557 + .max = .{ .width = 8, .height = 1 }, 558 + .cell_size = .{ .width = 10, .height = 20 }, 559 + }; 560 + _ = draw_ctx; 561 + 562 + var ctx: vxfw.EventContext = .{ 563 + .alloc = arena.allocator(), 564 + .cmds = .empty, 565 + }; 566 + 567 + // Enough boiler plate...Create the text field 568 + var text_field = TextField.init(std.testing.allocator); 569 + defer text_field.deinit(); 570 + text_field.onChange = Foo.onChange; 571 + text_field.onSubmit = Foo.onChange; 572 + text_field.userdata = &foo; 573 + 574 + const tf_widget = text_field.widget(); 575 + // Send some key events to the widget 576 + try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'H', .text = "H" } }); 577 + // The foo object stores the last text that we saw from an onChange call 578 + try std.testing.expectEqualStrings("H", foo.text); 579 + try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'e', .text = "e" } }); 580 + try std.testing.expectEqualStrings("He", foo.text); 581 + try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } }); 582 + try std.testing.expectEqualStrings("Hel", foo.text); 583 + try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } }); 584 + try std.testing.expectEqualStrings("Hell", foo.text); 585 + try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'o', .text = "o" } }); 586 + try std.testing.expectEqualStrings("Hello", foo.text); 587 + 588 + // An arrow moves the cursor. The text doesn't change 589 + try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.left } }); 590 + try std.testing.expectEqualStrings("Hello", foo.text); 591 + 592 + try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = '_', .text = "_" } }); 593 + try std.testing.expectEqualStrings("Hell_o", foo.text); 594 + } 595 + 596 + test "refAllDecls" { 597 + std.testing.refAllDecls(@This()); 598 + }
+584
src/vxfw/vxfw.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + const uucode = @import("uucode"); 4 + 5 + const testing = std.testing; 6 + 7 + const assert = std.debug.assert; 8 + 9 + const Allocator = std.mem.Allocator; 10 + 11 + pub const App = @import("App.zig"); 12 + 13 + // Widgets 14 + pub const Border = @import("Border.zig"); 15 + pub const Button = @import("Button.zig"); 16 + pub const Center = @import("Center.zig"); 17 + pub const FlexColumn = @import("FlexColumn.zig"); 18 + pub const FlexRow = @import("FlexRow.zig"); 19 + pub const ListView = @import("ListView.zig"); 20 + pub const Padding = @import("Padding.zig"); 21 + pub const RichText = @import("RichText.zig"); 22 + pub const ScrollView = @import("ScrollView.zig"); 23 + pub const ScrollBars = @import("ScrollBars.zig"); 24 + pub const SizedBox = @import("SizedBox.zig"); 25 + pub const SplitView = @import("SplitView.zig"); 26 + pub const Spinner = @import("Spinner.zig"); 27 + pub const Text = @import("Text.zig"); 28 + pub const TextField = @import("TextField.zig"); 29 + 30 + pub const CommandList = std.ArrayList(Command); 31 + 32 + pub const UserEvent = struct { 33 + name: []const u8, 34 + data: ?*const anyopaque = null, 35 + }; 36 + 37 + pub const Event = union(enum) { 38 + key_press: vaxis.Key, 39 + key_release: vaxis.Key, 40 + mouse: vaxis.Mouse, 41 + focus_in, // window has gained focus 42 + focus_out, // window has lost focus 43 + paste_start, // bracketed paste start 44 + paste_end, // bracketed paste end 45 + paste: []const u8, // osc 52 paste, caller must free 46 + color_report: vaxis.Color.Report, // osc 4, 10, 11, 12 response 47 + color_scheme: vaxis.Color.Scheme, // light / dark OS theme changes 48 + winsize: vaxis.Winsize, // the window size has changed. This event is always sent when the loop is started 49 + app: UserEvent, // A custom event from the app 50 + tick, // An event from a Tick command 51 + init, // sent when the application starts 52 + mouse_leave, // The mouse has left the widget 53 + mouse_enter, // The mouse has enterred the widget 54 + }; 55 + 56 + pub const Tick = struct { 57 + deadline_ms: i64, 58 + widget: Widget, 59 + 60 + pub fn lessThan(_: void, lhs: Tick, rhs: Tick) bool { 61 + return lhs.deadline_ms > rhs.deadline_ms; 62 + } 63 + 64 + pub fn in(ms: u32, widget: Widget) Command { 65 + const now = std.time.milliTimestamp(); 66 + return .{ .tick = .{ 67 + .deadline_ms = now + ms, 68 + .widget = widget, 69 + } }; 70 + } 71 + }; 72 + 73 + pub const Command = union(enum) { 74 + /// Callback the event with a tick event at the specified deadlline 75 + tick: Tick, 76 + /// Change the mouse shape. This also has an implicit redraw 77 + set_mouse_shape: vaxis.Mouse.Shape, 78 + /// Request that this widget receives focus 79 + request_focus: Widget, 80 + 81 + /// Try to copy the provided text to the host clipboard. Uses OSC 52. Silently fails if terminal 82 + /// doesn't support OSC 52 83 + copy_to_clipboard: []const u8, 84 + 85 + /// Set the title of the terminal 86 + set_title: []const u8, 87 + 88 + /// Queue a refresh of the entire screen. Implicitly sets redraw 89 + queue_refresh, 90 + 91 + /// Send a system notification 92 + notify: struct { 93 + title: ?[]const u8, 94 + body: []const u8, 95 + }, 96 + 97 + query_color: vaxis.Cell.Color.Kind, 98 + }; 99 + 100 + pub const EventContext = struct { 101 + phase: Phase = .at_target, 102 + alloc: Allocator, 103 + cmds: CommandList, 104 + 105 + /// The event was handled, do not pass it on 106 + consume_event: bool = false, 107 + /// Tells the event loop to redraw the UI 108 + redraw: bool = true, 109 + /// Quit the application 110 + quit: bool = false, 111 + 112 + pub const Phase = enum { 113 + capturing, 114 + at_target, 115 + bubbling, 116 + }; 117 + 118 + pub fn addCmd(self: *EventContext, cmd: Command) Allocator.Error!void { 119 + try self.cmds.append(self.alloc, cmd); 120 + } 121 + 122 + pub fn tick(self: *EventContext, ms: u32, widget: Widget) Allocator.Error!void { 123 + try self.addCmd(Tick.in(ms, widget)); 124 + } 125 + 126 + pub fn consumeAndRedraw(self: *EventContext) void { 127 + self.consume_event = true; 128 + self.redraw = true; 129 + } 130 + 131 + pub fn consumeEvent(self: *EventContext) void { 132 + self.consume_event = true; 133 + } 134 + 135 + pub fn setMouseShape(self: *EventContext, shape: vaxis.Mouse.Shape) Allocator.Error!void { 136 + try self.addCmd(.{ .set_mouse_shape = shape }); 137 + self.redraw = true; 138 + } 139 + 140 + pub fn requestFocus(self: *EventContext, widget: Widget) Allocator.Error!void { 141 + try self.addCmd(.{ .request_focus = widget }); 142 + } 143 + 144 + /// Copy content to clipboard. 145 + /// content is duplicated using self.alloc. 146 + /// Caller retains ownership of their copy of content. 147 + pub fn copyToClipboard(self: *EventContext, content: []const u8) Allocator.Error!void { 148 + try self.addCmd(.{ .copy_to_clipboard = try self.alloc.dupe(u8, content) }); 149 + } 150 + 151 + /// Set window title. 152 + /// title is duplicated using self.alloc. 153 + /// Caller retains ownership of their copy of title. 154 + pub fn setTitle(self: *EventContext, title: []const u8) Allocator.Error!void { 155 + try self.addCmd(.{ .set_title = try self.alloc.dupe(u8, title) }); 156 + } 157 + 158 + pub fn queueRefresh(self: *EventContext) Allocator.Error!void { 159 + try self.addCmd(.queue_refresh); 160 + self.redraw = true; 161 + } 162 + 163 + /// Send a system notification. This function dupes title and body using it's own allocator. 164 + /// They will be freed once the notification has been sent 165 + pub fn sendNotification( 166 + self: *EventContext, 167 + maybe_title: ?[]const u8, 168 + body: []const u8, 169 + ) Allocator.Error!void { 170 + const alloc = self.alloc; 171 + if (maybe_title) |title| { 172 + return self.addCmd(.{ .notify = .{ 173 + .title = try alloc.dupe(u8, title), 174 + .body = try alloc.dupe(u8, body), 175 + } }); 176 + } 177 + return self.addCmd(.{ .notify = .{ 178 + .title = null, 179 + .body = try alloc.dupe(u8, body), 180 + } }); 181 + } 182 + 183 + pub fn queryColor(self: *EventContext, kind: vaxis.Cell.Color.Kind) Allocator.Error!void { 184 + try self.addCmd(.{ .query_color = kind }); 185 + } 186 + }; 187 + 188 + pub const DrawContext = struct { 189 + // Allocator backed by an arena. Widgets do not need to free their own resources, they will be 190 + // freed after rendering 191 + arena: std.mem.Allocator, 192 + // Constraints 193 + min: Size, 194 + max: MaxSize, 195 + 196 + // Size of a single cell, in pixels 197 + cell_size: Size, 198 + 199 + // Unicode stuff 200 + var width_method: vaxis.gwidth.Method = .unicode; 201 + 202 + pub fn init(method: vaxis.gwidth.Method) void { 203 + DrawContext.width_method = method; 204 + } 205 + 206 + pub fn stringWidth(_: DrawContext, str: []const u8) usize { 207 + return vaxis.gwidth.gwidth( 208 + str, 209 + DrawContext.width_method, 210 + ); 211 + } 212 + 213 + pub fn graphemeIterator(_: DrawContext, str: []const u8) vaxis.unicode.GraphemeIterator { 214 + return vaxis.unicode.graphemeIterator(str); 215 + } 216 + 217 + pub fn withConstraints(self: DrawContext, min: Size, max: MaxSize) DrawContext { 218 + return .{ 219 + .arena = self.arena, 220 + .min = min, 221 + .max = max, 222 + .cell_size = self.cell_size, 223 + }; 224 + } 225 + }; 226 + 227 + pub const Size = struct { 228 + width: u16 = 0, 229 + height: u16 = 0, 230 + }; 231 + 232 + pub const MaxSize = struct { 233 + width: ?u16 = null, 234 + height: ?u16 = null, 235 + 236 + /// Returns true if the row would fall outside of this height. A null height value is infinite 237 + /// and always returns false 238 + pub fn outsideHeight(self: MaxSize, row: u16) bool { 239 + const max = self.height orelse return false; 240 + return row >= max; 241 + } 242 + 243 + /// Returns true if the col would fall outside of this width. A null width value is infinite 244 + /// and always returns false 245 + pub fn outsideWidth(self: MaxSize, col: u16) bool { 246 + const max = self.width orelse return false; 247 + return col >= max; 248 + } 249 + 250 + /// Asserts that neither height nor width are null 251 + pub fn size(self: MaxSize) Size { 252 + assert(self.width != null); 253 + assert(self.height != null); 254 + return .{ 255 + .width = self.width.?, 256 + .height = self.height.?, 257 + }; 258 + } 259 + 260 + pub fn fromSize(other: Size) MaxSize { 261 + return .{ 262 + .width = other.width, 263 + .height = other.height, 264 + }; 265 + } 266 + }; 267 + 268 + /// The Widget interface 269 + pub const Widget = struct { 270 + userdata: *anyopaque, 271 + captureHandler: ?*const fn (userdata: *anyopaque, ctx: *EventContext, event: Event) anyerror!void = null, 272 + eventHandler: ?*const fn (userdata: *anyopaque, ctx: *EventContext, event: Event) anyerror!void = null, 273 + drawFn: *const fn (userdata: *anyopaque, ctx: DrawContext) Allocator.Error!Surface, 274 + 275 + pub fn captureEvent(self: Widget, ctx: *EventContext, event: Event) anyerror!void { 276 + if (self.captureHandler) |handle| { 277 + return handle(self.userdata, ctx, event); 278 + } 279 + } 280 + 281 + pub fn handleEvent(self: Widget, ctx: *EventContext, event: Event) anyerror!void { 282 + if (self.eventHandler) |handle| { 283 + return handle(self.userdata, ctx, event); 284 + } 285 + } 286 + 287 + pub fn draw(self: Widget, ctx: DrawContext) Allocator.Error!Surface { 288 + return self.drawFn(self.userdata, ctx); 289 + } 290 + 291 + /// Returns true if the Widgets point to the same widget instance. To be considered the same, 292 + /// the userdata and drawFn fields must point to the same values in both widgets 293 + pub fn eql(self: Widget, other: Widget) bool { 294 + return @intFromPtr(self.userdata) == @intFromPtr(other.userdata) and 295 + @intFromPtr(self.drawFn) == @intFromPtr(other.drawFn); 296 + } 297 + }; 298 + 299 + pub const FlexItem = struct { 300 + widget: Widget, 301 + /// A value of zero means the child will have it's inherent size. Any value greater than zero 302 + /// and the remaining space will be proportioned to each item 303 + flex: u8 = 1, 304 + 305 + pub fn init(child: Widget, flex: u8) FlexItem { 306 + return .{ .widget = child, .flex = flex }; 307 + } 308 + }; 309 + 310 + pub const Point = struct { 311 + row: u16, 312 + col: u16, 313 + }; 314 + 315 + pub const RelativePoint = struct { 316 + row: i17, 317 + col: i17, 318 + }; 319 + 320 + /// Result of a hit test 321 + pub const HitResult = struct { 322 + local: Point, 323 + widget: Widget, 324 + }; 325 + 326 + pub const CursorState = struct { 327 + /// Local coordinates 328 + row: u16, 329 + /// Local coordinates 330 + col: u16, 331 + shape: vaxis.Cell.CursorShape = .default, 332 + }; 333 + 334 + pub const Surface = struct { 335 + /// Size of this surface 336 + size: Size, 337 + /// The widget this surface belongs to 338 + widget: Widget, 339 + 340 + /// Cursor state 341 + cursor: ?CursorState = null, 342 + 343 + /// Contents of this surface. Must be len == 0 or len == size.width * size.height 344 + buffer: []vaxis.Cell, 345 + 346 + children: []SubSurface, 347 + 348 + pub fn empty(widget: Widget) Surface { 349 + return .{ 350 + .size = .{}, 351 + .widget = widget, 352 + .buffer = &.{}, 353 + .children = &.{}, 354 + }; 355 + } 356 + 357 + /// Creates a slice of vaxis.Cell's equal to size.width * size.height 358 + pub fn createBuffer(allocator: Allocator, size: Size) Allocator.Error![]vaxis.Cell { 359 + const buffer = try allocator.alloc(vaxis.Cell, size.width * size.height); 360 + @memset(buffer, .{ .default = true }); 361 + return buffer; 362 + } 363 + 364 + pub fn init(allocator: Allocator, widget: Widget, size: Size) Allocator.Error!Surface { 365 + return .{ 366 + .size = size, 367 + .widget = widget, 368 + .buffer = try Surface.createBuffer(allocator, size), 369 + .children = &.{}, 370 + }; 371 + } 372 + 373 + pub fn initWithChildren( 374 + allocator: Allocator, 375 + widget: Widget, 376 + size: Size, 377 + children: []SubSurface, 378 + ) Allocator.Error!Surface { 379 + return .{ 380 + .size = size, 381 + .widget = widget, 382 + .buffer = try Surface.createBuffer(allocator, size), 383 + .children = children, 384 + }; 385 + } 386 + 387 + pub fn writeCell(self: Surface, col: u16, row: u16, cell: vaxis.Cell) void { 388 + if (self.size.width <= col) return; 389 + if (self.size.height <= row) return; 390 + const i = (row * self.size.width) + col; 391 + assert(i < self.buffer.len); 392 + self.buffer[i] = cell; 393 + } 394 + 395 + pub fn readCell(self: Surface, col: usize, row: usize) vaxis.Cell { 396 + assert(col < self.size.width and row < self.size.height); 397 + const i = (row * self.size.width) + col; 398 + assert(i < self.buffer.len); 399 + return self.buffer[i]; 400 + } 401 + 402 + /// Creates a new surface of the same width, with the buffer trimmed to a given height 403 + pub fn trimHeight(self: Surface, height: u16) Surface { 404 + assert(height <= self.size.height); 405 + return .{ 406 + .size = .{ .width = self.size.width, .height = height }, 407 + .widget = self.widget, 408 + .buffer = self.buffer[0 .. self.size.width * height], 409 + .children = self.children, 410 + }; 411 + } 412 + 413 + /// Walks the Surface tree to produce a list of all widgets that intersect Point. Point will 414 + /// always be translated to local Surface coordinates. Asserts that this Surface does contain Point 415 + pub fn hitTest(self: Surface, allocator: Allocator, list: *std.ArrayList(HitResult), point: Point) Allocator.Error!void { 416 + assert(point.col < self.size.width and point.row < self.size.height); 417 + // Add this widget to the hit list if it has an event or capture handler 418 + if (self.widget.eventHandler != null or self.widget.captureHandler != null) 419 + try list.append(allocator, .{ .local = point, .widget = self.widget }); 420 + for (self.children) |child| { 421 + if (!child.containsPoint(point)) continue; 422 + const child_point: Point = .{ 423 + .row = @intCast(point.row - child.origin.row), 424 + .col = @intCast(point.col - child.origin.col), 425 + }; 426 + try child.surface.hitTest(allocator, list, child_point); 427 + } 428 + } 429 + 430 + /// Copies all cells from Surface to Window 431 + pub fn render(self: Surface, win: vaxis.Window, focused: Widget) void { 432 + // render self first 433 + if (self.buffer.len > 0) { 434 + assert(self.buffer.len == self.size.width * self.size.height); 435 + for (self.buffer, 0..) |cell, i| { 436 + const row = i / self.size.width; 437 + const col = i % self.size.width; 438 + win.writeCell(@intCast(col), @intCast(row), cell); 439 + } 440 + } 441 + 442 + if (self.cursor) |cursor| { 443 + if (self.widget.eql(focused)) { 444 + win.showCursor(cursor.col, cursor.row); 445 + win.setCursorShape(cursor.shape); 446 + } 447 + } 448 + 449 + // Sort children by z-index 450 + std.mem.sort(SubSurface, self.children, {}, SubSurface.lessThan); 451 + 452 + // for each child, we make a window and render to it 453 + for (self.children) |child| { 454 + const child_win = win.child(.{ 455 + .x_off = @intCast(child.origin.col), 456 + .y_off = @intCast(child.origin.row), 457 + .width = @intCast(child.surface.size.width), 458 + .height = @intCast(child.surface.size.height), 459 + }); 460 + child.surface.render(child_win, focused); 461 + } 462 + } 463 + 464 + /// Returns true if the surface satisfies a set of constraints 465 + pub fn satisfiesConstraints(self: Surface, min: Size, max: Size) bool { 466 + return self.size.width < max.width and 467 + self.size.width > min.width and 468 + self.size.height < max.height and 469 + self.size.height > min.height; 470 + } 471 + }; 472 + 473 + pub const SubSurface = struct { 474 + /// Origin relative to parent 475 + origin: RelativePoint, 476 + /// This surface 477 + surface: Surface, 478 + /// z-index relative to siblings 479 + z_index: u8 = 0, 480 + 481 + pub fn lessThan(_: void, lhs: SubSurface, rhs: SubSurface) bool { 482 + return lhs.z_index < rhs.z_index; 483 + } 484 + 485 + /// Returns true if this SubSurface contains Point. Point must be in parent local units 486 + pub fn containsPoint(self: SubSurface, point: Point) bool { 487 + return point.col >= self.origin.col and 488 + point.row >= self.origin.row and 489 + point.col < (self.origin.col + self.surface.size.width) and 490 + point.row < (self.origin.row + self.surface.size.height); 491 + } 492 + }; 493 + 494 + test { 495 + std.testing.refAllDecls(@This()); 496 + } 497 + 498 + test "SubSurface: containsPoint" { 499 + const surf: SubSurface = .{ 500 + .origin = .{ .row = 2, .col = 2 }, 501 + .surface = .{ 502 + .size = .{ .width = 10, .height = 10 }, 503 + .widget = undefined, 504 + .children = &.{}, 505 + .buffer = &.{}, 506 + }, 507 + .z_index = 0, 508 + }; 509 + 510 + try testing.expect(surf.containsPoint(.{ .row = 2, .col = 2 })); 511 + try testing.expect(surf.containsPoint(.{ .row = 3, .col = 3 })); 512 + try testing.expect(surf.containsPoint(.{ .row = 11, .col = 11 })); 513 + 514 + try testing.expect(!surf.containsPoint(.{ .row = 1, .col = 1 })); 515 + try testing.expect(!surf.containsPoint(.{ .row = 12, .col = 12 })); 516 + try testing.expect(!surf.containsPoint(.{ .row = 2, .col = 12 })); 517 + try testing.expect(!surf.containsPoint(.{ .row = 12, .col = 2 })); 518 + } 519 + 520 + test "refAllDecls" { 521 + std.testing.refAllDecls(@This()); 522 + } 523 + 524 + test "Surface: satisfiesConstraints" { 525 + const surf: Surface = .{ 526 + .size = .{ .width = 10, .height = 10 }, 527 + .widget = undefined, 528 + .children = &.{}, 529 + .buffer = &.{}, 530 + }; 531 + 532 + try testing.expect(surf.satisfiesConstraints(.{ .width = 1, .height = 1 }, .{ .width = 20, .height = 20 })); 533 + try testing.expect(!surf.satisfiesConstraints(.{ .width = 10, .height = 10 }, .{ .width = 20, .height = 20 })); 534 + try testing.expect(!surf.satisfiesConstraints(.{ .width = 1, .height = 1 }, .{ .width = 10, .height = 10 })); 535 + } 536 + 537 + test "All widgets have a doctest and refAllDecls test" { 538 + // This test goes through every file in src/ and checks that it has a doctest (the filename 539 + // stripped of ".zig" matches a test name) and a test called "refAllDecls". It makes no 540 + // guarantees about the quality of the test, but it does ensure it exists which at least makes 541 + // it easy to fail CI early, or spot bad tests vs non-existant tests 542 + const excludes = &[_][]const u8{ "vxfw.zig", "App.zig" }; 543 + 544 + var cwd = try std.fs.cwd().openDir("./src/vxfw", .{ .iterate = true }); 545 + var iter = cwd.iterate(); 546 + defer cwd.close(); 547 + outer: while (try iter.next()) |file| { 548 + if (file.kind != .file) continue; 549 + for (excludes) |ex| if (std.mem.eql(u8, ex, file.name)) continue :outer; 550 + 551 + const container_name = if (std.mem.lastIndexOf(u8, file.name, ".zig")) |idx| 552 + file.name[0..idx] 553 + else 554 + continue; 555 + const data = try cwd.readFileAllocOptions(std.testing.allocator, file.name, 10_000_000, null, .of(u8), 0x00); 556 + defer std.testing.allocator.free(data); 557 + var ast = try std.zig.Ast.parse(std.testing.allocator, data, .zig); 558 + defer ast.deinit(std.testing.allocator); 559 + 560 + var has_doctest: bool = false; 561 + var has_refAllDecls: bool = false; 562 + for (ast.rootDecls()) |root_decl| { 563 + const decl = ast.nodes.get(@intFromEnum(root_decl)); 564 + switch (decl.tag) { 565 + .test_decl => { 566 + const test_name = ast.tokenSlice(decl.main_token + 1); 567 + if (std.mem.eql(u8, "\"refAllDecls\"", test_name)) 568 + has_refAllDecls = true 569 + else if (std.mem.eql(u8, container_name, test_name)) 570 + has_doctest = true; 571 + }, 572 + else => continue, 573 + } 574 + } 575 + if (!has_doctest) { 576 + std.log.err("file {s} has no doctest", .{file.name}); 577 + return error.TestExpectedDoctest; 578 + } 579 + if (!has_refAllDecls) { 580 + std.log.err("file {s} has no 'refAllDecls' test", .{file.name}); 581 + return error.TestExpectedRefAllDecls; 582 + } 583 + } 584 + }
+5 -5
src/widgets/CodeView.zig
··· 4 4 const LineNumbers = vaxis.widgets.LineNumbers; 5 5 6 6 pub const DrawOptions = struct { 7 - highlighted_line: usize = 0, 7 + highlighted_line: u16 = 0, 8 8 draw_line_numbers: bool = true, 9 - indentation: usize = 0, 9 + indentation: u16 = 0, 10 10 }; 11 11 12 12 pub const Buffer = vaxis.widgets.TextView.Buffer; ··· 39 39 nl.draw(win.child(.{ 40 40 .x_off = 0, 41 41 .y_off = 0, 42 - .width = .{ .limit = pad_left }, 43 - .height = .{ .limit = win.height }, 42 + .width = pad_left, 43 + .height = win.height, 44 44 }), self.scroll_view.scroll.y); 45 45 } 46 46 self.drawCode(win.child(.{ .x_off = pad_left }), buffer, opts); ··· 98 98 self.scroll_view.writeCell(win, pos.x, pos.y, cell); 99 99 } else { 100 100 self.scroll_view.writeCell(win, pos.x, pos.y, .{ 101 - .char = .{ .grapheme = cluster, .width = width }, 101 + .char = .{ .grapheme = cluster, .width = @intCast(width) }, 102 102 .style = style, 103 103 }); 104 104 }
+3 -3
src/widgets/LineNumbers.zig
··· 12 12 return (v / (std.math.powi(usize, 10, n) catch unreachable)) % 10; 13 13 } 14 14 15 - pub fn numDigits(v: usize) usize { 15 + pub fn numDigits(v: usize) u8 { 16 16 return switch (v) { 17 17 0...9 => 1, 18 18 10...99 => 2, ··· 35 35 const num_digits = numDigits(line); 36 36 for (0..num_digits) |i| { 37 37 const digit = extractDigit(line, i); 38 - win.writeCell(win.width -| (i + 2), line -| (y_scroll +| 1), .{ 38 + win.writeCell(@intCast(win.width -| (i + 2)), @intCast(line -| (y_scroll +| 1)), .{ 39 39 .char = .{ 40 40 .width = 1, 41 41 .grapheme = digits[digit .. digit + 1], ··· 45 45 } 46 46 if (highlighted) { 47 47 for (num_digits + 1..win.width) |i| { 48 - win.writeCell(i, line -| (y_scroll +| 1), .{ 48 + win.writeCell(@intCast(i), @intCast(line -| (y_scroll +| 1)), .{ 49 49 .style = if (highlighted) self.highlighted_style else self.style, 50 50 }); 51 51 }
+6 -6
src/widgets/ScrollView.zig
··· 65 65 }; 66 66 const bg = parent.child(.{ 67 67 .x_off = parent.width -| opts.character.width, 68 - .width = .{ .limit = opts.character.width }, 69 - .height = .{ .limit = parent.height }, 68 + .width = opts.character.width, 69 + .height = parent.height, 70 70 }); 71 71 bg.fill(.{ .char = opts.character, .style = opts.bg }); 72 72 vbar.draw(bg); ··· 115 115 pub fn writeCell(self: *@This(), parent: vaxis.Window, col: usize, row: usize, cell: vaxis.Cell) void { 116 116 const b = self.bounds(parent); 117 117 if (!b.inside(col, row)) return; 118 - const win = parent.child(.{ .width = .{ .limit = b.x2 - b.x1 }, .height = .{ .limit = b.y2 - b.y1 } }); 119 - win.writeCell(col -| self.scroll.x, row -| self.scroll.y, cell); 118 + const win = parent.child(.{ .width = @intCast(b.x2 - b.x1), .height = @intCast(b.y2 - b.y1) }); 119 + win.writeCell(@intCast(col -| self.scroll.x), @intCast(row -| self.scroll.y), cell); 120 120 } 121 121 122 122 /// Use this function instead of `Window.readCell` to read the correct cell in scrolling context. 123 123 pub fn readCell(self: *@This(), parent: vaxis.Window, col: usize, row: usize) ?vaxis.Cell { 124 124 const b = self.bounds(parent); 125 125 if (!b.inside(col, row)) return; 126 - const win = parent.child(.{ .width = .{ .limit = b.width }, .height = .{ .limit = b.height } }); 127 - return win.readCell(col -| self.scroll.x, row -| self.scroll.y); 126 + const win = parent.child(.{ .width = @intCast(b.x2 - b.x1), .height = @intCast(b.y2 - b.y1) }); 127 + return win.readCell(@intCast(col -| self.scroll.x), @intCast(row -| self.scroll.y)); 128 128 }
+1 -1
src/widgets/Scrollbar.zig
··· 29 29 const bar_top = self.top * win.height / self.total; 30 30 var i: usize = 0; 31 31 while (i < bar_height) : (i += 1) 32 - win.writeCell(0, i + bar_top, .{ .char = self.character, .style = self.style }); 32 + win.writeCell(0, @intCast(i + bar_top), .{ .char = self.character, .style = self.style }); 33 33 }
+343 -95
src/widgets/Table.zig
··· 8 8 9 9 /// Table Context for maintaining state and drawing Tables with `drawTable()`. 10 10 pub const TableContext = struct { 11 - /// Current selected Row of the Table. 12 - row: usize = 0, 13 - /// Current selected Column of the Table. 14 - col: usize = 0, 11 + /// Current active Row of the Table. 12 + row: u16 = 0, 13 + /// Current active Column of the Table. 14 + col: u16 = 0, 15 15 /// Starting point within the Data List. 16 - start: usize = 0, 16 + start: u16 = 0, 17 + /// Selected Rows. 18 + sel_rows: ?[]u16 = null, 17 19 18 20 /// Active status of the Table. 19 21 active: bool = false, 22 + /// Active Content Callback Function. 23 + /// If available, this will be called to vertically expand the active row with additional info. 24 + active_content_fn: ?*const fn (*vaxis.Window, *const anyopaque) anyerror!u16 = null, 25 + /// Active Content Context 26 + /// This will be provided to the `active_content` callback when called. 27 + active_ctx: *const anyopaque = &{}, 28 + /// Y Offset for rows beyond the Active Content. 29 + /// (This will be calculated automatically) 30 + active_y_off: u16 = 0, 20 31 21 - /// The Background Color for Selected Rows and Column Headers. 32 + /// The Background Color for Selected Rows. 22 33 selected_bg: vaxis.Cell.Color, 34 + /// The Foreground Color for Selected Rows. 35 + selected_fg: vaxis.Cell.Color = .default, 36 + /// The Background Color for the Active Row and Column Header. 37 + active_bg: vaxis.Cell.Color, 38 + /// The Foreground Color for the Active Row and Column Header. 39 + active_fg: vaxis.Cell.Color = .default, 23 40 /// First Column Header Background Color 24 41 hdr_bg_1: vaxis.Cell.Color = .{ .rgb = [_]u8{ 64, 64, 64 } }, 25 42 /// Second Column Header Background Color ··· 30 47 row_bg_2: vaxis.Cell.Color = .{ .rgb = [_]u8{ 8, 8, 8 } }, 31 48 32 49 /// Y Offset for drawing to the parent Window. 33 - y_off: usize = 0, 50 + y_off: u16 = 0, 51 + /// X Offset for printing each Cell/Item. 52 + cell_x_off: u16 = 1, 34 53 35 54 /// Column Width 36 - /// Note, this should be treated as Read Only. The Column Width will be calculated during `drawTable()`. 37 - col_width: usize = 0, 55 + /// Note, if this is left `null` the Column Width will be dynamically calculated during `drawTable()`. 56 + //col_width: ?usize = null, 57 + col_width: WidthStyle = .dynamic_fill, 58 + 59 + // Header Names 60 + header_names: HeaderNames = .field_names, 61 + // Column Indexes 62 + col_indexes: ColumnIndexes = .all, 63 + // Header Alignment 64 + header_align: HorizontalAlignment = .center, 65 + // Column Alignment 66 + col_align: ColumnAlignment = .{ .all = .left }, 67 + 68 + // Header Borders 69 + header_borders: bool = false, 70 + // Row Borders 71 + //row_borders: bool = false, 72 + // Col Borders 73 + col_borders: bool = false, 74 + }; 75 + 76 + /// Width Styles for `col_width`. 77 + pub const WidthStyle = union(enum) { 78 + /// Dynamically calculate Column Widths such that the entire (or most) of the screen is filled horizontally. 79 + dynamic_fill, 80 + /// Dynamically calculate the Column Width for each Column based on its Header Length and the provided Padding length. 81 + dynamic_header_len: u16, 82 + /// Statically set all Column Widths to the same value. 83 + static_all: u16, 84 + /// Statically set individual Column Widths to specific values. 85 + static_individual: []const u16, 86 + }; 87 + 88 + /// Column Indexes 89 + pub const ColumnIndexes = union(enum) { 90 + /// Use all of the Columns. 91 + all, 92 + /// Use Columns from the specified indexes. 93 + by_idx: []const usize, 94 + }; 95 + 96 + /// Header Names 97 + pub const HeaderNames = union(enum) { 98 + /// Use Field Names as Headers 99 + field_names, 100 + /// Custom 101 + custom: []const []const u8, 102 + }; 103 + 104 + /// Horizontal Alignment 105 + pub const HorizontalAlignment = enum { 106 + left, 107 + center, 108 + }; 109 + /// Column Alignment 110 + pub const ColumnAlignment = union(enum) { 111 + all: HorizontalAlignment, 112 + by_idx: []const HorizontalAlignment, 38 113 }; 39 114 40 115 /// Draw a Table for the TUI. 41 116 pub fn drawTable( 42 117 /// This should be an ArenaAllocator that can be deinitialized after each event call. 43 - /// The Allocator is only used in two cases: 44 - /// 1. If a cell is a non-String. If the Allocator is not provided, those cells will show "[unsupported (TypeName)]". 45 - /// 2. To show that a value is too large to fit into a cell. If the Allocator is not provided, they'll just be cutoff. 118 + /// The Allocator is only used in three cases: 119 + /// 1. If a cell is a non-String. (If the Allocator is not provided, those cells will show "[unsupported (TypeName)]".) 120 + /// 2. To show that a value is too large to fit into a cell using '...'. (If the Allocator is not provided, they'll just be cutoff.) 121 + /// 3. To copy a MultiArrayList into a normal slice. (Note, this is an expensive operation. Prefer to pass a Slice or ArrayList if possible.) 46 122 alloc: ?mem.Allocator, 47 123 /// The parent Window to draw to. 48 124 win: vaxis.Window, 49 - /// Headers for the Table 50 - headers: []const []const u8, 51 - /// This must be an ArrayList. 125 + /// This must be a Slice, ArrayList, or MultiArrayList. 126 + /// Note, MultiArrayList support currently requires allocation. 52 127 data_list: anytype, 53 128 // The Table Context for this Table. 54 129 table_ctx: *TableContext, 55 130 ) !void { 56 - const table_win = win.initChild( 57 - 0, 58 - table_ctx.y_off, 59 - .{ .limit = win.width }, 60 - .{ .limit = win.height }, 61 - ); 131 + var di_is_mal = false; 132 + const data_items = getData: { 133 + const DataListT = @TypeOf(data_list); 134 + const data_ti = @typeInfo(DataListT); 135 + switch (data_ti) { 136 + .pointer => |ptr| { 137 + if (ptr.size != .slice) return error.UnsupportedTableDataType; 138 + break :getData data_list; 139 + }, 140 + .@"struct" => { 141 + const di_fields = meta.fields(DataListT); 142 + const al_fields = meta.fields(std.ArrayList([]const u8)); 143 + const mal_fields = meta.fields(std.MultiArrayList(struct { a: u8 = 0, b: u32 = 0 })); 144 + // Probably an ArrayList 145 + const is_al = comptime if (mem.indexOf(u8, @typeName(DataListT), "MultiArrayList") == null and 146 + mem.indexOf(u8, @typeName(DataListT), "ArrayList") != null and 147 + al_fields.len == di_fields.len) 148 + isAL: { 149 + var is = true; 150 + for (al_fields, di_fields) |al_field, di_field| 151 + is = is and mem.eql(u8, al_field.name, di_field.name); 152 + break :isAL is; 153 + } else false; 154 + if (is_al) break :getData data_list.items; 155 + 156 + // Probably a MultiArrayList 157 + const is_mal = if (mem.indexOf(u8, @typeName(DataListT), "MultiArrayList") != null and 158 + mal_fields.len == di_fields.len) 159 + isMAL: { 160 + var is = true; 161 + inline for (mal_fields, di_fields) |mal_field, di_field| 162 + is = is and mem.eql(u8, mal_field.name, di_field.name); 163 + break :isMAL is; 164 + } else false; 165 + if (!is_mal) return error.UnsupportedTableDataType; 166 + if (alloc) |_alloc| { 167 + di_is_mal = true; 168 + const mal_slice = data_list.slice(); 169 + const DataT = dataType: { 170 + const fn_info = @typeInfo(@TypeOf(@field(@TypeOf(mal_slice), "get"))); 171 + break :dataType fn_info.@"fn".return_type orelse @panic("No Child Type"); 172 + }; 173 + var data_out_list = std.ArrayList(DataT){}; 174 + for (0..mal_slice.len) |idx| try data_out_list.append(_alloc, mal_slice.get(idx)); 175 + break :getData try data_out_list.toOwnedSlice(_alloc); 176 + } 177 + return error.UnsupportedTableDataType; 178 + }, 179 + else => return error.UnsupportedTableDataType, 180 + } 181 + }; 182 + defer if (di_is_mal) alloc.?.free(data_items); 183 + const DataT = @TypeOf(data_items[0]); 184 + const fields = meta.fields(DataT); 185 + const field_indexes = switch (table_ctx.col_indexes) { 186 + .all => comptime allIdx: { 187 + var indexes_buf: [fields.len]usize = undefined; 188 + for (0..fields.len) |idx| indexes_buf[idx] = idx; 189 + const indexes = indexes_buf; 190 + break :allIdx indexes[0..]; 191 + }, 192 + .by_idx => |by_idx| by_idx, 193 + }; 62 194 63 - table_ctx.col_width = table_win.width / headers.len; 64 - if (table_ctx.col_width % 2 != 0) table_ctx.col_width +|= 1; 65 - while (table_ctx.col_width * headers.len < table_win.width - 1) table_ctx.col_width +|= 1; 195 + // Headers for the Table 196 + var hdrs_buf: [fields.len][]const u8 = undefined; 197 + const headers = hdrs: { 198 + switch (table_ctx.header_names) { 199 + .field_names => { 200 + for (field_indexes) |f_idx| { 201 + inline for (fields, 0..) |field, idx| { 202 + if (f_idx == idx) 203 + hdrs_buf[idx] = field.name; 204 + } 205 + } 206 + break :hdrs hdrs_buf[0..]; 207 + }, 208 + .custom => |hdrs| break :hdrs hdrs, 209 + } 210 + }; 66 211 67 - if (table_ctx.col > headers.len - 1) table_ctx.*.col = headers.len - 1; 212 + const table_win = win.child(.{ 213 + .y_off = table_ctx.y_off, 214 + .width = win.width, 215 + .height = win.height, 216 + }); 217 + 218 + // Headers 219 + if (table_ctx.col > headers.len - 1) table_ctx.col = @intCast(headers.len - 1); 220 + var col_start: u16 = 0; 68 221 for (headers[0..], 0..) |hdr_txt, idx| { 69 - const hdr_bg = 70 - if (table_ctx.active and idx == table_ctx.col) table_ctx.selected_bg else if (idx % 2 == 0) table_ctx.hdr_bg_1 else table_ctx.hdr_bg_2; 71 - const hdr_win = table_win.initChild( 72 - idx * table_ctx.col_width, 73 - 0, 74 - .{ .limit = table_ctx.col_width }, 75 - .{ .limit = 1 }, 222 + const col_width = try calcColWidth( 223 + @intCast(idx), 224 + headers, 225 + table_ctx.col_width, 226 + table_win, 76 227 ); 77 - var hdr = vaxis.widgets.alignment.center(hdr_win, @min(table_ctx.col_width -| 1, hdr_txt.len +| 1), 1); 228 + defer col_start += col_width; 229 + const hdr_fg, const hdr_bg = hdrColors: { 230 + if (table_ctx.active and idx == table_ctx.col) 231 + break :hdrColors .{ table_ctx.active_fg, table_ctx.active_bg } 232 + else if (idx % 2 == 0) 233 + break :hdrColors .{ .default, table_ctx.hdr_bg_1 } 234 + else 235 + break :hdrColors .{ .default, table_ctx.hdr_bg_2 }; 236 + }; 237 + const hdr_win = table_win.child(.{ 238 + .x_off = col_start, 239 + .y_off = 0, 240 + .width = col_width, 241 + .height = 1, 242 + .border = .{ .where = if (table_ctx.header_borders and idx > 0) .left else .none }, 243 + }); 244 + var hdr = switch (table_ctx.header_align) { 245 + .left => hdr_win, 246 + .center => vaxis.widgets.alignment.center(hdr_win, @min(col_width -| 1, hdr_txt.len +| 1), 1), 247 + }; 78 248 hdr_win.fill(.{ .style = .{ .bg = hdr_bg } }); 79 249 var seg = [_]vaxis.Cell.Segment{.{ 80 - .text = if (hdr_txt.len > table_ctx.col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{hdr_txt[0..(table_ctx.col_width -| 4)]}) else hdr_txt, 250 + .text = if (hdr_txt.len > col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{hdr_txt[0..(col_width -| 4)]}) else hdr_txt, 81 251 .style = .{ 252 + .fg = hdr_fg, 82 253 .bg = hdr_bg, 83 254 .bold = true, 84 255 .ul_style = if (idx == table_ctx.col) .single else .dotted, 85 256 }, 86 257 }}; 87 - _ = try hdr.print(seg[0..], .{ .wrap = .word }); 258 + _ = hdr.print(seg[0..], .{ .wrap = .word }); 88 259 } 89 260 90 - const max_items = if (data_list.items.len > table_win.height -| 1) table_win.height -| 1 else data_list.items.len; 91 - var end = table_ctx.*.start + max_items; 92 - if (end > data_list.items.len) end = data_list.items.len; 93 - table_ctx.*.start = tableStart: { 261 + // Rows 262 + if (table_ctx.active_content_fn == null) table_ctx.active_y_off = 0; 263 + const max_items: u16 = 264 + if (data_items.len > table_win.height -| 1) table_win.height -| 1 else @intCast(data_items.len); 265 + var end = table_ctx.start + max_items; 266 + if (table_ctx.row + table_ctx.active_y_off >= win.height -| 2) 267 + end -|= table_ctx.active_y_off; 268 + if (end > data_items.len) end = @intCast(data_items.len); 269 + table_ctx.start = tableStart: { 94 270 if (table_ctx.row == 0) 95 271 break :tableStart 0; 96 272 if (table_ctx.row < table_ctx.start) 97 273 break :tableStart table_ctx.start - (table_ctx.start - table_ctx.row); 98 - if (table_ctx.row >= data_list.items.len - 1) 99 - table_ctx.*.row = data_list.items.len - 1; 274 + if (table_ctx.row >= data_items.len - 1) 275 + table_ctx.row = @intCast(data_items.len - 1); 100 276 if (table_ctx.row >= end) 101 277 break :tableStart table_ctx.start + (table_ctx.row - end + 1); 102 278 break :tableStart table_ctx.start; 103 279 }; 104 - end = table_ctx.*.start + max_items; 105 - if (end > data_list.items.len) end = data_list.items.len; 106 - for (data_list.items[table_ctx.start..end], 0..) |data, idx| { 107 - const row_bg = 108 - if (table_ctx.active and table_ctx.start + idx == table_ctx.row) table_ctx.selected_bg else if (idx % 2 == 0) table_ctx.row_bg_1 else table_ctx.row_bg_2; 109 - 110 - const row_win = table_win.initChild( 111 - 0, 112 - 1 + idx, 113 - .{ .limit = table_win.width }, 114 - .{ .limit = 1 }, 115 - ); 116 - const DataT = @TypeOf(data); 117 - if (DataT == []const u8) { 118 - row_win.fill(.{ .style = .{ .bg = row_bg } }); 119 - var seg = [_]vaxis.Cell.Segment{.{ 120 - .text = if (data.len > table_ctx.col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{data[0..(table_ctx.col_width -| 4)]}) else data, 121 - .style = .{ .bg = row_bg }, 122 - }}; 123 - _ = try row_win.print(seg[0..], .{ .wrap = .word }); 124 - return; 280 + end = table_ctx.start + max_items; 281 + if (table_ctx.row + table_ctx.active_y_off >= win.height -| 2) 282 + end -|= table_ctx.active_y_off; 283 + if (end > data_items.len) end = @intCast(data_items.len); 284 + table_ctx.start = @min(table_ctx.start, end); 285 + table_ctx.active_y_off = 0; 286 + for (data_items[table_ctx.start..end], 0..) |data, row| { 287 + const row_fg, const row_bg = rowColors: { 288 + if (table_ctx.active and table_ctx.start + row == table_ctx.row) 289 + break :rowColors .{ table_ctx.active_fg, table_ctx.active_bg }; 290 + if (table_ctx.sel_rows) |rows| { 291 + if (mem.indexOfScalar(u16, rows, @intCast(table_ctx.start + row)) != null) 292 + break :rowColors .{ table_ctx.selected_fg, table_ctx.selected_bg }; 293 + } 294 + if (row % 2 == 0) break :rowColors .{ .default, table_ctx.row_bg_1 }; 295 + break :rowColors .{ .default, table_ctx.row_bg_2 }; 296 + }; 297 + var row_win = table_win.child(.{ 298 + .x_off = 0, 299 + .y_off = @intCast(1 + row + table_ctx.active_y_off), 300 + .width = table_win.width, 301 + .height = 1, 302 + //.border = .{ .where = if (table_ctx.row_borders) .top else .none }, 303 + }); 304 + if (table_ctx.start + row == table_ctx.row) { 305 + table_ctx.active_y_off = if (table_ctx.active_content_fn) |content| try content(&row_win, table_ctx.active_ctx) else 0; 125 306 } 307 + col_start = 0; 126 308 const item_fields = meta.fields(DataT); 127 - inline for (item_fields[0..], 0..) |item_field, item_idx| { 128 - const item = @field(data, item_field.name); 129 - const ItemT = @TypeOf(item); 130 - const item_win = row_win.initChild( 131 - item_idx * table_ctx.col_width, 132 - 0, 133 - .{ .limit = table_ctx.col_width }, 134 - .{ .limit = 1 }, 135 - ); 136 - const item_txt = switch (ItemT) { 137 - []const u8 => item, 138 - else => nonStr: { 139 - switch (@typeInfo(ItemT)) { 140 - .Optional => { 141 - const opt_item = item orelse break :nonStr "-"; 142 - switch (@typeInfo(ItemT).Optional.child) { 143 - []const u8 => break :nonStr opt_item, 144 - else => { 145 - break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{any}", .{opt_item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)}); 146 - }, 147 - } 309 + var col_idx: usize = 0; 310 + for (field_indexes) |f_idx| { 311 + inline for (item_fields[0..], 0..) |item_field, item_idx| contFields: { 312 + switch (table_ctx.col_indexes) { 313 + .all => {}, 314 + .by_idx => { 315 + if (item_idx != f_idx) break :contFields; 316 + }, 317 + } 318 + defer col_idx += 1; 319 + const col_width = try calcColWidth( 320 + item_idx, 321 + headers, 322 + table_ctx.col_width, 323 + table_win, 324 + ); 325 + defer col_start += col_width; 326 + const item = @field(data, item_field.name); 327 + const ItemT = @TypeOf(item); 328 + const item_win = row_win.child(.{ 329 + .x_off = col_start, 330 + .y_off = 0, 331 + .width = col_width, 332 + .height = 1, 333 + .border = .{ .where = if (table_ctx.col_borders and col_idx > 0) .left else .none }, 334 + }); 335 + const item_txt = switch (ItemT) { 336 + []const u8 => item, 337 + [][]const u8, []const []const u8 => strSlice: { 338 + if (alloc) |_alloc| break :strSlice try fmt.allocPrint(_alloc, "{s}", .{item}); 339 + break :strSlice item; 340 + }, 341 + else => nonStr: { 342 + switch (@typeInfo(ItemT)) { 343 + .@"enum" => break :nonStr @tagName(item), 344 + .optional => { 345 + const opt_item = item orelse break :nonStr "-"; 346 + switch (@typeInfo(ItemT).optional.child) { 347 + []const u8 => break :nonStr opt_item, 348 + [][]const u8, []const []const u8 => { 349 + break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{s}", .{opt_item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)}); 350 + }, 351 + else => { 352 + break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{any}", .{opt_item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)}); 353 + }, 354 + } 355 + }, 356 + else => { 357 + break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{any}", .{item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)}); 358 + }, 359 + } 360 + }, 361 + }; 362 + item_win.fill(.{ .style = .{ .bg = row_bg } }); 363 + const item_align_win = itemAlignWin: { 364 + const col_align = switch (table_ctx.col_align) { 365 + .all => |all| all, 366 + .by_idx => |aligns| aligns[col_idx], 367 + }; 368 + break :itemAlignWin switch (col_align) { 369 + .left => item_win, 370 + .center => center: { 371 + const center = vaxis.widgets.alignment.center(item_win, @min(col_width -| 1, item_txt.len +| 1), 1); 372 + center.fill(.{ .style = .{ .bg = row_bg } }); 373 + break :center center; 148 374 }, 149 - else => { 150 - break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{any}", .{item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)}); 151 - }, 152 - } 153 - }, 154 - }; 155 - item_win.fill(.{ .style = .{ .bg = row_bg } }); 156 - var seg = [_]vaxis.Cell.Segment{.{ 157 - .text = if (item_txt.len > table_ctx.col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{item_txt[0..(table_ctx.col_width -| 4)]}) else item_txt, 158 - .style = .{ .bg = row_bg }, 159 - }}; 160 - _ = try item_win.print(seg[0..], .{ .wrap = .word }); 375 + }; 376 + }; 377 + var seg = [_]vaxis.Cell.Segment{.{ 378 + .text = if (item_txt.len > col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{item_txt[0..(col_width -| 4)]}) else item_txt, 379 + .style = .{ .fg = row_fg, .bg = row_bg }, 380 + }}; 381 + _ = item_align_win.print(seg[0..], .{ .wrap = .word, .col_offset = table_ctx.cell_x_off }); 382 + } 161 383 } 162 384 } 163 385 } 386 + 387 + /// Calculate the Column Width of `col` using the provided Number of Headers (`num_hdrs`), Width Style (`style`), and Table Window (`table_win`). 388 + pub fn calcColWidth( 389 + col: u16, 390 + headers: []const []const u8, 391 + style: WidthStyle, 392 + table_win: vaxis.Window, 393 + ) !u16 { 394 + return switch (style) { 395 + .dynamic_fill => dynFill: { 396 + var cw: u16 = table_win.width / @as(u16, @intCast(headers.len)); 397 + if (cw % 2 != 0) cw +|= 1; 398 + while (cw * headers.len < table_win.width - 1) cw +|= 1; 399 + break :dynFill cw; 400 + }, 401 + .dynamic_header_len => dynHdrs: { 402 + if (col >= headers.len) break :dynHdrs error.NotEnoughStaticWidthsProvided; 403 + break :dynHdrs @as(u16, @intCast(headers[col].len)) + (style.dynamic_header_len * 2); 404 + }, 405 + .static_all => style.static_all, 406 + .static_individual => statInd: { 407 + if (col >= headers.len) break :statInd error.NotEnoughStaticWidthsProvided; 408 + break :statInd style.static_individual[col]; 409 + }, 410 + }; 411 + }
+289 -175
src/widgets/TextInput.zig
··· 3 3 const Key = @import("../Key.zig"); 4 4 const Cell = @import("../Cell.zig"); 5 5 const Window = @import("../Window.zig"); 6 - const GapBuffer = @import("gap_buffer").GapBuffer; 7 - const Unicode = @import("../Unicode.zig"); 6 + const unicode = @import("../unicode.zig"); 8 7 9 8 const TextInput = @This(); 10 9 ··· 16 15 const ellipsis: Cell.Character = .{ .grapheme = "โ€ฆ", .width = 1 }; 17 16 18 17 // Index of our cursor 19 - cursor_idx: usize = 0, 20 - grapheme_count: usize = 0, 21 - buf: GapBuffer(u8), 18 + buf: Buffer, 22 19 23 20 /// the number of graphemes to skip when drawing. Used for horizontal scrolling 24 - draw_offset: usize = 0, 21 + draw_offset: u16 = 0, 25 22 /// the column we placed the cursor the last time we drew 26 - prev_cursor_col: usize = 0, 23 + prev_cursor_col: u16 = 0, 27 24 /// the grapheme index of the cursor the last time we drew 28 - prev_cursor_idx: usize = 0, 25 + prev_cursor_idx: u16 = 0, 29 26 /// approximate distance from an edge before we scroll 30 - scroll_offset: usize = 4, 27 + scroll_offset: u16 = 4, 31 28 32 - unicode: *const Unicode, 33 - 34 - pub fn init(alloc: std.mem.Allocator, unicode: *const Unicode) TextInput { 29 + pub fn init(alloc: std.mem.Allocator) TextInput { 35 30 return TextInput{ 36 - .buf = GapBuffer(u8).init(alloc), 37 - .unicode = unicode, 31 + .buf = Buffer.init(alloc), 38 32 }; 39 33 } 40 34 ··· 46 40 switch (event) { 47 41 .key_press => |key| { 48 42 if (key.matches(Key.backspace, .{})) { 49 - if (self.cursor_idx == 0) return; 50 - try self.deleteBeforeCursor(); 43 + self.deleteBeforeCursor(); 51 44 } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) { 52 - if (self.cursor_idx == self.grapheme_count) return; 53 - try self.deleteAtCursor(); 45 + self.deleteAfterCursor(); 54 46 } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) { 55 - if (self.cursor_idx > 0) self.cursor_idx -= 1; 47 + self.cursorLeft(); 56 48 } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) { 57 - if (self.cursor_idx < self.grapheme_count) self.cursor_idx += 1; 58 - } else if (key.matches('a', .{ .ctrl = true })) { 59 - self.cursor_idx = 0; 60 - } else if (key.matches('e', .{ .ctrl = true })) { 61 - self.cursor_idx = self.grapheme_count; 49 + self.cursorRight(); 50 + } else if (key.matches('a', .{ .ctrl = true }) or key.matches(Key.home, .{})) { 51 + self.buf.moveGapLeft(self.buf.firstHalf().len); 52 + } else if (key.matches('e', .{ .ctrl = true }) or key.matches(Key.end, .{})) { 53 + self.buf.moveGapRight(self.buf.secondHalf().len); 62 54 } else if (key.matches('k', .{ .ctrl = true })) { 63 - try self.deleteToEnd(); 55 + self.deleteToEnd(); 64 56 } else if (key.matches('u', .{ .ctrl = true })) { 65 - try self.deleteToStart(); 57 + self.deleteToStart(); 58 + } else if (key.matches('b', .{ .alt = true }) or key.matches(Key.left, .{ .alt = true })) { 59 + self.moveBackwardWordwise(); 60 + } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) { 61 + self.moveForwardWordwise(); 62 + } else if (key.matches('w', .{ .ctrl = true }) or key.matches(Key.backspace, .{ .alt = true })) { 63 + self.deleteWordBefore(); 64 + } else if (key.matches('d', .{ .alt = true })) { 65 + self.deleteWordAfter(); 66 66 } else if (key.text) |text| { 67 - try self.buf.insertSliceBefore(self.byteOffsetToCursor(), text); 68 - self.cursor_idx += 1; 69 - self.grapheme_count += 1; 67 + try self.insertSliceAtCursor(text); 70 68 } 71 69 }, 72 70 } 73 71 } 74 72 75 73 /// insert text at the cursor position 76 - pub fn insertSliceAtCursor(self: *TextInput, data: []const u8) !void { 77 - var iter = self.unicode.graphemeIterator(data); 78 - var byte_offset_to_cursor = self.byteOffsetToCursor(); 74 + pub fn insertSliceAtCursor(self: *TextInput, data: []const u8) std.mem.Allocator.Error!void { 75 + var iter = unicode.graphemeIterator(data); 79 76 while (iter.next()) |text| { 80 - try self.buf.insertSliceBefore(byte_offset_to_cursor, text.bytes(data)); 81 - byte_offset_to_cursor += text.len; 82 - self.cursor_idx += 1; 83 - self.grapheme_count += 1; 77 + try self.buf.insertSliceAtCursor(text.bytes(data)); 84 78 } 85 79 } 86 80 87 81 pub fn sliceToCursor(self: *TextInput, buf: []u8) []const u8 { 88 - const offset = self.byteOffsetToCursor(); 89 - assert(offset <= buf.len); // provided buf was too small 90 - 91 - if (offset <= self.buf.items.len) { 92 - @memcpy(buf[0..offset], self.buf.items[0..offset]); 93 - } else { 94 - @memcpy(buf[0..self.buf.items.len], self.buf.items); 95 - const second_half = self.buf.secondHalf(); 96 - const copy_len = offset - self.buf.items.len; 97 - @memcpy(buf[self.buf.items.len .. self.buf.items.len + copy_len], second_half[0..copy_len]); 98 - } 99 - return buf[0..offset]; 82 + assert(buf.len >= self.buf.cursor); 83 + @memcpy(buf[0..self.buf.cursor], self.buf.firstHalf()); 84 + return buf[0..self.buf.cursor]; 100 85 } 101 86 102 87 /// calculates the display width from the draw_offset to the cursor 103 - fn widthToCursor(self: *TextInput, win: Window) usize { 104 - var width: usize = 0; 105 - var first_iter = self.unicode.graphemeIterator(self.buf.items); 88 + pub fn widthToCursor(self: *TextInput, win: Window) u16 { 89 + var width: u16 = 0; 90 + const first_half = self.buf.firstHalf(); 91 + var first_iter = unicode.graphemeIterator(first_half); 106 92 var i: usize = 0; 107 93 while (first_iter.next()) |grapheme| { 108 94 defer i += 1; 109 95 if (i < self.draw_offset) { 110 96 continue; 111 97 } 112 - if (i == self.cursor_idx) return width; 113 - const g = grapheme.bytes(self.buf.items); 98 + const g = grapheme.bytes(first_half); 114 99 width += win.gwidth(g); 115 100 } 116 - const second_half = self.buf.secondHalf(); 117 - var second_iter = self.unicode.graphemeIterator(second_half); 118 - while (second_iter.next()) |grapheme| { 119 - defer i += 1; 120 - if (i < self.draw_offset) { 121 - continue; 122 - } 123 - if (i == self.cursor_idx) return width; 124 - const g = grapheme.bytes(second_half); 125 - width += win.gwidth(g); 101 + return width; 102 + } 103 + 104 + pub fn cursorLeft(self: *TextInput) void { 105 + // We need to find the size of the last grapheme in the first half 106 + var iter = unicode.graphemeIterator(self.buf.firstHalf()); 107 + var len: usize = 0; 108 + while (iter.next()) |grapheme| { 109 + len = grapheme.len; 126 110 } 127 - return width; 111 + self.buf.moveGapLeft(len); 112 + } 113 + 114 + pub fn cursorRight(self: *TextInput) void { 115 + var iter = unicode.graphemeIterator(self.buf.secondHalf()); 116 + const grapheme = iter.next() orelse return; 117 + self.buf.moveGapRight(grapheme.len); 118 + } 119 + 120 + pub fn graphemesBeforeCursor(self: *const TextInput) u16 { 121 + const first_half = self.buf.firstHalf(); 122 + var first_iter = unicode.graphemeIterator(first_half); 123 + var i: u16 = 0; 124 + while (first_iter.next()) |_| { 125 + i += 1; 126 + } 127 + return i; 128 128 } 129 129 130 130 pub fn draw(self: *TextInput, win: Window) void { 131 - if (self.cursor_idx < self.draw_offset) self.draw_offset = self.cursor_idx; 131 + self.drawWithStyle(win, .{}); 132 + } 133 + 134 + pub fn drawWithStyle(self: *TextInput, win: Window, style: Cell.Style) void { 135 + const cursor_idx = self.graphemesBeforeCursor(); 136 + if (cursor_idx < self.draw_offset) self.draw_offset = cursor_idx; 132 137 if (win.width == 0) return; 133 138 while (true) { 134 139 const width = self.widthToCursor(win); ··· 138 143 } else break; 139 144 } 140 145 141 - self.prev_cursor_idx = self.cursor_idx; 146 + self.prev_cursor_idx = cursor_idx; 142 147 self.prev_cursor_col = 0; 143 148 144 149 // assumption!! the gap is never within a grapheme 145 150 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. 146 - var first_iter = self.unicode.graphemeIterator(self.buf.items); 147 - var col: usize = 0; 148 - var i: usize = 0; 151 + const first_half = self.buf.firstHalf(); 152 + var first_iter = unicode.graphemeIterator(first_half); 153 + var col: u16 = 0; 154 + var i: u16 = 0; 149 155 while (first_iter.next()) |grapheme| { 150 156 if (i < self.draw_offset) { 151 157 i += 1; 152 158 continue; 153 159 } 154 - const g = grapheme.bytes(self.buf.items); 160 + const g = grapheme.bytes(first_half); 155 161 const w = win.gwidth(g); 156 162 if (col + w >= win.width) { 157 - win.writeCell(win.width - 1, 0, .{ .char = ellipsis }); 163 + win.writeCell(win.width - 1, 0, .{ 164 + .char = ellipsis, 165 + .style = style, 166 + }); 158 167 break; 159 168 } 160 169 win.writeCell(col, 0, .{ 161 170 .char = .{ 162 171 .grapheme = g, 163 - .width = w, 172 + .width = @intCast(w), 164 173 }, 174 + .style = style, 165 175 }); 166 176 col += w; 167 177 i += 1; 168 - if (i == self.cursor_idx) self.prev_cursor_col = col; 178 + if (i == cursor_idx) self.prev_cursor_col = col; 169 179 } 170 180 const second_half = self.buf.secondHalf(); 171 - var second_iter = self.unicode.graphemeIterator(second_half); 181 + var second_iter = unicode.graphemeIterator(second_half); 172 182 while (second_iter.next()) |grapheme| { 173 183 if (i < self.draw_offset) { 174 184 i += 1; ··· 177 187 const g = grapheme.bytes(second_half); 178 188 const w = win.gwidth(g); 179 189 if (col + w > win.width) { 180 - win.writeCell(win.width - 1, 0, .{ .char = ellipsis }); 190 + win.writeCell(win.width - 1, 0, .{ 191 + .char = ellipsis, 192 + .style = style, 193 + }); 181 194 break; 182 195 } 183 196 win.writeCell(col, 0, .{ 184 197 .char = .{ 185 198 .grapheme = g, 186 - .width = w, 199 + .width = @intCast(w), 187 200 }, 201 + .style = style, 188 202 }); 189 203 col += w; 190 204 i += 1; 191 - if (i == self.cursor_idx) self.prev_cursor_col = col; 205 + if (i == cursor_idx) self.prev_cursor_col = col; 192 206 } 193 207 if (self.draw_offset > 0) { 194 - win.writeCell(0, 0, .{ .char = ellipsis }); 208 + win.writeCell(0, 0, .{ 209 + .char = ellipsis, 210 + .style = style, 211 + }); 195 212 } 196 213 win.showCursor(self.prev_cursor_col, 0); 197 214 } ··· 211 228 return self.buf.toOwnedSlice(); 212 229 } 213 230 214 - fn reset(self: *TextInput) void { 215 - self.cursor_idx = 0; 216 - self.grapheme_count = 0; 231 + pub fn reset(self: *TextInput) void { 217 232 self.draw_offset = 0; 218 233 self.prev_cursor_col = 0; 219 234 self.prev_cursor_idx = 0; 220 235 } 221 236 222 237 // returns the number of bytes before the cursor 223 - // (since GapBuffers are strictly speaking not contiguous, this is a number in 0..realLength() 224 - // which would need to be fed to realIndex() to get an actual offset into self.buf.items.ptr) 225 238 pub fn byteOffsetToCursor(self: TextInput) usize { 226 - // assumption! the gap is never in the middle of a grapheme 227 - // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. 228 - var iter = self.unicode.graphemeIterator(self.buf.items); 229 - var offset: usize = 0; 230 - var i: usize = 0; 231 - while (iter.next()) |grapheme| { 232 - if (i == self.cursor_idx) break; 233 - offset += grapheme.len; 234 - i += 1; 235 - } else { 236 - var second_iter = self.unicode.graphemeIterator(self.buf.secondHalf()); 237 - while (second_iter.next()) |grapheme| { 238 - if (i == self.cursor_idx) break; 239 - offset += grapheme.len; 240 - i += 1; 241 - } 242 - } 243 - return offset; 239 + return self.buf.cursor; 244 240 } 245 241 246 - fn deleteToEnd(self: *TextInput) !void { 247 - const offset = self.byteOffsetToCursor(); 248 - try self.buf.replaceRangeAfter(offset, self.buf.realLength() - offset, &.{}); 249 - self.grapheme_count = self.cursor_idx; 242 + pub fn deleteToEnd(self: *TextInput) void { 243 + self.buf.growGapRight(self.buf.secondHalf().len); 250 244 } 251 245 252 - fn deleteToStart(self: *TextInput) !void { 253 - const offset = self.byteOffsetToCursor(); 254 - try self.buf.replaceRangeBefore(0, offset, &.{}); 255 - self.grapheme_count -= self.cursor_idx; 256 - self.cursor_idx = 0; 246 + pub fn deleteToStart(self: *TextInput) void { 247 + self.buf.growGapLeft(self.buf.cursor); 257 248 } 258 249 259 - fn deleteBeforeCursor(self: *TextInput) !void { 260 - // assumption! the gap is never in the middle of a grapheme 261 - // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. 262 - var iter = self.unicode.graphemeIterator(self.buf.items); 263 - var offset: usize = 0; 264 - var i: usize = 1; 250 + pub fn deleteBeforeCursor(self: *TextInput) void { 251 + // We need to find the size of the last grapheme in the first half 252 + var iter = unicode.graphemeIterator(self.buf.firstHalf()); 253 + var len: usize = 0; 265 254 while (iter.next()) |grapheme| { 266 - if (i == self.cursor_idx) { 267 - try self.buf.replaceRangeBefore(offset, grapheme.len, &.{}); 268 - self.cursor_idx -= 1; 269 - self.grapheme_count -= 1; 270 - return; 271 - } 272 - offset += grapheme.len; 273 - i += 1; 274 - } else { 275 - var second_iter = self.unicode.graphemeIterator(self.buf.secondHalf()); 276 - while (second_iter.next()) |grapheme| { 277 - if (i == self.cursor_idx) { 278 - try self.buf.replaceRangeBefore(offset, grapheme.len, &.{}); 279 - self.cursor_idx -= 1; 280 - self.grapheme_count -= 1; 281 - return; 282 - } 283 - offset += grapheme.len; 284 - i += 1; 285 - } 255 + len = grapheme.len; 286 256 } 257 + self.buf.growGapLeft(len); 287 258 } 288 259 289 - fn deleteAtCursor(self: *TextInput) !void { 290 - // assumption! the gap is never in the middle of a grapheme 291 - // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. 292 - var iter = self.unicode.graphemeIterator(self.buf.items); 293 - var offset: usize = 0; 294 - var i: usize = 1; 295 - while (iter.next()) |grapheme| { 296 - if (i == self.cursor_idx + 1) { 297 - try self.buf.replaceRangeAfter(offset, grapheme.len, &.{}); 298 - self.grapheme_count -= 1; 299 - return; 300 - } 301 - offset += grapheme.len; 302 - i += 1; 303 - } else { 304 - var second_iter = self.unicode.graphemeIterator(self.buf.secondHalf()); 305 - while (second_iter.next()) |grapheme| { 306 - if (i == self.cursor_idx + 1) { 307 - try self.buf.replaceRangeAfter(offset, grapheme.len, &.{}); 308 - self.grapheme_count -= 1; 309 - return; 310 - } 311 - offset += grapheme.len; 312 - i += 1; 313 - } 314 - } 260 + pub fn deleteAfterCursor(self: *TextInput) void { 261 + var iter = unicode.graphemeIterator(self.buf.secondHalf()); 262 + const grapheme = iter.next() orelse return; 263 + self.buf.growGapRight(grapheme.len); 264 + } 265 + 266 + /// Moves the cursor backward by words. If the character before the cursor is a space, the cursor is 267 + /// positioned just after the next previous space 268 + pub fn moveBackwardWordwise(self: *TextInput) void { 269 + const trimmed = std.mem.trimRight(u8, self.buf.firstHalf(), " "); 270 + const idx = if (std.mem.lastIndexOfScalar(u8, trimmed, ' ')) |last| 271 + last + 1 272 + else 273 + 0; 274 + self.buf.moveGapLeft(self.buf.cursor - idx); 275 + } 276 + 277 + pub fn moveForwardWordwise(self: *TextInput) void { 278 + const second_half = self.buf.secondHalf(); 279 + var i: usize = 0; 280 + while (i < second_half.len and second_half[i] == ' ') : (i += 1) {} 281 + const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len; 282 + self.buf.moveGapRight(idx); 283 + } 284 + 285 + pub fn deleteWordBefore(self: *TextInput) void { 286 + // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 287 + // moved 288 + const pre = self.buf.cursor; 289 + self.moveBackwardWordwise(); 290 + self.buf.growGapRight(pre - self.buf.cursor); 291 + } 292 + 293 + pub fn deleteWordAfter(self: *TextInput) void { 294 + // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 295 + // moved 296 + const second_half = self.buf.secondHalf(); 297 + var i: usize = 0; 298 + while (i < second_half.len and second_half[i] == ' ') : (i += 1) {} 299 + const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len; 300 + self.buf.growGapRight(idx); 315 301 } 316 302 317 303 test "assertion" { 318 - const alloc = std.testing.allocator_instance.allocator(); 319 - const unicode = try Unicode.init(alloc); 320 - defer unicode.deinit(); 321 304 const astronaut = "๐Ÿ‘ฉโ€๐Ÿš€"; 322 305 const astronaut_emoji: Key = .{ 323 306 .text = astronaut, 324 307 .codepoint = try std.unicode.utf8Decode(astronaut[0..4]), 325 308 }; 326 - var input = TextInput.init(std.testing.allocator, &unicode); 309 + var input = TextInput.init(std.testing.allocator); 327 310 defer input.deinit(); 328 311 for (0..6) |_| { 329 312 try input.update(.{ .key_press = astronaut_emoji }); ··· 331 314 } 332 315 333 316 test "sliceToCursor" { 334 - const alloc = std.testing.allocator_instance.allocator(); 335 - const unicode = try Unicode.init(alloc); 336 - defer unicode.deinit(); 337 - var input = init(alloc, &unicode); 317 + var input = init(std.testing.allocator); 338 318 defer input.deinit(); 339 319 try input.insertSliceAtCursor("hello, world"); 340 - input.cursor_idx = 2; 320 + input.cursorLeft(); 321 + input.cursorLeft(); 322 + input.cursorLeft(); 341 323 var buf: [32]u8 = undefined; 342 - try std.testing.expectEqualStrings("he", input.sliceToCursor(&buf)); 343 - input.buf.moveGap(3); 344 - input.cursor_idx = 5; 345 - try std.testing.expectEqualStrings("hello", input.sliceToCursor(&buf)); 324 + try std.testing.expectEqualStrings("hello, wo", input.sliceToCursor(&buf)); 325 + input.cursorRight(); 326 + try std.testing.expectEqualStrings("hello, wor", input.sliceToCursor(&buf)); 327 + } 328 + 329 + pub const Buffer = struct { 330 + allocator: std.mem.Allocator, 331 + buffer: []u8, 332 + cursor: usize, 333 + gap_size: usize, 334 + 335 + pub fn init(allocator: std.mem.Allocator) Buffer { 336 + return .{ 337 + .allocator = allocator, 338 + .buffer = &.{}, 339 + .cursor = 0, 340 + .gap_size = 0, 341 + }; 342 + } 343 + 344 + pub fn deinit(self: *Buffer) void { 345 + self.allocator.free(self.buffer); 346 + } 347 + 348 + pub fn firstHalf(self: Buffer) []const u8 { 349 + return self.buffer[0..self.cursor]; 350 + } 351 + 352 + pub fn secondHalf(self: Buffer) []const u8 { 353 + return self.buffer[self.cursor + self.gap_size ..]; 354 + } 355 + 356 + pub fn grow(self: *Buffer, n: usize) std.mem.Allocator.Error!void { 357 + // Always grow by 512 bytes 358 + const new_size = self.buffer.len + n + 512; 359 + // Allocate the new memory 360 + const new_memory = try self.allocator.alloc(u8, new_size); 361 + // Copy the first half 362 + @memcpy(new_memory[0..self.cursor], self.firstHalf()); 363 + // Copy the second half 364 + const second_half = self.secondHalf(); 365 + @memcpy(new_memory[new_size - second_half.len ..], second_half); 366 + self.allocator.free(self.buffer); 367 + self.buffer = new_memory; 368 + self.gap_size = new_size - second_half.len - self.cursor; 369 + } 370 + 371 + pub fn insertSliceAtCursor(self: *Buffer, slice: []const u8) std.mem.Allocator.Error!void { 372 + if (slice.len == 0) return; 373 + if (self.gap_size <= slice.len) try self.grow(slice.len); 374 + @memcpy(self.buffer[self.cursor .. self.cursor + slice.len], slice); 375 + self.cursor += slice.len; 376 + self.gap_size -= slice.len; 377 + } 378 + 379 + /// Move the gap n bytes to the left 380 + pub fn moveGapLeft(self: *Buffer, n: usize) void { 381 + const new_idx = self.cursor -| n; 382 + const dst = self.buffer[new_idx + self.gap_size ..]; 383 + const src = self.buffer[new_idx..self.cursor]; 384 + std.mem.copyForwards(u8, dst, src); 385 + self.cursor = new_idx; 386 + } 387 + 388 + pub fn moveGapRight(self: *Buffer, n: usize) void { 389 + const new_idx = self.cursor + n; 390 + const dst = self.buffer[self.cursor..]; 391 + const src = self.buffer[self.cursor + self.gap_size .. new_idx + self.gap_size]; 392 + std.mem.copyForwards(u8, dst, src); 393 + self.cursor = new_idx; 394 + } 395 + 396 + /// grow the gap by moving the cursor n bytes to the left 397 + pub fn growGapLeft(self: *Buffer, n: usize) void { 398 + // gap grows by the delta 399 + self.gap_size += n; 400 + self.cursor -|= n; 401 + } 402 + 403 + /// grow the gap by removing n bytes after the cursor 404 + pub fn growGapRight(self: *Buffer, n: usize) void { 405 + self.gap_size = @min(self.gap_size + n, self.buffer.len - self.cursor); 406 + } 407 + 408 + pub fn clearAndFree(self: *Buffer) void { 409 + self.cursor = 0; 410 + self.allocator.free(self.buffer); 411 + self.buffer = &.{}; 412 + self.gap_size = 0; 413 + } 414 + 415 + pub fn clearRetainingCapacity(self: *Buffer) void { 416 + self.cursor = 0; 417 + self.gap_size = self.buffer.len; 418 + } 419 + 420 + pub fn toOwnedSlice(self: *Buffer) std.mem.Allocator.Error![]const u8 { 421 + const first_half = self.firstHalf(); 422 + const second_half = self.secondHalf(); 423 + const buf = try self.allocator.alloc(u8, first_half.len + second_half.len); 424 + @memcpy(buf[0..first_half.len], first_half); 425 + @memcpy(buf[first_half.len..], second_half); 426 + self.clearAndFree(); 427 + return buf; 428 + } 429 + 430 + pub fn realLength(self: *const Buffer) usize { 431 + return self.firstHalf().len + self.secondHalf().len; 432 + } 433 + }; 434 + 435 + test "TextInput.zig: Buffer" { 436 + var gap_buf = Buffer.init(std.testing.allocator); 437 + defer gap_buf.deinit(); 438 + 439 + try gap_buf.insertSliceAtCursor("abc"); 440 + try std.testing.expectEqualStrings("abc", gap_buf.firstHalf()); 441 + try std.testing.expectEqualStrings("", gap_buf.secondHalf()); 442 + 443 + gap_buf.moveGapLeft(1); 444 + try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 445 + try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 446 + 447 + try gap_buf.insertSliceAtCursor(" "); 448 + try std.testing.expectEqualStrings("ab ", gap_buf.firstHalf()); 449 + try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 450 + 451 + gap_buf.growGapLeft(1); 452 + try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 453 + try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 454 + try std.testing.expectEqual(2, gap_buf.cursor); 455 + 456 + gap_buf.growGapRight(1); 457 + try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 458 + try std.testing.expectEqualStrings("", gap_buf.secondHalf()); 459 + try std.testing.expectEqual(2, gap_buf.cursor); 346 460 }
+58 -25
src/widgets/TextView.zig
··· 1 1 const std = @import("std"); 2 2 const vaxis = @import("../main.zig"); 3 - const grapheme = @import("grapheme"); 4 - const DisplayWidth = @import("DisplayWidth"); 3 + const uucode = @import("uucode"); 5 4 const ScrollView = vaxis.widgets.ScrollView; 6 5 6 + /// Simple grapheme representation to replace Graphemes.Grapheme 7 + const Grapheme = struct { 8 + len: u16, 9 + offset: u32, 10 + }; 11 + 7 12 pub const BufferWriter = struct { 8 13 pub const Error = error{OutOfMemory}; 9 14 pub const Writer = std.io.GenericWriter(@This(), Error, write); 10 15 11 16 allocator: std.mem.Allocator, 12 17 buffer: *Buffer, 13 - gd: *const grapheme.GraphemeData, 14 - wd: *const DisplayWidth.DisplayWidthData, 15 18 16 19 pub fn write(self: @This(), bytes: []const u8) Error!usize { 17 20 try self.buffer.append(self.allocator, .{ 18 21 .bytes = bytes, 19 - .gd = self.gd, 20 - .wd = self.wd, 21 22 }); 22 23 return bytes.len; 23 24 } ··· 33 34 34 35 pub const Content = struct { 35 36 bytes: []const u8, 36 - gd: *const grapheme.GraphemeData, 37 - wd: *const DisplayWidth.DisplayWidthData, 38 37 }; 39 38 40 39 pub const Style = struct { ··· 45 44 46 45 pub const Error = error{OutOfMemory}; 47 46 48 - grapheme: std.MultiArrayList(grapheme.Grapheme) = .{}, 47 + grapheme: std.MultiArrayList(Grapheme) = .{}, 49 48 content: std.ArrayListUnmanaged(u8) = .{}, 50 49 style_list: StyleList = .{}, 51 50 style_map: StyleMap = .{}, ··· 78 77 /// Appends content to the buffer. 79 78 pub fn append(self: *@This(), allocator: std.mem.Allocator, content: Content) Error!void { 80 79 var cols: usize = self.last_cols; 81 - var iter = grapheme.Iterator.init(content.bytes, content.gd); 82 - const dw: DisplayWidth = .{ .data = content.wd }; 83 - while (iter.next()) |g| { 80 + var iter = uucode.grapheme.Iterator(uucode.utf8.Iterator).init(.init(content.bytes)); 81 + 82 + var grapheme_start: usize = 0; 83 + var prev_break: bool = true; 84 + 85 + while (iter.next()) |result| { 86 + if (prev_break and !result.is_break) { 87 + // Start of a new grapheme 88 + const cp_len: usize = std.unicode.utf8CodepointSequenceLength(result.cp) catch 1; 89 + grapheme_start = iter.i - cp_len; 90 + } 91 + 92 + if (result.is_break) { 93 + // End of a grapheme 94 + const grapheme_end = iter.i; 95 + const grapheme_len = grapheme_end - grapheme_start; 96 + 97 + try self.grapheme.append(allocator, .{ 98 + .len = @intCast(grapheme_len), 99 + .offset = @intCast(self.content.items.len + grapheme_start), 100 + }); 101 + 102 + const cluster = content.bytes[grapheme_start..grapheme_end]; 103 + if (std.mem.eql(u8, cluster, "\n")) { 104 + self.cols = @max(self.cols, cols); 105 + cols = 0; 106 + } else { 107 + // Calculate width using gwidth 108 + const w = vaxis.gwidth.gwidth(cluster, .unicode); 109 + cols +|= w; 110 + } 111 + 112 + grapheme_start = grapheme_end; 113 + } 114 + prev_break = result.is_break; 115 + } 116 + 117 + // Flush the last grapheme if we ended mid-cluster 118 + if (!prev_break and grapheme_start < content.bytes.len) { 119 + const grapheme_len = content.bytes.len - grapheme_start; 120 + 84 121 try self.grapheme.append(allocator, .{ 85 - .len = g.len, 86 - .offset = @as(u32, @intCast(self.content.items.len)) + g.offset, 122 + .len = @intCast(grapheme_len), 123 + .offset = @intCast(self.content.items.len + grapheme_start), 87 124 }); 88 - const cluster = g.bytes(content.bytes); 89 - if (std.mem.eql(u8, cluster, "\n")) { 90 - self.cols = @max(self.cols, cols); 91 - cols = 0; 92 - continue; 125 + 126 + const cluster = content.bytes[grapheme_start..]; 127 + if (!std.mem.eql(u8, cluster, "\n")) { 128 + const w = vaxis.gwidth.gwidth(cluster, .unicode); 129 + cols +|= w; 93 130 } 94 - cols +|= dw.strWidth(cluster); 95 131 } 132 + 96 133 try self.content.appendSlice(allocator, content.bytes); 97 134 self.last_cols = cols; 98 135 self.cols = @max(self.cols, cols); ··· 124 161 pub fn writer( 125 162 self: *@This(), 126 163 allocator: std.mem.Allocator, 127 - gd: *const grapheme.GraphemeData, 128 - wd: *const DisplayWidth.DisplayWidthData, 129 164 ) BufferWriter.Writer { 130 165 return .{ 131 166 .context = .{ 132 167 .allocator = allocator, 133 168 .buffer = self, 134 - .gd = gd, 135 - .wd = wd, 136 169 }, 137 170 }; 138 171 } ··· 184 217 }; 185 218 186 219 self.scroll_view.writeCell(win, pos.x, pos.y, .{ 187 - .char = .{ .grapheme = cluster, .width = width }, 220 + .char = .{ .grapheme = cluster, .width = @intCast(width) }, 188 221 .style = style, 189 222 }); 190 223 }
+157
src/widgets/View.zig
··· 1 + //! A View is effectively an "oversized" Window that can be written to and rendered in pieces. 2 + 3 + const std = @import("std"); 4 + const mem = std.mem; 5 + 6 + const View = @This(); 7 + 8 + const gw = @import("../gwidth.zig"); 9 + 10 + const Screen = @import("../Screen.zig"); 11 + const Window = @import("../Window.zig"); 12 + const unicode = @import("../unicode.zig"); 13 + const Cell = @import("../Cell.zig"); 14 + 15 + /// View Allocator 16 + alloc: mem.Allocator, 17 + 18 + /// Underlying Screen 19 + screen: Screen, 20 + 21 + /// View Initialization Config 22 + pub const Config = struct { 23 + width: u16, 24 + height: u16, 25 + }; 26 + 27 + /// Initialize a new View 28 + pub fn init(alloc: mem.Allocator, config: Config) mem.Allocator.Error!View { 29 + return .{ 30 + .alloc = alloc, 31 + .screen = try Screen.init(alloc, .{ 32 + .cols = config.width, 33 + .rows = config.height, 34 + .x_pixel = 0, 35 + .y_pixel = 0, 36 + }), 37 + }; 38 + } 39 + 40 + pub fn window(self: *View) Window { 41 + return .{ 42 + .x_off = 0, 43 + .y_off = 0, 44 + .parent_x_off = 0, 45 + .parent_y_off = 0, 46 + .width = self.screen.width, 47 + .height = self.screen.height, 48 + .screen = &self.screen, 49 + }; 50 + } 51 + 52 + /// Deinitialize this View 53 + pub fn deinit(self: *View) void { 54 + self.screen.deinit(self.alloc); 55 + } 56 + 57 + pub const DrawOptions = struct { 58 + x_off: u16 = 0, 59 + y_off: u16 = 0, 60 + }; 61 + 62 + pub fn draw(self: *View, win: Window, opts: DrawOptions) void { 63 + if (opts.x_off >= self.screen.width) return; 64 + if (opts.y_off >= self.screen.height) return; 65 + 66 + const width = @min(win.width, self.screen.width - opts.x_off); 67 + const height = @min(win.height, self.screen.height - opts.y_off); 68 + 69 + for (0..height) |_row| { 70 + const row: i17 = @intCast(_row); 71 + const src_start: usize = @intCast(opts.x_off + ((row + opts.y_off) * self.screen.width)); 72 + const src_end: usize = @intCast(src_start + width); 73 + const dst_start: usize = @intCast(win.x_off + ((row + win.y_off) * win.screen.width)); 74 + const dst_end: usize = @intCast(dst_start + width); 75 + @memcpy(win.screen.buf[dst_start..dst_end], self.screen.buf[src_start..src_end]); 76 + } 77 + } 78 + 79 + /// Render Config for `toWin()` 80 + pub const RenderConfig = struct { 81 + x: u16 = 0, 82 + y: u16 = 0, 83 + width: Extent = .fit, 84 + height: Extent = .fit, 85 + 86 + pub const Extent = union(enum) { 87 + fit, 88 + max: u16, 89 + }; 90 + }; 91 + 92 + /// Render a portion of this View to the provided Window (`win`). 93 + /// This will return the bounded X (col), Y (row) coordinates based on the rendering. 94 + pub fn toWin(self: *View, win: Window, config: RenderConfig) !struct { u16, u16 } { 95 + var x = @min(self.screen.width - 1, config.x); 96 + var y = @min(self.screen.height - 1, config.y); 97 + const width = width: { 98 + var width = switch (config.width) { 99 + .fit => win.width, 100 + .max => |w| @min(win.width, w), 101 + }; 102 + width = @min(width, self.screen.width); 103 + break :width @min(width, self.screen.width -| 1 -| x +| win.width); 104 + }; 105 + const height = height: { 106 + var height = switch (config.height) { 107 + .fit => win.height, 108 + .max => |h| @min(win.height, h), 109 + }; 110 + height = @min(height, self.screen.height); 111 + break :height @min(height, self.screen.height -| 1 -| y +| win.height); 112 + }; 113 + x = @min(x, self.screen.width -| width); 114 + y = @min(y, self.screen.height -| height); 115 + const child = win.child(.{ 116 + .width = width, 117 + .height = height, 118 + }); 119 + self.draw(child, .{ .x_off = x, .y_off = y }); 120 + return .{ x, y }; 121 + } 122 + 123 + /// Writes a cell to the location in the View 124 + pub fn writeCell(self: *View, col: u16, row: u16, cell: Cell) void { 125 + self.screen.writeCell(col, row, cell); 126 + } 127 + 128 + /// Reads a cell at the location in the View 129 + pub fn readCell(self: *const View, col: u16, row: u16) ?Cell { 130 + return self.screen.readCell(col, row); 131 + } 132 + 133 + /// Fills the View with the default cell 134 + pub fn clear(self: View) void { 135 + self.fill(.{ .default = true }); 136 + } 137 + 138 + /// Returns the width of the grapheme. This depends on the terminal capabilities 139 + pub fn gwidth(self: View, str: []const u8) u16 { 140 + return gw.gwidth(str, self.screen.width_method); 141 + } 142 + 143 + /// Fills the View with the provided cell 144 + pub fn fill(self: View, cell: Cell) void { 145 + @memset(self.screen.buf, cell); 146 + } 147 + 148 + /// Prints segments to the View. Returns true if the text overflowed with the 149 + /// given wrap strategy and size. 150 + pub fn print(self: *View, segments: []const Cell.Segment, opts: Window.PrintOptions) Window.PrintResult { 151 + return self.window().print(segments, opts); 152 + } 153 + 154 + /// Print a single segment. This is just a shortcut for print(&.{segment}, opts) 155 + pub fn printSegment(self: *View, segment: Cell.Segment, opts: Window.PrintOptions) Window.PrintResult { 156 + return self.print(&.{segment}, opts); 157 + }
+31 -2
src/widgets/alignment.zig
··· 1 1 const Window = @import("../Window.zig"); 2 2 3 - pub fn center(parent: Window, cols: usize, rows: usize) Window { 3 + pub fn center(parent: Window, cols: u16, rows: u16) Window { 4 4 const y_off = (parent.height / 2) -| (rows / 2); 5 5 const x_off = (parent.width / 2) -| (cols / 2); 6 - return parent.initChild(x_off, y_off, .{ .limit = cols }, .{ .limit = rows }); 6 + return parent.child(.{ 7 + .x_off = x_off, 8 + .y_off = y_off, 9 + .width = cols, 10 + .height = rows, 11 + }); 12 + } 13 + 14 + pub fn topLeft(parent: Window, cols: u16, rows: u16) Window { 15 + const y_off: u16 = 0; 16 + const x_off: u16 = 0; 17 + return parent.child(.{ .x_off = x_off, .y_off = y_off, .width = cols, .height = rows }); 18 + } 19 + 20 + pub fn topRight(parent: Window, cols: u16, rows: u16) Window { 21 + const y_off: u16 = 0; 22 + const x_off = parent.width -| cols; 23 + return parent.child(.{ .x_off = x_off, .y_off = y_off, .width = cols, .height = rows }); 24 + } 25 + 26 + pub fn bottomLeft(parent: Window, cols: u16, rows: u16) Window { 27 + const y_off = parent.height -| rows; 28 + const x_off: u16 = 0; 29 + return parent.child(.{ .x_off = x_off, .y_off = y_off, .width = cols, .height = rows }); 30 + } 31 + 32 + pub fn bottomRight(parent: Window, cols: u16, rows: u16) Window { 33 + const y_off = parent.height -| rows; 34 + const x_off = parent.width -| cols; 35 + return parent.child(.{ .x_off = x_off, .y_off = y_off, .width = cols, .height = rows }); 7 36 }
-52
src/widgets/border.zig
··· 1 - const Cell = @import("../Cell.zig"); 2 - const Window = @import("../Window.zig"); 3 - 4 - const Style = Cell.Style; 5 - const Character = Cell.Character; 6 - 7 - const horizontal = Character{ .grapheme = "โ”€", .width = 1 }; 8 - const vertical = Character{ .grapheme = "โ”‚", .width = 1 }; 9 - const top_left = Character{ .grapheme = "โ•ญ", .width = 1 }; 10 - const top_right = Character{ .grapheme = "โ•ฎ", .width = 1 }; 11 - const bottom_right = Character{ .grapheme = "โ•ฏ", .width = 1 }; 12 - const bottom_left = Character{ .grapheme = "โ•ฐ", .width = 1 }; 13 - 14 - pub fn all(win: Window, style: Style) Window { 15 - const h = win.height; 16 - const w = win.width; 17 - win.writeCell(0, 0, .{ .char = top_left, .style = style }); 18 - win.writeCell(0, h -| 1, .{ .char = bottom_left, .style = style }); 19 - win.writeCell(w -| 1, 0, .{ .char = top_right, .style = style }); 20 - win.writeCell(w -| 1, h -| 1, .{ .char = bottom_right, .style = style }); 21 - var i: usize = 1; 22 - while (i < (h -| 1)) : (i += 1) { 23 - win.writeCell(0, i, .{ .char = vertical, .style = style }); 24 - win.writeCell(w -| 1, i, .{ .char = vertical, .style = style }); 25 - } 26 - i = 1; 27 - while (i < w -| 1) : (i += 1) { 28 - win.writeCell(i, 0, .{ .char = horizontal, .style = style }); 29 - win.writeCell(i, h -| 1, .{ .char = horizontal, .style = style }); 30 - } 31 - return win.initChild(1, 1, .{ .limit = w -| 2 }, .{ .limit = h -| 2 }); 32 - } 33 - 34 - pub fn right(win: Window, style: Style) Window { 35 - const h = win.height; 36 - const w = win.width; 37 - var i: usize = 0; 38 - while (i < h) : (i += 1) { 39 - win.writeCell(w -| 1, i, .{ .char = vertical, .style = style }); 40 - } 41 - return win.initChild(0, 0, .{ .limit = w -| 1 }, .expand); 42 - } 43 - 44 - pub fn bottom(win: Window, style: Style) Window { 45 - const h = win.height; 46 - const w = win.width; 47 - var i: usize = 0; 48 - while (i < w) : (i += 1) { 49 - win.writeCell(i, h -| 1, .{ .char = horizontal, .style = style }); 50 - } 51 - return win.initChild(0, 0, .expand, .{ .limit = h -| 1 }); 52 - }
+10 -10
src/widgets/terminal/Command.zig
··· 36 36 37 37 // set the controlling terminal 38 38 var u: c_uint = std.posix.STDIN_FILENO; 39 - if (posix.system.ioctl(self.pty.tty, posix.T.IOCSCTTY, @intFromPtr(&u)) != 0) return error.IoctlError; 39 + if (posix.system.ioctl(self.pty.tty.handle, posix.T.IOCSCTTY, @intFromPtr(&u)) != 0) return error.IoctlError; 40 40 41 41 // set up io 42 - try posix.dup2(self.pty.tty, std.posix.STDIN_FILENO); 43 - try posix.dup2(self.pty.tty, std.posix.STDOUT_FILENO); 44 - try posix.dup2(self.pty.tty, std.posix.STDERR_FILENO); 42 + try posix.dup2(self.pty.tty.handle, std.posix.STDIN_FILENO); 43 + try posix.dup2(self.pty.tty.handle, std.posix.STDOUT_FILENO); 44 + try posix.dup2(self.pty.tty.handle, std.posix.STDERR_FILENO); 45 45 46 - posix.close(self.pty.tty); 47 - if (self.pty.pty > 2) posix.close(self.pty.pty); 46 + self.pty.tty.close(); 47 + if (self.pty.pty.handle > 2) self.pty.pty.close(); 48 48 49 49 if (self.working_directory) |wd| { 50 50 try std.posix.chdir(wd); ··· 64 64 .handler = .{ .handler = handleSigChild }, 65 65 .mask = switch (builtin.os.tag) { 66 66 .macos => 0, 67 - .linux => posix.empty_sigset, 67 + .linux => posix.sigemptyset(), 68 68 else => @compileError("os not supported"), 69 69 }, 70 70 .flags = 0, 71 71 }; 72 - try posix.sigaction(posix.SIG.CHLD, &act, null); 72 + posix.sigaction(posix.SIG.CHLD, &act, null); 73 73 } 74 74 75 75 return; 76 76 } 77 77 78 - fn handleSigChild(_: c_int) callconv(.C) void { 78 + fn handleSigChild(_: c_int) callconv(.c) void { 79 79 const result = std.posix.waitpid(-1, 0); 80 80 81 81 Terminal.global_vt_mutex.lock(); ··· 107 107 { 108 108 var it = map.iterator(); 109 109 while (it.next()) |pair| { 110 - envp_buf[i] = try std.fmt.allocPrintZ(arena, "{s}={s}", .{ pair.key_ptr.*, pair.value_ptr.* }); 110 + envp_buf[i] = try std.fmt.allocPrintSentinel(arena, "{s}={s}", .{ pair.key_ptr.*, pair.value_ptr.* }, 0); 111 111 i += 1; 112 112 } 113 113 }
+26 -28
src/widgets/terminal/Parser.zig
··· 2 2 const Parser = @This(); 3 3 4 4 const std = @import("std"); 5 - const Reader = std.io.AnyReader; 5 + const Reader = std.Io.Reader; 6 6 const ansi = @import("ansi.zig"); 7 - const BufferedReader = std.io.BufferedReader(4096, std.io.AnyReader); 8 7 9 8 /// A terminal event 10 9 const Event = union(enum) { ··· 18 17 apc: []const u8, 19 18 }; 20 19 21 - buf: std.ArrayList(u8), 20 + buf: std.array_list.Managed(u8), 22 21 /// a leftover byte from a ground event 23 22 pending_byte: ?u8 = null, 24 23 25 - pub fn parseReader(self: *Parser, buffered: *BufferedReader) !Event { 26 - const reader = buffered.reader().any(); 24 + pub fn parseReader(self: *Parser, reader: *Reader) !Event { 27 25 self.buf.clearRetainingCapacity(); 28 26 while (true) { 29 - const b = if (self.pending_byte) |p| p else try reader.readByte(); 27 + const b = if (self.pending_byte) |p| p else try reader.takeByte(); 30 28 self.pending_byte = null; 31 29 switch (b) { 32 30 // Escape sequence 33 31 0x1b => { 34 - const next = try reader.readByte(); 32 + const next = try reader.takeByte(); 35 33 switch (next) { 36 - 0x4E => return .{ .ss2 = try reader.readByte() }, 37 - 0x4F => return .{ .ss3 = try reader.readByte() }, 34 + 0x4E => return .{ .ss2 = try reader.takeByte() }, 35 + 0x4F => return .{ .ss3 = try reader.takeByte() }, 38 36 0x50 => try skipUntilST(reader), // DCS 39 37 0x58 => try skipUntilST(reader), // SOS 40 38 0x5B => return self.parseCsi(reader), // CSI ··· 58 56 => return .{ .c0 = @enumFromInt(b) }, 59 57 else => { 60 58 try self.buf.append(b); 61 - return self.parseGround(buffered); 59 + return self.parseGround(reader); 62 60 }, 63 61 } 64 62 } 65 63 } 66 64 67 - inline fn parseGround(self: *Parser, reader: *BufferedReader) !Event { 65 + inline fn parseGround(self: *Parser, reader: *Reader) !Event { 68 66 var buf: [1]u8 = undefined; 69 67 { 70 68 std.debug.assert(self.buf.items.len > 0); ··· 72 70 const len = try std.unicode.utf8ByteSequenceLength(self.buf.items[0]); 73 71 var i: usize = 1; 74 72 while (i < len) : (i += 1) { 75 - const read = try reader.read(&buf); 73 + const read = try reader.readSliceShort(&buf); 76 74 if (read == 0) return error.EOF; 77 75 try self.buf.append(buf[0]); 78 76 } 79 77 } 80 78 while (true) { 81 - if (reader.start == reader.end) return .{ .print = self.buf.items }; 82 - const n = try reader.read(&buf); 79 + if (reader.bufferedLen() == 0) return .{ .print = self.buf.items }; 80 + const n = try reader.readSliceShort(&buf); 83 81 if (n == 0) return error.EOF; 84 82 const b = buf[0]; 85 83 switch (b) { ··· 92 90 const len = try std.unicode.utf8ByteSequenceLength(b); 93 91 var i: usize = 1; 94 92 while (i < len) : (i += 1) { 95 - const read = try reader.read(&buf); 93 + const read = try reader.readSliceShort(&buf); 96 94 if (read == 0) return error.EOF; 97 95 98 96 try self.buf.append(buf[0]); ··· 103 101 } 104 102 105 103 /// parse until b >= 0x30 106 - inline fn parseEscape(self: *Parser, reader: Reader) !Event { 104 + inline fn parseEscape(self: *Parser, reader: *Reader) !Event { 107 105 while (true) { 108 - const b = try reader.readByte(); 106 + const b = try reader.takeByte(); 109 107 switch (b) { 110 108 0x20...0x2F => continue, 111 109 else => { ··· 116 114 } 117 115 } 118 116 119 - inline fn parseApc(self: *Parser, reader: Reader) !Event { 117 + inline fn parseApc(self: *Parser, reader: *Reader) !Event { 120 118 while (true) { 121 - const b = try reader.readByte(); 119 + const b = try reader.takeByte(); 122 120 switch (b) { 123 121 0x00...0x17, 124 122 0x19, 125 123 0x1c...0x1f, 126 124 => continue, 127 125 0x1b => { 128 - try reader.skipBytes(1, .{ .buf_size = 1 }); 126 + _ = try reader.discard(std.Io.Limit.limited(1)); 129 127 return .{ .apc = self.buf.items }; 130 128 }, 131 129 else => try self.buf.append(b), ··· 134 132 } 135 133 136 134 /// Skips sequences until we see an ST (String Terminator, ESC \) 137 - inline fn skipUntilST(reader: Reader) !void { 138 - try reader.skipUntilDelimiterOrEof('\x1b'); 139 - try reader.skipBytes(1, .{ .buf_size = 1 }); 135 + inline fn skipUntilST(reader: *Reader) !void { 136 + _ = try reader.discardDelimiterExclusive('\x1b'); 137 + _ = try reader.discard(std.Io.Limit.limited(1)); 140 138 } 141 139 142 140 /// Parses an OSC sequence 143 - inline fn parseOsc(self: *Parser, reader: Reader) !Event { 141 + inline fn parseOsc(self: *Parser, reader: *Reader) !Event { 144 142 while (true) { 145 - const b = try reader.readByte(); 143 + const b = try reader.takeByte(); 146 144 switch (b) { 147 145 0x00...0x06, 148 146 0x08...0x17, ··· 150 148 0x1c...0x1f, 151 149 => continue, 152 150 0x1b => { 153 - try reader.skipBytes(1, .{ .buf_size = 1 }); 151 + _ = try reader.discard(std.Io.Limit.limited(1)); 154 152 return .{ .osc = self.buf.items }; 155 153 }, 156 154 0x07 => return .{ .osc = self.buf.items }, ··· 159 157 } 160 158 } 161 159 162 - inline fn parseCsi(self: *Parser, reader: Reader) !Event { 160 + inline fn parseCsi(self: *Parser, reader: *Reader) !Event { 163 161 var intermediate: ?u8 = null; 164 162 var pm: ?u8 = null; 165 163 166 164 while (true) { 167 - const b = try reader.readByte(); 165 + const b = try reader.takeByte(); 168 166 switch (b) { 169 167 0x20...0x2F => intermediate = b, 170 168 0x30...0x3B => try self.buf.append(b),
+12 -12
src/widgets/terminal/Pty.zig
··· 7 7 8 8 const posix = std.posix; 9 9 10 - pty: posix.fd_t, 11 - tty: posix.fd_t, 10 + pty: std.fs.File, 11 + tty: std.fs.File, 12 12 13 13 /// opens a new tty/pty pair 14 14 pub fn init() !Pty { ··· 20 20 21 21 /// closes the tty and pty 22 22 pub fn deinit(self: Pty) void { 23 - posix.close(self.pty); 24 - posix.close(self.tty); 23 + self.pty.close(); 24 + self.tty.close(); 25 25 } 26 26 27 27 /// sets the size of the pty 28 28 pub fn setSize(self: Pty, ws: Winsize) !void { 29 29 const _ws: posix.winsize = .{ 30 - .ws_row = @truncate(ws.rows), 31 - .ws_col = @truncate(ws.cols), 32 - .ws_xpixel = @truncate(ws.x_pixel), 33 - .ws_ypixel = @truncate(ws.y_pixel), 30 + .row = @truncate(ws.rows), 31 + .col = @truncate(ws.cols), 32 + .xpixel = @truncate(ws.x_pixel), 33 + .ypixel = @truncate(ws.y_pixel), 34 34 }; 35 - if (posix.system.ioctl(self.pty, posix.T.IOCSWINSZ, @intFromPtr(&_ws)) != 0) 35 + if (posix.system.ioctl(self.pty.handle, posix.T.IOCSWINSZ, @intFromPtr(&_ws)) != 0) 36 36 return error.SetWinsizeError; 37 37 } 38 38 ··· 48 48 if (posix.system.ioctl(p, posix.T.IOCGPTN, @intFromPtr(&n)) != 0) return error.IoctlError; 49 49 var buf: [16]u8 = undefined; 50 50 const sname = try std.fmt.bufPrint(&buf, "/dev/pts/{d}", .{n}); 51 - std.log.err("pts: {s}", .{sname}); 51 + std.log.debug("pts: {s}", .{sname}); 52 52 53 53 const t = try posix.open(sname, .{ .ACCMODE = .RDWR, .NOCTTY = true }, 0); 54 54 55 55 return .{ 56 - .pty = p, 57 - .tty = t, 56 + .pty = .{ .handle = p }, 57 + .tty = .{ .handle = t }, 58 58 }; 59 59 }
+47 -46
src/widgets/terminal/Screen.zig
··· 9 9 const Screen = @This(); 10 10 11 11 pub const Cell = struct { 12 - char: std.ArrayList(u8) = undefined, 12 + char: std.ArrayList(u8) = .empty, 13 13 style: vaxis.Style = .{}, 14 - uri: std.ArrayList(u8) = undefined, 15 - uri_id: std.ArrayList(u8) = undefined, 14 + uri: std.ArrayList(u8) = .empty, 15 + uri_id: std.ArrayList(u8) = .empty, 16 16 width: u8 = 1, 17 17 18 18 wrapped: bool = false, 19 19 dirty: bool = true, 20 20 21 - pub fn erase(self: *Cell, bg: vaxis.Color) void { 21 + pub fn erase(self: *Cell, allocator: std.mem.Allocator, bg: vaxis.Color) void { 22 22 self.char.clearRetainingCapacity(); 23 - self.char.append(' ') catch unreachable; // we never completely free this list 23 + self.char.append(allocator, ' ') catch unreachable; // we never completely free this list 24 24 self.style = .{}; 25 25 self.style.bg = bg; 26 26 self.uri.clearRetainingCapacity(); ··· 30 30 self.dirty = true; 31 31 } 32 32 33 - pub fn copyFrom(self: *Cell, src: Cell) !void { 33 + pub fn copyFrom(self: *Cell, allocator: std.mem.Allocator, src: Cell) !void { 34 34 self.char.clearRetainingCapacity(); 35 - try self.char.appendSlice(src.char.items); 35 + try self.char.appendSlice(allocator, src.char.items); 36 36 self.style = src.style; 37 37 self.uri.clearRetainingCapacity(); 38 - try self.uri.appendSlice(src.uri.items); 38 + try self.uri.appendSlice(allocator, src.uri.items); 39 39 self.uri_id.clearRetainingCapacity(); 40 - try self.uri_id.appendSlice(src.uri_id.items); 40 + try self.uri_id.appendSlice(allocator, src.uri_id.items); 41 41 self.width = src.width; 42 42 self.wrapped = src.wrapped; 43 43 ··· 49 49 style: vaxis.Style = .{}, 50 50 uri: std.ArrayList(u8) = undefined, 51 51 uri_id: std.ArrayList(u8) = undefined, 52 - col: usize = 0, 53 - row: usize = 0, 52 + col: u16 = 0, 53 + row: u16 = 0, 54 54 pending_wrap: bool = false, 55 55 shape: vaxis.Cell.CursorShape = .default, 56 56 visible: bool = true, ··· 68 68 }; 69 69 70 70 pub const ScrollingRegion = struct { 71 - top: usize, 72 - bottom: usize, 73 - left: usize, 74 - right: usize, 71 + top: u16, 72 + bottom: u16, 73 + left: u16, 74 + right: u16, 75 75 76 76 pub fn contains(self: ScrollingRegion, col: usize, row: usize) bool { 77 77 return col >= self.left and ··· 81 81 } 82 82 }; 83 83 84 - width: usize = 0, 85 - height: usize = 0, 84 + allocator: std.mem.Allocator, 85 + 86 + width: u16 = 0, 87 + height: u16 = 0, 86 88 87 89 scrolling_region: ScrollingRegion, 88 90 ··· 93 95 csi_u_flags: vaxis.Key.KittyFlags = @bitCast(@as(u5, 0)), 94 96 95 97 /// sets each cell to the default cell 96 - pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !Screen { 98 + pub fn init(alloc: std.mem.Allocator, w: u16, h: u16) !Screen { 97 99 var screen = Screen{ 98 - .buf = try alloc.alloc(Cell, w * h), 100 + .allocator = alloc, 101 + .buf = try alloc.alloc(Cell, @as(usize, @intCast(w)) * h), 99 102 .scrolling_region = .{ 100 103 .top = 0, 101 104 .bottom = h - 1, ··· 107 110 }; 108 111 for (screen.buf, 0..) |_, i| { 109 112 screen.buf[i] = .{ 110 - .char = try std.ArrayList(u8).initCapacity(alloc, 1), 111 - .uri = std.ArrayList(u8).init(alloc), 112 - .uri_id = std.ArrayList(u8).init(alloc), 113 + .char = try .initCapacity(alloc, 1), 113 114 }; 114 - try screen.buf[i].char.append(' '); 115 + try screen.buf[i].char.append(alloc, ' '); 115 116 } 116 117 return screen; 117 118 } 118 119 119 120 pub fn deinit(self: *Screen, alloc: std.mem.Allocator) void { 120 121 for (self.buf, 0..) |_, i| { 121 - self.buf[i].char.deinit(); 122 - self.buf[i].uri.deinit(); 123 - self.buf[i].uri_id.deinit(); 122 + self.buf[i].char.deinit(alloc); 123 + self.buf[i].uri.deinit(alloc); 124 + self.buf[i].uri_id.deinit(alloc); 124 125 } 125 126 126 127 alloc.free(self.buf); 127 128 } 128 129 129 130 /// copies the visible area to the destination screen 130 - pub fn copyTo(self: *Screen, dst: *Screen) !void { 131 + pub fn copyTo(self: *Screen, allocator: std.mem.Allocator, dst: *Screen) !void { 131 132 dst.cursor = self.cursor; 132 133 for (self.buf, 0..) |cell, i| { 133 134 if (!cell.dirty) continue; 134 135 self.buf[i].dirty = false; 135 136 const grapheme = cell.char.items; 136 137 dst.buf[i].char.clearRetainingCapacity(); 137 - try dst.buf[i].char.appendSlice(grapheme); 138 + try dst.buf[i].char.appendSlice(allocator, grapheme); 138 139 dst.buf[i].width = cell.width; 139 140 dst.buf[i].style = cell.style; 140 141 } ··· 182 183 const i = (row * self.width) + col; 183 184 assert(i < self.buf.len); 184 185 self.buf[i].char.clearRetainingCapacity(); 185 - self.buf[i].char.appendSlice(grapheme) catch { 186 + self.buf[i].char.appendSlice(self.allocator, grapheme) catch { 186 187 log.warn("couldn't write grapheme", .{}); 187 188 }; 188 189 self.buf[i].uri.clearRetainingCapacity(); 189 - self.buf[i].uri.appendSlice(self.cursor.uri.items) catch { 190 + self.buf[i].uri.appendSlice(self.allocator, self.cursor.uri.items) catch { 190 191 log.warn("couldn't write uri", .{}); 191 192 }; 192 193 self.buf[i].uri_id.clearRetainingCapacity(); 193 - self.buf[i].uri_id.appendSlice(self.cursor.uri_id.items) catch { 194 + self.buf[i].uri_id.appendSlice(self.allocator, self.cursor.uri_id.items) catch { 194 195 log.warn("couldn't write uri_id", .{}); 195 196 }; 196 197 self.buf[i].style = self.cursor.style; ··· 313 314 } 314 315 } 315 316 316 - pub fn cursorUp(self: *Screen, n: usize) void { 317 + pub fn cursorUp(self: *Screen, n: u16) void { 317 318 self.cursor.pending_wrap = false; 318 319 if (self.withinScrollingRegion()) 319 320 self.cursor.row = @max( ··· 324 325 self.cursor.row -|= n; 325 326 } 326 327 327 - pub fn cursorLeft(self: *Screen, n: usize) void { 328 + pub fn cursorLeft(self: *Screen, n: u16) void { 328 329 self.cursor.pending_wrap = false; 329 330 if (self.withinScrollingRegion()) 330 331 self.cursor.col = @max( ··· 335 336 self.cursor.col = self.cursor.col -| n; 336 337 } 337 338 338 - pub fn cursorRight(self: *Screen, n: usize) void { 339 + pub fn cursorRight(self: *Screen, n: u16) void { 339 340 self.cursor.pending_wrap = false; 340 341 if (self.withinScrollingRegion()) 341 342 self.cursor.col = @min( ··· 368 369 const end = (self.cursor.row * self.width) + (self.width); 369 370 var i = (self.cursor.row * self.width) + self.cursor.col; 370 371 while (i < end) : (i += 1) { 371 - self.buf[i].erase(self.cursor.style.bg); 372 + self.buf[i].erase(self.allocator, self.cursor.style.bg); 372 373 } 373 374 } 374 375 ··· 378 379 const end = start + self.cursor.col + 1; 379 380 var i = start; 380 381 while (i < end) : (i += 1) { 381 - self.buf[i].erase(self.cursor.style.bg); 382 + self.buf[i].erase(self.allocator, self.cursor.style.bg); 382 383 } 383 384 } 384 385 ··· 388 389 const end = start + self.width; 389 390 var i = start; 390 391 while (i < end) : (i += 1) { 391 - self.buf[i].erase(self.cursor.style.bg); 392 + self.buf[i].erase(self.allocator, self.cursor.style.bg); 392 393 } 393 394 } 394 395 ··· 411 412 while (col <= self.scrolling_region.right) : (col += 1) { 412 413 const i = (row * self.width) + col; 413 414 if (row + cnt > self.scrolling_region.bottom) 414 - self.buf[i].erase(self.cursor.style.bg) 415 + self.buf[i].erase(self.allocator, self.cursor.style.bg) 415 416 else 416 - try self.buf[i].copyFrom(self.buf[i + stride]); 417 + try self.buf[i].copyFrom(self.allocator, self.buf[i + stride]); 417 418 } 418 419 } 419 420 } ··· 434 435 var col: usize = self.scrolling_region.left; 435 436 while (col <= self.scrolling_region.right) : (col += 1) { 436 437 const i = (row * self.width) + col; 437 - try self.buf[i].copyFrom(self.buf[i - stride]); 438 + try self.buf[i].copyFrom(self.allocator, self.buf[i - stride]); 438 439 } 439 440 } 440 441 ··· 443 444 var col: usize = self.scrolling_region.left; 444 445 while (col <= self.scrolling_region.right) : (col += 1) { 445 446 const i = (row * self.width) + col; 446 - self.buf[i].erase(self.cursor.style.bg); 447 + self.buf[i].erase(self.allocator, self.cursor.style.bg); 447 448 } 448 449 } 449 450 } ··· 454 455 const start = (self.cursor.row * self.width) + (self.width); 455 456 var i = start; 456 457 while (i < self.buf.len) : (i += 1) { 457 - self.buf[i].erase(self.cursor.style.bg); 458 + self.buf[i].erase(self.allocator, self.cursor.style.bg); 458 459 } 459 460 } 460 461 ··· 465 466 const end = self.cursor.row * self.width; 466 467 var i = start; 467 468 while (i < end) : (i += 1) { 468 - self.buf[i].erase(self.cursor.style.bg); 469 + self.buf[i].erase(self.allocator, self.cursor.style.bg); 469 470 } 470 471 } 471 472 472 473 pub fn eraseAll(self: *Screen) void { 473 474 var i: usize = 0; 474 475 while (i < self.buf.len) : (i += 1) { 475 - self.buf[i].erase(self.cursor.style.bg); 476 + self.buf[i].erase(self.allocator, self.cursor.style.bg); 476 477 } 477 478 } 478 479 ··· 483 484 var col = self.cursor.col; 484 485 while (col <= self.scrolling_region.right) : (col += 1) { 485 486 if (col + n <= self.scrolling_region.right) 486 - try self.buf[col].copyFrom(self.buf[col + n]) 487 + try self.buf[col].copyFrom(self.allocator, self.buf[col + n]) 487 488 else 488 - self.buf[col].erase(self.cursor.style.bg); 489 + self.buf[col].erase(self.allocator, self.cursor.style.bg); 489 490 } 490 491 } 491 492
+69 -79
src/widgets/terminal/Terminal.zig
··· 10 10 const vaxis = @import("../../main.zig"); 11 11 const Winsize = vaxis.Winsize; 12 12 const Screen = @import("Screen.zig"); 13 - const DisplayWidth = @import("DisplayWidth"); 14 13 const Key = vaxis.Key; 15 14 const Queue = vaxis.Queue(Event, 16); 16 - const code_point = @import("code_point"); 17 15 const key = @import("key.zig"); 18 16 19 17 pub const Event = union(enum) { ··· 24 22 pwd_change: []const u8, 25 23 }; 26 24 27 - const grapheme = @import("grapheme"); 28 - 29 25 const posix = std.posix; 30 26 31 27 const log = std.log.scoped(.terminal); 32 28 33 29 pub const Options = struct { 34 - scrollback_size: usize = 500, 30 + scrollback_size: u16 = 500, 35 31 winsize: Winsize = .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }, 36 32 initial_working_directory: ?[]const u8 = null, 37 33 }; ··· 52 48 pub var global_sigchild_installed: bool = false; 53 49 54 50 allocator: std.mem.Allocator, 55 - scrollback_size: usize, 51 + scrollback_size: u16, 56 52 57 53 pty: Pty, 54 + pty_writer: std.fs.File.Writer, 58 55 cmd: Command, 59 56 thread: ?std.Thread = null, 60 57 ··· 72 69 // dirty is protected by back_mutex. Only access this field when you hold that mutex 73 70 dirty: bool = false, 74 71 75 - unicode: *const vaxis.Unicode, 76 72 should_quit: bool = false, 77 73 78 74 mode: Mode = .{}, 79 75 80 76 tab_stops: std.ArrayList(u16), 81 - title: std.ArrayList(u8), 82 - working_directory: std.ArrayList(u8), 77 + title: std.ArrayList(u8) = .empty, 78 + working_directory: std.ArrayList(u8) = .empty, 83 79 84 80 last_printed: []const u8 = "", 85 81 ··· 91 87 allocator: std.mem.Allocator, 92 88 argv: []const []const u8, 93 89 env: *const std.process.EnvMap, 94 - unicode: *const vaxis.Unicode, 95 90 opts: Options, 91 + write_buf: []u8, 96 92 ) !Terminal { 97 93 // Verify we have an absolute path 98 94 if (opts.initial_working_directory) |pwd| { ··· 106 102 .pty = pty, 107 103 .working_directory = opts.initial_working_directory, 108 104 }; 109 - var tabs = try std.ArrayList(u16).initCapacity(allocator, opts.winsize.cols / 8); 105 + var tabs: std.ArrayList(u16) = try .initCapacity(allocator, opts.winsize.cols / 8); 110 106 var col: u16 = 0; 111 107 while (col < opts.winsize.cols) : (col += 8) { 112 - try tabs.append(col); 108 + try tabs.append(allocator, col); 113 109 } 114 110 return .{ 115 111 .allocator = allocator, 116 112 .pty = pty, 113 + .pty_writer = pty.pty.writerStreaming(write_buf), 117 114 .cmd = cmd, 118 115 .scrollback_size = opts.scrollback_size, 119 116 .front_screen = try Screen.init(allocator, opts.winsize.cols, opts.winsize.rows), 120 117 .back_screen_pri = try Screen.init(allocator, opts.winsize.cols, opts.winsize.rows + opts.scrollback_size), 121 118 .back_screen_alt = try Screen.init(allocator, opts.winsize.cols, opts.winsize.rows), 122 - .unicode = unicode, 123 119 .tab_stops = tabs, 124 - .title = std.ArrayList(u8).init(allocator), 125 - .working_directory = std.ArrayList(u8).init(allocator), 126 120 }; 127 121 } 128 122 ··· 145 139 if (self.thread) |thread| { 146 140 // write an EOT into the tty to trigger a read on our thread 147 141 const EOT = "\x04"; 148 - _ = std.posix.write(self.pty.tty, EOT) catch {}; 142 + _ = self.pty.tty.write(EOT) catch {}; 149 143 thread.join(); 150 144 self.thread = null; 151 145 } ··· 153 147 self.front_screen.deinit(self.allocator); 154 148 self.back_screen_pri.deinit(self.allocator); 155 149 self.back_screen_alt.deinit(self.allocator); 156 - self.tab_stops.deinit(); 157 - self.title.deinit(); 158 - self.working_directory.deinit(); 150 + self.tab_stops.deinit(self.allocator); 151 + self.title.deinit(self.allocator); 152 + self.working_directory.deinit(self.allocator); 159 153 } 160 154 161 155 pub fn spawn(self: *Terminal) !void { ··· 166 160 167 161 self.working_directory.clearRetainingCapacity(); 168 162 if (self.cmd.working_directory) |pwd| { 169 - try self.working_directory.appendSlice(pwd); 163 + try self.working_directory.appendSlice(self.allocator, pwd); 170 164 } else { 171 165 const pwd = std.fs.cwd(); 172 - var buffer: [std.fs.MAX_PATH_BYTES]u8 = undefined; 166 + var buffer: [std.fs.max_path_bytes]u8 = undefined; 173 167 const out_path = try std.os.getFdPath(pwd.fd, &buffer); 174 - try self.working_directory.appendSlice(out_path); 168 + try self.working_directory.appendSlice(self.allocator, out_path); 175 169 } 176 170 177 171 { ··· 210 204 try self.pty.setSize(ws); 211 205 } 212 206 213 - pub fn draw(self: *Terminal, win: vaxis.Window) !void { 207 + pub fn draw(self: *Terminal, allocator: std.mem.Allocator, win: vaxis.Window) !void { 214 208 if (self.back_mutex.tryLock()) { 215 209 defer self.back_mutex.unlock(); 216 210 // We keep this as a separate condition so we don't deadlock by obtaining the lock but not 217 211 // having sync 218 212 if (!self.mode.sync) { 219 - try self.back_screen.copyTo(&self.front_screen); 213 + try self.back_screen.copyTo(allocator, &self.front_screen); 220 214 self.dirty = false; 221 215 } 222 216 } 223 217 224 - var row: usize = 0; 218 + var row: u16 = 0; 225 219 while (row < self.front_screen.height) : (row += 1) { 226 - var col: usize = 0; 220 + var col: u16 = 0; 227 221 while (col < self.front_screen.width) { 228 222 const cell = self.front_screen.readCell(col, row) orelse continue; 229 223 win.writeCell(col, row, cell); ··· 243 237 244 238 pub fn update(self: *Terminal, event: InputEvent) !void { 245 239 switch (event) { 246 - .key_press => |k| try key.encode(self.anyWriter(), k, true, self.back_screen.csi_u_flags), 240 + .key_press => |k| { 241 + const pty_writer = self.get_pty_writer(); 242 + defer pty_writer.flush() catch {}; 243 + try key.encode(pty_writer, k, true, self.back_screen.csi_u_flags); 244 + }, 247 245 } 248 246 } 249 247 250 - fn opaqueWrite(ptr: *const anyopaque, buf: []const u8) !usize { 251 - const self: *const Terminal = @ptrCast(@alignCast(ptr)); 252 - return posix.write(self.pty.pty, buf); 248 + pub fn get_pty_writer(self: *Terminal) *std.Io.Writer { 249 + return &self.pty_writer.interface; 253 250 } 254 251 255 - pub fn anyWriter(self: *const Terminal) std.io.AnyWriter { 256 - return .{ 257 - .context = self, 258 - .writeFn = Terminal.opaqueWrite, 259 - }; 260 - } 261 - 262 - fn opaqueRead(ptr: *const anyopaque, buf: []u8) !usize { 263 - const self: *const Terminal = @ptrCast(@alignCast(ptr)); 264 - return posix.read(self.pty.pty, buf); 265 - } 266 - 267 - fn anyReader(self: *const Terminal) std.io.AnyReader { 268 - return .{ 269 - .context = self, 270 - .readFn = Terminal.opaqueRead, 271 - }; 252 + fn reader(self: *const Terminal, buf: []u8) std.fs.File.Reader { 253 + return self.pty.pty.readerStreaming(buf); 272 254 } 273 255 274 256 /// process the output from the command on the pty 275 257 fn run(self: *Terminal) !void { 276 258 var parser: Parser = .{ 277 - .buf = try std.ArrayList(u8).initCapacity(self.allocator, 128), 259 + .buf = try .initCapacity(self.allocator, 128), 278 260 }; 279 261 defer parser.buf.deinit(); 280 262 281 - // Use our anyReader to make a buffered reader, then get *that* any reader 282 - var reader = std.io.bufferedReader(self.anyReader()); 263 + var reader_buf: [4096]u8 = undefined; 264 + var reader_ = self.reader(&reader_buf); 283 265 284 266 while (!self.should_quit) { 285 - const event = try parser.parseReader(&reader); 267 + const event = try parser.parseReader(&reader_.interface); 286 268 self.back_mutex.lock(); 287 269 defer self.back_mutex.unlock(); 288 270 ··· 291 273 292 274 switch (event) { 293 275 .print => |str| { 294 - var iter = grapheme.Iterator.init(str, &self.unicode.grapheme_data); 295 - while (iter.next()) |g| { 296 - const gr = g.bytes(str); 276 + var iter = vaxis.unicode.graphemeIterator(str); 277 + while (iter.next()) |grapheme| { 278 + const gr = grapheme.bytes(str); 297 279 // TODO: use actual instead of .unicode 298 - const w = try vaxis.gwidth.gwidth(gr, .unicode, &self.unicode.width_data); 280 + const w = vaxis.gwidth.gwidth(gr, .unicode); 299 281 try self.back_screen.print(gr, @truncate(w), self.mode.autowrap); 300 282 } 301 283 }, ··· 317 299 if (ts == self.back_screen.cursor.col) break true; 318 300 } else false; 319 301 if (already_set) continue; 320 - try self.tab_stops.append(@truncate(self.back_screen.cursor.col)); 302 + try self.tab_stops.append(self.allocator, @truncate(self.back_screen.cursor.col)); 321 303 std.mem.sort(u16, self.tab_stops.items, {}, std.sort.asc(u16)); 322 304 }, 323 305 // Reverse Index ··· 468 450 self.tab_stops.clearRetainingCapacity(); 469 451 var col: u16 = 0; 470 452 while (col < self.back_screen.width) : (col += 8) { 471 - try self.tab_stops.append(col); 453 + try self.tab_stops.append(self.allocator, col); 472 454 } 473 455 } 474 456 }, ··· 484 466 ); 485 467 var i: usize = start; 486 468 while (i < end) : (i += 1) { 487 - self.back_screen.buf[i].erase(self.back_screen.cursor.style.bg); 469 + self.back_screen.buf[i].erase(self.allocator, self.back_screen.cursor.style.bg); 488 470 } 489 471 }, 490 472 'Z' => { ··· 511 493 var iter = seq.iterator(u16); 512 494 const n = iter.next() orelse 1; 513 495 // TODO: maybe not .unicode 514 - const w = try vaxis.gwidth.gwidth(self.last_printed, .unicode, &self.unicode.width_data); 496 + const w = vaxis.gwidth.gwidth(self.last_printed, .unicode); 515 497 var i: usize = 0; 516 498 while (i < n) : (i += 1) { 517 499 try self.back_screen.print(self.last_printed, @truncate(w), self.mode.autowrap); ··· 519 501 }, 520 502 // Device Attributes 521 503 'c' => { 504 + const pty_writer = self.get_pty_writer(); 505 + defer pty_writer.flush() catch {}; 522 506 if (seq.private_marker) |pm| { 523 507 switch (pm) { 524 508 // Secondary 525 - '>' => try self.anyWriter().writeAll("\x1B[>1;69;0c"), 526 - '=' => try self.anyWriter().writeAll("\x1B[=0000c"), 527 - else => log.info("unhandled CSI: {}", .{seq}), 509 + '>' => try pty_writer.writeAll("\x1B[>1;69;0c"), 510 + '=' => try pty_writer.writeAll("\x1B[=0000c"), 511 + else => log.info("unhandled CSI: {f}", .{seq}), 528 512 } 529 513 } else { 530 514 // Primary 531 - try self.anyWriter().writeAll("\x1B[?62;22c"); 515 + try pty_writer.writeAll("\x1B[?62;22c"); 532 516 } 533 517 }, 534 518 // Cursor Vertical Position Absolute ··· 562 546 const n = iter.next() orelse 0; 563 547 switch (n) { 564 548 0 => { 565 - const current = try self.tab_stops.toOwnedSlice(); 566 - defer self.tab_stops.allocator.free(current); 549 + const current = try self.tab_stops.toOwnedSlice(self.allocator); 550 + defer self.allocator.free(current); 567 551 self.tab_stops.clearRetainingCapacity(); 568 552 for (current) |stop| { 569 553 if (stop == self.back_screen.cursor.col) continue; 570 - try self.tab_stops.append(stop); 554 + try self.tab_stops.append(self.allocator, stop); 571 555 } 572 556 }, 573 - 3 => self.tab_stops.clearAndFree(), 574 - else => log.info("unhandled CSI: {}", .{seq}), 557 + 3 => self.tab_stops.clearAndFree(self.allocator), 558 + else => log.info("unhandled CSI: {f}", .{seq}), 575 559 } 576 560 }, 577 561 'h', 'l' => { ··· 592 576 var iter = seq.iterator(u16); 593 577 const ps = iter.next() orelse 0; 594 578 if (seq.intermediate == null and seq.private_marker == null) { 579 + const pty_writer = self.get_pty_writer(); 580 + defer pty_writer.flush() catch {}; 595 581 switch (ps) { 596 - 5 => try self.anyWriter().writeAll("\x1b[0n"), 597 - 6 => try self.anyWriter().print("\x1b[{d};{d}R", .{ 582 + 5 => try pty_writer.writeAll("\x1b[0n"), 583 + 6 => try pty_writer.print("\x1b[{d};{d}R", .{ 598 584 self.back_screen.cursor.row + 1, 599 585 self.back_screen.cursor.col + 1, 600 586 }), 601 - else => log.info("unhandled CSI: {}", .{seq}), 587 + else => log.info("unhandled CSI: {f}", .{seq}), 602 588 } 603 589 } 604 590 }, ··· 609 595 switch (int) { 610 596 // report mode 611 597 '$' => { 598 + const pty_writer = self.get_pty_writer(); 599 + defer pty_writer.flush() catch {}; 612 600 switch (ps) { 613 - 2026 => try self.anyWriter().writeAll("\x1b[?2026;2$p"), 601 + 2026 => try pty_writer.writeAll("\x1b[?2026;2$p"), 614 602 else => { 615 603 std.log.warn("unhandled mode: {}", .{ps}); 616 - try self.anyWriter().print("\x1b[?{d};0$p", .{ps}); 604 + try pty_writer.print("\x1b[?{d};0$p", .{ps}); 617 605 }, 618 606 } 619 607 }, 620 - else => log.info("unhandled CSI: {}", .{seq}), 608 + else => log.info("unhandled CSI: {f}", .{seq}), 621 609 } 622 610 } 623 611 }, ··· 633 621 } 634 622 } 635 623 if (seq.private_marker) |pm| { 624 + const pty_writer = self.get_pty_writer(); 625 + defer pty_writer.flush() catch {}; 636 626 switch (pm) { 637 627 // XTVERSION 638 - '>' => try self.anyWriter().print( 628 + '>' => try pty_writer.print( 639 629 "\x1bP>|libvaxis {s}\x1B\\", 640 630 .{"dev"}, 641 631 ), 642 - else => log.info("unhandled CSI: {}", .{seq}), 632 + else => log.info("unhandled CSI: {f}", .{seq}), 643 633 } 644 634 } 645 635 }, ··· 667 657 self.back_screen.cursor.row = 0; 668 658 } 669 659 }, 670 - else => log.info("unhandled CSI: {}", .{seq}), 660 + else => log.info("unhandled CSI: {f}", .{seq}), 671 661 } 672 662 }, 673 663 .osc => |osc| { ··· 682 672 switch (ps) { 683 673 0 => { 684 674 self.title.clearRetainingCapacity(); 685 - try self.title.appendSlice(osc[semicolon + 1 ..]); 675 + try self.title.appendSlice(self.allocator, osc[semicolon + 1 ..]); 686 676 self.event_queue.push(.{ .title_change = self.title.items }); 687 677 }, 688 678 7 => { ··· 701 691 defer i += 2; 702 692 break :blk try std.fmt.parseUnsigned(u8, enc[i + 1 .. i + 3], 16); 703 693 } else enc[i]; 704 - try self.working_directory.append(b); 694 + try self.working_directory.append(self.allocator, b); 705 695 } 706 696 self.event_queue.push(.{ .pwd_change = self.working_directory.items }); 707 697 },
+5 -12
src/widgets/terminal/ansi.zig
··· 55 55 return .{ .bytes = self.params }; 56 56 } 57 57 58 - pub fn format( 59 - self: CSI, 60 - comptime layout: []const u8, 61 - opts: std.fmt.FormatOptions, 62 - writer: anytype, 63 - ) !void { 64 - _ = layout; 65 - _ = opts; 58 + pub fn format(self: CSI, writer: anytype) !void { 66 59 if (self.private_marker == null and self.intermediate == null) 67 - try std.fmt.format(writer, "CSI {s} {c}", .{ 60 + try writer.print("CSI {s} {c}", .{ 68 61 self.params, 69 62 self.final, 70 63 }) 71 64 else if (self.private_marker != null and self.intermediate == null) 72 - try std.fmt.format(writer, "CSI {c} {s} {c}", .{ 65 + try writer.print("CSI {c} {s} {c}", .{ 73 66 self.private_marker.?, 74 67 self.params, 75 68 self.final, 76 69 }) 77 70 else if (self.private_marker == null and self.intermediate != null) 78 - try std.fmt.format(writer, "CSI {s} {c} {c}", .{ 71 + try writer.print("CSI {s} {c} {c}", .{ 79 72 self.params, 80 73 self.intermediate.?, 81 74 self.final, 82 75 }) 83 76 else 84 - try std.fmt.format(writer, "CSI {c} {s} {c} {c}", .{ 77 + try writer.print("CSI {c} {s} {c} {c}", .{ 85 78 self.private_marker.?, 86 79 self.params, 87 80 self.intermediate.?,
+2 -2
src/widgets/terminal/key.zig
··· 2 2 const vaxis = @import("../../main.zig"); 3 3 4 4 pub fn encode( 5 - writer: std.io.AnyWriter, 5 + writer: *std.Io.Writer, 6 6 key: vaxis.Key, 7 7 press: bool, 8 8 kitty_flags: vaxis.Key.KittyFlags, ··· 19 19 } 20 20 } 21 21 22 - fn legacy(writer: std.io.AnyWriter, key: vaxis.Key) !void { 22 + fn legacy(writer: *std.Io.Writer, key: vaxis.Key) !void { 23 23 // If we have text, we always write it directly 24 24 if (key.text) |text| { 25 25 try writer.writeAll(text);
+2 -7
src/widgets.zig
··· 1 1 //! Specialized TUI Widgets 2 2 3 - const opts = @import("build_options"); 4 - 5 - pub const border = @import("widgets/border.zig"); 6 3 pub const alignment = @import("widgets/alignment.zig"); 7 4 pub const Scrollbar = @import("widgets/Scrollbar.zig"); 8 5 pub const Table = @import("widgets/Table.zig"); ··· 11 8 pub const TextView = @import("widgets/TextView.zig"); 12 9 pub const CodeView = @import("widgets/CodeView.zig"); 13 10 pub const Terminal = @import("widgets/terminal/Terminal.zig"); 14 - 15 - // Widgets with dependencies 16 - 17 - pub const TextInput = if (opts.text_input) @import("widgets/TextInput.zig") else undefined; 11 + pub const TextInput = @import("widgets/TextInput.zig"); 12 + pub const View = @import("widgets/View.zig");
-497
src/windows/Tty.zig
··· 1 - //! A Windows TTY implementation, using virtual terminal process output and 2 - //! native windows input 3 - const Tty = @This(); 4 - 5 - const std = @import("std"); 6 - const Event = @import("../event.zig").Event; 7 - const Key = @import("../Key.zig"); 8 - const Mouse = @import("../Mouse.zig"); 9 - const Parser = @import("../Parser.zig"); 10 - const windows = std.os.windows; 11 - 12 - stdin: windows.HANDLE, 13 - stdout: windows.HANDLE, 14 - 15 - initial_codepage: c_uint, 16 - initial_input_mode: u32, 17 - initial_output_mode: u32, 18 - 19 - // a buffer to write key text into 20 - buf: [4]u8 = undefined, 21 - 22 - /// The last mouse button that was pressed. We store the previous state of button presses on each 23 - /// mouse event so we can detect which button was released 24 - last_mouse_button_press: u16 = 0, 25 - 26 - pub var global_tty: ?Tty = null; 27 - 28 - const utf8_codepage: c_uint = 65001; 29 - 30 - const InputMode = struct { 31 - const enable_window_input: u32 = 0x0008; // resize events 32 - const enable_mouse_input: u32 = 0x0010; 33 - const enable_extended_flags: u32 = 0x0080; // allows mouse events 34 - 35 - pub fn rawMode() u32 { 36 - return enable_window_input | enable_mouse_input | enable_extended_flags; 37 - } 38 - }; 39 - 40 - const OutputMode = struct { 41 - const enable_processed_output: u32 = 0x0001; // handle control sequences 42 - const enable_virtual_terminal_processing: u32 = 0x0004; // handle ANSI sequences 43 - const disable_newline_auto_return: u32 = 0x0008; // disable inserting a new line when we write at the last column 44 - const enable_lvb_grid_worldwide: u32 = 0x0010; // enables reverse video and underline 45 - 46 - fn rawMode() u32 { 47 - return enable_processed_output | 48 - enable_virtual_terminal_processing | 49 - disable_newline_auto_return | 50 - enable_lvb_grid_worldwide; 51 - } 52 - }; 53 - 54 - pub fn init() !Tty { 55 - const stdin = try windows.GetStdHandle(windows.STD_INPUT_HANDLE); 56 - const stdout = try windows.GetStdHandle(windows.STD_OUTPUT_HANDLE); 57 - 58 - // get initial modes 59 - var initial_input_mode: windows.DWORD = undefined; 60 - var initial_output_mode: windows.DWORD = undefined; 61 - const initial_output_codepage = windows.kernel32.GetConsoleOutputCP(); 62 - { 63 - if (windows.kernel32.GetConsoleMode(stdin, &initial_input_mode) == 0) { 64 - return windows.unexpectedError(windows.kernel32.GetLastError()); 65 - } 66 - if (windows.kernel32.GetConsoleMode(stdout, &initial_output_mode) == 0) { 67 - return windows.unexpectedError(windows.kernel32.GetLastError()); 68 - } 69 - } 70 - 71 - // set new modes 72 - { 73 - if (SetConsoleMode(stdin, InputMode.rawMode()) == 0) 74 - return windows.unexpectedError(windows.kernel32.GetLastError()); 75 - 76 - if (SetConsoleMode(stdout, OutputMode.rawMode()) == 0) 77 - return windows.unexpectedError(windows.kernel32.GetLastError()); 78 - 79 - if (windows.kernel32.SetConsoleOutputCP(utf8_codepage) == 0) 80 - return windows.unexpectedError(windows.kernel32.GetLastError()); 81 - } 82 - 83 - const self: Tty = .{ 84 - .stdin = stdin, 85 - .stdout = stdout, 86 - .initial_codepage = initial_output_codepage, 87 - .initial_input_mode = initial_input_mode, 88 - .initial_output_mode = initial_output_mode, 89 - }; 90 - 91 - // save a copy of this tty as the global_tty for panic handling 92 - global_tty = self; 93 - 94 - return self; 95 - } 96 - 97 - pub fn deinit(self: Tty) void { 98 - _ = windows.kernel32.SetConsoleOutputCP(self.initial_codepage); 99 - _ = SetConsoleMode(self.stdin, self.initial_input_mode); 100 - _ = SetConsoleMode(self.stdout, self.initial_output_mode); 101 - windows.CloseHandle(self.stdin); 102 - windows.CloseHandle(self.stdout); 103 - } 104 - 105 - pub fn opaqueWrite(ptr: *const anyopaque, bytes: []const u8) !usize { 106 - const self: *const Tty = @ptrCast(@alignCast(ptr)); 107 - return windows.WriteFile(self.stdout, bytes, null); 108 - } 109 - 110 - pub fn anyWriter(self: *const Tty) std.io.AnyWriter { 111 - return .{ 112 - .context = self, 113 - .writeFn = Tty.opaqueWrite, 114 - }; 115 - } 116 - 117 - pub fn bufferedWriter(self: *const Tty) std.io.BufferedWriter(4096, std.io.AnyWriter) { 118 - return std.io.bufferedWriter(self.anyWriter()); 119 - } 120 - 121 - pub fn nextEvent(self: *Tty, parser: *Parser, paste_allocator: ?std.mem.Allocator) !Event { 122 - // We use a loop so we can ignore certain events 123 - var state: EventState = .{}; 124 - while (true) { 125 - var event_count: u32 = 0; 126 - var input_record: INPUT_RECORD = undefined; 127 - if (ReadConsoleInputW(self.stdin, &input_record, 1, &event_count) == 0) 128 - return windows.unexpectedError(windows.kernel32.GetLastError()); 129 - 130 - if (try self.eventFromRecord(&input_record, &state, parser, paste_allocator)) |ev| { 131 - return ev; 132 - } 133 - } 134 - } 135 - 136 - pub const EventState = struct { 137 - ansi_buf: [128]u8 = undefined, 138 - ansi_idx: usize = 0, 139 - utf16_buf: [2]u16 = undefined, 140 - utf16_half: bool = false, 141 - }; 142 - 143 - pub fn eventFromRecord(self: *Tty, record: *const INPUT_RECORD, state: *EventState, parser: *Parser, paste_allocator: ?std.mem.Allocator) !?Event { 144 - switch (record.EventType) { 145 - 0x0001 => { // Key event 146 - const event = record.Event.KeyEvent; 147 - 148 - if (state.utf16_half) half: { 149 - state.utf16_half = false; 150 - state.utf16_buf[1] = event.uChar.UnicodeChar; 151 - const codepoint: u21 = std.unicode.utf16DecodeSurrogatePair(&state.utf16_buf) catch break :half; 152 - const n = std.unicode.utf8Encode(codepoint, &self.buf) catch return null; 153 - 154 - const key: Key = .{ 155 - .codepoint = codepoint, 156 - .base_layout_codepoint = codepoint, 157 - .mods = translateMods(event.dwControlKeyState), 158 - .text = self.buf[0..n], 159 - }; 160 - 161 - switch (event.bKeyDown) { 162 - 0 => return .{ .key_release = key }, 163 - else => return .{ .key_press = key }, 164 - } 165 - } 166 - 167 - const base_layout: u16 = switch (event.wVirtualKeyCode) { 168 - 0x00 => blk: { // delivered when we get an escape sequence or a unicode codepoint 169 - if (state.ansi_idx == 0 and event.uChar.AsciiChar != 27) 170 - break :blk event.uChar.UnicodeChar; 171 - state.ansi_buf[state.ansi_idx] = event.uChar.AsciiChar; 172 - state.ansi_idx += 1; 173 - if (state.ansi_idx <= 2) return null; 174 - const result = try parser.parse(state.ansi_buf[0..state.ansi_idx], paste_allocator); 175 - return if (result.n == 0) null else evt: { 176 - state.ansi_idx = 0; 177 - break :evt result.event; 178 - }; 179 - }, 180 - 0x08 => Key.backspace, 181 - 0x09 => Key.tab, 182 - 0x0D => Key.enter, 183 - 0x13 => Key.pause, 184 - 0x14 => Key.caps_lock, 185 - 0x1B => Key.escape, 186 - 0x20 => Key.space, 187 - 0x21 => Key.page_up, 188 - 0x22 => Key.page_down, 189 - 0x23 => Key.end, 190 - 0x24 => Key.home, 191 - 0x25 => Key.left, 192 - 0x26 => Key.up, 193 - 0x27 => Key.right, 194 - 0x28 => Key.down, 195 - 0x2c => Key.print_screen, 196 - 0x2d => Key.insert, 197 - 0x2e => Key.delete, 198 - 0x30...0x39 => |k| k, 199 - 0x41...0x5a => |k| k + 0x20, // translate to lowercase 200 - 0x5b => Key.left_meta, 201 - 0x5c => Key.right_meta, 202 - 0x60 => Key.kp_0, 203 - 0x61 => Key.kp_1, 204 - 0x62 => Key.kp_2, 205 - 0x63 => Key.kp_3, 206 - 0x64 => Key.kp_4, 207 - 0x65 => Key.kp_5, 208 - 0x66 => Key.kp_6, 209 - 0x67 => Key.kp_7, 210 - 0x68 => Key.kp_8, 211 - 0x69 => Key.kp_9, 212 - 0x6a => Key.kp_multiply, 213 - 0x6b => Key.kp_add, 214 - 0x6c => Key.kp_separator, 215 - 0x6d => Key.kp_subtract, 216 - 0x6e => Key.kp_decimal, 217 - 0x6f => Key.kp_divide, 218 - 0x70 => Key.f1, 219 - 0x71 => Key.f2, 220 - 0x72 => Key.f3, 221 - 0x73 => Key.f4, 222 - 0x74 => Key.f5, 223 - 0x75 => Key.f6, 224 - 0x76 => Key.f8, 225 - 0x77 => Key.f8, 226 - 0x78 => Key.f9, 227 - 0x79 => Key.f10, 228 - 0x7a => Key.f11, 229 - 0x7b => Key.f12, 230 - 0x7c => Key.f13, 231 - 0x7d => Key.f14, 232 - 0x7e => Key.f15, 233 - 0x7f => Key.f16, 234 - 0x80 => Key.f17, 235 - 0x81 => Key.f18, 236 - 0x82 => Key.f19, 237 - 0x83 => Key.f20, 238 - 0x84 => Key.f21, 239 - 0x85 => Key.f22, 240 - 0x86 => Key.f23, 241 - 0x87 => Key.f24, 242 - 0x90 => Key.num_lock, 243 - 0x91 => Key.scroll_lock, 244 - 0xa0 => Key.left_shift, 245 - 0xa1 => Key.right_shift, 246 - 0xa2 => Key.left_control, 247 - 0xa3 => Key.right_control, 248 - 0xa4 => Key.left_alt, 249 - 0xa5 => Key.right_alt, 250 - 0xad => Key.mute_volume, 251 - 0xae => Key.lower_volume, 252 - 0xaf => Key.raise_volume, 253 - 0xb0 => Key.media_track_next, 254 - 0xb1 => Key.media_track_previous, 255 - 0xb2 => Key.media_stop, 256 - 0xb3 => Key.media_play_pause, 257 - 0xba => ';', 258 - 0xbb => '+', 259 - 0xbc => ',', 260 - 0xbd => '-', 261 - 0xbe => '.', 262 - 0xbf => '/', 263 - 0xc0 => '`', 264 - 0xdb => '[', 265 - 0xdc => '\\', 266 - 0xdd => ']', 267 - 0xde => '\'', 268 - else => return null, 269 - }; 270 - 271 - if (std.unicode.utf16IsHighSurrogate(base_layout)) { 272 - state.utf16_buf[0] = base_layout; 273 - state.utf16_half = true; 274 - return null; 275 - } 276 - if (std.unicode.utf16IsLowSurrogate(base_layout)) { 277 - return null; 278 - } 279 - 280 - var codepoint: u21 = base_layout; 281 - var text: ?[]const u8 = null; 282 - switch (event.uChar.UnicodeChar) { 283 - 0x00...0x1F => {}, 284 - else => |cp| { 285 - codepoint = cp; 286 - const n = try std.unicode.utf8Encode(codepoint, &self.buf); 287 - text = self.buf[0..n]; 288 - }, 289 - } 290 - 291 - const key: Key = .{ 292 - .codepoint = codepoint, 293 - .base_layout_codepoint = base_layout, 294 - .mods = translateMods(event.dwControlKeyState), 295 - .text = text, 296 - }; 297 - 298 - switch (event.bKeyDown) { 299 - 0 => return .{ .key_release = key }, 300 - else => return .{ .key_press = key }, 301 - } 302 - }, 303 - 0x0002 => { // Mouse event 304 - // see https://learn.microsoft.com/en-us/windows/console/mouse-event-record-str 305 - 306 - const event = record.Event.MouseEvent; 307 - 308 - // High word of dwButtonState represents mouse wheel. Positive is wheel_up, negative 309 - // is wheel_down 310 - // Low word represents button state 311 - const mouse_wheel_direction: i16 = blk: { 312 - const wheelu32: u32 = event.dwButtonState >> 16; 313 - const wheelu16: u16 = @truncate(wheelu32); 314 - break :blk @bitCast(wheelu16); 315 - }; 316 - 317 - const buttons: u16 = @truncate(event.dwButtonState); 318 - // save the current state when we are done 319 - defer self.last_mouse_button_press = buttons; 320 - const button_xor = self.last_mouse_button_press ^ buttons; 321 - 322 - var event_type: Mouse.Type = .press; 323 - const btn: Mouse.Button = switch (button_xor) { 324 - 0x0000 => blk: { 325 - // Check wheel event 326 - if (event.dwEventFlags & 0x0004 > 0) { 327 - if (mouse_wheel_direction > 0) 328 - break :blk .wheel_up 329 - else 330 - break :blk .wheel_down; 331 - } 332 - 333 - // If we have no change but one of the buttons is still pressed we have a 334 - // drag event. Find out which button is held down 335 - if (buttons > 0 and event.dwEventFlags & 0x0001 > 0) { 336 - event_type = .drag; 337 - if (buttons & 0x0001 > 0) break :blk .left; 338 - if (buttons & 0x0002 > 0) break :blk .right; 339 - if (buttons & 0x0004 > 0) break :blk .middle; 340 - if (buttons & 0x0008 > 0) break :blk .button_8; 341 - if (buttons & 0x0010 > 0) break :blk .button_9; 342 - } 343 - 344 - if (event.dwEventFlags & 0x0001 > 0) event_type = .motion; 345 - break :blk .none; 346 - }, 347 - 0x0001 => blk: { 348 - if (buttons & 0x0001 == 0) event_type = .release; 349 - break :blk .left; 350 - }, 351 - 0x0002 => blk: { 352 - if (buttons & 0x0002 == 0) event_type = .release; 353 - break :blk .right; 354 - }, 355 - 0x0004 => blk: { 356 - if (buttons & 0x0004 == 0) event_type = .release; 357 - break :blk .middle; 358 - }, 359 - 0x0008 => blk: { 360 - if (buttons & 0x0008 == 0) event_type = .release; 361 - break :blk .button_8; 362 - }, 363 - 0x0010 => blk: { 364 - if (buttons & 0x0010 == 0) event_type = .release; 365 - break :blk .button_9; 366 - }, 367 - else => { 368 - std.log.warn("unknown mouse event: {}", .{event}); 369 - return null; 370 - }, 371 - }; 372 - 373 - const shift: u32 = 0x0010; 374 - const alt: u32 = 0x0001 | 0x0002; 375 - const ctrl: u32 = 0x0004 | 0x0008; 376 - const mods: Mouse.Modifiers = .{ 377 - .shift = event.dwControlKeyState & shift > 0, 378 - .alt = event.dwControlKeyState & alt > 0, 379 - .ctrl = event.dwControlKeyState & ctrl > 0, 380 - }; 381 - 382 - const mouse: Mouse = .{ 383 - .col = @as(u16, @bitCast(event.dwMousePosition.X)), // Windows reports with 0 index 384 - .row = @as(u16, @bitCast(event.dwMousePosition.Y)), // Windows reports with 0 index 385 - .mods = mods, 386 - .type = event_type, 387 - .button = btn, 388 - }; 389 - return .{ .mouse = mouse }; 390 - }, 391 - 0x0004 => { // Screen resize events 392 - // NOTE: Even though the event comes with a size, it may not be accurate. We ask for 393 - // the size directly when we get this event 394 - var console_info: windows.CONSOLE_SCREEN_BUFFER_INFO = undefined; 395 - if (windows.kernel32.GetConsoleScreenBufferInfo(self.stdout, &console_info) == 0) { 396 - return windows.unexpectedError(windows.kernel32.GetLastError()); 397 - } 398 - const window_rect = console_info.srWindow; 399 - const width = window_rect.Right - window_rect.Left + 1; 400 - const height = window_rect.Bottom - window_rect.Top + 1; 401 - return .{ 402 - .winsize = .{ 403 - .cols = @intCast(width), 404 - .rows = @intCast(height), 405 - .x_pixel = 0, 406 - .y_pixel = 0, 407 - }, 408 - }; 409 - }, 410 - 0x0010 => { // Focus events 411 - switch (record.Event.FocusEvent.bSetFocus) { 412 - 0 => return .focus_out, 413 - else => return .focus_in, 414 - } 415 - }, 416 - else => {}, 417 - } 418 - return null; 419 - } 420 - 421 - fn translateMods(mods: u32) Key.Modifiers { 422 - const left_alt: u32 = 0x0002; 423 - const right_alt: u32 = 0x0001; 424 - const left_ctrl: u32 = 0x0008; 425 - const right_ctrl: u32 = 0x0004; 426 - 427 - const caps: u32 = 0x0080; 428 - const num_lock: u32 = 0x0020; 429 - const shift: u32 = 0x0010; 430 - const alt: u32 = left_alt | right_alt; 431 - const ctrl: u32 = left_ctrl | right_ctrl; 432 - 433 - return .{ 434 - .shift = mods & shift > 0, 435 - .alt = mods & alt > 0, 436 - .ctrl = mods & ctrl > 0, 437 - .caps_lock = mods & caps > 0, 438 - .num_lock = mods & num_lock > 0, 439 - }; 440 - } 441 - 442 - // From gitub.com/ziglibs/zig-windows-console. Thanks :) 443 - // 444 - // Events 445 - const union_unnamed_248 = extern union { 446 - UnicodeChar: windows.WCHAR, 447 - AsciiChar: windows.CHAR, 448 - }; 449 - pub const KEY_EVENT_RECORD = extern struct { 450 - bKeyDown: windows.BOOL, 451 - wRepeatCount: windows.WORD, 452 - wVirtualKeyCode: windows.WORD, 453 - wVirtualScanCode: windows.WORD, 454 - uChar: union_unnamed_248, 455 - dwControlKeyState: windows.DWORD, 456 - }; 457 - pub const PKEY_EVENT_RECORD = *KEY_EVENT_RECORD; 458 - 459 - pub const MOUSE_EVENT_RECORD = extern struct { 460 - dwMousePosition: windows.COORD, 461 - dwButtonState: windows.DWORD, 462 - dwControlKeyState: windows.DWORD, 463 - dwEventFlags: windows.DWORD, 464 - }; 465 - pub const PMOUSE_EVENT_RECORD = *MOUSE_EVENT_RECORD; 466 - 467 - pub const WINDOW_BUFFER_SIZE_RECORD = extern struct { 468 - dwSize: windows.COORD, 469 - }; 470 - pub const PWINDOW_BUFFER_SIZE_RECORD = *WINDOW_BUFFER_SIZE_RECORD; 471 - 472 - pub const MENU_EVENT_RECORD = extern struct { 473 - dwCommandId: windows.UINT, 474 - }; 475 - pub const PMENU_EVENT_RECORD = *MENU_EVENT_RECORD; 476 - 477 - pub const FOCUS_EVENT_RECORD = extern struct { 478 - bSetFocus: windows.BOOL, 479 - }; 480 - pub const PFOCUS_EVENT_RECORD = *FOCUS_EVENT_RECORD; 481 - 482 - const union_unnamed_249 = extern union { 483 - KeyEvent: KEY_EVENT_RECORD, 484 - MouseEvent: MOUSE_EVENT_RECORD, 485 - WindowBufferSizeEvent: WINDOW_BUFFER_SIZE_RECORD, 486 - MenuEvent: MENU_EVENT_RECORD, 487 - FocusEvent: FOCUS_EVENT_RECORD, 488 - }; 489 - pub const INPUT_RECORD = extern struct { 490 - EventType: windows.WORD, 491 - Event: union_unnamed_249, 492 - }; 493 - pub const PINPUT_RECORD = *INPUT_RECORD; 494 - 495 - pub extern "kernel32" fn ReadConsoleInputW(hConsoleInput: windows.HANDLE, lpBuffer: PINPUT_RECORD, nLength: windows.DWORD, lpNumberOfEventsRead: *windows.DWORD) callconv(windows.WINAPI) windows.BOOL; 496 - // TODO: remove this in zig 0.13.0 497 - pub extern "kernel32" fn SetConsoleMode(in_hConsoleHandle: windows.HANDLE, in_dwMode: windows.DWORD) callconv(windows.WINAPI) windows.BOOL;
-271
src/xev.zig
··· 1 - const std = @import("std"); 2 - const xev = @import("xev"); 3 - 4 - const Tty = @import("main.zig").Tty; 5 - const Winsize = @import("main.zig").Winsize; 6 - const Vaxis = @import("Vaxis.zig"); 7 - const Parser = @import("Parser.zig"); 8 - const Key = @import("Key.zig"); 9 - const Mouse = @import("Mouse.zig"); 10 - const Color = @import("Cell.zig").Color; 11 - 12 - const log = std.log.scoped(.vaxis_xev); 13 - 14 - pub const Event = union(enum) { 15 - key_press: Key, 16 - key_release: Key, 17 - mouse: Mouse, 18 - focus_in, 19 - focus_out, 20 - paste_start, // bracketed paste start 21 - paste_end, // bracketed paste end 22 - paste: []const u8, // osc 52 paste, caller must free 23 - color_report: Color.Report, // osc 4, 10, 11, 12 response 24 - color_scheme: Color.Scheme, 25 - winsize: Winsize, 26 - }; 27 - 28 - pub fn TtyWatcher(comptime Userdata: type) type { 29 - return struct { 30 - const Self = @This(); 31 - 32 - file: xev.File, 33 - tty: *Tty, 34 - 35 - read_buf: [4096]u8, 36 - read_buf_start: usize, 37 - read_cmp: xev.Completion, 38 - 39 - winsize_wakeup: xev.Async, 40 - winsize_cmp: xev.Completion, 41 - 42 - callback: *const fn ( 43 - ud: ?*Userdata, 44 - loop: *xev.Loop, 45 - watcher: *Self, 46 - event: Event, 47 - ) xev.CallbackAction, 48 - 49 - ud: ?*Userdata, 50 - vx: *Vaxis, 51 - parser: Parser, 52 - 53 - pub fn init( 54 - self: *Self, 55 - tty: *Tty, 56 - vaxis: *Vaxis, 57 - loop: *xev.Loop, 58 - userdata: ?*Userdata, 59 - callback: *const fn ( 60 - ud: ?*Userdata, 61 - loop: *xev.Loop, 62 - watcher: *Self, 63 - event: Event, 64 - ) xev.CallbackAction, 65 - ) !void { 66 - self.* = .{ 67 - .tty = tty, 68 - .file = xev.File.initFd(tty.fd), 69 - .read_buf = undefined, 70 - .read_buf_start = 0, 71 - .read_cmp = .{}, 72 - 73 - .winsize_wakeup = try xev.Async.init(), 74 - .winsize_cmp = .{}, 75 - 76 - .callback = callback, 77 - .ud = userdata, 78 - .vx = vaxis, 79 - .parser = .{ .grapheme_data = &vaxis.unicode.grapheme_data }, 80 - }; 81 - 82 - self.file.read( 83 - loop, 84 - &self.read_cmp, 85 - .{ .slice = &self.read_buf }, 86 - Self, 87 - self, 88 - Self.ttyReadCallback, 89 - ); 90 - self.winsize_wakeup.wait( 91 - loop, 92 - &self.winsize_cmp, 93 - Self, 94 - self, 95 - winsizeCallback, 96 - ); 97 - const handler: Tty.SignalHandler = .{ 98 - .context = self, 99 - .callback = Self.signalCallback, 100 - }; 101 - try Tty.notifyWinsize(handler); 102 - } 103 - 104 - fn signalCallback(ptr: *anyopaque) void { 105 - const self: *Self = @ptrCast(@alignCast(ptr)); 106 - self.winsize_wakeup.notify() catch |err| { 107 - log.warn("couldn't wake up winsize callback: {}", .{err}); 108 - }; 109 - } 110 - 111 - fn ttyReadCallback( 112 - ud: ?*Self, 113 - loop: *xev.Loop, 114 - c: *xev.Completion, 115 - _: xev.File, 116 - buf: xev.ReadBuffer, 117 - r: xev.ReadError!usize, 118 - ) xev.CallbackAction { 119 - const n = r catch |err| { 120 - log.err("read error: {}", .{err}); 121 - return .disarm; 122 - }; 123 - const self = ud orelse unreachable; 124 - 125 - // reset read start state 126 - self.read_buf_start = 0; 127 - 128 - var seq_start: usize = 0; 129 - parse_loop: while (seq_start < n) { 130 - const result = self.parser.parse(buf.slice[seq_start..n], null) catch |err| { 131 - log.err("couldn't parse input: {}", .{err}); 132 - return .disarm; 133 - }; 134 - if (result.n == 0) { 135 - // copy the read to the beginning. We don't use memcpy because 136 - // this could be overlapping, and it's also rare 137 - const initial_start = seq_start; 138 - while (seq_start < n) : (seq_start += 1) { 139 - self.read_buf[seq_start - initial_start] = self.read_buf[seq_start]; 140 - } 141 - self.read_buf_start = seq_start - initial_start + 1; 142 - return .rearm; 143 - } 144 - seq_start += n; 145 - const event_inner = result.event orelse { 146 - log.debug("unknown event: {s}", .{self.read_buf[seq_start - n + 1 .. seq_start]}); 147 - continue :parse_loop; 148 - }; 149 - 150 - // Capture events we want to bubble up 151 - const event: ?Event = switch (event_inner) { 152 - .key_press => |key| .{ .key_press = key }, 153 - .key_release => |key| .{ .key_release = key }, 154 - .mouse => |mouse| .{ .mouse = mouse }, 155 - .focus_in => .focus_in, 156 - .focus_out => .focus_out, 157 - .paste_start => .paste_start, 158 - .paste_end => .paste_end, 159 - .paste => |paste| .{ .paste = paste }, 160 - .color_report => |report| .{ .color_report = report }, 161 - .color_scheme => |scheme| .{ .color_scheme = scheme }, 162 - .winsize => |ws| .{ .winsize = ws }, 163 - 164 - // capability events which we handle below 165 - .cap_kitty_keyboard, 166 - .cap_kitty_graphics, 167 - .cap_rgb, 168 - .cap_unicode, 169 - .cap_sgr_pixels, 170 - .cap_color_scheme_updates, 171 - .cap_da1, 172 - => null, // handled below 173 - }; 174 - 175 - if (event) |ev| { 176 - const action = self.callback(self.ud, loop, self, ev); 177 - switch (action) { 178 - .disarm => return .disarm, 179 - else => continue :parse_loop, 180 - } 181 - } 182 - 183 - switch (event_inner) { 184 - .key_press, 185 - .key_release, 186 - .mouse, 187 - .focus_in, 188 - .focus_out, 189 - .paste_start, 190 - .paste_end, 191 - .paste, 192 - .color_report, 193 - .color_scheme, 194 - .winsize, 195 - => unreachable, // handled above 196 - 197 - .cap_kitty_keyboard => { 198 - log.info("kitty keyboard capability detected", .{}); 199 - self.vx.caps.kitty_keyboard = true; 200 - }, 201 - .cap_kitty_graphics => { 202 - if (!self.vx.caps.kitty_graphics) { 203 - log.info("kitty graphics capability detected", .{}); 204 - self.vx.caps.kitty_graphics = true; 205 - } 206 - }, 207 - .cap_rgb => { 208 - log.info("rgb capability detected", .{}); 209 - self.vx.caps.rgb = true; 210 - }, 211 - .cap_unicode => { 212 - log.info("unicode capability detected", .{}); 213 - self.vx.caps.unicode = .unicode; 214 - self.vx.screen.width_method = .unicode; 215 - }, 216 - .cap_sgr_pixels => { 217 - log.info("pixel mouse capability detected", .{}); 218 - self.vx.caps.sgr_pixels = true; 219 - }, 220 - .cap_color_scheme_updates => { 221 - log.info("color_scheme_updates capability detected", .{}); 222 - self.vx.caps.color_scheme_updates = true; 223 - }, 224 - .cap_da1 => { 225 - self.vx.enableDetectedFeatures(self.tty.anyWriter()) catch |err| { 226 - log.err("couldn't enable features: {}", .{err}); 227 - }; 228 - }, 229 - } 230 - } 231 - 232 - self.file.read( 233 - loop, 234 - c, 235 - .{ .slice = &self.read_buf }, 236 - Self, 237 - self, 238 - Self.ttyReadCallback, 239 - ); 240 - return .disarm; 241 - } 242 - 243 - fn winsizeCallback( 244 - ud: ?*Self, 245 - l: *xev.Loop, 246 - c: *xev.Completion, 247 - r: xev.Async.WaitError!void, 248 - ) xev.CallbackAction { 249 - _ = r catch |err| { 250 - log.err("async error: {}", .{err}); 251 - return .disarm; 252 - }; 253 - const self = ud orelse unreachable; // no userdata 254 - const winsize = Tty.getWinsize(self.tty.fd) catch |err| { 255 - log.err("couldn't get winsize: {}", .{err}); 256 - return .disarm; 257 - }; 258 - const ret = self.callback(self.ud, l, self, .{ .winsize = winsize }); 259 - if (ret == .disarm) return .disarm; 260 - 261 - self.winsize_wakeup.wait( 262 - l, 263 - c, 264 - Self, 265 - self, 266 - winsizeCallback, 267 - ); 268 - return .disarm; 269 - } 270 - }; 271 - }