2c4e9a52
main: add blink state machine + draw-rule pure helpers
a73x 2026-04-19 07:04
Commit message
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 = .{ |