Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A scrollbar: a track along one side of a view, and a thumb in it whose//! length is the part of the content in view and whose place is where the//! view is in it.//!//! As in `gitk`, the track is always there, so that the view beside it//! does not change size as its content does; the thumb is drawn only when//! there is more content than view. A scrollbar holds no state of its own://! the view it belongs to has the offset, and the application, a drag.
const std = @import("std");const aerie = @import("aerie");const Canvas = aerie.gpu.Canvas;const Rect = Canvas.Rect;
const theme = @import("theme.zig");
const Scrollbar = @This();
/// Where the track is.track: Rect,axis: Axis,/// How long the content is, and how much of it is in view, along `axis`.content: f32,view: f32,/// How far the view is scrolled into the content.offset: f32,
pub const Axis = enum { vertical, horizontal };
/// What a press on the track is on.pub const Hit = enum { /// Before the thumb: a page back. before, thumb, /// After it: a page on. after,};
/// How wide a track is, at `scale`.pub fn thickness(scale: f32) f32 { return @round(12 * scale);}
/// The shortest a thumb gets, so that there is always something to grab.fn minThumb(s: Scrollbar) f32 { return @min(s.length(), @round(2 * s.breadth()));}
fn length(s: Scrollbar) f32 { return switch (s.axis) { .vertical => s.track.height, .horizontal => s.track.width, };}
fn breadth(s: Scrollbar) f32 { return switch (s.axis) { .vertical => s.track.width, .horizontal => s.track.height, };}
fn start(s: Scrollbar) f32 { return switch (s.axis) { .vertical => s.track.y, .horizontal => s.track.x, };}
/// How far the content scrolls.pub fn maxOffset(s: Scrollbar) f32 { return @max(0, s.content - s.view);}
/// Where the thumb starts along the track, and how long it is; null when/// everything is in view and there is nothing to scroll.fn thumbSpan(s: Scrollbar) ?struct { at: f32, len: f32 } { const max = s.maxOffset(); if (max <= 0 or s.length() <= 0) return null; const len = @max(s.minThumb(), @round(s.length() * s.view / s.content)); const room = s.length() - len; const at = s.start() + @round(room * std.math.clamp(s.offset / max, 0, 1)); return .{ .at = at, .len = len };}
pub fn thumb(s: Scrollbar) ?Rect { const t = s.thumbSpan() orelse return null; return switch (s.axis) { .vertical => .{ .x = s.track.x, .y = t.at, .width = s.track.width, .height = t.len }, .horizontal => .{ .x = t.at, .y = s.track.y, .width = t.len, .height = s.track.height }, };}
/// What `x`, `y` is on, if it is on the track and there is a thumb.pub fn hit(s: Scrollbar, x: f32, y: f32) ?Hit { if (!s.track.contains(x, y)) return null; const t = s.thumbSpan() orelse return null; const pos = s.along(x, y); if (pos < t.at) return .before; if (pos >= t.at + t.len) return .after; return .thumb;}
/// `x`, `y` measured along the axis.pub fn along(s: Scrollbar, x: f32, y: f32) f32 { return switch (s.axis) { .vertical => y, .horizontal => x, };}
/// Where the thumb starts, along the axis, for a drag that holds it/// `grab` from its start.pub fn grabOf(s: Scrollbar, x: f32, y: f32) f32 { const t = s.thumbSpan() orelse return 0; return s.along(x, y) - t.at;}
/// The offset that puts the thumb's start at `pos` along the axis, as a/// drag moves it, clamped to the content.pub fn offsetFor(s: Scrollbar, pos: f32) f32 { const t = s.thumbSpan() orelse return 0; const room = s.length() - t.len; if (room <= 0) return 0; return std.math.clamp((pos - s.start()) / room, 0, 1) * s.maxOffset();}
/// Draws the track, and the thumb if there is one; `active` while it is/// dragged.pub fn draw(s: Scrollbar, canvas: *Canvas, active: bool, scale: f32) !void { const r = s.track; try canvas.rect(r.x, r.y, r.width, r.height, theme.current.scrollbar_track); const t = s.thumb() orelse return; // Inset from the track's sides, so that it reads as a thing in it. const inset = @max(1, @round(2 * scale)); try canvas.rect(t.x + inset, t.y + inset, @max(1, t.width - 2 * inset), @max(1, t.height - 2 * inset), if (active) theme.current.scrollbar_thumb_active else theme.current.scrollbar_thumb);}
const testing = std.testing;
test "the thumb is the view's share of the content, where the view is" { var s: Scrollbar = .{ .track = .{ .x = 90, .y = 0, .width = 10, .height = 100 }, .axis = .vertical, .content = 400, .view = 100, .offset = 0 }; try testing.expectEqual(Rect{ .x = 90, .y = 0, .width = 10, .height = 25 }, s.thumb().?); s.offset = 300; try testing.expectEqual(@as(f32, 75), s.thumb().?.y); s.offset = 150; try testing.expectEqual(@as(f32, 38), s.thumb().?.y); // Everything in view: no thumb, and a press does nothing. s.content = 80; try testing.expectEqual(@as(?Rect, null), s.thumb()); try testing.expectEqual(@as(?Hit, null), s.hit(95, 50));}
test "a long content still has a thumb to grab" { const s: Scrollbar = .{ .track = .{ .x = 0, .y = 90, .width = 200, .height = 10 }, .axis = .horizontal, .content = 1_000_000, .view = 100, .offset = 0 }; try testing.expectEqual(@as(f32, 20), s.thumb().?.width);}
test "a press, and a drag back to an offset" { const s: Scrollbar = .{ .track = .{ .x = 90, .y = 0, .width = 10, .height = 100 }, .axis = .vertical, .content = 400, .view = 100, .offset = 100 }; // The thumb is 25 long, at 25. try testing.expectEqual(Hit.before, s.hit(95, 10).?); try testing.expectEqual(Hit.thumb, s.hit(95, 30).?); try testing.expectEqual(Hit.after, s.hit(95, 60).?); try testing.expectEqual(@as(?Hit, null), s.hit(50, 30)); try testing.expectEqual(@as(f32, 5), s.grabOf(95, 30)); // The thumb's start at 25 is where it is; at the end of the room, 75, // the content's end; past it, still the end. try testing.expectEqual(@as(f32, 100), s.offsetFor(25)); try testing.expectEqual(@as(f32, 300), s.offsetFor(75)); try testing.expectEqual(@as(f32, 300), s.offsetFor(500)); try testing.expectEqual(@as(f32, 0), s.offsetFor(-10));}