tui-td 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chunky_png"
4
+
5
+ module TUITD
6
+ class Screenshot
7
+ CELL_W = 8
8
+ CELL_H = 16
9
+
10
+ FONT = [
11
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # space (32)
12
+ 0x00, 0x00, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00, 0x00, # ! (33)
13
+ 0x00, 0x66, 0x66, 0x66, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # " (34)
14
+ 0x00, 0x00, 0x6c, 0x6c, 0xfe, 0x6c, 0x6c, 0x6c, 0x6c, 0xfe, 0x6c, 0x6c, 0x00, 0x00, 0x00, 0x00, # # (35)
15
+ 0x00, 0x10, 0x7e, 0xd0, 0xd0, 0xd0, 0x7c, 0x16, 0x16, 0x16, 0x16, 0xfc, 0x10, 0x00, 0x00, 0x00, # $ (36)
16
+ 0x00, 0x00, 0x06, 0x66, 0x6c, 0x0c, 0x18, 0x18, 0x30, 0x36, 0x66, 0x60, 0x00, 0x00, 0x00, 0x00, # % (37)
17
+ 0x00, 0x00, 0x38, 0x6c, 0x6c, 0x6c, 0x38, 0x70, 0xda, 0xcc, 0xcc, 0x7a, 0x00, 0x00, 0x00, 0x00, # & (38)
18
+ 0x00, 0x18, 0x18, 0x18, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # ' (39)
19
+ 0x00, 0x0e, 0x18, 0x30, 0x30, 0x60, 0x60, 0x60, 0x60, 0x30, 0x30, 0x18, 0x0e, 0x00, 0x00, 0x00, # ( (40)
20
+ 0x00, 0x70, 0x18, 0x0c, 0x0c, 0x06, 0x06, 0x06, 0x06, 0x0c, 0x0c, 0x18, 0x70, 0x00, 0x00, 0x00, # ) (41)
21
+ 0x00, 0x00, 0x00, 0x00, 0x66, 0x3c, 0x18, 0xff, 0x18, 0x3c, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, # * (42)
22
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x7e, 0x18, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # + (43)
23
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x30, 0x00, 0x00, 0x00, # , (44)
24
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # - (45)
25
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00, 0x00, # . (46)
26
+ 0x00, 0x06, 0x06, 0x0c, 0x0c, 0x18, 0x18, 0x30, 0x30, 0x60, 0x60, 0xc0, 0xc0, 0x00, 0x00, 0x00, # / (47)
27
+ 0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xce, 0xde, 0xf6, 0xe6, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00, # 0 (48)
28
+ 0x00, 0x00, 0x18, 0x38, 0x78, 0x58, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7e, 0x00, 0x00, 0x00, 0x00, # 1 (49)
29
+ 0x00, 0x00, 0x7c, 0xc6, 0x06, 0x06, 0x0c, 0x18, 0x30, 0x60, 0xc6, 0xfe, 0x00, 0x00, 0x00, 0x00, # 2 (50)
30
+ 0x00, 0x00, 0x7c, 0xc6, 0x06, 0x06, 0x3c, 0x06, 0x06, 0x06, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00, # 3 (51)
31
+ 0x00, 0x00, 0xc0, 0xc0, 0xcc, 0xcc, 0xcc, 0xcc, 0xfe, 0x0c, 0x0c, 0x0c, 0x00, 0x00, 0x00, 0x00, # 4 (52)
32
+ 0x00, 0x00, 0xfe, 0xc6, 0xc0, 0xc0, 0xfc, 0x06, 0x06, 0x06, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00, # 5 (53)
33
+ 0x00, 0x00, 0x7c, 0xc6, 0xc0, 0xc0, 0xfc, 0xc6, 0xc6, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00, # 6 (54)
34
+ 0x00, 0x00, 0xfe, 0xc6, 0x06, 0x06, 0x0c, 0x18, 0x30, 0x30, 0x30, 0x30, 0x00, 0x00, 0x00, 0x00, # 7 (55)
35
+ 0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xc6, 0x7c, 0xc6, 0xc6, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00, # 8 (56)
36
+ 0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xc6, 0xc6, 0x7e, 0x06, 0x06, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00, # 9 (57)
37
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00, 0x00, # : (58)
38
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00, 0x18, 0x18, 0x30, 0x00, 0x00, 0x00, # ; (59)
39
+ 0x00, 0x00, 0x06, 0x0c, 0x18, 0x30, 0x60, 0x60, 0x30, 0x18, 0x0c, 0x06, 0x00, 0x00, 0x00, 0x00, # < (60)
40
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x7e, 0x00, 0x00, 0x7e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # = (61)
41
+ 0x00, 0x00, 0x60, 0x30, 0x18, 0x0c, 0x06, 0x06, 0x0c, 0x18, 0x30, 0x60, 0x00, 0x00, 0x00, 0x00, # > (62)
42
+ 0x00, 0x00, 0x7c, 0xc6, 0x06, 0x0c, 0x18, 0x30, 0x30, 0x00, 0x30, 0x30, 0x00, 0x00, 0x00, 0x00, # ? (63)
43
+ 0x00, 0x00, 0x00, 0x7c, 0xc2, 0xda, 0xda, 0xda, 0xda, 0xde, 0xc0, 0x7c, 0x00, 0x00, 0x00, 0x00, # @ (64)
44
+ 0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xc6, 0xfe, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # A (65)
45
+ 0x00, 0x00, 0xfc, 0xc6, 0xc6, 0xc6, 0xfc, 0xc6, 0xc6, 0xc6, 0xc6, 0xfc, 0x00, 0x00, 0x00, 0x00, # B (66)
46
+ 0x00, 0x00, 0x7e, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0x7e, 0x00, 0x00, 0x00, 0x00, # C (67)
47
+ 0x00, 0x00, 0xfc, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xfc, 0x00, 0x00, 0x00, 0x00, # D (68)
48
+ 0x00, 0x00, 0x7e, 0xc0, 0xc0, 0xc0, 0xf8, 0xc0, 0xc0, 0xc0, 0xc0, 0x7e, 0x00, 0x00, 0x00, 0x00, # E (69)
49
+ 0x00, 0x00, 0x7e, 0xc0, 0xc0, 0xc0, 0xf8, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0x00, 0x00, 0x00, 0x00, # F (70)
50
+ 0x00, 0x00, 0x7e, 0xc0, 0xc0, 0xc0, 0xde, 0xc6, 0xc6, 0xc6, 0xc6, 0x7e, 0x00, 0x00, 0x00, 0x00, # G (71)
51
+ 0x00, 0x00, 0xc6, 0xc6, 0xc6, 0xc6, 0xfe, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # H (72)
52
+ 0x00, 0x00, 0x7e, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7e, 0x00, 0x00, 0x00, 0x00, # I (73)
53
+ 0x00, 0x00, 0x7e, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0xf0, 0x00, 0x00, 0x00, 0x00, # J (74)
54
+ 0x00, 0x00, 0xc6, 0xc6, 0xc6, 0xcc, 0xf8, 0xcc, 0xc6, 0xc6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # K (75)
55
+ 0x00, 0x00, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0x7e, 0x00, 0x00, 0x00, 0x00, # L (76)
56
+ 0x00, 0x00, 0xc6, 0xee, 0xfe, 0xd6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # M (77)
57
+ 0x00, 0x00, 0xc6, 0xc6, 0xe6, 0xe6, 0xd6, 0xd6, 0xce, 0xce, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # N (78)
58
+ 0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00, # O (79)
59
+ 0x00, 0x00, 0xfc, 0xc6, 0xc6, 0xc6, 0xfc, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0x00, 0x00, 0x00, 0x00, # P (80)
60
+ 0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xd6, 0xd6, 0x7c, 0x18, 0x0c, 0x00, 0x00, # Q (81)
61
+ 0x00, 0x00, 0xfc, 0xc6, 0xc6, 0xc6, 0xfc, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # R (82)
62
+ 0x00, 0x00, 0x7e, 0xc0, 0xc0, 0xc0, 0x7c, 0x06, 0x06, 0x06, 0x06, 0xfc, 0x00, 0x00, 0x00, 0x00, # S (83)
63
+ 0x00, 0x00, 0xff, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, 0x00, 0x00, 0x00, # T (84)
64
+ 0x00, 0x00, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x7e, 0x00, 0x00, 0x00, 0x00, # U (85)
65
+ 0x00, 0x00, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x6c, 0x38, 0x10, 0x00, 0x00, 0x00, 0x00, # V (86)
66
+ 0x00, 0x00, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xd6, 0xfe, 0xee, 0xc6, 0x00, 0x00, 0x00, 0x00, # W (87)
67
+ 0x00, 0x00, 0xc6, 0xc6, 0xc6, 0x6c, 0x38, 0x6c, 0xc6, 0xc6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # X (88)
68
+ 0x00, 0x00, 0xc6, 0xc6, 0xc6, 0xc6, 0x7e, 0x06, 0x06, 0x06, 0x06, 0xfc, 0x00, 0x00, 0x00, 0x00, # Y (89)
69
+ 0x00, 0x00, 0xfe, 0x06, 0x06, 0x0c, 0x18, 0x30, 0x60, 0xc0, 0xc0, 0xfe, 0x00, 0x00, 0x00, 0x00, # Z (90)
70
+ 0x00, 0x3e, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3e, 0x00, 0x00, 0x00, # [ (91)
71
+ 0x00, 0xc0, 0xc0, 0x60, 0x60, 0x30, 0x30, 0x18, 0x18, 0x0c, 0x0c, 0x06, 0x06, 0x00, 0x00, 0x00, # \ (92)
72
+ 0x00, 0x7c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x7c, 0x00, 0x00, 0x00, # ] (93)
73
+ 0x00, 0x10, 0x38, 0x6c, 0xc6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # ^ (94)
74
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x00, # _ (95)
75
+ 0x00, 0x30, 0x18, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # ` (96)
76
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x06, 0x7e, 0xc6, 0xc6, 0xc6, 0x7e, 0x00, 0x00, 0x00, 0x00, # a (97)
77
+ 0x00, 0x00, 0xc0, 0xc0, 0xc0, 0xfc, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xfc, 0x00, 0x00, 0x00, 0x00, # b (98)
78
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x7e, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0x7e, 0x00, 0x00, 0x00, 0x00, # c (99)
79
+ 0x00, 0x00, 0x06, 0x06, 0x06, 0x7e, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x7e, 0x00, 0x00, 0x00, 0x00, # d (100)
80
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x7e, 0xc6, 0xc6, 0xfe, 0xc0, 0xc0, 0x7e, 0x00, 0x00, 0x00, 0x00, # e (101)
81
+ 0x00, 0x00, 0x1e, 0x30, 0x30, 0x30, 0x7c, 0x30, 0x30, 0x30, 0x30, 0x30, 0x00, 0x00, 0x00, 0x00, # f (102)
82
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x7e, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x7c, 0x06, 0x06, 0xfc, 0x00, # g (103)
83
+ 0x00, 0x00, 0xc0, 0xc0, 0xc0, 0xfc, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # h (104)
84
+ 0x00, 0x00, 0x18, 0x18, 0x00, 0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1c, 0x00, 0x00, 0x00, 0x00, # i (105)
85
+ 0x00, 0x00, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x70, 0x00, # j (106)
86
+ 0x00, 0x00, 0xc0, 0xc0, 0xc0, 0xcc, 0xd8, 0xf0, 0xf0, 0xd8, 0xcc, 0xc6, 0x00, 0x00, 0x00, 0x00, # k (107)
87
+ 0x00, 0x00, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x1c, 0x00, 0x00, 0x00, 0x00, # l (108)
88
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0xec, 0xd6, 0xd6, 0xd6, 0xd6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # m (109)
89
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # n (110)
90
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x7c, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x7c, 0x00, 0x00, 0x00, 0x00, # o (111)
91
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xfc, 0xc0, 0xc0, 0xc0, 0x00, # p (112)
92
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x7e, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x7e, 0x06, 0x06, 0x06, 0x00, # q (113)
93
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x7e, 0xc6, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0, 0x00, 0x00, 0x00, 0x00, # r (114)
94
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x7e, 0xc0, 0xc0, 0x7c, 0x06, 0x06, 0xfc, 0x00, 0x00, 0x00, 0x00, # s (115)
95
+ 0x00, 0x00, 0x30, 0x30, 0x30, 0x7c, 0x30, 0x30, 0x30, 0x30, 0x30, 0x1e, 0x00, 0x00, 0x00, 0x00, # t (116)
96
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x7e, 0x00, 0x00, 0x00, 0x00, # u (117)
97
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0xc6, 0xc6, 0xc6, 0xc6, 0x6c, 0x38, 0x10, 0x00, 0x00, 0x00, 0x00, # v (118)
98
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0xc6, 0xc6, 0xd6, 0xd6, 0xd6, 0xd6, 0x6e, 0x00, 0x00, 0x00, 0x00, # w (119)
99
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0xc6, 0x6c, 0x38, 0x38, 0x6c, 0xc6, 0xc6, 0x00, 0x00, 0x00, 0x00, # x (120)
100
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0x7e, 0x06, 0x06, 0xfc, 0x00, # y (121)
101
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x06, 0x0c, 0x18, 0x30, 0x60, 0xfe, 0x00, 0x00, 0x00, 0x00, # z (122)
102
+ 0x00, 0x0e, 0x18, 0x18, 0x18, 0x18, 0x70, 0x70, 0x18, 0x18, 0x18, 0x18, 0x0e, 0x00, 0x00, 0x00, # { (123)
103
+ 0x00, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, 0x00, 0x00, # | (124)
104
+ 0x00, 0x70, 0x18, 0x18, 0x18, 0x18, 0x0e, 0x0e, 0x18, 0x18, 0x18, 0x18, 0x70, 0x00, 0x00, 0x00, # } (125)
105
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x32, 0x7e, 0x4c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # ~ (126)
106
+ ].freeze
107
+ private_constant :FONT
108
+
109
+ ANSI_RGB = {
110
+ "black" => [0x00, 0x00, 0x00],
111
+ "red" => [0xAA, 0x00, 0x00],
112
+ "green" => [0x00, 0xAA, 0x00],
113
+ "yellow" => [0xAA, 0x55, 0x00],
114
+ "blue" => [0x00, 0x00, 0xAA],
115
+ "magenta" => [0xAA, 0x00, 0xAA],
116
+ "cyan" => [0x00, 0xAA, 0xAA],
117
+ "white" => [0xAA, 0xAA, 0xAA],
118
+ "bright_black" => [0x55, 0x55, 0x55],
119
+ "bright_red" => [0xFF, 0x55, 0x55],
120
+ "bright_green" => [0x55, 0xFF, 0x55],
121
+ "bright_yellow" => [0xFF, 0xFF, 0x55],
122
+ "bright_blue" => [0x55, 0x55, 0xFF],
123
+ "bright_magenta"=> [0xFF, 0x55, 0xFF],
124
+ "bright_cyan" => [0x55, 0xFF, 0xFF],
125
+ "bright_white" => [0xFF, 0xFF, 0xFF],
126
+ }.freeze
127
+ private_constant :ANSI_RGB
128
+
129
+ CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
130
+ private_constant :CUBE
131
+
132
+ ANSI_INDEX = %w[
133
+ black red green yellow blue magenta cyan white
134
+ bright_black bright_red bright_green bright_yellow
135
+ bright_blue bright_magenta bright_cyan bright_white
136
+ ].freeze
137
+ private_constant :ANSI_INDEX
138
+
139
+ DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
140
+ DEFAULT_BG = [0x00, 0x00, 0x00].freeze
141
+
142
+ def initialize(state)
143
+ @state = state
144
+ @rows = _dig(state, :size, :rows) || 40
145
+ @cols = _dig(state, :size, :cols) || 120
146
+ @grid = state[:rows] || state["rows"] || []
147
+ end
148
+
149
+ def render(output_path)
150
+ width = @cols * CELL_W
151
+ height = @rows * CELL_H
152
+ image = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::BLACK)
153
+
154
+ @grid.each_with_index do |row, ri|
155
+ next unless row
156
+ row.each_with_index do |cell, ci|
157
+ next unless cell
158
+ render_cell(image, ri, ci, cell)
159
+ end
160
+ end
161
+
162
+ image.save(output_path)
163
+ output_path
164
+ end
165
+
166
+ private
167
+
168
+ def render_cell(image, ri, ci, cell)
169
+ char = cell[:char] || cell["char"] || " "
170
+ fg = cell[:fg] || cell["fg"] || "default"
171
+ bg = cell[:bg] || cell["bg"] || "default"
172
+ bold = cell[:bold] || cell["bold"] || false
173
+ italic = cell[:italic] || cell["italic"] || false
174
+ underline = cell[:underline] || cell["underline"] || false
175
+
176
+ fg_rgb = resolve_color(fg, DEFAULT_FG)
177
+ bg_rgb = resolve_color(bg, DEFAULT_BG)
178
+
179
+ px = ci * CELL_W
180
+ py = ri * CELL_H
181
+
182
+ fill_rect(image, px, py, CELL_W, CELL_H, bg_rgb)
183
+
184
+ return if char == " " || char.ord < 32 || char.ord > 126
185
+
186
+ rows_data = glyph_rows(char)
187
+ return unless rows_data
188
+
189
+ draw_glyph(image, px, py, rows_data, fg_rgb, bold: bold, italic: italic)
190
+
191
+ draw_underline(image, px, py, CELL_W, fg_rgb) if underline
192
+ end
193
+
194
+ def resolve_color(name, fallback)
195
+ case name
196
+ when "default"
197
+ fallback
198
+ when /^#([0-9a-fA-F]{6})$/
199
+ [$1[0..1].to_i(16), $1[2..3].to_i(16), $1[4..5].to_i(16)]
200
+ when /\Acolor(\d+)\z/
201
+ xterm_256($1.to_i)
202
+ when /\Abright_(.+)\z/
203
+ ANSI_RGB[name] || fallback
204
+ else
205
+ ANSI_RGB[name] || fallback
206
+ end
207
+ end
208
+
209
+ def xterm_256(index)
210
+ if index < 16
211
+ name = ANSI_INDEX[index]
212
+ ANSI_RGB[name] || DEFAULT_FG
213
+ elsif index < 232
214
+ r = CUBE[((index - 16) / 36) % 6]
215
+ g = CUBE[((index - 16) / 6) % 6]
216
+ b = CUBE[(index - 16) % 6]
217
+ [r, g, b]
218
+ else
219
+ v = 8 + (index - 232) * 10
220
+ [v, v, v]
221
+ end
222
+ end
223
+
224
+ def fill_rect(image, x, y, w, h, rgb)
225
+ color = ChunkyPNG::Color.rgb(*rgb)
226
+ h.times do |dy|
227
+ w.times do |dx|
228
+ image[x + dx, y + dy] = color
229
+ end
230
+ end
231
+ end
232
+
233
+ def glyph_rows(char)
234
+ idx = (char.ord - 32) * 16
235
+ return nil if idx < 0 || idx + 15 >= FONT.length
236
+
237
+ FONT[idx, 16]
238
+ end
239
+
240
+ def draw_glyph(image, px, py, rows, fg_rgb, bold:, italic:)
241
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
242
+
243
+ rows.each_with_index do |byte, dy|
244
+ next if byte == 0
245
+
246
+ slant = italic ? dy / 8 : 0
247
+
248
+ 8.times do |dx|
249
+ next unless (byte >> (7 - dx)) & 1 == 1
250
+
251
+ image[px + dx + slant, py + dy] = color
252
+ image[px + dx + slant + 1, py + dy] = color if bold
253
+ end
254
+ end
255
+ end
256
+
257
+ def draw_underline(image, px, py, w, fg_rgb)
258
+ color = ChunkyPNG::Color.rgb(*fg_rgb)
259
+ y = py + CELL_H - 2
260
+ w.times { |dx| image[px + dx, y] = color }
261
+ end
262
+
263
+ def _dig(hash, *keys)
264
+ keys.each do |k|
265
+ return nil unless hash
266
+ hash = hash[k] || hash[k.to_s]
267
+ end
268
+ hash
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUITD
4
+ # Represents the parsed state of a terminal screen.
5
+ # Provides high-level query methods for AI consumption.
6
+ class State
7
+ attr_reader :rows, :cols, :grid, :cursor
8
+
9
+ def initialize(data)
10
+ @rows = data[:size][:rows]
11
+ @cols = data[:size][:cols]
12
+ @grid = data[:rows]
13
+ @cursor = data[:cursor]
14
+ end
15
+
16
+ # Get plain text of the entire terminal (no ANSI)
17
+ def plain_text
18
+ @grid.map { |row| row.map { |c| c[:char] }.join.rstrip }.join("\n")
19
+ end
20
+
21
+ # Get text at a specific position
22
+ def text_at(row, col, length = @cols - col)
23
+ return "" if row >= @rows || col >= @cols
24
+ @grid[row][col, length].map { |c| c[:char] }.join
25
+ end
26
+
27
+ # Search for text across the entire terminal
28
+ def find_text(pattern)
29
+ results = []
30
+ @grid.each_with_index do |row, ri|
31
+ text = row.map { |c| c[:char] }.join
32
+ pos = 0
33
+ while (match = text.index(pattern, pos))
34
+ results << { row: ri, col: match, text: pattern, full_line: text }
35
+ pos = match + 1
36
+ end
37
+ end
38
+ results
39
+ end
40
+
41
+ # Get the color at a specific cell
42
+ def foreground_at(row, col)
43
+ return nil if row >= @rows || col >= @cols
44
+ @grid[row][col][:fg]
45
+ end
46
+
47
+ def background_at(row, col)
48
+ return nil if row >= @rows || col >= @cols
49
+ @grid[row][col][:bg]
50
+ end
51
+
52
+ def style_at(row, col)
53
+ return nil if row >= @rows || col >= @cols
54
+ cell = @grid[row][col]
55
+ { bold: cell[:bold], italic: cell[:italic], underline: cell[:underline] }
56
+ end
57
+
58
+ def to_ai_json
59
+ h = extract_highlights
60
+ cursor_info = @cursor.is_a?(Hash) ? @cursor : {}
61
+ r = cursor_info[:row] || cursor_info["row"] || 0
62
+ c = cursor_info[:col] || cursor_info["col"] || 0
63
+ styled_count = h.count { |hl| hl[:bold] || hl[:italic] || hl[:underline] || hl[:fg] || hl[:bg] }
64
+
65
+ summary = +"Cursor at [#{r},#{c}]. "
66
+ summary << "#{styled_count} styled row#{styled_count == 1 ? '' : 's'}"
67
+ fgs = h.flat_map { |hl| hl[:fg] }.compact.uniq
68
+ bgs = h.flat_map { |hl| hl[:bg] }.compact.uniq
69
+ summary << ", colors: fg=#{fgs.sort.join(',')}" unless fgs.empty?
70
+ summary << ", bg=#{bgs.sort.join(',')}" unless bgs.empty?
71
+ summary << "."
72
+
73
+ {
74
+ size: { rows: @rows, cols: @cols },
75
+ cursor: cursor_info,
76
+ text: plain_text,
77
+ highlights: h,
78
+ summary: summary,
79
+ }
80
+ end
81
+
82
+ private
83
+
84
+ def extract_highlights
85
+ highlights = []
86
+ @grid.each_with_index do |row, ri|
87
+ row_text = row.map { |c| c[:char] }.join
88
+ next if row_text.strip.empty?
89
+
90
+ fgs = row.map { |c| c[:fg] || c["fg"] || "default" }
91
+ .uniq.reject { |c| c == "default" }
92
+ bgs = row.map { |c| c[:bg] || c["bg"] || "default" }
93
+ .uniq.reject { |c| c == "default" }
94
+ bold = row.any? { |c| c[:bold] || c["bold"] }
95
+ italic = row.any? { |c| c[:italic] || c["italic"] }
96
+ underline = row.any? { |c| c[:underline] || c["underline"] }
97
+
98
+ next if fgs.empty? && bgs.empty? && !bold && !italic && !underline
99
+
100
+ h = { row: ri, text: row_text }
101
+ h[:bold] = true if bold
102
+ h[:italic] = true if italic
103
+ h[:underline] = true if underline
104
+ h[:fg] = fgs.size == 1 ? fgs.first : fgs unless fgs.empty?
105
+ h[:bg] = bgs.size == 1 ? bgs.first : bgs unless bgs.empty?
106
+ highlights << h
107
+ end
108
+ highlights
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module TUITD
6
+ # Executes TUI tests defined in JSON format.
7
+ #
8
+ # plan = File.read("test/hello.json")
9
+ # results = TUITD::TestRunner.new(plan).run
10
+ # puts results[:passed] # => true
11
+ #
12
+ # JSON format:
13
+ # {
14
+ # "name": "My test",
15
+ # "rows": 24, "cols": 80, "timeout": 10,
16
+ # "steps": [
17
+ # {"start": "my_tui"},
18
+ # {"wait_for_text": "> "},
19
+ # {"send": "hello\n"},
20
+ # {"assert_text": "hello"},
21
+ # {"assert_fg": [0, 0], "is": "cyan"},
22
+ # {"close": true}
23
+ # ]
24
+ # }
25
+ #
26
+ class TestRunner
27
+ Result = Struct.new(:step, :passed, :message, keyword_init: true)
28
+
29
+ def initialize(source)
30
+ raw = source.is_a?(String) ? JSON.parse(source) : source
31
+ @plan = raw.transform_keys(&:to_sym)
32
+ @plan[:steps] = @plan[:steps].map { |s| s.transform_keys(&:to_sym) }
33
+ end
34
+
35
+ def run
36
+ results = []
37
+ all_passed = true
38
+ driver = nil
39
+ rows = @plan[:rows] || 40
40
+ cols = @plan[:cols] || 120
41
+ timeout = @plan[:timeout] || 30
42
+ chdir = @plan[:chdir]
43
+
44
+ @plan[:steps].each do |step|
45
+ action = step.keys.first.to_s
46
+ value = step.values.first
47
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+
49
+ begin
50
+ r = case action
51
+ when "start"
52
+ driver&.close
53
+ driver = Driver.new(value.to_s, rows: rows, cols: cols, timeout: timeout, chdir: chdir)
54
+ driver.start
55
+ Result.new(step: action, passed: true, message: "Started: #{value}")
56
+
57
+ when "send"
58
+ ensure_driver!(driver)
59
+ driver.send(value.to_s)
60
+ Result.new(step: action, passed: true, message: "Sent #{value.to_s.length} characters")
61
+
62
+ when "send_key"
63
+ ensure_driver!(driver)
64
+ driver.send_keys(value.to_s.to_sym)
65
+ Result.new(step: action, passed: true, message: "Sent key: #{value}")
66
+
67
+ when "wait_for_text"
68
+ ensure_driver!(driver)
69
+ driver.wait_for_text(value.to_s)
70
+ Result.new(step: action, passed: true, message: "Found: #{value}")
71
+
72
+ when "wait_for_stable"
73
+ ensure_driver!(driver)
74
+ driver.wait_for_stable
75
+ Result.new(step: action, passed: true, message: "Stable")
76
+
77
+ when "assert_text"
78
+ ensure_driver!(driver)
79
+ state = State.new(driver.state_data)
80
+ if state.find_text(value.to_s).any?
81
+ Result.new(step: action, passed: true, message: "Text found: #{value}")
82
+ else
83
+ Result.new(step: action, passed: false, message: "Text NOT found: #{value}")
84
+ end
85
+
86
+ when "assert_fg"
87
+ ensure_driver!(driver)
88
+ row, col = coords(step)
89
+ expected = step[:is] || step["is"]
90
+ state = State.new(driver.state_data)
91
+ actual = state.foreground_at(row, col)
92
+ if actual == expected
93
+ Result.new(step: action, passed: true, message: "FG at [#{row},#{col}] is #{expected}")
94
+ else
95
+ Result.new(step: action, passed: false, message: "FG at [#{row},#{col}] is #{actual}, expected #{expected}")
96
+ end
97
+
98
+ when "assert_bg"
99
+ ensure_driver!(driver)
100
+ row, col = coords(step)
101
+ expected = step[:is] || step["is"]
102
+ state = State.new(driver.state_data)
103
+ actual = state.background_at(row, col)
104
+ if actual == expected
105
+ Result.new(step: action, passed: true, message: "BG at [#{row},#{col}] is #{expected}")
106
+ else
107
+ Result.new(step: action, passed: false, message: "BG at [#{row},#{col}] is #{actual}, expected #{expected}")
108
+ end
109
+
110
+ when "assert_style"
111
+ ensure_driver!(driver)
112
+ row, col = coords(step)
113
+ state = State.new(driver.state_data)
114
+ actual = state.style_at(row, col)
115
+ expected = {}
116
+ expected[:bold] = step[:bold] unless step[:bold].nil?
117
+ expected[:italic] = step[:italic] unless step[:italic].nil?
118
+ expected[:underline] = step[:underline] unless step[:underline].nil?
119
+ match = expected.all? { |k, v| actual[k] == v }
120
+ if match
121
+ Result.new(step: action, passed: true, message: "Style at [#{row},#{col}] matches #{expected}")
122
+ else
123
+ Result.new(step: action, passed: false, message: "Style at [#{row},#{col}] is #{actual}, expected #{expected}")
124
+ end
125
+
126
+ when "screenshot"
127
+ ensure_driver!(driver)
128
+ path = value.is_a?(String) ? value : "/tmp/tui_td_#{Time.now.to_i}.png"
129
+ driver.screenshot(path)
130
+ Result.new(step: action, passed: true, message: "Saved: #{path}")
131
+
132
+ when "html"
133
+ ensure_driver!(driver)
134
+ path = value.is_a?(String) ? value : "/tmp/tui_td_#{Time.now.to_i}.html"
135
+ HtmlRenderer.new(driver.state_data).render(path)
136
+ Result.new(step: action, passed: true, message: "Saved: #{path}")
137
+
138
+ when "close"
139
+ driver&.close
140
+ driver = nil
141
+ Result.new(step: action, passed: true, message: "Closed")
142
+
143
+ else
144
+ Result.new(step: action, passed: false, message: "Unknown action: #{action}")
145
+ end
146
+
147
+ rescue StandardError => e
148
+ r = Result.new(step: action, passed: false, message: "#{e.class}: #{e.message}")
149
+ end
150
+
151
+ results << r
152
+ all_passed &&= r.passed
153
+ end
154
+
155
+ driver&.close
156
+
157
+ {
158
+ name: @plan[:name] || "(unnamed)",
159
+ passed: all_passed,
160
+ results: results.map(&:to_h)
161
+ }
162
+ end
163
+
164
+ private
165
+
166
+ def ensure_driver!(driver)
167
+ raise Error, "No session. Add a 'start' step first." if driver.nil?
168
+ end
169
+
170
+ def coords(step)
171
+ pos = step[:assert_fg] || step[:assert_bg] || step[:assert_style]
172
+ pos = value if pos.nil? && (value = step.values.first).is_a?(Array)
173
+ row = pos.is_a?(Array) ? pos[0] : (pos[:row] || pos["row"] || 0)
174
+ col = pos.is_a?(Array) ? pos[1] : (pos[:col] || pos["col"] || 0)
175
+ [row, col]
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUITD
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tui_td.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUITD
4
+ class Error < StandardError; end
5
+ end
6
+
7
+ require_relative "tui_td/version"
8
+ require_relative "tui_td/driver"
9
+ require_relative "tui_td/ansi_parser"
10
+ require_relative "tui_td/state"
11
+ require_relative "tui_td/screenshot"
12
+ require_relative "tui_td/html_renderer"
13
+ require_relative "tui_td/test_runner"
14
+ require_relative "tui_td/mcp/server"
15
+ require_relative "tui_td/cli"
16
+
17
+ module TUITD
18
+
19
+ # Convenience method: start a TUI driver, capture initial state
20
+ def self.drive(command, **opts)
21
+ driver = Driver.new(command, **opts)
22
+ driver.start
23
+ driver
24
+ end
25
+ end