a73x

2c4e9a52

main: add blink state machine + draw-rule pure helpers

a73x   2026-04-19 07:04

Commit message
main: add blink state machine + draw-rule pure helpers

tickBlinkPhase, reconfigureBlink, shouldDrawCursor as pure
functions with unit tests. Next commit wires them into the
main loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

src/main.zig
Old New
@@ -802,6 +802,81 @@ fn computePollTimeoutMs(
802 return next_blink_in_ms orelse -1; 802 return next_blink_in_ms orelse -1;
803 } 803 }
804 804
805 const blink_period_ns: i128 = 500 * std.time.ns_per_ms;
806
807 const BlinkState = struct {
808 blink_on: bool = true,
809 next_deadline_ns: ?i128 = null,
810 };
811
812 const BlinkPhaseTick = struct {
813 state: BlinkState,
814 flipped: bool,
815 };
816
817 fn tickBlinkPhase(state: BlinkState, now_ns: i128) BlinkPhaseTick {
818 const deadline = state.next_deadline_ns orelse return .{ .state = state, .flipped = false };
819 if (now_ns < deadline) return .{ .state = state, .flipped = false };
820 return .{
821 .state = .{
822 .blink_on = !state.blink_on,
823 .next_deadline_ns = now_ns + blink_period_ns,
824 },
825 .flipped = true,
826 };
827 }
828
829 const BlinkReconfigure = struct {
830 state: BlinkState,
831 cursor_rebuild: bool,
832 };
833
834 fn reconfigureBlink(
835 state: BlinkState,
836 want_blink: bool,
837 cursor_identity_changed: bool,
838 focus_regained: bool,
839 now_ns: i128,
840 ) BlinkReconfigure {
841 if (want_blink) {
842 if (state.next_deadline_ns == null) {
843 return .{
844 .state = .{ .blink_on = true, .next_deadline_ns = now_ns + blink_period_ns },
845 .cursor_rebuild = true,
846 };
847 }
848 if (cursor_identity_changed or focus_regained) {
849 return .{
850 .state = .{ .blink_on = true, .next_deadline_ns = now_ns + blink_period_ns },
851 .cursor_rebuild = true,
852 };
853 }
854 return .{ .state = state, .cursor_rebuild = false };
855 }
856 // want_blink == false
857 if (state.next_deadline_ns != null or !state.blink_on) {
858 return .{
859 .state = .{ .blink_on = true, .next_deadline_ns = null },
860 .cursor_rebuild = true,
861 };
862 }
863 return .{ .state = state, .cursor_rebuild = false };
864 }
865
866 fn shouldDrawCursor(
867 visible: bool,
868 viewport_present: bool,
869 has_focus: bool,
870 blinking: bool,
871 blink_on: bool,
872 ) bool {
873 if (!visible) return false;
874 if (!viewport_present) return false;
875 if (!has_focus) return true;
876 if (!blinking) return true;
877 return blink_on;
878 }
879
805 fn extractSelectedText( 880 fn extractSelectedText(
806 alloc: std.mem.Allocator, 881 alloc: std.mem.Allocator,
807 row_data: anytype, 882 row_data: anytype,
@@ -1658,6 +1733,148 @@ test "computePollTimeoutMs: returns min of two deadlines" {
1658 try std.testing.expectEqual(@as(i32, 7), computePollTimeoutMs(7, 7, false)); 1733 try std.testing.expectEqual(@as(i32, 7), computePollTimeoutMs(7, 7, false));
1659 } 1734 }
1660 1735
1736 // --- blink state machine ---
1737
1738 test "tickBlinkPhase: no deadline → no flip" {
1739 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
1740 const r = tickBlinkPhase(s, 1_000_000_000);
1741 try std.testing.expect(!r.flipped);
1742 try std.testing.expectEqual(s.blink_on, r.state.blink_on);
1743 try std.testing.expect(r.state.next_deadline_ns == null);
1744 }
1745
1746 test "tickBlinkPhase: deadline in future → no flip" {
1747 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 2_000_000_000 };
1748 const r = tickBlinkPhase(s, 1_500_000_000);
1749 try std.testing.expect(!r.flipped);
1750 try std.testing.expectEqual(s, r.state);
1751 }
1752
1753 test "tickBlinkPhase: deadline reached → flip, new deadline set at now + 500ms" {
1754 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 2_000_000_000 };
1755 const now: i128 = 2_000_000_000;
1756 const r = tickBlinkPhase(s, now);
1757 try std.testing.expect(r.flipped);
1758 try std.testing.expect(!r.state.blink_on);
1759 try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
1760 }
1761
1762 test "tickBlinkPhase: deadline overshot → flip (reschedules from now, not deadline)" {
1763 const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 1_000_000_000 };
1764 const now: i128 = 5_000_000_000;
1765 const r = tickBlinkPhase(s, now);
1766 try std.testing.expect(r.flipped);
1767 try std.testing.expect(r.state.blink_on); // flipped from off → on
1768 try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
1769 }
1770
1771 test "reconfigureBlink: inactive → active arms timer and forces on-phase" {
1772 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
1773 const r = reconfigureBlink(s, true, false, false, 10_000_000_000);
1774 try std.testing.expect(r.cursor_rebuild);
1775 try std.testing.expect(r.state.blink_on);
1776 try std.testing.expectEqual(@as(?i128, 10_000_000_000 + blink_period_ns), r.state.next_deadline_ns);
1777 }
1778
1779 test "reconfigureBlink: active, no transition → no-op" {
1780 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 10_500_000_000 };
1781 const r = reconfigureBlink(s, true, false, false, 10_100_000_000);
1782 try std.testing.expect(!r.cursor_rebuild);
1783 try std.testing.expectEqual(s, r.state);
1784 }
1785
1786 test "reconfigureBlink: active → inactive mid off-phase resets to solid" {
1787 const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
1788 const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
1789 try std.testing.expect(r.cursor_rebuild);
1790 try std.testing.expect(r.state.blink_on);
1791 try std.testing.expect(r.state.next_deadline_ns == null);
1792 }
1793
1794 test "reconfigureBlink: active → inactive on-phase still disarms (for future eligibility)" {
1795 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 10_500_000_000 };
1796 const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
1797 try std.testing.expect(r.cursor_rebuild);
1798 try std.testing.expect(r.state.blink_on);
1799 try std.testing.expect(r.state.next_deadline_ns == null);
1800 }
1801
1802 test "reconfigureBlink: inactive → inactive is a no-op" {
1803 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
1804 const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
1805 try std.testing.expect(!r.cursor_rebuild);
1806 try std.testing.expectEqual(s, r.state);
1807 }
1808
1809 test "reconfigureBlink: cursor identity change while active resets phase + extends deadline" {
1810 const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
1811 const now: i128 = 10_200_000_000;
1812 const r = reconfigureBlink(s, true, true, false, now);
1813 try std.testing.expect(r.cursor_rebuild);
1814 try std.testing.expect(r.state.blink_on);
1815 try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
1816 }
1817
1818 test "reconfigureBlink: focus regained while active resets phase" {
1819 const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
1820 const now: i128 = 10_200_000_000;
1821 const r = reconfigureBlink(s, true, false, true, now);
1822 try std.testing.expect(r.cursor_rebuild);
1823 try std.testing.expect(r.state.blink_on);
1824 try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
1825 }
1826
1827 // --- draw rule ---
1828
1829 test "shouldDrawCursor: invisible never drawn" {
1830 try std.testing.expect(!shouldDrawCursor(false, true, true, true, true));
1831 try std.testing.expect(!shouldDrawCursor(false, false, false, false, false));
1832 }
1833
1834 test "shouldDrawCursor: no viewport never drawn" {
1835 try std.testing.expect(!shouldDrawCursor(true, false, true, true, true));
1836 }
1837
1838 test "shouldDrawCursor: unfocused → always drawn (solid)" {
1839 try std.testing.expect(shouldDrawCursor(true, true, false, true, false));
1840 try std.testing.expect(shouldDrawCursor(true, true, false, true, true));
1841 try std.testing.expect(shouldDrawCursor(true, true, false, false, false));
1842 }
1843
1844 test "shouldDrawCursor: blink mode off → always drawn (solid)" {
1845 try std.testing.expect(shouldDrawCursor(true, true, true, false, false));
1846 try std.testing.expect(shouldDrawCursor(true, true, true, false, true));
1847 }
1848
1849 test "shouldDrawCursor: focused + blinking → follows phase" {
1850 try std.testing.expect(shouldDrawCursor(true, true, true, true, true));
1851 try std.testing.expect(!shouldDrawCursor(true, true, true, true, false));
1852 }
1853
1854 // --- integration: poll-timeout + tickBlinkPhase walk ---
1855
1856 test "blink: poll timeout + tickBlinkPhase walk through two full phases" {
1857 const real_now = std.time.nanoTimestamp();
1858 var state: BlinkState = .{ .blink_on = true, .next_deadline_ns = real_now + blink_period_ns };
1859 var now: i128 = real_now;
1860
1861 // First wake: poll reports "500ms" remaining. Advance clock to deadline.
1862 const timeout_0 = remainingRepeatTimeoutMs(state.next_deadline_ns);
1863 try std.testing.expectEqual(@as(?i32, 500), timeout_0);
1864 now = state.next_deadline_ns.?;
1865 const tick_0 = tickBlinkPhase(state, now);
1866 try std.testing.expect(tick_0.flipped);
1867 try std.testing.expect(!tick_0.state.blink_on);
1868 state = tick_0.state;
1869
1870 // Second wake: deadline bumped by 500ms from now.
1871 try std.testing.expectEqual(@as(?i128, now + blink_period_ns), state.next_deadline_ns);
1872 now = state.next_deadline_ns.?;
1873 const tick_1 = tickBlinkPhase(state, now);
1874 try std.testing.expect(tick_1.flipped);
1875 try std.testing.expect(tick_1.state.blink_on);
1876 }
1877
1661 test "planRowRefresh requests full rebuild for full dirty state" { 1878 test "planRowRefresh requests full rebuild for full dirty state" {
1662 const plan = planRowRefresh(.full, &.{ false, true, false }, .{ 1879 const plan = planRowRefresh(.full, &.{ false, true, false }, .{
1663 .cursor = .{ 1880 .cursor = .{