Files
tui-diff/src/elements.zig
Yves Biener 0b34a432d1
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 50s
fix(lint): format using zig fmt
2026-01-08 23:23:05 +01:00

293 lines
11 KiB
Zig

//! Applications `Element` implementations to be used in *zterm* `Container`'s
pub fn Root(App: type) type {
return struct {
gpa: Allocator,
diffs: std.ArrayListUnmanaged(App.Scrollable) = .empty,
container: *App.Container = undefined,
pub fn init(gpa: Allocator) @This() {
return .{ .gpa = gpa };
}
fn deinit(ctx: *anyopaque) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
for (this.diffs.items) |*scrollable| scrollable.element().deinit();
this.diffs.deinit(this.gpa);
// the container is the root container, which is calling this function, do not deinitialize it!
}
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
.deinit = deinit,
.handle = handle,
},
};
}
fn handle(ctx: *anyopaque, _: *App.Model, event: App.Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
// NOTE assume that there is at least one diff
.init => this.container.elements.items[this.container.elements.items.len - 1].element = this.diffs.items[0].element(),
.file => |idx| this.container.elements.items[this.container.elements.items.len - 1].element = this.diffs.items[idx].element(),
.quit => this.container.elements.items[this.container.elements.items.len - 1].element = .{},
else => {},
}
}
};
}
// NOTE base contents from the model!
// -> assume that the model does not change! it is created initially and then never changed!
pub fn Tree(App: type) type {
return struct {
scrollback: usize = 0, // scrollback index for the changed files
idx: usize = 0, // index for the currently selected entry
len: usize = 0, // total amount of changed files
size: Point = .{}, // current available size for the `Element`
queue: *App.Queue,
pub fn init(queue: *App.Queue) @This() {
return .{ .queue = queue };
}
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
.resize = resize,
.minSize = minSize,
.handle = handle,
.content = content,
},
};
}
fn resize(ctx: *anyopaque, _: *const App.Model, size: Point) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.size = size;
}
fn minSize(ctx: *anyopaque, model: *const App.Model, _: Point) Point {
// NOTE as we assume the model contents do not change we could calculate the
// maximum width required for this `Element` and return that, instead of
// calculating the width every time anew. For now this works fine.
const this: *@This() = @ptrCast(@alignCast(ctx));
const changes: []const Model.Index = model.changes.keys();
const files = changes[this.scrollback..];
var width: u16 = 0;
for (files) |file| width = @max(@as(u16, @intCast(file.len)), width);
return .{ .x = width };
}
fn handle(ctx: *anyopaque, model: *App.Model, event: App.Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.init => {
this.len = model.changes.keys().len;
this.scrollback = 0;
this.idx = 0;
},
.key => |key| {
if (key.eql(.{ .cp = 'J' }) and this.idx < this.len - 1) {
this.idx += 1;
this.queue.push(.{ .file = this.idx });
}
if (key.eql(.{ .cp = 'K' }) and this.idx > 0) {
this.idx -= 1;
this.queue.push(.{ .file = this.idx });
}
},
.mouse => |mouse| if (this.len > 0) switch (mouse.button) {
.left => if (mouse.y + this.scrollback < this.len) {
this.idx = mouse.y + this.scrollback;
this.queue.push(.{ .file = this.idx });
},
.wheel_up => this.scrollback -|= 3,
.wheel_down => this.scrollback = @min(this.len - 1, this.scrollback + 3),
else => {},
},
else => {},
}
}
fn content(ctx: *anyopaque, model: *const App.Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
if (this.len == 0) return;
var cell_idx: usize = 0;
const changes: []const Model.Index = model.changes.keys();
const files = changes[this.scrollback..];
for (0..size.y) |row| {
if (row >= files.len) break;
const idx = this.scrollback + row;
const file = files[row];
const value = model.content[file.idx .. file.idx + file.len];
var value_idx: usize = 0;
if (value_idx >= value.len) break;
const row_color: zterm.Color = if (this.idx == idx) .blue else .default;
cell_idx = row * size.x;
for (0..size.x) |_| {
if (value_idx >= value.len) break;
const cp = value[value_idx];
defer value_idx += 1;
if (cell_idx >= cells.len) break;
switch (cp) {
'\n' => cell_idx += 1,
'\t' => cell_idx += 2,
else => {},
}
cells[cell_idx].cp = switch (cp) {
'\n', '\t' => continue,
else => cp,
};
cells[cell_idx].style.fg = row_color;
cell_idx += 1;
}
}
}
};
}
/// Individual `Change` associated to a parsed diff header.
pub fn Change(App: type) type {
return struct {
change: Model.Index,
// how does an individual `Change` knows it relation to the `Model` containing it's contents?
pub fn init(change: Model.Index) @This() {
return .{ .change = change };
}
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
.minSize = minSize,
.content = content,
},
};
}
fn minSize(ctx: *anyopaque, model: *const App.Model, size: Point) Point {
const this: *@This() = @ptrCast(@alignCast(ctx));
// TODO handle `\n`, '\t', etc. correctly in the fill for the `cells`
switch (model.render_mode) {
.side_by_side => {},
.stacked => {},
}
var y: u16 = 0;
const value = model.content[this.change.idx .. this.change.idx + this.change.len];
if (this.change.idx + this.change.len == model.content.len) y += 1;
for (value) |cp| if (cp == '\n') {
y += 1;
};
return .{ .x = size.x, .y = y };
}
fn content(ctx: *anyopaque, model: *const App.Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
// TODO render different modes accordingly
var cell_idx: usize = 0;
const value = model.content[this.change.idx .. this.change.idx + this.change.len];
var value_idx: usize = 0;
switch (model.render_mode) {
.side_by_side => for (0..size.y) |row| {
if (value_idx >= value.len) break;
const row_color: zterm.Color = switch (value[value_idx]) {
'+' => .green,
'-' => .red,
'@' => .blue,
else => .default,
};
cell_idx = row * size.x;
for (0..size.x) |_| {
cell_idx += 1;
if (value_idx >= value.len) break;
const cp = value[value_idx];
defer value_idx += 1;
if (cell_idx >= cells.len) break;
switch (cp) {
'\n' => {},
'\t' => cell_idx += 2,
else => {},
}
cells[cell_idx].cp = switch (cp) {
'\n' => break,
'\t' => continue,
else => cp,
};
cells[cell_idx].style.fg = row_color;
}
},
.stacked => for (0..size.y) |row| {
if (value_idx >= value.len) break;
const row_color: zterm.Color = switch (value[value_idx]) {
'+' => .green,
'-' => .red,
'@' => .blue,
else => .default,
};
cell_idx = row * size.x;
for (0..size.x) |_| {
cell_idx += 1;
if (value_idx >= value.len) break;
const cp = value[value_idx];
defer value_idx += 1;
if (cell_idx >= cells.len) break;
switch (cp) {
'\n' => {},
'\t' => cell_idx += 2,
else => {},
}
cells[cell_idx].cp = switch (cp) {
'\n' => break,
'\t' => continue,
else => cp,
};
cells[cell_idx].style.fg = row_color;
}
},
}
}
};
}
/// `Diff` describes a collection of `Change`'s (i.e. through a root `Container` inside an `App.Scrollable`)
pub fn Diff(App: type, gpa: Allocator, changes: []Change(App)) !App.Scrollable {
var diff: App.Container = try .init(gpa, .{
.layout = .{
.direction = .vertical,
.separator = .{
.enabled = true,
},
},
}, .{});
for (changes) |*change| try diff.append(try .init(gpa, .{}, change.element()));
return .init(diff, .disabled);
}
var random_alg = std.Random.DefaultCsprng.init(@splat(1));
var random = random_alg.random();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const zterm = @import("zterm");
const Model = @import("model.zig");
const Cell = zterm.Cell;
const Point = zterm.Point;