Merge remote-tracking branch 'upstream/main' into zig-0.14.0

This commit is contained in:
Kalle Carlbark 2025-03-15 22:55:35 +01:00
commit 46458f2e87
41 changed files with 2868 additions and 382 deletions

View file

@ -27,12 +27,12 @@ jobs:
uses: actions/configure-pages@v2
- uses: mlugg/setup-zig@v1
with:
version: 0.14.0-dev.2456+a68119f8f
version: 0.14.0
- run: zig build docs
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
uses: actions/upload-pages-artifact@v3
with:
path: "zig-out/docs"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1
uses: actions/deploy-pages@v4

View file

@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v4
- uses: https://github.com/mlugg/setup-zig@v1
with:
version: 0.14.0-dev.2456+a68119f8f
version: 0.14.0
- run: zig build test
check-fmt:
runs-on: docker
@ -23,5 +23,5 @@ jobs:
- uses: actions/checkout@v4
- uses: https://github.com/mlugg/setup-zig@v1
with:
version: 0.14.0-dev.2456+a68119f8f
version: 0.14.0
- run: zig fmt --check .

174
README.md
View file

@ -9,9 +9,7 @@ It begins with them, but ends with me. Their son, Vaxis
Libvaxis _does not use terminfo_. Support for vt features is detected through
terminal queries.
Contributions are welcome.
Vaxis uses zig `0.13.0`.
Vaxis uses zig `0.14.0`.
## Features
@ -32,12 +30,178 @@ Unix-likes.
- Color Mode Updates (Mode 2031)
- [In-Band Resize Reports](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) (Mode 2048)
- Images ([kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/))
- [Explicit Width](https://github.com/kovidgoyal/kitty/blob/master/docs/text-sizing-protocol.rst) (width modifiers only)
## Usage
[Documentation](https://rockorager.github.io/libvaxis/#vaxis.Vaxis)
[Starter repo](https://github.com/rockorager/libvaxis-starter)
The library provides both a low level API suitable for making applications of
any sort as well as a higher level framework. The low level API is suitable for
making applications of any type, providing your own event loop, and gives you
full control over each cell on the screen.
The high level API, called `vxfw` (Vaxis framework), provides a Flutter-like
style of API. The framework provides an application runtime which handles the
event loop, focus management, mouse handling, and more. Several widgets are
provided, and custom widgets are easy to build. This API is most likely what you
want to use for typical TUI applications.
### vxfw (Vaxis framework)
Let's build a simple button counter application. This example can be run using
the command `zig build example -Dexample=counter`. The below application has
full mouse support: the button *and mouse shape* will change style on hover, on
click, and has enough logic to cancel a press if the release does not occur over
the button. Try it! Click the button, move the mouse off the button and release.
All of this logic is baked into the base `Button` widget.
```zig
const std = @import("std");
const vaxis = @import("vaxis");
const vxfw = vaxis.vxfw;
/// Our main application state
const Model = struct {
/// State of the counter
count: u32 = 0,
/// The button. This widget is stateful and must live between frames
button: vxfw.Button,
/// Helper function to return a vxfw.Widget struct
pub fn widget(self: *Model) vxfw.Widget {
return .{
.userdata = self,
.eventHandler = Model.typeErasedEventHandler,
.drawFn = Model.typeErasedDrawFn,
};
}
/// This function will be called from the vxfw runtime.
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
const self: *Model = @ptrCast(@alignCast(ptr));
switch (event) {
// The root widget is always sent an init event as the first event. Users of the
// library can also send this event to other widgets they create if they need to do
// some initialization.
.init => return ctx.requestFocus(self.button.widget()),
.key_press => |key| {
if (key.matches('c', .{ .ctrl = true })) {
ctx.quit = true;
return;
}
},
// We can request a specific widget gets focus. In this case, we always want to focus
// our button. Having focus means that key events will be sent up the widget tree to
// the focused widget, and then bubble back down the tree to the root. Users can tell
// the runtime the event was handled and the capture or bubble phase will stop
.focus_in => return ctx.requestFocus(self.button.widget()),
else => {},
}
}
/// This function is called from the vxfw runtime. It will be called on a regular interval, and
/// only when any event handler has marked the redraw flag in EventContext as true. By
/// explicitly requiring setting the redraw flag, vxfw can prevent excessive redraws for events
/// which don't change state (ie mouse motion, unhandled key events, etc)
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface {
const self: *Model = @ptrCast(@alignCast(ptr));
// The DrawContext is inspired from Flutter. Each widget will receive a minimum and maximum
// constraint. The minimum constraint will always be set, even if it is set to 0x0. The
// maximum constraint can have null width and/or height - meaning there is no constraint in
// that direction and the widget should take up as much space as it needs. By calling size()
// on the max, we assert that it has some constrained size. This is *always* the case for
// the root widget - the maximum size will always be the size of the terminal screen.
const max_size = ctx.max.size();
// The DrawContext also contains an arena allocator that can be used for each frame. The
// lifetime of this allocation is until the next time we draw a frame. This is useful for
// temporary allocations such as the one below: we have an integer we want to print as text.
// We can safely allocate this with the ctx arena since we only need it for this frame.
const count_text = try std.fmt.allocPrint(ctx.arena, "{d}", .{self.count});
const text: vxfw.Text = .{ .text = count_text };
// Each widget returns a Surface from it's draw function. A Surface contains the rectangular
// area of the widget, as well as some information about the surface or widget: can we focus
// it? does it handle the mouse?
//
// It DOES NOT contain the location it should be within it's parent. Only the parent can set
// this via a SubSurface. Here, we will return a Surface for the root widget (Model), which
// has two SubSurfaces: one for the text and one for the button. A SubSurface is a Surface
// with an offset and a z-index - the offset can be negative. This lets a parent draw a
// child and place it within itself
const text_child: vxfw.SubSurface = .{
.origin = .{ .row = 0, .col = 0 },
.surface = try text.draw(ctx),
};
const button_child: vxfw.SubSurface = .{
.origin = .{ .row = 2, .col = 0 },
.surface = try self.button.draw(ctx.withConstraints(
ctx.min,
// Here we explicitly set a new maximum size constraint for the Button. A Button will
// expand to fill it's area and must have some hard limit in the maximum constraint
.{ .width = 16, .height = 3 },
)),
};
// We also can use our arena to allocate the slice for our SubSurfaces. This slice only
// needs to live until the next frame, making this safe.
const children = try ctx.arena.alloc(vxfw.SubSurface, 2);
children[0] = text_child;
children[1] = button_child;
return .{
// A Surface must have a size. Our root widget is the size of the screen
.size = max_size,
.widget = self.widget(),
// We didn't actually need to draw anything for the root. In this case, we can set
// buffer to a zero length slice. If this slice is *not zero length*, the runtime will
// assert that it's length is equal to the size.width * size.height.
.buffer = &.{},
.children = children,
};
}
/// The onClick callback for our button. This is also called if we press enter while the button
/// has focus
fn onClick(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext) anyerror!void {
const ptr = maybe_ptr orelse return;
const self: *Model = @ptrCast(@alignCast(ptr));
self.count +|= 1;
return ctx.consumeAndRedraw();
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var app = try vxfw.App.init(allocator);
defer app.deinit();
// We heap allocate our model because we will require a stable pointer to it in our Button
// widget
const model = try allocator.create(Model);
defer allocator.destroy(model);
// Set the initial state of our button
model.* = .{
.count = 0,
.button = .{
.label = "Click me!",
.onClick = Model.onClick,
.userdata = model,
},
};
try app.run(model.widget(), .{});
}
```
### Low level API
Vaxis requires three basic primitives to operate:
@ -56,8 +220,6 @@ also handle these query responses and update the Vaxis.caps struct accordingly.
See the `Loop` implementation to see how this is done if writing your own event
loop.
## Example
```zig
const std = @import("std");
const vaxis = @import("vaxis");

View file

@ -305,16 +305,12 @@ release and is shown here merely as a proof of concept.
```zig
const builtin = @import("builtin");
const std = @import("std");
const vaxis = @import("main.zig");
const handleEventGeneric = @import("Loop.zig").handleEventGeneric;
const vaxis = @import("vaxis");
const handleEventGeneric = vaxis.loop.handleEventGeneric;
const log = std.log.scoped(.vaxis_aio);
const Yield = enum { no_state, took_event };
pub fn Loop(T: type) type {
return LoopWithModules(T, @import("aio"), @import("coro"));
}
/// zig-aio based event loop
/// <https://github.com/Cloudef/zig-aio>
pub fn LoopWithModules(T: type, aio: type, coro: type) type {

View file

@ -29,9 +29,12 @@ pub fn build(b: *std.Build) void {
// Examples
const Example = enum {
cli,
counter,
fuzzy,
image,
main,
scroll,
split_view,
table,
text_input,
vaxis,

View file

@ -1,15 +1,16 @@
.{
.name = "vaxis",
.name = .vaxis,
.fingerprint = 0x14fbbb94fc556305,
.version = "0.1.0",
.minimum_zig_version = "0.13.0",
.minimum_zig_version = "0.14.0",
.dependencies = .{
.zigimg = .{
.url = "https://git.kcbark.net/zig/zigimg/archive/refs/heads/master.tar.gz",
.hash = "12206991c867bc9dcceac856ea8505db411859d48c5ac1411e920aa79f77c43a60b8",
.hash = "zigimg-0.1.0-6EC2bT5oEACE-3wd0vLyYpL40DmplOztjn0APgTCyg7y",
},
.zg = .{
.url = "git+https://codeberg.org/utkarshmalik/zg#7ca90b6f8796cd6615ddc61e12cd292ea26662ce",
.hash = "1220f3e29bc40856bfc06e0ee133f814b0011c76de987d8a6a458c2f34d82708899a",
.url = "git+https://codeberg.org/atman/zg#4a002763419a34d61dcbb1f415821b83b9bf8ddc",
.hash = "zg-0.13.4-AAAAAGiZ7QLz4pvECFa_wG4O4TP4FLABHHbemH2KakWM",
},
},
.paths = .{

View file

@ -62,7 +62,7 @@ pub fn main() !void {
} else {
selected_option.? = selected_option.? -| 1;
}
} else if (key.matches(vaxis.Key.enter, .{})) {
} else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
if (selected_option) |i| {
log.err("enter", .{});
try text_input.insertSliceAtCursor(options[i]);

139
examples/counter.zig Normal file
View file

@ -0,0 +1,139 @@
const std = @import("std");
const vaxis = @import("vaxis");
const vxfw = vaxis.vxfw;
/// Our main application state
const Model = struct {
/// State of the counter
count: u32 = 0,
/// The button. This widget is stateful and must live between frames
button: vxfw.Button,
/// Helper function to return a vxfw.Widget struct
pub fn widget(self: *Model) vxfw.Widget {
return .{
.userdata = self,
.eventHandler = Model.typeErasedEventHandler,
.drawFn = Model.typeErasedDrawFn,
};
}
/// This function will be called from the vxfw runtime.
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
const self: *Model = @ptrCast(@alignCast(ptr));
switch (event) {
// The root widget is always sent an init event as the first event. Users of the
// library can also send this event to other widgets they create if they need to do
// some initialization.
.init => return ctx.requestFocus(self.button.widget()),
.key_press => |key| {
if (key.matches('c', .{ .ctrl = true })) {
ctx.quit = true;
return;
}
},
// We can request a specific widget gets focus. In this case, we always want to focus
// our button. Having focus means that key events will be sent up the widget tree to
// the focused widget, and then bubble back down the tree to the root. Users can tell
// the runtime the event was handled and the capture or bubble phase will stop
.focus_in => return ctx.requestFocus(self.button.widget()),
else => {},
}
}
/// This function is called from the vxfw runtime. It will be called on a regular interval, and
/// only when any event handler has marked the redraw flag in EventContext as true. By
/// explicitly requiring setting the redraw flag, vxfw can prevent excessive redraws for events
/// which don't change state (ie mouse motion, unhandled key events, etc)
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface {
const self: *Model = @ptrCast(@alignCast(ptr));
// The DrawContext is inspired from Flutter. Each widget will receive a minimum and maximum
// constraint. The minimum constraint will always be set, even if it is set to 0x0. The
// maximum constraint can have null width and/or height - meaning there is no constraint in
// that direction and the widget should take up as much space as it needs. By calling size()
// on the max, we assert that it has some constrained size. This is *always* the case for
// the root widget - the maximum size will always be the size of the terminal screen.
const max_size = ctx.max.size();
// The DrawContext also contains an arena allocator that can be used for each frame. The
// lifetime of this allocation is until the next time we draw a frame. This is useful for
// temporary allocations such as the one below: we have an integer we want to print as text.
// We can safely allocate this with the ctx arena since we only need it for this frame.
if (self.count > 0) {
self.button.label = try std.fmt.allocPrint(ctx.arena, "Clicks: {d}", .{self.count});
} else {
self.button.label = "Click me!";
}
// Each widget returns a Surface from it's draw function. A Surface contains the rectangular
// area of the widget, as well as some information about the surface or widget: can we focus
// it? does it handle the mouse?
//
// It DOES NOT contain the location it should be within it's parent. Only the parent can set
// this via a SubSurface. Here, we will return a Surface for the root widget (Model), which
// has two SubSurfaces: one for the text and one for the button. A SubSurface is a Surface
// with an offset and a z-index - the offset can be negative. This lets a parent draw a
// child and place it within itself
const button_child: vxfw.SubSurface = .{
.origin = .{ .row = 0, .col = 0 },
.surface = try self.button.draw(ctx.withConstraints(
ctx.min,
// Here we explicitly set a new maximum size constraint for the Button. A Button will
// expand to fill it's area and must have some hard limit in the maximum constraint
.{ .width = 16, .height = 3 },
)),
};
// We also can use our arena to allocate the slice for our SubSurfaces. This slice only
// needs to live until the next frame, making this safe.
const children = try ctx.arena.alloc(vxfw.SubSurface, 1);
children[0] = button_child;
return .{
// A Surface must have a size. Our root widget is the size of the screen
.size = max_size,
.widget = self.widget(),
// We didn't actually need to draw anything for the root. In this case, we can set
// buffer to a zero length slice. If this slice is *not zero length*, the runtime will
// assert that it's length is equal to the size.width * size.height.
.buffer = &.{},
.children = children,
};
}
/// The onClick callback for our button. This is also called if we press enter while the button
/// has focus
fn onClick(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext) anyerror!void {
const ptr = maybe_ptr orelse return;
const self: *Model = @ptrCast(@alignCast(ptr));
self.count +|= 1;
return ctx.consumeAndRedraw();
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var app = try vxfw.App.init(allocator);
defer app.deinit();
// We heap allocate our model because we will require a stable pointer to it in our Button
// widget
const model = try allocator.create(Model);
defer allocator.destroy(model);
// Set the initial state of our button
model.* = .{
.count = 0,
.button = .{
.label = "Click me!",
.onClick = Model.onClick,
.userdata = model,
},
};
try app.run(model.widget(), .{});
}

View file

@ -54,14 +54,13 @@ const Model = struct {
const self: *Model = @ptrCast(@alignCast(ptr));
const max = ctx.max.size();
var list_view: vxfw.SubSurface = .{
const list_view: vxfw.SubSurface = .{
.origin = .{ .row = 2, .col = 0 },
.surface = try self.list_view.draw(ctx.withConstraints(
ctx.min,
.{ .width = max.width, .height = max.height - 3 },
)),
};
list_view.surface.focusable = false;
const text_field: vxfw.SubSurface = .{
.origin = .{ .row = 0, .col = 2 },
@ -86,7 +85,6 @@ const Model = struct {
return .{
.size = max,
.widget = self.widget(),
.focusable = true,
.buffer = &.{},
.children = children,
};
@ -209,12 +207,12 @@ pub fn main() !void {
var fd = std.process.Child.init(&.{"fd"}, allocator);
fd.stdout_behavior = .Pipe;
fd.stderr_behavior = .Pipe;
var stdout = std.ArrayList(u8).init(allocator);
var stderr = std.ArrayList(u8).init(allocator);
defer stdout.deinit();
defer stderr.deinit();
var stdout: std.ArrayListUnmanaged(u8) = .empty;
var stderr: std.ArrayListUnmanaged(u8) = .empty;
defer stdout.deinit(allocator);
defer stderr.deinit(allocator);
try fd.spawn();
try fd.collectOutput(&stdout, &stderr, 10_000_000);
try fd.collectOutput(allocator, &stdout, &stderr, 10_000_000);
_ = try fd.wait();
var iter = std.mem.splitScalar(u8, stdout.items, '\n');

214
examples/scroll.zig Normal file
View file

@ -0,0 +1,214 @@
const std = @import("std");
const vaxis = @import("vaxis");
const vxfw = vaxis.vxfw;
const ModelRow = struct {
text: []const u8,
idx: usize,
wrap_lines: bool = true,
pub fn widget(self: *ModelRow) vxfw.Widget {
return .{
.userdata = self,
.drawFn = ModelRow.typeErasedDrawFn,
};
}
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface {
const self: *ModelRow = @ptrCast(@alignCast(ptr));
const idx_text = try std.fmt.allocPrint(ctx.arena, "{d: >4}", .{self.idx});
const idx_widget: vxfw.Text = .{ .text = idx_text };
const idx_surf: vxfw.SubSurface = .{
.origin = .{ .row = 0, .col = 0 },
.surface = try idx_widget.draw(ctx.withConstraints(
// We're only interested in constraining the width, and we know the height will
// always be 1 row.
.{ .width = 1, .height = 1 },
.{ .width = 4, .height = 1 },
)),
};
const text_widget: vxfw.Text = .{ .text = self.text, .softwrap = self.wrap_lines };
const text_surf: vxfw.SubSurface = .{
.origin = .{ .row = 0, .col = 6 },
.surface = try text_widget.draw(ctx.withConstraints(
ctx.min,
// We've shifted the origin over 6 columns so we need to take that into account or
// we'll draw outside the window.
if (self.wrap_lines)
.{ .width = ctx.min.width -| 6, .height = ctx.max.height }
else
.{ .width = if (ctx.max.width) |w| w - 6 else null, .height = ctx.max.height },
)),
};
const children = try ctx.arena.alloc(vxfw.SubSurface, 2);
children[0] = idx_surf;
children[1] = text_surf;
return .{
.size = .{
.width = 6 + text_surf.surface.size.width,
.height = @max(idx_surf.surface.size.height, text_surf.surface.size.height),
},
.widget = self.widget(),
.buffer = &.{},
.children = children,
};
}
};
const Model = struct {
scroll_bars: vxfw.ScrollBars,
rows: std.ArrayList(ModelRow),
pub fn widget(self: *Model) vxfw.Widget {
return .{
.userdata = self,
.eventHandler = Model.typeErasedEventHandler,
.drawFn = Model.typeErasedDrawFn,
};
}
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
const self: *Model = @ptrCast(@alignCast(ptr));
switch (event) {
.key_press => |key| {
if (key.matches('c', .{ .ctrl = true })) {
ctx.quit = true;
return;
}
if (key.matches('w', .{ .ctrl = true })) {
for (self.rows.items) |*row| {
row.wrap_lines = !row.wrap_lines;
}
self.scroll_bars.estimated_content_height =
if (self.scroll_bars.estimated_content_height == 800)
@intCast(self.rows.items.len)
else
800;
return ctx.consumeAndRedraw();
}
if (key.matches('e', .{ .ctrl = true })) {
if (self.scroll_bars.estimated_content_height == null)
self.scroll_bars.estimated_content_height = 800
else
self.scroll_bars.estimated_content_height = null;
return ctx.consumeAndRedraw();
}
if (key.matches(vaxis.Key.tab, .{})) {
self.scroll_bars.scroll_view.draw_cursor = !self.scroll_bars.scroll_view.draw_cursor;
return ctx.consumeAndRedraw();
}
if (key.matches('v', .{ .ctrl = true })) {
self.scroll_bars.draw_vertical_scrollbar = !self.scroll_bars.draw_vertical_scrollbar;
return ctx.consumeAndRedraw();
}
if (key.matches('h', .{ .ctrl = true })) {
self.scroll_bars.draw_horizontal_scrollbar = !self.scroll_bars.draw_horizontal_scrollbar;
return ctx.consumeAndRedraw();
}
if (key.matches(vaxis.Key.tab, .{ .shift = true })) {
self.scroll_bars.draw_vertical_scrollbar = !self.scroll_bars.draw_vertical_scrollbar;
self.scroll_bars.draw_horizontal_scrollbar = !self.scroll_bars.draw_horizontal_scrollbar;
return ctx.consumeAndRedraw();
}
return self.scroll_bars.scroll_view.handleEvent(ctx, event);
},
else => {},
}
}
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface {
const self: *Model = @ptrCast(@alignCast(ptr));
const max = ctx.max.size();
const scroll_view: vxfw.SubSurface = .{
.origin = .{ .row = 0, .col = 0 },
.surface = try self.scroll_bars.draw(ctx),
};
const children = try ctx.arena.alloc(vxfw.SubSurface, 1);
children[0] = scroll_view;
return .{
.size = max,
.widget = self.widget(),
.buffer = &.{},
.children = children,
};
}
fn widgetBuilder(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
const self: *const Model = @ptrCast(@alignCast(ptr));
if (idx >= self.rows.items.len) return null;
return self.rows.items[idx].widget();
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var app = try vxfw.App.init(allocator);
errdefer app.deinit();
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const model = try allocator.create(Model);
defer allocator.destroy(model);
model.* = .{
.scroll_bars = .{
.scroll_view = .{
.children = .{
.builder = .{
.userdata = model,
.buildFn = Model.widgetBuilder,
},
},
},
// NOTE: This is not the actual content height, but rather an estimate. In reality
// you would want to do some calculations to keep this up to date and as close to
// the real value as possible, but this suffices for the sake of the example. Try
// playing around with the value to see how it affects the scrollbar. Try removing
// it as well to see what that does.
.estimated_content_height = 800,
},
.rows = std.ArrayList(ModelRow).init(allocator),
};
defer model.rows.deinit();
var lipsum = std.ArrayList([]const u8).init(allocator);
defer lipsum.deinit();
try lipsum.append(" 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.");
try lipsum.append(" 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.");
try lipsum.append(" 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.");
try lipsum.append(" 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.");
try lipsum.append(" 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.");
try lipsum.append(" 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.");
try lipsum.append(" 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.");
try lipsum.append(" 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.");
try lipsum.append(" 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.");
try lipsum.append(" 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.");
// Try playing around with the amount of items in the scroll view to see how the scrollbar
// reacts.
for (0..10) |i| {
for (lipsum.items, 0..) |paragraph, j| {
const number = i * 10 + j;
try model.rows.append(.{ .idx = number, .text = paragraph });
}
}
try app.run(model.widget(), .{});
app.deinit();
}

View file

@ -167,12 +167,12 @@ pub fn main() !void {
demo_tbl.sel_rows = try rows_list.toOwnedSlice();
}
// See Row Content
if (key.matches(vaxis.Key.enter, .{})) see_content = !see_content;
if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) see_content = !see_content;
},
.btm => {
if (key.matchesAny(&.{ vaxis.Key.up, 'k' }, .{}) and moving) active = .mid
// Run Command and Clear Command Bar
else if (key.matchExact(vaxis.Key.enter, .{})) {
// Run Command and Clear Command Bar
else if (key.matchExact(vaxis.Key.enter, .{}) or key.matchExact('j', .{ .ctrl = true })) {
const cmd = try cmd_input.toOwnedSlice();
defer alloc.free(cmd);
if (mem.eql(u8, ":q", cmd) or

View file

@ -99,7 +99,7 @@ pub fn main() !void {
try loop.start();
try vx.enterAltScreen(tty.anyWriter());
vx.queueRefresh();
} else if (key.matches(vaxis.Key.enter, .{})) {
} else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
text_input.clearAndFree();
} else {
try text_input.update(.{ .key_press = key });

View file

@ -121,7 +121,7 @@ pub fn draw(self: Image, win: Window, opts: DrawOptions) !void {
.rows = win.height,
}
// Does the image require horizontal scaling?
// Does the image require horizontal scaling?
else if (!fit_x and fit_y)
p_opts.size = .{
.cols = win.width,

View file

@ -116,8 +116,14 @@ pub fn readCell(self: *InternalScreen, col: u16, row: u16) ?Cell {
}
const i = (row * self.width) + col;
assert(i < self.buf.len);
const cell = self.buf[i];
return .{
.char = .{ .grapheme = self.buf[i].char.items },
.style = self.buf[i].style,
.char = .{ .grapheme = cell.char.items },
.style = cell.style,
.link = .{
.uri = cell.uri.items,
.params = cell.uri_id.items,
},
.default = cell.default,
};
}

View file

@ -13,6 +13,12 @@ pub const Modifiers = packed struct(u8) {
meta: bool = false,
caps_lock: bool = false,
num_lock: bool = false,
pub fn eql(self: Modifiers, other: Modifiers) bool {
const a: u8 = @bitCast(self);
const b: u8 = @bitCast(other);
return a == b;
}
};
/// Flags for the Kitty Protocol.
@ -283,6 +289,11 @@ pub const name_map = blk: {
.{ "comma", ',' },
// special keys
.{ "tab", tab },
.{ "enter", enter },
.{ "escape", escape },
.{ "space", space },
.{ "backspace", backspace },
.{ "insert", insert },
.{ "delete", delete },
.{ "left", left },

View file

@ -177,6 +177,18 @@ pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Even
}
},
.key_press => |key| {
// Check for a cursor position response for our explicity width query. This will
// always be an F3 key with shift = true, and we must be looking for queries
if (key.codepoint == vaxis.Key.f3 and
key.mods.shift and
!vx.queries_done.load(.unordered))
{
log.info("explicit width capability detected", .{});
vx.caps.explicit_width = true;
vx.caps.unicode = .unicode;
vx.screen.width_method = .unicode;
return;
}
if (@hasField(Event, "key_press")) {
// HACK: yuck. there has to be a better way
var mut_key = key;
@ -198,14 +210,41 @@ pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Even
},
.cap_da1 => {
std.Thread.Futex.wake(&vx.query_futex, 10);
vx.queries_done.store(true, .unordered);
},
.mouse => {}, // Unsupported currently
.mouse => |mouse| {
if (@hasField(Event, "mouse")) {
return self.postEvent(.{ .mouse = vx.translateMouse(mouse) });
}
},
.focus_in => {
if (@hasField(Event, "focus_in")) {
return self.postEvent(.focus_in);
}
},
.focus_out => {
if (@hasField(Event, "focus_out")) {
return self.postEvent(.focus_out);
}
}, // Unsupported currently
else => {},
}
},
else => {
switch (event) {
.key_press => |key| {
// Check for a cursor position response for our explicity width query. This will
// always be an F3 key with shift = true, and we must be looking for queries
if (key.codepoint == vaxis.Key.f3 and
key.mods.shift and
!vx.queries_done.load(.unordered))
{
log.info("explicit width capability detected", .{});
vx.caps.explicit_width = true;
vx.caps.unicode = .unicode;
vx.screen.width_method = .unicode;
return;
}
if (@hasField(Event, "key_press")) {
// HACK: yuck. there has to be a better way
var mut_key = key;
@ -297,6 +336,7 @@ pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Even
},
.cap_da1 => {
std.Thread.Futex.wake(&vx.query_futex, 10);
vx.queries_done.store(true, .unordered);
},
.winsize => |winsize| {
vx.state.in_band_resize = true;

View file

@ -94,9 +94,8 @@ inline fn parseGround(input: []const u8, data: *const grapheme.GraphemeData) !Re
0x00 => .{ .codepoint = '@', .mods = .{ .ctrl = true } },
0x08 => .{ .codepoint = Key.backspace },
0x09 => .{ .codepoint = Key.tab },
0x0A,
0x0D,
=> .{ .codepoint = Key.enter },
0x0A => .{ .codepoint = 'j', .mods = .{ .ctrl = true } },
0x0D => .{ .codepoint = Key.enter },
0x01...0x07,
0x0B...0x0C,
0x0E...0x1A,
@ -593,6 +592,28 @@ inline fn parseCsi(input: []const u8, text_buf: []u8) Result {
key.text = text_buf[0..total];
}
{
// We check if we have *only* shift, no text, and a printable character. This can
// happen when we have disambiguate on and a key is pressed and encoded as CSI u,
// for example shift + space can produce CSI 32 ; 2 u
const mod_test: Key.Modifiers = .{
.shift = true,
.caps_lock = key.mods.caps_lock,
.num_lock = key.mods.num_lock,
};
if (key.text == null and
key.mods.eql(mod_test) and
key.codepoint <= std.math.maxInt(u8) and
std.ascii.isPrint(@intCast(key.codepoint)))
{
// Encode the codepoint as upper
const upper = std.ascii.toUpper(@intCast(key.codepoint));
const n = std.unicode.utf8Encode(upper, text_buf) catch unreachable;
key.text = text_buf[0..n];
key.shifted_codepoint = upper;
}
}
const event: Event = if (is_release)
.{ .key_release = key }
else
@ -908,11 +929,12 @@ test "parse: kitty: shift+a without text reporting" {
.codepoint = 'a',
.shifted_codepoint = 'A',
.mods = .{ .shift = true },
.text = "A",
};
const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(10, result.n);
try testing.expectEqual(expected_event, result.event);
try testing.expectEqualDeep(expected_event, result.event);
}
test "parse: kitty: alt+shift+a without text reporting" {
@ -1128,3 +1150,22 @@ test "parse(csi): mouse" {
try testing.expectEqual(expected.n, result.n);
try testing.expectEqual(expected.event, result.event);
}
test "parse: disambiguate shift + space" {
const alloc = testing.allocator_instance.allocator();
const grapheme_data = try grapheme.GraphemeData.init(alloc);
defer grapheme_data.deinit();
const input = "\x1b[32;2u";
var parser: Parser = .{ .grapheme_data = &grapheme_data };
const result = try parser.parse(input, alloc);
const expected_key: Key = .{
.codepoint = ' ',
.shifted_codepoint = ' ',
.mods = .{ .shift = true },
.text = " ",
};
const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(7, result.n);
try testing.expectEqualDeep(expected_event, result.event);
}

View file

@ -36,6 +36,7 @@ pub const Capabilities = struct {
unicode: gwidth.Method = .wcwidth,
sgr_pixels: bool = false,
color_scheme_updates: bool = false,
explicit_width: bool = false,
};
pub const Options = struct {
@ -63,6 +64,11 @@ refresh: bool = false,
/// futex times out
query_futex: atomic.Value(u32) = atomic.Value(u32).init(0),
/// If Queries were sent, we set this to false. We reset to true when all queries are complete. This
/// is used because we do explicit cursor position reports in the queries, which interfere with F3
/// key encoding. This can be used as a flag to determine how we should evaluate this sequence
queries_done: atomic.Value(bool) = atomic.Value(bool).init(true),
// images
next_img_id: u32 = 1,
@ -236,13 +242,15 @@ pub fn queryTerminal(self: *Vaxis, tty: AnyWriter, timeout_ns: u64) !void {
try self.queryTerminalSend(tty);
// 1 second timeout
std.Thread.Futex.timedWait(&self.query_futex, 0, timeout_ns) catch {};
self.queries_done.store(true, .unordered);
try self.enableDetectedFeatures(tty);
}
/// write queries to the terminal to determine capabilities. This function
/// is only for use with a custom main loop. Call Vaxis.queryTerminal() if
/// you are using Loop.run()
pub fn queryTerminalSend(_: Vaxis, tty: AnyWriter) !void {
pub fn queryTerminalSend(vx: *Vaxis, tty: AnyWriter) !void {
vx.queries_done.store(false, .unordered);
// TODO: re-enable this
// const colorterm = std.posix.getenv("COLORTERM") orelse "";
@ -263,6 +271,15 @@ pub fn queryTerminalSend(_: Vaxis, tty: AnyWriter) !void {
ctlseqs.decrqm_unicode ++
ctlseqs.decrqm_color_scheme ++
ctlseqs.in_band_resize_set ++
// Explicit width query. We send the cursor home, then do an explicit width command, then
// query the position. If the parsed value is an F3 with shift, we support explicit width.
// The returned response will be something like \x1b[1;2R...which when parsed as a Key is a
// shift + F3 (the row is ignored). We only care if the column has moved from 1->2, which is
// why we see a Shift modifier
ctlseqs.home ++
ctlseqs.explicit_width_query ++
ctlseqs.cursor_position_request ++
ctlseqs.xtversion ++
ctlseqs.csi_u_query ++
ctlseqs.kitty_graphics_query ++
@ -302,7 +319,8 @@ pub fn enableDetectedFeatures(self: *Vaxis, tty: AnyWriter) !void {
if (self.caps.kitty_keyboard) {
try self.enableKittyKeyboard(tty, self.opts.kitty_keyboard_flags);
}
if (self.caps.unicode == .unicode) {
// Only enable mode 2027 if we don't have explicit width
if (self.caps.unicode == .unicode and !self.caps.explicit_width) {
try tty.writeAll(ctlseqs.unicode_set);
}
},
@ -611,7 +629,13 @@ pub fn render(self: *Vaxis, tty: AnyWriter) !void {
}
try tty.print(ctlseqs.osc8, .{ ps, cell.link.uri });
}
try tty.writeAll(cell.char.grapheme);
// If we have explicit width and our width is greater than 1, let's use it
if (self.caps.explicit_width and w > 1) {
try tty.print(ctlseqs.explicit_width, .{ w, cell.char.grapheme });
} else {
try tty.writeAll(cell.char.grapheme);
}
cursor_pos.col = col + w;
cursor_pos.row = row;
}

View file

@ -36,16 +36,18 @@ fn initChild(
maybe_width: ?u16,
maybe_height: ?u16,
) Window {
const width: u16 = maybe_width orelse @max(self.width - x_off, 0);
const height: u16 = maybe_height orelse @max(self.height - y_off, 0);
const max_height = @max(self.height - y_off, 0);
const max_width = @max(self.width - x_off, 0);
const width: u16 = maybe_width orelse max_width;
const height: u16 = maybe_height orelse max_height;
return Window{
.x_off = x_off + self.x_off,
.y_off = y_off + self.y_off,
.parent_x_off = @min(self.parent_x_off + x_off, 0),
.parent_y_off = @min(self.parent_y_off + y_off, 0),
.width = width,
.height = height,
.width = @min(width, max_width),
.height = @min(height, max_height),
.screen = self.screen,
};
}
@ -504,8 +506,8 @@ test "Window size set too big" {
};
const ch = parent.initChild(0, 0, 21, 21);
try std.testing.expectEqual(21, ch.width);
try std.testing.expectEqual(21, ch.height);
try std.testing.expectEqual(20, ch.width);
try std.testing.expectEqual(20, ch.height);
}
test "Window size set too big with offset" {
@ -520,8 +522,8 @@ test "Window size set too big with offset" {
};
const ch = parent.initChild(10, 10, 21, 21);
try std.testing.expectEqual(21, ch.width);
try std.testing.expectEqual(21, ch.height);
try std.testing.expectEqual(10, ch.width);
try std.testing.expectEqual(10, ch.height);
}
test "Window size nested offsets" {

View file

@ -11,6 +11,8 @@ pub const decrqm_color_scheme = "\x1b[?2031$p";
pub const csi_u_query = "\x1b[?u";
pub const kitty_graphics_query = "\x1b_Gi=1,a=q\x1b\\";
pub const sixel_geometry_query = "\x1b[?2;1;0S";
pub const cursor_position_request = "\x1b[6n";
pub const explicit_width_query = "\x1b]66;w=1; \x1b\\";
// mouse. We try for button motion and any motion. terminals will enable the
// last one we tried (any motion). This was added because zellij doesn't
@ -31,6 +33,7 @@ pub const sync_reset = "\x1b[?2026l";
// unicode
pub const unicode_set = "\x1b[?2027h";
pub const unicode_reset = "\x1b[?2027l";
pub const explicit_width = "\x1b]66;w={d};{s}\x1b\\";
// bracketed paste
pub const bp_set = "\x1b[?2004h";

View file

@ -1,11 +1,12 @@
const std = @import("std");
const builtin = @import("builtin");
const tty = @import("tty.zig");
pub const tty = @import("tty.zig");
pub const Vaxis = @import("Vaxis.zig");
pub const Loop = @import("Loop.zig").Loop;
pub const loop = @import("Loop.zig");
pub const Loop = loop.Loop;
pub const zigimg = @import("zigimg");
@ -58,9 +59,9 @@ pub const Panic = struct {
};
/// Resets terminal state on a panic, then calls the default zig panic handler
pub fn panic_handler(msg: []const u8, error_return_trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn {
pub fn panic_handler(msg: []const u8, _: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn {
recover();
std.debug.defaultPanic(msg, error_return_trace, ret_addr);
std.debug.defaultPanic(msg, ret_addr);
}
/// Resets the terminal state using the global tty instance. Use this only to recover during a panic

View file

@ -30,7 +30,12 @@ pub fn init(allocator: Allocator) !App {
return .{
.allocator = allocator,
.tty = try vaxis.Tty.init(),
.vx = try vaxis.init(allocator, .{ .system_clipboard_allocator = allocator }),
.vx = try vaxis.init(allocator, .{
.system_clipboard_allocator = allocator,
.kitty_keyboard_flags = .{
.report_events = true,
},
}),
.timers = std.ArrayList(vxfw.Tick).init(allocator),
.wants_focus = null,
};
@ -52,9 +57,13 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void {
// Send the init event
loop.postEvent(.init);
// Also always initialize the app with a focus event
loop.postEvent(.focus_in);
try vx.enterAltScreen(tty.anyWriter());
try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s);
try vx.setBracketedPaste(tty.anyWriter(), true);
try vx.subscribeToColorSchemeUpdates(tty.anyWriter());
{
// This part deserves a comment. loop.init installs a signal handler for the tty. We wait to
@ -78,12 +87,9 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void {
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
var buffered = tty.bufferedWriter();
var mouse_handler = MouseHandler.init(widget);
defer mouse_handler.deinit(self.allocator);
var focus_handler = FocusHandler.init(self.allocator, widget);
focus_handler.intrusiveInit();
try focus_handler.path_to_focused.append(widget);
defer focus_handler.deinit();
@ -124,11 +130,16 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void {
try focus_handler.handleEvent(&ctx, event);
try self.handleCommand(&ctx.cmds);
},
.focus_out => try mouse_handler.mouseExit(self, &ctx),
.focus_out => {
try mouse_handler.mouseExit(self, &ctx);
try focus_handler.handleEvent(&ctx, .focus_out);
},
.focus_in => {
try focus_handler.handleEvent(&ctx, .focus_in);
},
.mouse => |mouse| try mouse_handler.handleMouse(self, &ctx, mouse),
.winsize => |ws| {
try vx.resize(self.allocator, buffered.writer().any(), ws);
try buffered.flush();
try vx.resize(self.allocator, tty.anyWriter(), ws);
ctx.redraw = true;
},
else => {
@ -138,9 +149,11 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void {
}
}
// If we have a focus change, handle that event before we layout
if (self.wants_focus) |wants_focus| {
try focus_handler.focusWidget(&ctx, wants_focus);
try self.handleCommand(&ctx.cmds);
self.wants_focus = null;
}
// Check if we should quit
@ -149,40 +162,84 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void {
// Check if we need a redraw
if (!ctx.redraw) continue;
ctx.redraw = false;
// Clear the arena.
_ = arena.reset(.free_all);
// Assert that we have handled all commands
assert(ctx.cmds.items.len == 0);
_ = arena.reset(.retain_capacity);
const surface: vxfw.Surface = blk: {
// Draw the root widget
const surface = try self.doLayout(widget, &arena);
const draw_context: vxfw.DrawContext = .{
.arena = arena.allocator(),
.min = .{ .width = 0, .height = 0 },
.max = .{
.width = @intCast(vx.screen.width),
.height = @intCast(vx.screen.height),
},
// Check if any hover or mouse effects changed
try mouse_handler.updateMouse(self, surface, &ctx);
// Our focus may have changed. Handle that here
if (self.wants_focus) |wants_focus| {
try focus_handler.focusWidget(&ctx, wants_focus);
try self.handleCommand(&ctx.cmds);
self.wants_focus = null;
}
assert(ctx.cmds.items.len == 0);
if (!ctx.redraw) break :blk surface;
// If updating the mouse required a redraw, we do the layout again
break :blk try self.doLayout(widget, &arena);
};
const win = vx.window();
win.clear();
win.hideCursor();
win.setCursorShape(.default);
const surface = try widget.draw(draw_context);
const root_win = win.child(.{
.width = surface.size.width,
.height = surface.size.height,
});
surface.render(root_win, focus_handler.focused_widget);
try vx.render(buffered.writer().any());
try buffered.flush();
// Store the last frame
mouse_handler.last_frame = surface;
// Update the focus handler list
try focus_handler.update(surface);
self.wants_focus = null;
try self.render(surface, focus_handler.focused_widget);
}
}
fn doLayout(
self: *App,
widget: vxfw.Widget,
arena: *std.heap.ArenaAllocator,
) !vxfw.Surface {
const vx = &self.vx;
const draw_context: vxfw.DrawContext = .{
.arena = arena.allocator(),
.min = .{ .width = 0, .height = 0 },
.max = .{
.width = @intCast(vx.screen.width),
.height = @intCast(vx.screen.height),
},
.cell_size = .{
.width = vx.screen.width_pix / vx.screen.width,
.height = vx.screen.height_pix / vx.screen.height,
},
};
return widget.draw(draw_context);
}
fn render(
self: *App,
surface: vxfw.Surface,
focused_widget: vxfw.Widget,
) !void {
const vx = &self.vx;
const tty = &self.tty;
const win = vx.window();
win.clear();
win.hideCursor();
win.setCursorShape(.default);
const root_win = win.child(.{
.width = surface.size.width,
.height = surface.size.height,
});
surface.render(root_win, focused_widget);
var buffered = tty.bufferedWriter();
try vx.render(buffered.writer().any());
try buffered.flush();
}
fn addTick(self: *App, tick: vxfw.Tick) Allocator.Error!void {
try self.timers.append(tick);
std.sort.insertion(vxfw.Tick, self.timers.items, {}, vxfw.Tick.lessThan);
@ -203,6 +260,27 @@ fn handleCommand(self: *App, cmds: *vxfw.CommandList) Allocator.Error!void {
}
};
},
.set_title => |title| {
self.vx.setTitle(self.tty.anyWriter(), title) catch |err| {
std.log.err("set_title error: {}", .{err});
};
},
.queue_refresh => self.vx.queueRefresh(),
.notify => |notification| {
self.vx.notify(self.tty.anyWriter(), notification.title, notification.body) catch |err| {
std.log.err("notify error: {}", .{err});
};
const alloc = cmds.allocator;
if (notification.title) |title| {
alloc.free(title);
}
alloc.free(notification.body);
},
.query_color => |kind| {
self.vx.queryColor(self.tty.anyWriter(), kind) catch |err| {
std.log.err("queryColor error: {}", .{err});
};
},
}
}
}
@ -211,7 +289,7 @@ fn checkTimers(self: *App, ctx: *vxfw.EventContext) anyerror!void {
const now_ms = std.time.milliTimestamp();
// timers are always sorted descending
while (self.timers.popOrNull()) |tick| {
while (self.timers.pop()) |tick| {
if (now_ms < tick.deadline_ms) {
// re-add the timer
try self.timers.append(tick);
@ -225,6 +303,7 @@ fn checkTimers(self: *App, ctx: *vxfw.EventContext) anyerror!void {
const MouseHandler = struct {
last_frame: vxfw.Surface,
last_hit_list: []vxfw.HitResult,
mouse: ?vaxis.Mouse,
fn init(root: Widget) MouseHandler {
return .{
@ -235,6 +314,7 @@ const MouseHandler = struct {
.children = &.{},
},
.last_hit_list = &.{},
.mouse = null,
};
}
@ -242,9 +322,74 @@ const MouseHandler = struct {
gpa.free(self.last_hit_list);
}
fn updateMouse(
self: *MouseHandler,
app: *App,
surface: vxfw.Surface,
ctx: *vxfw.EventContext,
) anyerror!void {
const mouse = self.mouse orelse return;
// For mouse events we store the last frame and use that for hit testing
const last_frame = surface;
var hits = std.ArrayList(vxfw.HitResult).init(app.allocator);
defer hits.deinit();
const sub: vxfw.SubSurface = .{
.origin = .{ .row = 0, .col = 0 },
.surface = last_frame,
.z_index = 0,
};
const mouse_point: vxfw.Point = .{
.row = @intCast(mouse.row),
.col = @intCast(mouse.col),
};
if (sub.containsPoint(mouse_point)) {
try last_frame.hitTest(&hits, mouse_point);
}
// We store the hit list from the last mouse event to determine mouse_enter and mouse_leave
// events. If list a is the previous hit list, and list b is the current hit list:
// - Widgets in a but not in b get a mouse_leave event
// - Widgets in b but not in a get a mouse_enter event
// - Widgets in both receive nothing
const a = self.last_hit_list;
const b = hits.items;
// Find widgets in a but not b
for (a) |a_item| {
const a_widget = a_item.widget;
for (b) |b_item| {
const b_widget = b_item.widget;
if (a_widget.eql(b_widget)) break;
} else {
// a_item is not in b
try a_widget.handleEvent(ctx, .mouse_leave);
try app.handleCommand(&ctx.cmds);
}
}
// Widgets in b but not in a
for (b) |b_item| {
const b_widget = b_item.widget;
for (a) |a_item| {
const a_widget = a_item.widget;
if (b_widget.eql(a_widget)) break;
} else {
// b_item is not in a.
try b_widget.handleEvent(ctx, .mouse_enter);
try app.handleCommand(&ctx.cmds);
}
}
// Store a copy of this hit list for next frame
app.allocator.free(self.last_hit_list);
self.last_hit_list = try app.allocator.dupe(vxfw.HitResult, hits.items);
}
fn handleMouse(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext, mouse: vaxis.Mouse) anyerror!void {
// For mouse events we store the last frame and use that for hit testing
const last_frame = self.last_frame;
self.mouse = mouse;
var hits = std.ArrayList(vxfw.HitResult).init(app.allocator);
defer hits.deinit();
@ -302,7 +447,7 @@ const MouseHandler = struct {
self.last_hit_list = try app.allocator.dupe(vxfw.HitResult, hits.items);
}
const target = hits.popOrNull() orelse return;
const target = hits.pop() orelse return;
// capturing phase
ctx.phase = .capturing;
@ -330,7 +475,7 @@ const MouseHandler = struct {
// Bubbling phase
ctx.phase = .bubbling;
while (hits.popOrNull()) |item| {
while (hits.pop()) |item| {
var m_local = mouse;
m_local.col = item.local.col;
m_local.row = item.local.row;
@ -353,159 +498,39 @@ const MouseHandler = struct {
/// Maintains a tree of focusable nodes. Delivers events to the currently focused node, walking up
/// the tree until the event is handled
const FocusHandler = struct {
arena: std.heap.ArenaAllocator,
root: Node,
focused: *Node,
root: Widget,
focused_widget: vxfw.Widget,
path_to_focused: std.ArrayList(Widget),
const Node = struct {
widget: Widget,
parent: ?*Node,
children: []*Node,
fn nextSibling(self: Node) ?*Node {
const parent = self.parent orelse return null;
const idx = for (0..parent.children.len) |i| {
const node = parent.children[i];
if (self.widget.eql(node.widget))
break i;
} else unreachable;
// Return null if last child
if (idx == parent.children.len - 1)
return null
else
return parent.children[idx + 1];
}
fn prevSibling(self: Node) ?*Node {
const parent = self.parent orelse return null;
const idx = for (0..parent.children.len) |i| {
const node = parent.children[i];
if (self.widget.eql(node.widget))
break i;
} else unreachable;
// Return null if first child
if (idx == 0)
return null
else
return parent.children[idx - 1];
}
fn lastChild(self: Node) ?*Node {
if (self.children.len > 0)
return self.children[self.children.len - 1]
else
return null;
}
fn firstChild(self: Node) ?*Node {
if (self.children.len > 0)
return self.children[0]
else
return null;
}
/// returns the next logical node in the tree
fn nextNode(self: *Node) *Node {
// If we have a sibling, we return it's first descendant line
if (self.nextSibling()) |sibling| {
var node = sibling;
while (node.firstChild()) |child| {
node = child;
}
return node;
}
// If we don't have a sibling, we return our parent
if (self.parent) |parent| return parent;
// If we don't have a parent, we are the root and we return or first descendant
var node = self;
while (node.firstChild()) |child| {
node = child;
}
return node;
}
fn prevNode(self: *Node) *Node {
// If we have children, we return the last child descendant
if (self.children.len > 0) {
var node = self;
while (node.lastChild()) |child| {
node = child;
}
return node;
}
// If we have siblings, we return the last descendant line of the sibling
if (self.prevSibling()) |sibling| {
var node = sibling;
while (node.lastChild()) |child| {
node = child;
}
return node;
}
// If we don't have a sibling, we return our parent
if (self.parent) |parent| return parent;
// If we don't have a parent, we are the root and we return our last descendant
var node = self;
while (node.lastChild()) |child| {
node = child;
}
return node;
}
};
fn init(allocator: Allocator, root: Widget) FocusHandler {
const node: Node = .{
.widget = root,
.parent = null,
.children = &.{},
};
return .{
.root = node,
.focused = undefined,
.root = root,
.focused_widget = root,
.arena = std.heap.ArenaAllocator.init(allocator),
.path_to_focused = std.ArrayList(Widget).init(allocator),
};
}
fn intrusiveInit(self: *FocusHandler) void {
self.focused = &self.root;
}
fn deinit(self: *FocusHandler) void {
self.path_to_focused.deinit();
self.arena.deinit();
}
/// Update the focus list
fn update(self: *FocusHandler, root: vxfw.Surface) Allocator.Error!void {
_ = self.arena.reset(.retain_capacity);
var list = std.ArrayList(*Node).init(self.arena.allocator());
for (root.children) |child| {
try self.findFocusableChildren(&self.root, &list, child.surface);
}
// Update children
self.root.children = list.items;
// Update path
fn update(self: *FocusHandler, surface: vxfw.Surface) Allocator.Error!void {
// clear path
self.path_to_focused.clearAndFree();
if (!self.root.widget.eql(root.widget)) {
// Always make sure the root widget (the one we started with) is the first item, even if
// it isn't focusable or in the path
try self.path_to_focused.append(self.root.widget);
// Find the path to the focused widget. This builds a list that has the first element as the
// focused widget, and walks backward to the root. It's possible our focused widget is *not*
// in this tree. If this is the case, we refocus to the root widget
const has_focus = try self.childHasFocus(surface);
// We assert that the focused widget *must* be in the widget tree. There is certianly a
// logic bug in the code somewhere if this is not the case
assert(has_focus); // Focused widget not found in Surface tree
if (!self.root.eql(surface.widget)) {
// If the root of surface is not the initial widget, we append the initial widget
try self.path_to_focused.append(self.root);
}
_ = try childHasFocus(root, &self.path_to_focused, self.focused.widget);
// reverse path_to_focused so that it is root first
std.mem.reverse(Widget, self.path_to_focused.items);
@ -513,62 +538,27 @@ const FocusHandler = struct {
/// Returns true if a child of surface is the focused widget
fn childHasFocus(
self: *FocusHandler,
surface: vxfw.Surface,
list: *std.ArrayList(Widget),
focused: Widget,
) Allocator.Error!bool {
// Check if we are the focused widget
if (focused.eql(surface.widget)) {
try list.append(surface.widget);
if (self.focused_widget.eql(surface.widget)) {
try self.path_to_focused.append(surface.widget);
return true;
}
for (surface.children) |child| {
// Add child to list if it is the focused widget or one of it's own children is
if (try childHasFocus(child.surface, list, focused)) {
try list.append(surface.widget);
if (try self.childHasFocus(child.surface)) {
try self.path_to_focused.append(surface.widget);
return true;
}
}
return false;
}
/// Walks the surface tree, adding all focusable nodes to list
fn findFocusableChildren(
self: *FocusHandler,
parent: *Node,
list: *std.ArrayList(*Node),
surface: vxfw.Surface,
) Allocator.Error!void {
if (self.root.widget.eql(surface.widget)) {
// Never add the root_widget. We will always have this as the root
for (surface.children) |child| {
try self.findFocusableChildren(parent, list, child.surface);
}
} else if (surface.focusable) {
// We are a focusable child of parent. Create a new node, and find our own focusable
// children
const node = try self.arena.allocator().create(Node);
var child_list = std.ArrayList(*Node).init(self.arena.allocator());
for (surface.children) |child| {
try self.findFocusableChildren(node, &child_list, child.surface);
}
node.* = .{
.widget = surface.widget,
.parent = parent,
.children = child_list.items,
};
if (self.focused_widget.eql(surface.widget)) {
self.focused = node;
}
try list.append(node);
} else {
for (surface.children) |child| {
try self.findFocusableChildren(parent, list, child.surface);
}
}
}
fn focusWidget(self: *FocusHandler, ctx: *vxfw.EventContext, widget: vxfw.Widget) anyerror!void {
// Focusing a widget requires it to have an event handler
assert(widget.eventHandler != null);
if (self.focused_widget.eql(widget)) return;
ctx.phase = .at_target;
@ -577,45 +567,26 @@ const FocusHandler = struct {
try self.focused_widget.handleEvent(ctx, .focus_in);
}
fn focusNode(self: *FocusHandler, ctx: *vxfw.EventContext, node: *Node) anyerror!void {
if (self.focused.widget.eql(node.widget)) return;
try self.focused.widget.handleEvent(ctx, .focus_out);
self.focused = node;
try self.focused.widget.handleEvent(ctx, .focus_in);
}
/// Focuses the next focusable widget
fn focusNext(self: *FocusHandler, ctx: *vxfw.EventContext) anyerror!void {
return self.focusNode(ctx, self.focused.nextNode());
}
/// Focuses the previous focusable widget
fn focusPrev(self: *FocusHandler, ctx: *vxfw.EventContext) anyerror!void {
return self.focusNode(ctx, self.focused.prevNode());
}
fn handleEvent(self: *FocusHandler, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
const path = self.path_to_focused.items;
if (path.len == 0) return;
assert(path.len > 0);
const target_idx = path.len - 1;
// Capturing phase
// Capturing phase. We send capture events from the root to the target (inclusive of target)
ctx.phase = .capturing;
for (path[0..target_idx]) |widget| {
for (path) |widget| {
try widget.captureEvent(ctx, event);
if (ctx.consume_event) return;
}
// Target phase
// Target phase. This is only sent to the target
ctx.phase = .at_target;
const target = path[target_idx];
const target = self.path_to_focused.getLast();
try target.handleEvent(ctx, event);
if (ctx.consume_event) return;
// Bubbling phase
// Bubbling phase. Bubbling phase moves from target (exclusive) to the root
ctx.phase = .bubbling;
const target_idx = path.len - 1;
var iter = std.mem.reverseIterator(path[0..target_idx]);
while (iter.next()) |widget| {
try widget.handleEvent(ctx, event);

View file

@ -85,6 +85,7 @@ test Border {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 10, .height = 10 },
.cell_size = .{ .width = 10, .height = 20 },
};
const surface = try border.draw(ctx);

View file

@ -44,7 +44,7 @@ fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.
pub fn handleEvent(self: *Button, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
switch (event) {
.key_press => |key| {
if (key.matches(vaxis.Key.enter, .{})) {
if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
return self.doClick(ctx);
}
},
@ -107,9 +107,8 @@ pub fn draw(self: *Button, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
const center: Center = .{ .child = text.widget() };
const surf = try center.draw(ctx);
var button_surf = try vxfw.Surface.initWithChildren(ctx.arena, self.widget(), surf.size, surf.children);
const button_surf = try vxfw.Surface.initWithChildren(ctx.arena, self.widget(), surf.size, surf.children);
@memset(button_surf.buffer, .{ .style = style });
button_surf.focusable = true;
return button_surf;
}
@ -194,6 +193,7 @@ test Button {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 13, .height = 3 },
.cell_size = .{ .width = 10, .height = 20 },
};
const surface = try b_widget.draw(draw_ctx);

View file

@ -64,6 +64,7 @@ test Center {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 10, .height = 10 },
.cell_size = .{ .width = 10, .height = 20 },
};
const surface = try center.draw(ctx);
@ -88,6 +89,7 @@ test Center {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 5, .height = 3 },
.cell_size = .{ .width = 10, .height = 20 },
};
const surface = try center.draw(ctx);

View file

@ -35,6 +35,7 @@ pub fn draw(self: *const FlexColumn, ctx: vxfw.DrawContext) Allocator.Error!vxfw
.min = .{ .width = 0, .height = 0 },
.max = .{ .width = ctx.max.width, .height = null },
.arena = layout_arena.allocator(),
.cell_size = ctx.cell_size,
};
// Store the inherent size of each widget
@ -122,6 +123,7 @@ test FlexColumn {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 16, .height = 16 },
.cell_size = .{ .width = 10, .height = 20 },
};
const surface = try flex_widget.draw(ctx);

View file

@ -35,6 +35,7 @@ pub fn draw(self: *const FlexRow, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Su
.min = .{ .width = 0, .height = 0 },
.max = .{ .width = null, .height = ctx.max.height },
.arena = layout_arena.allocator(),
.cell_size = ctx.cell_size,
};
var first_pass_width: u16 = 0;
@ -120,6 +121,7 @@ test FlexRow {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 16, .height = 16 },
.cell_size = .{ .width = 10, .height = 20 },
};
const surface = try flex_widget.draw(ctx);

View file

@ -289,10 +289,14 @@ fn drawBuilder(self: *ListView, ctx: vxfw.DrawContext, builder: Builder) Allocat
.buffer = &.{},
.children = &.{},
};
if (self.draw_cursor) {
// If we are drawing the cursor, we need to allocate a buffer so that we obscure anything
// underneath us
surface.buffer = try vxfw.Surface.createBuffer(ctx.arena, max_size);
}
// Set state
{
surface.focusable = true;
// Assume we have more. We only know we don't after drawing
self.scroll.has_more = true;
}
@ -350,14 +354,12 @@ fn drawBuilder(self: *ListView, ctx: vxfw.DrawContext, builder: Builder) Allocat
// Set up constraints. We let the child be the entire height if it wants
const child_ctx = ctx.withConstraints(
.{ .width = max_size.width - child_offset, .height = 0 },
.{ .width = max_size.width - child_offset, .height = null },
.{ .width = max_size.width -| child_offset, .height = 0 },
.{ .width = max_size.width -| child_offset, .height = null },
);
// Draw the child
var surf = try child.draw(child_ctx);
// We set the child to non-focusable so that we can manage where the keyevents go
surf.focusable = false;
const surf = try child.draw(child_ctx);
// Add the child surface to our list. It's offset from parent is the accumulated height
try child_list.append(.{
@ -401,10 +403,11 @@ fn drawBuilder(self: *ListView, ctx: vxfw.DrawContext, builder: Builder) Allocat
.surface = child.surface,
.z_index = 0,
};
const size = child.surface.size;
const cursor_surf = try vxfw.Surface.initWithChildren(
ctx.arena,
self.widget(),
.{ .width = child_offset, .height = child.surface.size.height },
.{ .width = child_offset + size.width, .height = size.height },
sub,
);
for (0..cursor_surf.size.height) |row| {
@ -541,6 +544,7 @@ test ListView {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 16, .height = 4 },
.cell_size = .{ .width = 10, .height = 20 },
};
var surface = try list_widget.draw(draw_ctx);
@ -712,6 +716,7 @@ test "ListView: uneven scroll" {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 16, .height = 4 },
.cell_size = .{ .width = 10, .height = 20 },
};
var surface = try list_widget.draw(draw_ctx);

View file

@ -121,6 +121,7 @@ test Padding {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 10, .height = 10 },
.cell_size = .{ .width = 10, .height = 20 },
};
const pad_widget = padding.widget();

View file

@ -7,10 +7,7 @@ const Allocator = std.mem.Allocator;
const RichText = @This();
pub const TextSpan = struct {
text: []const u8,
style: vaxis.Style = .{},
};
pub const TextSpan = vaxis.Segment;
text: []const TextSpan,
text_align: enum { left, center, right } = .left,
@ -32,6 +29,14 @@ fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw
}
pub fn draw(self: *const RichText, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
if (ctx.max.width != null and ctx.max.width.? == 0) {
return .{
.size = ctx.min,
.widget = self.widget(),
.buffer = &.{},
.children = &.{},
};
}
var iter = try SoftwrapIterator.init(self.text, ctx);
const container_size = self.findContainerSize(&iter);
@ -166,6 +171,7 @@ pub const SoftwrapIterator = struct {
const cell: vaxis.Cell = .{
.char = .{ .grapheme = " ", .width = 1 },
.style = span.style,
.link = span.link,
};
for (0..8) |_| {
try list.append(cell);
@ -176,6 +182,7 @@ pub const SoftwrapIterator = struct {
const cell: vaxis.Cell = .{
.char = .{ .grapheme = char, .width = @intCast(width) },
.style = span.style,
.link = span.link,
};
try list.append(cell);
}
@ -364,6 +371,7 @@ test RichText {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 7, .height = 2 },
.cell_size = .{ .width = 10, .height = 20 },
};
{
@ -403,6 +411,7 @@ test "long word wrapping" {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = width, .height = null },
.cell_size = .{ .width = 10, .height = 20 },
};
const surface = try rich_widget.draw(ctx);

632
src/vxfw/ScrollBars.zig Normal file
View file

@ -0,0 +1,632 @@
const std = @import("std");
const vaxis = @import("../main.zig");
const vxfw = @import("vxfw.zig");
const Allocator = std.mem.Allocator;
const ScrollBars = @This();
/// The ScrollBars widget must contain a ScrollView widget. The scroll bars drawn will be for the
/// scroll view contained in the ScrollBars widget.
scroll_view: vxfw.ScrollView,
/// If `true` a horizontal scroll bar will be drawn. Set to `false` to hide the horizontal scroll
/// bar. Defaults to `true`.
draw_horizontal_scrollbar: bool = true,
/// If `true` a vertical scroll bar will be drawn. Set to `false` to hide the vertical scroll bar.
/// Defaults to `true`.
draw_vertical_scrollbar: bool = true,
/// The estimated height of all the content in the ScrollView. When provided this height will be
/// used to calculate the size of the scrollbar's thumb. If this is not provided the widget will
/// make a best effort estimate of the size of the thumb using the number of elements rendered at
/// any given time. This will cause inconsistent thumb sizes - and possibly inconsistent
/// positioning - if different elements in the ScrollView have different heights. For the best user
/// experience, providing this estimate is strongly recommended.
///
/// Note that this doesn't necessarily have to be an accurate estimate and the tolerance for larger
/// views is quite forgiving, especially if you overshoot the estimate.
estimated_content_height: ?u32 = null,
/// The estimated width of all the content in the ScrollView. When provided this width will be used
/// to calculate the size of the scrollbar's thumb. If this is not provided the widget will make a
/// best effort estimate of the size of the thumb using the width of the elements rendered at any
/// given time. This will cause inconsistent thumb sizes - and possibly inconsistent positioning -
/// if different elements in the ScrollView have different widths. For the best user experience,
/// providing this estimate is strongly recommended.
///
/// Note that this doesn't necessarily have to be
/// an accurate estimate and the tolerance for larger views is quite forgiving, especially if you
/// overshoot the estimate.
estimated_content_width: ?u32 = null,
/// The cell drawn for the vertical scroll thumb. Replace this to customize the scroll thumb. Must
/// have a 1 column width.
vertical_scrollbar_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "", .width = 1 } },
/// The cell drawn for the vertical scroll thumb while it's being hovered. Replace this to customize
/// the scroll thumb. Must have a 1 column width.
vertical_scrollbar_hover_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "", .width = 1 } },
/// The cell drawn for the vertical scroll thumb while it's being dragged by the mouse. Replace this
/// to customize the scroll thumb. Must have a 1 column width.
vertical_scrollbar_drag_thumb: vaxis.Cell = .{
.char = .{ .grapheme = "", .width = 1 },
.style = .{ .fg = .{ .index = 4 } },
},
/// The cell drawn for the vertical scroll thumb. Replace this to customize the scroll thumb. Must
/// have a 1 column width.
horizontal_scrollbar_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "", .width = 1 } },
/// The cell drawn for the horizontal scroll thumb while it's being hovered. Replace this to
/// customize the scroll thumb. Must have a 1 column width.
horizontal_scrollbar_hover_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "", .width = 1 } },
/// The cell drawn for the horizontal scroll thumb while it's being dragged by the mouse. Replace
/// this to customize the scroll thumb. Must have a 1 column width.
horizontal_scrollbar_drag_thumb: vaxis.Cell = .{
.char = .{ .grapheme = "", .width = 1 },
.style = .{ .fg = .{ .index = 4 } },
},
/// You should not change this variable, treat it as private to the implementation. Used to track
/// the size of the widget so we can locate scroll bars for mouse interaction.
last_frame_size: vxfw.Size = .{ .width = 0, .height = 0 },
/// You should not change this variable, treat it as private to the implementation. Used to track
/// the width of the content so we map horizontal scroll thumb position to view position.
last_frame_max_content_width: u32 = 0,
/// You should not change this variable, treat it as private to the implementation. Used to track
/// the position of the mouse relative to the scroll thumb for mouse interaction.
mouse_offset_into_thumb: u8 = 0,
/// You should not change this variable, treat it as private to the implementation. Used to track
/// the position of the scroll thumb for mouse interaction.
vertical_thumb_top_row: u32 = 0,
/// You should not change this variable, treat it as private to the implementation. Used to track
/// the position of the scroll thumb for mouse interaction.
vertical_thumb_bottom_row: u32 = 0,
/// You should not change this variable, treat it as private to the implementation. Used to track
/// whether the scroll thumb is hovered or not so we can set the right hover style for the thumb.
is_hovering_vertical_thumb: bool = false,
/// You should not change this variable, treat it as private to the implementation. Used to track
/// whether the thumb is currently being dragged, which is important to allowing the mouse to leave
/// the scroll thumb while it's being dragged.
is_dragging_vertical_thumb: bool = false,
/// You should not change this variable, treat it as private to the implementation. Used to track
/// the position of the scroll thumb for mouse interaction.
horizontal_thumb_start_col: u32 = 0,
/// You should not change this variable, treat it as private to the implementation. Used to track
/// the position of the scroll thumb for mouse interaction.
horizontal_thumb_end_col: u32 = 0,
/// You should not change this variable, treat it as private to the implementation. Used to track
/// whether the scroll thumb is hovered or not so we can set the right hover style for the thumb.
is_hovering_horizontal_thumb: bool = false,
/// You should not change this variable, treat it as private to the implementation. Used to track
/// whether the thumb is currently being dragged, which is important to allowing the mouse to leave
/// the scroll thumb while it's being dragged.
is_dragging_horizontal_thumb: bool = false,
pub fn widget(self: *const ScrollBars) vxfw.Widget {
return .{
.userdata = @constCast(self),
.eventHandler = typeErasedEventHandler,
.captureHandler = typeErasedCaptureHandler,
.drawFn = typeErasedDrawFn,
};
}
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
const self: *ScrollBars = @ptrCast(@alignCast(ptr));
return self.handleEvent(ctx, event);
}
fn typeErasedCaptureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
const self: *ScrollBars = @ptrCast(@alignCast(ptr));
return self.handleCapture(ctx, event);
}
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
const self: *ScrollBars = @ptrCast(@alignCast(ptr));
return self.draw(ctx);
}
pub fn handleCapture(self: *ScrollBars, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
switch (event) {
.mouse => |mouse| {
if (self.is_dragging_vertical_thumb) {
// Stop dragging the thumb when the mouse is released.
if (mouse.type == .release and
mouse.button == .left and
self.is_dragging_vertical_thumb)
{
// If we just let the scroll thumb go after dragging we need to make sure we
// redraw so the right style is immediately applied to the thumb.
if (self.is_dragging_vertical_thumb) {
self.is_dragging_vertical_thumb = false;
ctx.redraw = true;
}
const is_mouse_over_vertical_thumb =
mouse.col == self.last_frame_size.width -| 1 and
mouse.row >= self.vertical_thumb_top_row and
mouse.row < self.vertical_thumb_bottom_row;
// If we're not hovering the scroll bar after letting it go, we should trigger a
// redraw so it goes back to its narrow, non-active, state immediately.
if (!is_mouse_over_vertical_thumb) {
self.is_hovering_vertical_thumb = false;
ctx.redraw = true;
}
// No need to redraw yet, but we must consume the event so ending the drag
// action doesn't trigger some other event handler.
return ctx.consumeEvent();
}
// Process dragging the vertical thumb.
if (mouse.type == .drag) {
// Make sure we consume the event if we're currently dragging the mouse so other
// events aren't sent in the mean time.
ctx.consumeEvent();
// New scroll thumb position.
const new_thumb_top = mouse.row -| self.mouse_offset_into_thumb;
// If the new thumb position is at the top we know we've scrolled to the top of
// the scroll view.
if (new_thumb_top == 0) {
self.scroll_view.scroll.top = 0;
return ctx.consumeAndRedraw();
}
const new_thumb_top_f: f32 = @floatFromInt(new_thumb_top);
const widget_height_f: f32 = @floatFromInt(self.last_frame_size.height);
const total_num_children_f: f32 = count: {
if (self.scroll_view.item_count) |c| break :count @floatFromInt(c);
switch (self.scroll_view.children) {
.slice => |slice| break :count @floatFromInt(slice.len),
.builder => |builder| {
var counter: usize = 0;
while (builder.itemAtIdx(counter, self.scroll_view.cursor)) |_|
counter += 1;
break :count @floatFromInt(counter);
},
}
};
const new_top_child_idx_f =
new_thumb_top_f *
total_num_children_f / widget_height_f;
self.scroll_view.scroll.top = @intFromFloat(new_top_child_idx_f);
return ctx.consumeAndRedraw();
}
}
if (self.is_dragging_horizontal_thumb) {
// Stop dragging the thumb when the mouse is released.
if (mouse.type == .release and
mouse.button == .left and
self.is_dragging_horizontal_thumb)
{
// If we just let the scroll thumb go after dragging we need to make sure we
// redraw so the right style is immediately applied to the thumb.
if (self.is_dragging_horizontal_thumb) {
self.is_dragging_horizontal_thumb = false;
ctx.redraw = true;
}
const is_mouse_over_horizontal_thumb =
mouse.row == self.last_frame_size.height -| 1 and
mouse.col >= self.horizontal_thumb_start_col and
mouse.col < self.horizontal_thumb_end_col;
// If we're not hovering the scroll bar after letting it go, we should trigger a
// redraw so it goes back to its narrow, non-active, state immediately.
if (!is_mouse_over_horizontal_thumb) {
self.is_hovering_horizontal_thumb = false;
ctx.redraw = true;
}
// No need to redraw yet, but we must consume the event so ending the drag
// action doesn't trigger some other event handler.
return ctx.consumeEvent();
}
// Process dragging the horizontal thumb.
if (mouse.type == .drag) {
// Make sure we consume the event if we're currently dragging the mouse so other
// events aren't sent in the mean time.
ctx.consumeEvent();
// New scroll thumb position.
const new_thumb_col_start = mouse.col -| self.mouse_offset_into_thumb;
// If the new thumb position is at the horizontal beginning of the current view
// we know we've scrolled to the beginning of the scroll view.
if (new_thumb_col_start == 0) {
self.scroll_view.scroll.left = 0;
return ctx.consumeAndRedraw();
}
const new_thumb_col_start_f: f32 = @floatFromInt(new_thumb_col_start);
const widget_width_f: f32 = @floatFromInt(self.last_frame_size.width);
const max_content_width_f: f32 =
@floatFromInt(self.last_frame_max_content_width);
const new_view_col_start_f =
new_thumb_col_start_f * max_content_width_f / widget_width_f;
const new_view_col_start: u32 = @intFromFloat(@ceil(new_view_col_start_f));
self.scroll_view.scroll.left =
@min(new_view_col_start, self.last_frame_max_content_width);
return ctx.consumeAndRedraw();
}
}
},
else => {},
}
}
pub fn handleEvent(self: *ScrollBars, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
switch (event) {
.mouse => |mouse| {
// 1. Process vertical scroll thumb hover.
const is_mouse_over_vertical_thumb =
mouse.col == self.last_frame_size.width -| 1 and
mouse.row >= self.vertical_thumb_top_row and
mouse.row < self.vertical_thumb_bottom_row;
// Make sure we only update the state and redraw when it's necessary.
if (!self.is_hovering_vertical_thumb and is_mouse_over_vertical_thumb) {
self.is_hovering_vertical_thumb = true;
ctx.redraw = true;
} else if (self.is_hovering_vertical_thumb and !is_mouse_over_vertical_thumb) {
self.is_hovering_vertical_thumb = false;
ctx.redraw = true;
}
const did_start_dragging_vertical_thumb = is_mouse_over_vertical_thumb and
mouse.type == .press and mouse.button == .left;
if (did_start_dragging_vertical_thumb) {
self.is_dragging_vertical_thumb = true;
self.mouse_offset_into_thumb = @intCast(mouse.row -| self.vertical_thumb_top_row);
// No need to redraw yet, but we must consume the event.
return ctx.consumeEvent();
}
// 2. Process horizontal scroll thumb hover.
const is_mouse_over_horizontal_thumb =
mouse.row == self.last_frame_size.height -| 1 and
mouse.col >= self.horizontal_thumb_start_col and
mouse.col < self.horizontal_thumb_end_col;
// Make sure we only update the state and redraw when it's necessary.
if (!self.is_hovering_horizontal_thumb and is_mouse_over_horizontal_thumb) {
self.is_hovering_horizontal_thumb = true;
ctx.redraw = true;
} else if (self.is_hovering_horizontal_thumb and !is_mouse_over_horizontal_thumb) {
self.is_hovering_horizontal_thumb = false;
ctx.redraw = true;
}
const did_start_dragging_horizontal_thumb = is_mouse_over_horizontal_thumb and
mouse.type == .press and mouse.button == .left;
if (did_start_dragging_horizontal_thumb) {
self.is_dragging_horizontal_thumb = true;
self.mouse_offset_into_thumb = @intCast(
mouse.col -| self.horizontal_thumb_start_col,
);
// No need to redraw yet, but we must consume the event.
return ctx.consumeEvent();
}
},
.mouse_leave => self.is_dragging_vertical_thumb = false,
else => {},
}
}
pub fn draw(self: *ScrollBars, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena);
// 1. If we're not drawing the scrollbars we can just draw the ScrollView directly.
if (!self.draw_vertical_scrollbar and !self.draw_horizontal_scrollbar) {
try children.append(.{
.origin = .{ .row = 0, .col = 0 },
.surface = try self.scroll_view.draw(ctx),
});
return .{
.size = ctx.max.size(),
.widget = self.widget(),
.buffer = &.{},
.children = children.items,
};
}
// 2. Otherwise we can draw the scrollbars.
const max = ctx.max.size();
self.last_frame_size = max;
// 3. Draw the scroll view itself.
const scroll_view_surface = try self.scroll_view.draw(ctx.withConstraints(
ctx.min,
.{
// We make sure to make room for the scrollbars if required.
.width = max.width -| @intFromBool(self.draw_vertical_scrollbar),
.height = max.height -| @intFromBool(self.draw_horizontal_scrollbar),
},
));
try children.append(.{
.origin = .{ .row = 0, .col = 0 },
.surface = scroll_view_surface,
});
// 4. Draw the vertical scroll bar.
if (self.draw_vertical_scrollbar) vertical: {
// If we can't scroll, then there's no need to draw the scroll bar.
if (self.scroll_view.scroll.top == 0 and !self.scroll_view.scroll.has_more_vertical)
break :vertical;
// To draw the vertical scrollbar we need to know how big the scroll bar thumb should be.
// If we've been provided with an estimated height we use that to figure out how big the
// thumb should be, otherwise we estimate the size based on how many of the children were
// actually drawn in the ScrollView.
const widget_height_f: f32 = @floatFromInt(scroll_view_surface.size.height);
const total_num_children_f: f32 = count: {
if (self.scroll_view.item_count) |c| break :count @floatFromInt(c);
switch (self.scroll_view.children) {
.slice => |slice| break :count @floatFromInt(slice.len),
.builder => |builder| {
var counter: usize = 0;
while (builder.itemAtIdx(counter, self.scroll_view.cursor)) |_|
counter += 1;
break :count @floatFromInt(counter);
},
}
};
const thumb_height: u16 = height: {
// If we know the height, we can use the height of the current view to determine the
// size of the thumb.
if (self.estimated_content_height) |h| {
const content_height_f: f32 = @floatFromInt(h);
const thumb_height_f = widget_height_f * widget_height_f / content_height_f;
break :height @intFromFloat(@max(thumb_height_f, 1));
}
// Otherwise we estimate the size of the thumb based on the number of child elements
// drawn in the scroll view, and the number of total child elements.
const num_children_rendered_f: f32 = @floatFromInt(scroll_view_surface.children.len);
const thumb_height_f = widget_height_f * num_children_rendered_f / total_num_children_f;
break :height @intFromFloat(@max(thumb_height_f, 1));
};
// We also need to know the position of the thumb in the scroll bar. To find that we use the
// index of the top-most child widget rendered in the ScrollView.
const thumb_top: u32 = if (self.scroll_view.scroll.top == 0)
0
else if (self.scroll_view.scroll.has_more_vertical) pos: {
const top_child_idx_f: f32 = @floatFromInt(self.scroll_view.scroll.top);
const thumb_top_f = widget_height_f * top_child_idx_f / total_num_children_f;
break :pos @intFromFloat(thumb_top_f);
} else max.height -| thumb_height;
// Once we know the thumb height and its position we can draw the scroll bar.
const scroll_bar = try vxfw.Surface.init(
ctx.arena,
self.widget(),
.{
.width = 1,
// We make sure to make room for the horizontal scroll bar if it's being drawn.
.height = max.height -| @intFromBool(self.draw_horizontal_scrollbar),
},
);
const thumb_end_row = thumb_top + thumb_height;
for (thumb_top..thumb_end_row) |row| {
scroll_bar.writeCell(
0,
@intCast(row),
if (self.is_dragging_vertical_thumb)
self.vertical_scrollbar_drag_thumb
else if (self.is_hovering_vertical_thumb)
self.vertical_scrollbar_hover_thumb
else
self.vertical_scrollbar_thumb,
);
}
self.vertical_thumb_top_row = thumb_top;
self.vertical_thumb_bottom_row = thumb_end_row;
try children.append(.{
.origin = .{ .row = 0, .col = max.width -| 1 },
.surface = scroll_bar,
});
}
// 5. Draw the horizontal scroll bar.
const is_horizontally_scrolled = self.scroll_view.scroll.left > 0;
const has_more_horizontal_content = self.scroll_view.scroll.has_more_horizontal;
const should_draw_scrollbar = is_horizontally_scrolled or has_more_horizontal_content;
if (self.draw_horizontal_scrollbar and should_draw_scrollbar) {
const scroll_bar = try vxfw.Surface.init(
ctx.arena,
self.widget(),
.{ .width = max.width, .height = 1 },
);
const widget_width_f: f32 = @floatFromInt(max.width);
const max_content_width: u32 = width: {
if (self.estimated_content_width) |w| break :width w;
var max_content_width: u32 = 0;
for (scroll_view_surface.children) |child| {
max_content_width = @max(max_content_width, child.surface.size.width);
}
break :width max_content_width;
};
const max_content_width_f: f32 =
if (self.scroll_view.scroll.left + max.width > max_content_width)
// If we've managed to overscroll horizontally for whatever reason - for example if the
// content changes - we make sure the scroll thumb doesn't disappear by increasing the
// max content width to match the current overscrolled position.
@floatFromInt(self.scroll_view.scroll.left + max.width)
else
@floatFromInt(max_content_width);
self.last_frame_max_content_width = max_content_width;
const thumb_width_f: f32 = widget_width_f * widget_width_f / max_content_width_f;
const thumb_width: u32 = @intFromFloat(@max(thumb_width_f, 1));
const view_start_col_f: f32 = @floatFromInt(self.scroll_view.scroll.left);
const thumb_start_f = view_start_col_f * widget_width_f / max_content_width_f;
const thumb_start: u32 = @intFromFloat(thumb_start_f);
const thumb_end = thumb_start + thumb_width;
for (thumb_start..thumb_end) |col| {
scroll_bar.writeCell(
@intCast(col),
0,
if (self.is_dragging_horizontal_thumb)
self.horizontal_scrollbar_drag_thumb
else if (self.is_hovering_horizontal_thumb)
self.horizontal_scrollbar_hover_thumb
else
self.horizontal_scrollbar_thumb,
);
}
self.horizontal_thumb_start_col = thumb_start;
self.horizontal_thumb_end_col = thumb_end;
try children.append(.{
.origin = .{ .row = max.height -| 1, .col = 0 },
.surface = scroll_bar,
});
}
return .{
.size = ctx.max.size(),
.widget = self.widget(),
.buffer = &.{},
.children = children.items,
};
}
test ScrollBars {
// Create child widgets
const Text = @import("Text.zig");
const abc: Text = .{ .text = "abc\n def\n ghi" };
const def: Text = .{ .text = "def" };
const ghi: Text = .{ .text = "ghi" };
const jklmno: Text = .{ .text = "jkl\n mno" };
//
// 0 |abc|
// 1 | d|ef
// 2 | g|hi
// 3 |def|
// 4 ghi
// 5 jkl
// 6 mno
// Create the scroll view
const ScrollView = @import("ScrollView.zig");
const scroll_view: ScrollView = .{
.wheel_scroll = 1, // Set wheel scroll to one
.children = .{ .slice = &.{
abc.widget(),
def.widget(),
ghi.widget(),
jklmno.widget(),
} },
};
// Create the scroll bars.
var scroll_bars: ScrollBars = .{
.scroll_view = scroll_view,
.estimated_content_height = 7,
.estimated_content_width = 5,
};
// Boiler plate draw context
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const ucd = try vaxis.Unicode.init(arena.allocator());
vxfw.DrawContext.init(&ucd, .unicode);
const scroll_widget = scroll_bars.widget();
const draw_ctx: vxfw.DrawContext = .{
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 3, .height = 4 },
.cell_size = .{ .width = 10, .height = 20 },
};
var surface = try scroll_widget.draw(draw_ctx);
// Scroll bars should have 3 children: both scrollbars and the scroll view.
try std.testing.expectEqual(3, surface.children.len);
// Hide only the horizontal scroll bar.
scroll_bars.draw_horizontal_scrollbar = false;
surface = try scroll_widget.draw(draw_ctx);
// Scroll bars should have 2 children: vertical scroll bar and the scroll view.
try std.testing.expectEqual(2, surface.children.len);
// Hide only the vertical scroll bar.
scroll_bars.draw_horizontal_scrollbar = true;
scroll_bars.draw_vertical_scrollbar = false;
surface = try scroll_widget.draw(draw_ctx);
// Scroll bars should have 2 children: vertical scroll bar and the scroll view.
try std.testing.expectEqual(2, surface.children.len);
// Hide both scroll bars.
scroll_bars.draw_horizontal_scrollbar = false;
surface = try scroll_widget.draw(draw_ctx);
// Scroll bars should have 1 child: the scroll view.
try std.testing.expectEqual(1, surface.children.len);
// Re-enable scroll bars.
scroll_bars.draw_horizontal_scrollbar = true;
scroll_bars.draw_vertical_scrollbar = true;
// Even though the estimated size is smaller than the draw area, we still render the scroll
// bars if the scroll view knows we haven't rendered everything.
scroll_bars.estimated_content_height = 2;
scroll_bars.estimated_content_width = 1;
surface = try scroll_widget.draw(draw_ctx);
// Scroll bars should have 3 children: both scrollbars and the scroll view.
try std.testing.expectEqual(3, surface.children.len);
// The scroll view should be able to tell whether the scroll bars need to be rendered or not
// even if estimated content sizes aren't provided.
scroll_bars.estimated_content_height = null;
scroll_bars.estimated_content_width = null;
surface = try scroll_widget.draw(draw_ctx);
// Scroll bars should have 3 children: both scrollbars and the scroll view.
try std.testing.expectEqual(3, surface.children.len);
}
test "refAllDecls" {
std.testing.refAllDecls(@This());
}

1087
src/vxfw/ScrollView.zig Normal file

File diff suppressed because it is too large Load diff

View file

@ -66,6 +66,7 @@ test SizedBox {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 16, .height = 16 },
.cell_size = .{ .width = 10, .height = 20 },
};
var test_widget: TestWidget = .{ .min = .{}, .max = .{} };

View file

@ -133,7 +133,12 @@ test Spinner {
try std.testing.expectEqual(1, spinner.frame);
// Simulate a draw
const surface = try spinner_widget.draw(.{ .arena = arena.allocator(), .min = .{}, .max = .{} });
const surface = try spinner_widget.draw(.{
.arena = arena.allocator(),
.min = .{},
.max = .{},
.cell_size = .{ .width = 10, .height = 20 },
});
// Spinner will try to be 1x1
try std.testing.expectEqual(1, surface.size.width);

View file

@ -18,8 +18,8 @@ max_width: ?u16 = null,
/// Target width to draw at
width: u16,
/// Statically allocated children
children: [2]vxfw.SubSurface = undefined,
/// Used to calculate mouse events when our constraint is rhs
last_max_width: ?u16 = null,
// State
pressed: bool = false,
@ -46,8 +46,13 @@ fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.
const mouse = event.mouse;
const separator_col: u16 = switch (self.constrain) {
.lhs => self.width + 1,
.rhs => self.width -| 1,
.lhs => self.width,
.rhs => if (self.last_max_width) |max|
max -| self.width -| 1
else {
ctx.redraw = true;
return;
},
};
// If we are on the separator, we always set the mouse shape
@ -74,9 +79,20 @@ fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.
// If pressed, we always keep the mouse shape and we update the width
if (self.pressed) {
try ctx.setMouseShape(.@"ew-resize");
self.width = @max(self.min_width, mouse.col -| 1);
if (self.max_width) |max| {
self.width = @min(self.width, max);
switch (self.constrain) {
.lhs => {
self.width = @max(self.min_width, mouse.col);
if (self.max_width) |max| {
self.width = @min(self.width, max);
}
},
.rhs => {
const last_max = self.last_max_width orelse return;
self.width = @min(last_max -| self.min_width, last_max -| mouse.col -| 1);
if (self.max_width) |max| {
self.width = @max(self.width, max);
}
},
}
ctx.consume_event = true;
}
@ -88,54 +104,81 @@ fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw
const max = ctx.max.size();
// Constrain width to the max
self.width = @min(self.width, max.width);
self.last_max_width = max.width;
// The constrained side is equal to the width
const constrained_min: vxfw.Size = .{ .width = self.width, .height = max.height };
const constrained_max: vxfw.MaxSize = .{ .width = self.width, .height = max.height };
const constrained_max = vxfw.MaxSize.fromSize(constrained_min);
const unconstrained_min: vxfw.Size = .{ .width = max.width - self.width - 2, .height = max.height };
const unconstrained_max: vxfw.MaxSize = .{ .width = max.width - self.width - 2, .height = max.height };
const unconstrained_min: vxfw.Size = .{ .width = max.width -| self.width -| 1, .height = max.height };
const unconstrained_max = vxfw.MaxSize.fromSize(unconstrained_min);
var children = try std.ArrayList(vxfw.SubSurface).initCapacity(ctx.arena, 2);
switch (self.constrain) {
.lhs => {
const lhs_ctx = ctx.withConstraints(constrained_min, constrained_max);
const lhs_surface = try self.lhs.draw(lhs_ctx);
self.children[0] = .{
.surface = lhs_surface,
.origin = .{ .row = 0, .col = 0 },
};
const rhs_ctx = ctx.withConstraints(unconstrained_min, unconstrained_max);
const rhs_surface = try self.rhs.draw(rhs_ctx);
self.children[1] = .{
.surface = rhs_surface,
.origin = .{ .row = 0, .col = self.width + 2 },
};
if (constrained_max.width.? > 0 and constrained_max.height.? > 0) {
const lhs_ctx = ctx.withConstraints(constrained_min, constrained_max);
const lhs_surface = try self.lhs.draw(lhs_ctx);
children.appendAssumeCapacity(.{
.surface = lhs_surface,
.origin = .{ .row = 0, .col = 0 },
});
}
if (unconstrained_max.width.? > 0 and unconstrained_max.height.? > 0) {
const rhs_ctx = ctx.withConstraints(unconstrained_min, unconstrained_max);
const rhs_surface = try self.rhs.draw(rhs_ctx);
children.appendAssumeCapacity(.{
.surface = rhs_surface,
.origin = .{ .row = 0, .col = self.width + 1 },
});
}
var surface = try vxfw.Surface.initWithChildren(
ctx.arena,
self.widget(),
max,
children.items,
);
for (0..max.height) |row| {
surface.writeCell(self.width, @intCast(row), .{
.char = .{ .grapheme = "", .width = 1 },
.style = self.style,
});
}
return surface;
},
.rhs => {
const lhs_ctx = ctx.withConstraints(unconstrained_min, unconstrained_max);
const lhs_surface = try self.lhs.draw(lhs_ctx);
self.children[0] = .{
.surface = lhs_surface,
.origin = .{ .row = 0, .col = 0 },
};
const rhs_ctx = ctx.withConstraints(constrained_min, constrained_max);
const rhs_surface = try self.rhs.draw(rhs_ctx);
self.children[1] = .{
.surface = rhs_surface,
.origin = .{ .row = 0, .col = self.width + 2 },
};
if (unconstrained_max.width.? > 0 and unconstrained_max.height.? > 0) {
const lhs_ctx = ctx.withConstraints(unconstrained_min, unconstrained_max);
const lhs_surface = try self.lhs.draw(lhs_ctx);
children.appendAssumeCapacity(.{
.surface = lhs_surface,
.origin = .{ .row = 0, .col = 0 },
});
}
if (constrained_max.width.? > 0 and constrained_max.height.? > 0) {
const rhs_ctx = ctx.withConstraints(constrained_min, constrained_max);
const rhs_surface = try self.rhs.draw(rhs_ctx);
children.appendAssumeCapacity(.{
.surface = rhs_surface,
.origin = .{ .row = 0, .col = unconstrained_max.width.? + 1 },
});
}
var surface = try vxfw.Surface.initWithChildren(
ctx.arena,
self.widget(),
max,
children.items,
);
for (0..max.height) |row| {
surface.writeCell(max.width -| self.width -| 1, @intCast(row), .{
.char = .{ .grapheme = "", .width = 1 },
.style = self.style,
});
}
return surface;
},
}
var surface = try vxfw.Surface.initWithChildren(ctx.arena, self.widget(), max, &self.children);
for (0..max.height) |row| {
surface.writeCell(self.width + 1, @intCast(row), .{
.char = .{ .grapheme = "", .width = 1 },
.style = self.style,
});
}
return surface;
}
test SplitView {
@ -149,6 +192,7 @@ test SplitView {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 16, .height = 16 },
.cell_size = .{ .width = 10, .height = 20 },
};
// Create LHS and RHS widgets
@ -174,8 +218,8 @@ test SplitView {
// Send the widget a mouse press on the separator
var mouse: vaxis.Mouse = .{
// The separator is width + 1
.col = split_view.width + 1,
// The separator is at width
.col = split_view.width,
.row = 0,
.type = .press,
.button = .left,
@ -197,7 +241,7 @@ test SplitView {
try split_widget.handleEvent(&ctx, .{ .mouse = mouse });
try std.testing.expect(ctx.redraw);
try std.testing.expect(split_view.pressed);
try std.testing.expectEqual(mouse.col - 1, split_view.width);
try std.testing.expectEqual(mouse.col, split_view.width);
}
test "refAllDecls" {

View file

@ -27,6 +27,14 @@ fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw
}
pub fn draw(self: *const Text, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
if (ctx.max.width != null and ctx.max.width.? == 0) {
return .{
.size = ctx.min,
.widget = self.widget(),
.buffer = &.{},
.children = &.{},
};
}
const container_size = self.findContainerSize(ctx);
// Create a surface of target width and max height. We'll trim the result after drawing
@ -295,6 +303,7 @@ test "SoftwrapIterator: LF breaks" {
.min = .{ .width = 0, .height = 0 },
.max = .{ .width = 20, .height = 10 },
.arena = arena.allocator(),
.cell_size = .{ .width = 10, .height = 20 },
};
var iter = SoftwrapIterator.init("Hello, \n world", ctx);
const first = iter.next();
@ -322,6 +331,7 @@ test "SoftwrapIterator: soft breaks that fit" {
.min = .{ .width = 0, .height = 0 },
.max = .{ .width = 6, .height = 10 },
.arena = arena.allocator(),
.cell_size = .{ .width = 10, .height = 20 },
};
var iter = SoftwrapIterator.init("Hello, \nworld", ctx);
const first = iter.next();
@ -349,6 +359,7 @@ test "SoftwrapIterator: soft breaks that are longer than width" {
.min = .{ .width = 0, .height = 0 },
.max = .{ .width = 6, .height = 10 },
.arena = arena.allocator(),
.cell_size = .{ .width = 10, .height = 20 },
};
var iter = SoftwrapIterator.init("very-long-word \nworld", ctx);
const first = iter.next();
@ -386,6 +397,7 @@ test "SoftwrapIterator: soft breaks with leading spaces" {
.min = .{ .width = 0, .height = 0 },
.max = .{ .width = 6, .height = 10 },
.arena = arena.allocator(),
.cell_size = .{ .width = 10, .height = 20 },
};
var iter = SoftwrapIterator.init("Hello, \n world", ctx);
const first = iter.next();
@ -481,6 +493,7 @@ test Text {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 7, .height = 2 },
.cell_size = .{ .width = 10, .height = 20 },
};
{

View file

@ -18,6 +18,9 @@ const ellipsis: Cell.Character = .{ .grapheme = "…", .width = 1 };
// Index of our cursor
buf: Buffer,
/// Style to draw the TextField with
style: vaxis.Style = .{},
/// the number of graphemes to skip when drawing. Used for horizontal scrolling
draw_offset: u16 = 0,
/// the column we placed the cursor the last time we drew
@ -102,9 +105,13 @@ pub fn handleEvent(self: *TextField, ctx: *vxfw.EventContext, event: vxfw.Event)
} else if (key.matches('d', .{ .alt = true })) {
self.deleteWordAfter();
return self.checkChanged(ctx);
} else if (key.matches(vaxis.Key.enter, .{})) {
} else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
if (self.onSubmit) |onSubmit| {
try onSubmit(self.userdata, ctx, self.previous_val);
const value = try self.toOwnedSlice();
// Get a ref to the allocator in case onSubmit deinits the TextField
const allocator = self.buf.allocator;
defer allocator.free(value);
try onSubmit(self.userdata, ctx, value);
return ctx.consumeAndRedraw();
}
} else if (key.text) |text| {
@ -117,17 +124,15 @@ pub fn handleEvent(self: *TextField, ctx: *vxfw.EventContext, event: vxfw.Event)
}
fn checkChanged(self: *TextField, ctx: *vxfw.EventContext) anyerror!void {
const new = try self.buf.dupe();
if (std.mem.eql(u8, new, self.previous_val)) {
self.buf.allocator.free(new);
return ctx.consumeAndRedraw();
}
self.buf.allocator.free(self.previous_val);
self.previous_val = new;
if (self.onChange) |onChange| {
try onChange(self.userdata, ctx, new);
}
ctx.consumeAndRedraw();
const onChange = self.onChange orelse return;
const new = try self.buf.dupe();
defer {
self.buf.allocator.free(self.previous_val);
self.previous_val = new;
}
if (std.mem.eql(u8, new, self.previous_val)) return;
try onChange(self.userdata, ctx, new);
}
/// insert text at the cursor position
@ -206,11 +211,10 @@ pub fn draw(self: *TextField, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surfac
self.widget(),
.{ .width = max_width, .height = @max(ctx.min.height, 1) },
);
surface.focusable = true;
const base: vaxis.Cell = .{ .style = .{} };
const base: vaxis.Cell = .{ .style = self.style };
@memset(surface.buffer, base);
const style: vaxis.Style = .{};
const style = self.style;
const cursor_idx = self.graphemesBeforeCursor();
if (cursor_idx < self.draw_offset) self.draw_offset = cursor_idx;
if (max_width == 0) return surface;
@ -558,6 +562,7 @@ test TextField {
.arena = arena.allocator(),
.min = .{},
.max = .{ .width = 8, .height = 1 },
.cell_size = .{ .width = 10, .height = 20 },
};
_ = draw_ctx;

View file

@ -19,6 +19,8 @@ pub const FlexRow = @import("FlexRow.zig");
pub const ListView = @import("ListView.zig");
pub const Padding = @import("Padding.zig");
pub const RichText = @import("RichText.zig");
pub const ScrollView = @import("ScrollView.zig");
pub const ScrollBars = @import("ScrollBars.zig");
pub const SizedBox = @import("SizedBox.zig");
pub const SplitView = @import("SplitView.zig");
pub const Spinner = @import("Spinner.zig");
@ -79,6 +81,20 @@ pub const Command = union(enum) {
/// Try to copy the provided text to the host clipboard. Uses OSC 52. Silently fails if terminal
/// doesn't support OSC 52
copy_to_clipboard: []const u8,
/// Set the title of the terminal
set_title: []const u8,
/// Queue a refresh of the entire screen. Implicitly sets redraw
queue_refresh,
/// Send a system notification
notify: struct {
title: ?[]const u8,
body: []const u8,
},
query_color: vaxis.Cell.Color.Kind,
};
pub const EventContext = struct {
@ -127,6 +143,39 @@ pub const EventContext = struct {
pub fn copyToClipboard(self: *EventContext, content: []const u8) Allocator.Error!void {
try self.addCmd(.{ .copy_to_clipboard = content });
}
pub fn setTitle(self: *EventContext, title: []const u8) Allocator.Error!void {
try self.addCmd(.{ .set_title = title });
}
pub fn queueRefresh(self: *EventContext) Allocator.Error!void {
try self.addCmd(.queue_refresh);
self.redraw = true;
}
/// Send a system notification. This function dupes title and body using it's own allocator.
/// They will be freed once the notification has been sent
pub fn sendNotification(
self: *EventContext,
maybe_title: ?[]const u8,
body: []const u8,
) Allocator.Error!void {
const alloc = self.cmds.allocator;
if (maybe_title) |title| {
return self.addCmd(.{ .notify = .{
.title = try alloc.dupe(u8, title),
.body = try alloc.dupe(u8, body),
} });
}
return self.addCmd(.{ .notify = .{
.title = null,
.body = try alloc.dupe(u8, body),
} });
}
pub fn queryColor(self: *EventContext, kind: vaxis.Cell.Color.Kind) Allocator.Error!void {
try self.addCmd(.{ .query_color = kind });
}
};
pub const DrawContext = struct {
@ -137,6 +186,9 @@ pub const DrawContext = struct {
min: Size,
max: MaxSize,
// Size of a single cell, in pixels
cell_size: Size,
// Unicode stuff
var unicode: ?*const vaxis.Unicode = null;
var width_method: vaxis.gwidth.Method = .unicode;
@ -165,6 +217,7 @@ pub const DrawContext = struct {
.arena = self.arena,
.min = min,
.max = max,
.cell_size = self.cell_size,
};
}
};
@ -201,6 +254,13 @@ pub const MaxSize = struct {
.height = self.height.?,
};
}
pub fn fromSize(other: Size) MaxSize {
return .{
.width = other.width,
.height = other.height,
};
}
};
/// The Widget interface
@ -275,9 +335,6 @@ pub const Surface = struct {
/// The widget this surface belongs to
widget: Widget,
/// If this widget / Surface is focusable
focusable: bool = false,
/// Cursor state
cursor: ?CursorState = null,
@ -286,6 +343,15 @@ pub const Surface = struct {
children: []SubSurface,
pub fn empty(widget: Widget) Surface {
return .{
.size = .{},
.widget = widget,
.buffer = &.{},
.children = &.{},
};
}
/// Creates a slice of vaxis.Cell's equal to size.width * size.height
pub fn createBuffer(allocator: Allocator, size: Size) Allocator.Error![]vaxis.Cell {
const buffer = try allocator.alloc(vaxis.Cell, size.width * size.height);
@ -339,7 +405,6 @@ pub const Surface = struct {
.widget = self.widget,
.buffer = self.buffer[0 .. self.size.width * height],
.children = self.children,
.focusable = self.focusable,
};
}

View file

@ -35,7 +35,7 @@ pub fn draw(self: @This(), win: vaxis.Window, y_scroll: usize) void {
const num_digits = numDigits(line);
for (0..num_digits) |i| {
const digit = extractDigit(line, i);
win.writeCell(win.width -| (i + 2), line -| (y_scroll +| 1), .{
win.writeCell(@intCast(win.width -| (i + 2)), @intCast(line -| (y_scroll +| 1)), .{
.char = .{
.width = 1,
.grapheme = digits[digit .. digit + 1],
@ -45,7 +45,7 @@ pub fn draw(self: @This(), win: vaxis.Window, y_scroll: usize) void {
}
if (highlighted) {
for (num_digits + 1..win.width) |i| {
win.writeCell(i, line -| (y_scroll +| 1), .{
win.writeCell(@intCast(i), @intCast(line -| (y_scroll +| 1)), .{
.style = if (highlighted) self.highlighted_style else self.style,
});
}

View file

@ -115,14 +115,14 @@ pub fn bounds(self: *@This(), parent: vaxis.Window) BoundingBox {
pub fn writeCell(self: *@This(), parent: vaxis.Window, col: usize, row: usize, cell: vaxis.Cell) void {
const b = self.bounds(parent);
if (!b.inside(col, row)) return;
const win = parent.child(.{ .width = b.x2 - b.x1, .height = b.y2 - b.y1 });
win.writeCell(col -| self.scroll.x, row -| self.scroll.y, cell);
const win = parent.child(.{ .width = @intCast(b.x2 - b.x1), .height = @intCast(b.y2 - b.y1) });
win.writeCell(@intCast(col -| self.scroll.x), @intCast(row -| self.scroll.y), cell);
}
/// Use this function instead of `Window.readCell` to read the correct cell in scrolling context.
pub fn readCell(self: *@This(), parent: vaxis.Window, col: usize, row: usize) ?vaxis.Cell {
const b = self.bounds(parent);
if (!b.inside(col, row)) return;
const win = parent.child(.{ .width = b.width, .height = b.height });
return win.readCell(col -| self.scroll.x, row -| self.scroll.y);
const win = parent.child(.{ .width = @intCast(b.x2 - b.x1), .height = @intCast(b.y2 - b.y1) });
return win.readCell(@intCast(col -| self.scroll.x), @intCast(row -| self.scroll.y));
}

View file

@ -29,5 +29,5 @@ pub fn draw(self: Scrollbar, win: vaxis.Window) void {
const bar_top = self.top * win.height / self.total;
var i: usize = 0;
while (i < bar_height) : (i += 1)
win.writeCell(0, i + bar_top, .{ .char = self.character, .style = self.style });
win.writeCell(0, @intCast(i + bar_top), .{ .char = self.character, .style = self.style });
}