// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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)); }