fatty 0.99.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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +2 -0
  3. data/.simplecov +23 -0
  4. data/.yardopts +4 -0
  5. data/CHANGELOG.md +34 -0
  6. data/CHANGELOG.org +38 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +31 -0
  9. data/README.org +166 -0
  10. data/Rakefile +15 -0
  11. data/TODO.org +163 -0
  12. data/examples/markdown/native-markdown.md +370 -0
  13. data/examples/markdown/ox-gfm-markdown.md +373 -0
  14. data/examples/markdown/ox-gfm-markdown.org +376 -0
  15. data/exe/fatty +275 -0
  16. data/fatty.gemspec +42 -0
  17. data/lib/fatty/accept_env.rb +32 -0
  18. data/lib/fatty/action.rb +103 -0
  19. data/lib/fatty/action_environment.rb +42 -0
  20. data/lib/fatty/actionable.rb +73 -0
  21. data/lib/fatty/alert.rb +93 -0
  22. data/lib/fatty/ansi/renderer.rb +168 -0
  23. data/lib/fatty/ansi.rb +352 -0
  24. data/lib/fatty/colors/color.rb +379 -0
  25. data/lib/fatty/colors/pairs.rb +73 -0
  26. data/lib/fatty/colors/palette.rb +73 -0
  27. data/lib/fatty/colors/rgb.txt +788 -0
  28. data/lib/fatty/colors.rb +5 -0
  29. data/lib/fatty/config.rb +86 -0
  30. data/lib/fatty/config_files/config.yml +50 -0
  31. data/lib/fatty/config_files/help.md +120 -0
  32. data/lib/fatty/config_files/help.org +124 -0
  33. data/lib/fatty/config_files/keybindings.yml +49 -0
  34. data/lib/fatty/config_files/keydefs.yml +23 -0
  35. data/lib/fatty/config_files/themes/mono.yml +76 -0
  36. data/lib/fatty/config_files/themes/nordic.yml +77 -0
  37. data/lib/fatty/config_files/themes/solarized_dark.yml +77 -0
  38. data/lib/fatty/config_files/themes/terminal.yml +90 -0
  39. data/lib/fatty/config_files/themes/wordperfect.yml +77 -0
  40. data/lib/fatty/config_files/themes/wordperfect_light.yml +77 -0
  41. data/lib/fatty/core_ext/string.rb +21 -0
  42. data/lib/fatty/core_ext.rb +3 -0
  43. data/lib/fatty/counter.rb +81 -0
  44. data/lib/fatty/curses/context.rb +279 -0
  45. data/lib/fatty/curses/curses_coder.rb +684 -0
  46. data/lib/fatty/curses/event_source.rb +230 -0
  47. data/lib/fatty/curses/key_decoder.rb +183 -0
  48. data/lib/fatty/curses/patch.rb +116 -0
  49. data/lib/fatty/curses/window_styling.rb +32 -0
  50. data/lib/fatty/curses.rb +16 -0
  51. data/lib/fatty/env.rb +100 -0
  52. data/lib/fatty/help.rb +41 -0
  53. data/lib/fatty/history/entry.rb +71 -0
  54. data/lib/fatty/history.rb +289 -0
  55. data/lib/fatty/input_buffer.rb +998 -0
  56. data/lib/fatty/input_field.rb +507 -0
  57. data/lib/fatty/key_event.rb +342 -0
  58. data/lib/fatty/key_map.rb +392 -0
  59. data/lib/fatty/keymaps/emacs.rb +189 -0
  60. data/lib/fatty/log_formats/json.rb +47 -0
  61. data/lib/fatty/log_formats/text.rb +67 -0
  62. data/lib/fatty/logger.rb +142 -0
  63. data/lib/fatty/markdown/ansi_renderer.rb +373 -0
  64. data/lib/fatty/markdown/render.rb +22 -0
  65. data/lib/fatty/markdown.rb +4 -0
  66. data/lib/fatty/menu_env.rb +22 -0
  67. data/lib/fatty/mouse_event.rb +32 -0
  68. data/lib/fatty/output_buffer.rb +78 -0
  69. data/lib/fatty/pager.rb +801 -0
  70. data/lib/fatty/prompt.rb +40 -0
  71. data/lib/fatty/renderer/curses.rb +697 -0
  72. data/lib/fatty/renderer/truecolor.rb +607 -0
  73. data/lib/fatty/renderer.rb +419 -0
  74. data/lib/fatty/screen.rb +96 -0
  75. data/lib/fatty/search.rb +43 -0
  76. data/lib/fatty/session/alert_session.rb +52 -0
  77. data/lib/fatty/session/input_session.rb +99 -0
  78. data/lib/fatty/session/isearch_session.rb +172 -0
  79. data/lib/fatty/session/keytest_session.rb +236 -0
  80. data/lib/fatty/session/modal_session.rb +61 -0
  81. data/lib/fatty/session/output_session.rb +105 -0
  82. data/lib/fatty/session/popup_session.rb +540 -0
  83. data/lib/fatty/session/prompt_session.rb +157 -0
  84. data/lib/fatty/session/search_session.rb +136 -0
  85. data/lib/fatty/session/shell_session.rb +566 -0
  86. data/lib/fatty/session.rb +173 -0
  87. data/lib/fatty/sessions.rb +14 -0
  88. data/lib/fatty/terminal/popup_owner.rb +26 -0
  89. data/lib/fatty/terminal/progress.rb +374 -0
  90. data/lib/fatty/terminal.rb +1067 -0
  91. data/lib/fatty/themes/loader.rb +136 -0
  92. data/lib/fatty/themes/manager.rb +71 -0
  93. data/lib/fatty/themes/registry.rb +64 -0
  94. data/lib/fatty/themes/resolver.rb +224 -0
  95. data/lib/fatty/themes/themes.rb +131 -0
  96. data/lib/fatty/themes.rb +6 -0
  97. data/lib/fatty/version.rb +5 -0
  98. data/lib/fatty/view/alert_view.rb +14 -0
  99. data/lib/fatty/view/cursor_view.rb +18 -0
  100. data/lib/fatty/view/input_view.rb +9 -0
  101. data/lib/fatty/view/output_view.rb +9 -0
  102. data/lib/fatty/view/status_view.rb +14 -0
  103. data/lib/fatty/view.rb +33 -0
  104. data/lib/fatty/viewport.rb +90 -0
  105. data/lib/fatty/views.rb +9 -0
  106. data/lib/fatty.rb +55 -0
  107. data/sig/fatty.rbs +4 -0
  108. metadata +250 -0
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ module Curses
5
+ class EventSource
6
+ MOUSE_BSTATE_BUTTON_MAP = {
7
+ ::Curses::BUTTON1_PRESSED => :left_pressed,
8
+ ::Curses::BUTTON1_RELEASED => :left_released,
9
+ ::Curses::BUTTON1_CLICKED => :left_clicked,
10
+ ::Curses::BUTTON1_DOUBLE_CLICKED => :left_double_clicked,
11
+ ::Curses::BUTTON1_TRIPLE_CLICKED => :left_triple_clicked,
12
+
13
+ ::Curses::BUTTON2_PRESSED => :middle_pressed,
14
+ ::Curses::BUTTON2_RELEASED => :middle_released,
15
+ ::Curses::BUTTON2_CLICKED => :middle_clicked,
16
+ ::Curses::BUTTON2_DOUBLE_CLICKED => :middle_double_clicked,
17
+ ::Curses::BUTTON2_TRIPLE_CLICKED => :middle_triple_clicked,
18
+
19
+ ::Curses::BUTTON3_PRESSED => :right_pressed,
20
+ ::Curses::BUTTON3_RELEASED => :right_released,
21
+ ::Curses::BUTTON3_CLICKED => :right_clicked,
22
+ ::Curses::BUTTON3_DOUBLE_CLICKED => :right_double_clicked,
23
+ ::Curses::BUTTON3_TRIPLE_CLICKED => :right_triple_clicked
24
+ }.freeze
25
+
26
+ ESCAPE_LOOKAHEAD_MS = 0
27
+
28
+ BRACKETED_PASTE_START = "[200~"
29
+ BRACKETED_PASTE_END = "\e[201~"
30
+
31
+ attr_reader :context, :key_decoder
32
+
33
+ def initialize(context:, key_decoder:, poll_ms: 200)
34
+ @context = context
35
+ @key_decoder = key_decoder
36
+ @poll_ms = poll_ms.to_i
37
+ configure_input_polling!
38
+ end
39
+
40
+ def next_event
41
+ raw = read_raw
42
+ return unless raw
43
+
44
+ if raw.is_a?(Fatty::MouseEvent)
45
+ return [:key, raw]
46
+ end
47
+
48
+ if raw.is_a?(Array) && raw.first == :paste
49
+ return [:cmd, :paste, { text: raw.last }]
50
+ end
51
+
52
+ ev = @key_decoder.decode(raw)
53
+ return unless ev
54
+
55
+ [:key, ev]
56
+ end
57
+
58
+ private
59
+
60
+ def window
61
+ context.input_win
62
+ end
63
+
64
+ def configure_input_polling!
65
+ win = window
66
+ return unless win
67
+
68
+ # Make getch return nil periodically so Terminal can keep rendering,
69
+ # which allows output to animate smoothly.
70
+ win.timeout = @poll_ms if win.respond_to?(:timeout=)
71
+ end
72
+
73
+ def read_raw
74
+ return unless window
75
+
76
+ window.timeout = @poll_ms if window.respond_to?(:timeout=)
77
+
78
+ ch = window.getch
79
+ return if ch == -1
80
+ return unless ch
81
+
82
+ if ch.is_a?(Integer) && ch == ::Curses::KEY_MOUSE
83
+ mouse = ::Curses.getmouse
84
+ Fatty.debug("EventSource#read_raw: bstate=#{mouse&.bstate}", tag: :mouse)
85
+ return decode_mouse(mouse)
86
+ end
87
+
88
+ if Fatty::Config.config.dig(:log, :tags)&.include?(:keycode)
89
+ Fatty.debug(
90
+ :curses_getch,
91
+ tag: :keycode,
92
+ ch_class: ch.class.name,
93
+ ch_inspect: ch.inspect,
94
+ ch_int: (ch.is_a?(Integer) ? ch : nil),
95
+ ch_chr: (ch.is_a?(Integer) && ch.between?(0, 255) ? ch.chr : nil),
96
+ )
97
+ end
98
+
99
+ if ch.is_a?(Integer) && ch == 27
100
+ nxt = with_window_timeout(ESCAPE_LOOKAHEAD_MS) { window.getch }
101
+
102
+ if nxt == -1 || !nxt
103
+ ch
104
+ elsif nxt == "[".ord || nxt == "["
105
+ suffix = read_escape_suffix
106
+ seq = "[" + suffix
107
+
108
+ if seq == BRACKETED_PASTE_START
109
+ [:paste, read_bracketed_paste]
110
+ else
111
+ [ch, nxt]
112
+ end
113
+ else
114
+ [ch, nxt]
115
+ end
116
+ else
117
+ ch
118
+ end
119
+ end
120
+
121
+ def with_window_timeout(ms)
122
+ result = nil
123
+ win = window
124
+ if win&.respond_to?(:timeout=)
125
+ begin
126
+ win.timeout = ms
127
+ result = yield
128
+ ensure
129
+ win.timeout = @poll_ms
130
+ end
131
+ else
132
+ result = yield
133
+ end
134
+ result
135
+ end
136
+
137
+ def decode_mouse(mouse)
138
+ return unless mouse&.respond_to?(:bstate)
139
+
140
+ bstate = mouse.bstate
141
+
142
+ ctrl = (bstate & ::Curses::BUTTON_CTRL).positive?
143
+ meta = (bstate & ::Curses::BUTTON_ALT).positive?
144
+ shift = (bstate & ::Curses::BUTTON_SHIFT).positive?
145
+
146
+ button = mouse_button_from_bstate(bstate)
147
+ return unless button
148
+
149
+ Fatty::MouseEvent.new(
150
+ button: button,
151
+ x: mouse.x,
152
+ y: mouse.y,
153
+ ctrl: ctrl,
154
+ meta: meta,
155
+ shift: shift,
156
+ raw: mouse,
157
+ )
158
+ end
159
+
160
+ def mouse_button_from_bstate(bstate)
161
+ return :scroll_up if (bstate & ::Curses::BUTTON4_PRESSED).positive?
162
+ return :scroll_down if (bstate & ::Curses::BUTTON5_PRESSED).positive?
163
+
164
+ MOUSE_BSTATE_BUTTON_MAP.each do |mask, button|
165
+ return button if (bstate & mask).positive?
166
+ end
167
+
168
+ nil
169
+ end
170
+
171
+ def read_escape_suffix
172
+ suffix = +""
173
+ done = false
174
+
175
+ until done
176
+ ch = with_window_timeout(0) { window.getch }
177
+
178
+ if ch == -1 || !ch
179
+ done = true
180
+ else
181
+ suffix << raw_char_to_s(ch)
182
+ done = true if suffix.end_with?("~")
183
+ end
184
+ end
185
+
186
+ suffix
187
+ end
188
+
189
+ def read_bracketed_paste
190
+ text = +""
191
+ done = false
192
+ limit = 1_000_000
193
+ hit_limit = false
194
+
195
+ until done
196
+ ch = window.getch
197
+
198
+ if ch == -1 || !ch
199
+ done = true
200
+ else
201
+ text << raw_char_to_s(ch)
202
+ if text.end_with?(BRACKETED_PASTE_END)
203
+ text = text.delete_suffix(BRACKETED_PASTE_END)
204
+ done = true
205
+ end
206
+ end
207
+
208
+ if text.length >= limit
209
+ hit_limit = true
210
+ done = true
211
+ end
212
+ end
213
+
214
+ Fatty.debug("EventSource#read_bracketed_paste: hit limit #{limit}", tag: :keycode) if hit_limit
215
+ text
216
+ end
217
+
218
+ def raw_char_to_s(ch)
219
+ case ch
220
+ when String
221
+ ch
222
+ when Integer
223
+ ch.between?(0, 255) ? ch.chr : ""
224
+ else
225
+ ""
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ module Curses
5
+ # The KeyDecoder class is responsible for converting raw key events returned
6
+ # by the curses library into KeyEvent's to be used by Fatty. This allows
7
+ # Fatty to deal with keyboard actions using friendly names, like
8
+ # :home. :page_down, :f5, and so forth. It also sets the modifiers, :shift,
9
+ # :ctrl, and :meta, in the KeyEvent so that the KeyMap can assign different
10
+ # actions to the modified keys.
11
+ class KeyDecoder
12
+ attr_reader :map, :env
13
+
14
+ def initialize(env:)
15
+ @env = env
16
+ @map = {}
17
+ load_builtin_map
18
+ load_user_config
19
+ end
20
+
21
+ # Return a KeyEvent object based on the raw keyboard input as returned by
22
+ # Screen#read_raw.
23
+ def decode(raw)
24
+ return unless raw
25
+
26
+ result =
27
+ case raw
28
+ when Array
29
+ decode_meta(raw)
30
+ else
31
+ decode_single(raw)
32
+ end
33
+ Fatty.debug("#{self.class}#decode(raw: #{raw}) -> #{result}", tag: :keycode)
34
+ result
35
+ end
36
+
37
+ private
38
+
39
+ # Handle the decoding of meta keys which are returned as an Array of the
40
+ # escape code followed by another key, nxt, which can be either a String
41
+ # or an Integer.
42
+ def decode_meta((esc, nxt))
43
+ case nxt
44
+ when String
45
+ # Meta + printable => action key, not self-insert
46
+ KeyEvent.new(
47
+ key: nxt.to_sym,
48
+ meta: true,
49
+ raw: [esc, nxt],
50
+ )
51
+ when Integer
52
+ # Meta + NUL => Meta-Ctrl-@
53
+ if nxt == 0
54
+ return KeyEvent.new(
55
+ key: :'@',
56
+ ctrl: true,
57
+ meta: true,
58
+ raw: [esc, nxt],
59
+ )
60
+ end
61
+
62
+ if (base = @map[nxt])
63
+ KeyEvent.new(
64
+ key: base.key,
65
+ ctrl: base.ctrl?,
66
+ shift: base.shift?,
67
+ meta: true,
68
+ raw: [esc, nxt],
69
+ )
70
+ else
71
+ KeyEvent.new(key: nxt, meta: true, raw: [esc, nxt])
72
+ end
73
+ end
74
+ end
75
+
76
+ # Decode a raw input returned as a single character rather than an Array.
77
+ def decode_single(ch)
78
+ if ch.is_a?(Integer) && @map.key?(ch)
79
+ Fatty.debug("#{self.class}#decode_single: #{ch} found in @map -> #{@map[ch]}", tag: :keycode)
80
+ ev = @map[ch]
81
+
82
+ # Some terminals/configs map printable ASCII keycodes (like 97 for "a")
83
+ # through keydefs. Those KeyEvents often have nil text, which breaks
84
+ # self-insert in sessions that fall back on ev.text.
85
+ if ev.respond_to?(:text) && ev.text.nil? &&
86
+ ev.respond_to?(:ctrl?) && ev.respond_to?(:meta?) &&
87
+ !ev.ctrl? && !ev.meta? &&
88
+ ch.between?(32, 126)
89
+
90
+ KeyEvent.new(
91
+ key: ev.key,
92
+ text: ch.chr,
93
+ ctrl: ev.ctrl?,
94
+ meta: ev.meta?,
95
+ shift: ev.shift?,
96
+ raw: ch,
97
+ )
98
+ else
99
+ ev
100
+ end
101
+ else
102
+ fallback_decode(ch)
103
+ end
104
+ end
105
+
106
+ def fallback_decode(ch, raw: nil)
107
+ case ch
108
+ when Integer
109
+ # Many Ruby curses builds return Integers for printable characters
110
+ # (e.g. 97 for "a"). Treat printable ASCII as self-inserting text.
111
+ if (32..126).cover?(ch)
112
+ fallback_decode(ch.chr, raw: ch)
113
+ elsif ch == 10 || ch == 13
114
+ # Enter can arrive as LF (10) or CR (13). Do this BEFORE ctrl-letter mapping.
115
+ KeyEvent.new(key: :enter, text: "\n", raw: ch)
116
+ elsif ch == 0
117
+ # Ctrl-@ => NUL
118
+ KeyEvent.new(key: :'@', ctrl: true, raw: ch)
119
+ elsif (1..26).cover?(ch)
120
+ KeyEvent.new(key: (ch + 96).chr.to_sym, ctrl: true, raw: ch)
121
+ elsif ch == 27
122
+ KeyEvent.new(key: :escape, raw: ch)
123
+ else
124
+ KeyEvent.new(key: ch, raw: ch)
125
+ end
126
+ when String
127
+ key =
128
+ case ch
129
+ when " " then :space
130
+ when "\t" then :tab
131
+ when "\n", "\r" then :enter
132
+ else
133
+ # bindable “literal” key, e.g. "h" => :h, "x" => :x, "." => :"."
134
+ ch.to_sym
135
+ end
136
+ KeyEvent.new(key: key, text: ch, raw: ch)
137
+ end
138
+ end
139
+
140
+ # Add to the @map any keydefs defined by the user for the terminal
141
+ # detected in the environment. The user config, usually in
142
+ # ~/.config/fatty/keydefs.yml, takes into account the vagaries of how
143
+ # different terminal programs process keys.
144
+ def load_user_config
145
+ config = Fatty::Config.keydefs
146
+ Fatty.info("KeyDecode#load_user_config", config: config, tag: :keycode)
147
+ return unless config
148
+
149
+ terminal = @env[:terminal]
150
+ Fatty.info("KeyDecoder#load_user_config: detected terminal `#{terminal}`")
151
+ Fatty.info("KeyDecoder#load_user_config: only keydefs for `#{terminal}` will be loaded")
152
+ section = config.dig(:terminal, terminal.to_sym, :map)
153
+ return unless section
154
+
155
+ section.each do |code, spec|
156
+ @map[Integer(code.to_s)] = KeyEvent.new(**normalize_spec(spec))
157
+ Fatty.debug("KeyDecoder#load_user_config: user keydef: code: #{code} -> #{spec}")
158
+ end
159
+ end
160
+
161
+ # Add to the @map keydefs as assumed by the curses library, defined in the
162
+ # constant Fatty::CUSRSES_TO_EVENT. These can be overridden by the the
163
+ # user config in #load_user_config.
164
+ def load_builtin_map
165
+ Fatty.info("#{self.class}#load_builtin_map from CURSES_TO_EVENT", tag: :keycode)
166
+ CURSES_TO_EVENT.each do |code, event|
167
+ @map[code] = event
168
+ Fatty.debug("KeyDecoder#load_builtin_map: system keydef: code: #{code} -> event: #{event}", tag: :keycode)
169
+ end
170
+ end
171
+
172
+ # Make sure the user config Hash uses symbols for its keys. Return the
173
+ # Hash as so corrected.
174
+ def normalize_spec(hash)
175
+ spec = hash.transform_keys(&:to_sym)
176
+ if spec[:key].is_a?(String)
177
+ spec[:key] = spec[:key].to_sym
178
+ end
179
+ spec
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This is here to supress a particular warning that is emitted by Curses mouse
4
+ # code.
5
+ module Warning
6
+ class << self
7
+ alias_method :fatty_orig_warn, :warn unless method_defined?(:fatty_orig_warn)
8
+ end
9
+
10
+ def self.warn(msg, category: nil, **kwargs)
11
+ return if msg.include?("undefining the allocator of T_DATA class Curses::MouseEvent")
12
+
13
+ fatty_orig_warn(msg, category: category, **kwargs)
14
+ end
15
+ end
16
+
17
+ # Curses button-related constants:
18
+ #
19
+ # * Button1 (Left button)
20
+ # BUTTON1_PRESSED 0x00000002
21
+ # BUTTON1_RELEASED 0x00000001
22
+ # BUTTON1_CLICKED 0x00000004
23
+ # BUTTON1_DOUBLE_CLICKED 0x00000008
24
+ # BUTTON1_TRIPLE_CLICKED 0x00000010
25
+ #
26
+ # * Button2 (Middle button)
27
+ # BUTTON2_PRESSED 0x00000080
28
+ # BUTTON2_RELEASED 0x00000040
29
+ # BUTTON2_CLICKED 0x00000100
30
+ # BUTTON2_DOUBLE_CLICKED 0x00000200
31
+ # BUTTON2_TRIPLE_CLICKED 0x00000400
32
+ #
33
+ # * Button3 (Right button)
34
+ # BUTTON3_PRESSED 0x00002000
35
+ # BUTTON3_RELEASED 0x00001000
36
+ # BUTTON3_CLICKED 0x00004000
37
+ # BUTTON3_DOUBLE_CLICKED 0x00008000
38
+ # BUTTON3_TRIPLE_CLICKED 0x00010000
39
+ #
40
+ # * Button4 (wheel up)
41
+ # BUTTON4_PRESSED 0x00020000
42
+ # BUTTON4_RELEASED 0x00040000
43
+ # BUTTON4_CLICKED 0x00080000
44
+ # BUTTON4_DOUBLE_CLICKED 0x00100000
45
+ # BUTTON4_TRIPLE_CLICKED 0x00200000
46
+ #
47
+ # * Button5 (wheel down)
48
+ # BUTTON5_PRESSED 0x00200000
49
+ # BUTTON5_RELEASED 0x00400000
50
+ # BUTTON5_CLICKED 0x00800000
51
+ # BUTTON5_DOUBLE_CLICKED 0x01000000
52
+ # BUTTON5_TRIPLE_CLICKED 0x02000000
53
+ #
54
+ # * Modifier Flags
55
+ # BUTTON_SHIFT 0x04000000
56
+ # BUTTON_CTRL 0x08000000
57
+ # BUTTON_ALT 0x10000000
58
+ #
59
+ # The ruby implementation of curses apparently does not expose some
60
+ # mouse-related constants. Here we provide a method to ensure that they are
61
+ # defined.
62
+ module Fatty
63
+ module Curses
64
+ module MouseConstants
65
+ CONSTANTS = {
66
+ # Button 1 (left)
67
+ BUTTON1_RELEASED: 0x00000001,
68
+ BUTTON1_PRESSED: 0x00000002,
69
+ BUTTON1_CLICKED: 0x00000004,
70
+ BUTTON1_DOUBLE_CLICKED: 0x00000008,
71
+ BUTTON1_TRIPLE_CLICKED: 0x00000010,
72
+
73
+ # Button 2 (middle)
74
+ BUTTON2_RELEASED: 0x00000040,
75
+ BUTTON2_PRESSED: 0x00000080,
76
+ BUTTON2_CLICKED: 0x00000100,
77
+ BUTTON2_DOUBLE_CLICKED: 0x00000200,
78
+ BUTTON2_TRIPLE_CLICKED: 0x00000400,
79
+
80
+ # Button 3 (right)
81
+ BUTTON3_RELEASED: 0x00001000,
82
+ BUTTON3_PRESSED: 0x00002000,
83
+ BUTTON3_CLICKED: 0x00004000,
84
+ BUTTON3_DOUBLE_CLICKED: 0x00008000,
85
+ BUTTON3_TRIPLE_CLICKED: 0x00010000,
86
+
87
+ # Wheel up
88
+ BUTTON4_PRESSED: 0x00020000,
89
+ BUTTON4_RELEASED: 0x00040000,
90
+ BUTTON4_CLICKED: 0x00080000,
91
+ BUTTON4_DOUBLE_CLICKED: 0x00100000,
92
+ BUTTON4_TRIPLE_CLICKED: 0x00200000,
93
+
94
+ # Wheel down
95
+ BUTTON5_PRESSED: 0x00200000,
96
+ BUTTON5_RELEASED: 0x00400000,
97
+ BUTTON5_CLICKED: 0x00800000,
98
+ BUTTON5_DOUBLE_CLICKED: 0x01000000,
99
+ BUTTON5_TRIPLE_CLICKED: 0x02000000,
100
+
101
+ # Modifier masks
102
+ BUTTON_SHIFT: 0x04000000,
103
+ BUTTON_CTRL: 0x08000000,
104
+ BUTTON_ALT: 0x10000000,
105
+ }.freeze
106
+
107
+ def self.ensure!
108
+ CONSTANTS.each do |name, value|
109
+ next if ::Curses.const_defined?(name)
110
+
111
+ ::Curses.const_set(name, value)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ module Curses
5
+ module WindowStyling
6
+ ATTR_FLAGS = {
7
+ bold: ::Curses::A_BOLD,
8
+ dim: ::Curses::A_DIM,
9
+ reverse: ::Curses::A_REVERSE,
10
+ underline: ::Curses::A_UNDERLINE,
11
+ }.freeze
12
+
13
+ def pair_attr(role, fallback:)
14
+ palette = context.palette
15
+ spec = palette&.[](role)
16
+ return fallback unless spec && spec[:pair]
17
+
18
+ attr = ::Curses.color_pair(spec[:pair])
19
+
20
+ Array(spec[:attrs]).each do |a|
21
+ attr |= attr_flag(a)
22
+ end
23
+
24
+ attr
25
+ end
26
+
27
+ def attr_flag(name)
28
+ ATTR_FLAGS.fetch(name.to_sym, 0)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Curses
4
+ class Window
5
+ def origin
6
+ [begy, begx]
7
+ end
8
+ end
9
+ end
10
+
11
+ require_relative "curses/patch"
12
+ require_relative "curses/curses_coder"
13
+ require_relative "curses/key_decoder"
14
+ require_relative "curses/event_source"
15
+ require_relative "curses/context"
16
+ require_relative "curses/window_styling"
data/lib/fatty/env.rb ADDED
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ module Env
5
+ extend self
6
+
7
+ def detect
8
+ {
9
+ os: os,
10
+ arch: arch,
11
+ ruby_platform: RUBY_PLATFORM,
12
+ term: ENV["TERM"],
13
+ terminal: detect_terminal_program,
14
+ terminal_version: ENV["KONSOLE_VERSION"] || ENV["TERM_PROGRAM_VERSION"],
15
+ tmux: tmux?,
16
+ screen: screen?,
17
+ ssh: ssh?,
18
+ curses: curses_info,
19
+ }
20
+ end
21
+
22
+ # -----------------------
23
+ # OS / platform
24
+ # -----------------------
25
+
26
+ def os
27
+ case RUBY_PLATFORM
28
+ when /linux/ then :linux
29
+ when /darwin/ then :darwin
30
+ when /bsd/ then :bsd
31
+ when /mswin|mingw|cygwin/ then :windows
32
+ else :unknown
33
+ end
34
+ end
35
+
36
+ def arch
37
+ case RUBY_PLATFORM
38
+ when /x86_64/ then :x86_64
39
+ when /arm64|aarch64/ then :arm64
40
+ else :unknown
41
+ end
42
+ end
43
+
44
+ # -----------------------
45
+ # Terminal identity
46
+ # -----------------------
47
+
48
+ def term_program
49
+ ENV["TERM_PROGRAM"]&.downcase ||
50
+ ENV["COLORTERM"]&.downcase
51
+ end
52
+
53
+ def detect_terminal_program
54
+ return "tmux" if ENV.key?("TMUX")
55
+ return "screen" if ENV.key?("STY")
56
+ return "konsole" if ENV.key?("KONSOLE_VERSION")
57
+ return "kitty" if ENV.key?("KITTY_WINDOW_ID")
58
+ return "wezterm" if ENV["TERM_PROGRAM"]&.downcase == "wezterm"
59
+ return "iterm" if ENV["TERM_PROGRAM"] == "iTerm.app"
60
+ return "ghostty" if ENV["TERM_PROGRAM"]&.downcase == "ghostty"
61
+ return "alacritty" if ENV.key?("ALACRITTY_LOG")
62
+ return "terminator" if ENV.key?("TERMINATOR_UUID")
63
+
64
+ if ENV.key?("TERM_PROGRAM")
65
+ ENV["TERM_PROGRAM"]&.downcase
66
+ elsif ENV.key?("TERM")
67
+ ENV["TERM"]&.downcase
68
+ else
69
+ 'unknown'
70
+ end
71
+ end
72
+
73
+ def tmux?
74
+ ENV.key?("TMUX")
75
+ end
76
+
77
+ def screen?
78
+ ENV.key?("STY")
79
+ end
80
+
81
+ def ssh?
82
+ ENV.key?("SSH_TTY") || ENV.key?("SSH_CONNECTION")
83
+ end
84
+
85
+ # -----------------------
86
+ # ncurses capabilities
87
+ # -----------------------
88
+
89
+ def curses_info
90
+ return {} unless defined?(::Curses)
91
+
92
+ {
93
+ key_min: ::Curses::KEY_MIN,
94
+ key_max: ::Curses::KEY_MAX,
95
+ }
96
+ rescue StandardError
97
+ {}
98
+ end
99
+ end
100
+ end