fatty 0.99.0 → 0.99.3

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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.org +11 -29
  3. data/README.md +2207 -18
  4. data/README.org +2066 -104
  5. data/exe/fatty +236 -93
  6. data/help/help.md +233 -0
  7. data/lib/fatty/action.rb +3 -7
  8. data/lib/fatty/actionable.rb +2 -0
  9. data/lib/fatty/alert.rb +65 -39
  10. data/lib/fatty/ansi/renderer.rb +26 -6
  11. data/lib/fatty/ansi.rb +1 -0
  12. data/lib/fatty/api/alert.rb +15 -0
  13. data/lib/fatty/api/choose.rb +145 -0
  14. data/lib/fatty/api/environment.rb +12 -0
  15. data/lib/fatty/api/keytest.rb +11 -0
  16. data/lib/fatty/api/menu.rb +104 -0
  17. data/lib/fatty/api/output.rb +55 -0
  18. data/lib/fatty/api/progress.rb +24 -0
  19. data/lib/fatty/api/prompt.rb +77 -0
  20. data/lib/fatty/api/status.rb +55 -0
  21. data/lib/fatty/api.rb +38 -0
  22. data/lib/fatty/callback_environment.rb +74 -0
  23. data/lib/fatty/colors/color.rb +366 -232
  24. data/lib/fatty/colors/pairs.rb +2 -0
  25. data/lib/fatty/command.rb +78 -0
  26. data/lib/fatty/config.rb +227 -39
  27. data/lib/fatty/config_files/config.yml +4 -12
  28. data/lib/fatty/config_files/keydefs.yml +13 -23
  29. data/lib/fatty/config_files/themes/capuchin_monk.yml +140 -0
  30. data/lib/fatty/config_files/themes/catppuccin_latte.yml +140 -0
  31. data/lib/fatty/config_files/themes/catppuccin_mocha.yml +140 -0
  32. data/lib/fatty/config_files/themes/cyberpunk.yml +141 -0
  33. data/lib/fatty/config_files/themes/dracula.yml +140 -0
  34. data/lib/fatty/config_files/themes/everforest_dark.yml +140 -0
  35. data/lib/fatty/config_files/themes/gruvbox_dark.yml +140 -0
  36. data/lib/fatty/config_files/themes/gruvbox_light.yml +140 -0
  37. data/lib/fatty/config_files/themes/mono.yml +119 -47
  38. data/lib/fatty/config_files/themes/monokai.yml +140 -0
  39. data/lib/fatty/config_files/themes/nordic.yml +1 -0
  40. data/lib/fatty/config_files/themes/onedark.yml +140 -0
  41. data/lib/fatty/config_files/themes/solarized_dark.yml +1 -0
  42. data/lib/fatty/config_files/themes/solarized_light.yml +140 -0
  43. data/lib/fatty/config_files/themes/terminal.yml +67 -0
  44. data/lib/fatty/config_files/themes/tokyo_night.yml +140 -0
  45. data/lib/fatty/config_files/themes/wordperfect.yml +80 -9
  46. data/lib/fatty/core_ext/hash.rb +19 -0
  47. data/lib/fatty/core_ext/string.rb +19 -12
  48. data/lib/fatty/core_ext.rb +1 -0
  49. data/lib/fatty/curses/context.rb +86 -84
  50. data/lib/fatty/curses/event_source.rb +128 -40
  51. data/lib/fatty/curses/key_decoder.rb +77 -11
  52. data/lib/fatty/env.rb +21 -20
  53. data/lib/fatty/help.rb +1 -29
  54. data/lib/fatty/history/entry.rb +2 -0
  55. data/lib/fatty/history.rb +17 -14
  56. data/lib/fatty/input_buffer.rb +71 -47
  57. data/lib/fatty/input_field.rb +216 -149
  58. data/lib/fatty/key_event.rb +67 -66
  59. data/lib/fatty/key_map.rb +54 -16
  60. data/lib/fatty/keymaps/emacs.rb +40 -6
  61. data/lib/fatty/log_formats/json.rb +2 -0
  62. data/lib/fatty/log_formats/text.rb +2 -0
  63. data/lib/fatty/logger.rb +7 -2
  64. data/lib/fatty/markdown/ansi_renderer.rb +75 -6
  65. data/lib/fatty/markdown/render.rb +2 -3
  66. data/lib/fatty/mouse_event.rb +142 -2
  67. data/lib/fatty/output_buffer.rb +2 -0
  68. data/lib/fatty/pager.rb +158 -47
  69. data/lib/fatty/progress.rb +417 -0
  70. data/lib/fatty/renderer/curses.rb +340 -184
  71. data/lib/fatty/renderer/truecolor.rb +266 -163
  72. data/lib/fatty/renderer.rb +115 -97
  73. data/lib/fatty/screen.rb +2 -0
  74. data/lib/fatty/session/alert_session.rb +39 -21
  75. data/lib/fatty/session/isearch_session.rb +92 -38
  76. data/lib/fatty/session/keytest_session.rb +105 -85
  77. data/lib/fatty/session/modal_session.rb +38 -1
  78. data/lib/fatty/session/output_session.rb +472 -21
  79. data/lib/fatty/session/popup_session.rb +251 -134
  80. data/lib/fatty/session/prompt_session.rb +68 -26
  81. data/lib/fatty/session/search_session.rb +79 -31
  82. data/lib/fatty/session/shell_session.rb +383 -368
  83. data/lib/fatty/session/status_session.rb +167 -0
  84. data/lib/fatty/session.rb +95 -95
  85. data/lib/fatty/terminal/popup_owner.rb +14 -11
  86. data/lib/fatty/terminal.rb +515 -813
  87. data/lib/fatty/themes/loader.rb +4 -2
  88. data/lib/fatty/themes/manager.rb +4 -0
  89. data/lib/fatty/themes/registry.rb +2 -0
  90. data/lib/fatty/themes/resolver.rb +175 -162
  91. data/lib/fatty/version.rb +1 -1
  92. data/lib/fatty/viewport.rb +8 -1
  93. data/lib/fatty.rb +35 -7
  94. metadata +42 -40
  95. data/.envrc +0 -2
  96. data/.simplecov +0 -23
  97. data/.yardopts +0 -4
  98. data/CHANGELOG.md +0 -34
  99. data/Rakefile +0 -15
  100. data/TODO.org +0 -163
  101. data/examples/markdown/native-markdown.md +0 -370
  102. data/examples/markdown/ox-gfm-markdown.md +0 -373
  103. data/examples/markdown/ox-gfm-markdown.org +0 -376
  104. data/fatty.gemspec +0 -42
  105. data/lib/fatty/accept_env.rb +0 -32
  106. data/lib/fatty/config_files/help.md +0 -120
  107. data/lib/fatty/config_files/help.org +0 -124
  108. data/lib/fatty/config_files/themes/wordperfect_light.yml +0 -77
  109. data/lib/fatty/menu_env.rb +0 -22
  110. data/lib/fatty/session/input_session.rb +0 -99
  111. data/lib/fatty/sessions.rb +0 -14
  112. data/lib/fatty/terminal/progress.rb +0 -374
  113. data/lib/fatty/view/alert_view.rb +0 -14
  114. data/lib/fatty/view/cursor_view.rb +0 -18
  115. data/lib/fatty/view/input_view.rb +0 -9
  116. data/lib/fatty/view/output_view.rb +0 -9
  117. data/lib/fatty/view/status_view.rb +0 -14
  118. data/lib/fatty/view.rb +0 -33
  119. data/lib/fatty/views.rb +0 -9
  120. data/sig/fatty.rbs +0 -4
@@ -19,9 +19,9 @@ module Fatty
19
19
  # Those responsibilities belong to EventSource and Renderer.
20
20
  #
21
21
  # Context exists so that all curses state is centralized and never leaks
22
- # into Sessions, Views, or Terminal.
22
+ # into Sessions or Terminal.
23
23
  class Context
24
- DEFAULT_ESC_DELAY = 25
24
+ DEFAULT_ESC_DELAY = 0
25
25
 
26
26
  attr_reader :input_win, :output_win, :status_win, :alert_win
27
27
  attr_reader :rows, :cols, :palette, :truecolor
@@ -33,8 +33,8 @@ module Fatty
33
33
  def start
34
34
  return self if @started
35
35
 
36
- ::Curses.init_screen
37
36
  configure_escape_delay!
37
+ ::Curses.init_screen
38
38
  MouseConstants.ensure!
39
39
 
40
40
  ::Curses.raw
@@ -49,6 +49,33 @@ module Fatty
49
49
  self
50
50
  end
51
51
 
52
+ def started?
53
+ @started
54
+ end
55
+
56
+ def environment
57
+ data = {
58
+ started: started?,
59
+ truecolor: truecolor,
60
+ key_min: ::Curses::KEY_MIN,
61
+ key_max: ::Curses::KEY_MAX,
62
+ }
63
+ if started?
64
+ data.merge(
65
+ lines: ::Curses.lines,
66
+ cols: ::Curses.cols,
67
+ has_colors: ::Curses.has_colors?,
68
+ colors: ::Curses.colors,
69
+ color_pairs: ::Curses.color_pairs,
70
+ can_change_color: ::Curses.can_change_color?,
71
+ )
72
+ else
73
+ data
74
+ end
75
+ rescue StandardError
76
+ {}
77
+ end
78
+
52
79
  def configure_escape_delay!
53
80
  delay =
54
81
  if ENV["ESCDELAY"]
@@ -56,7 +83,7 @@ module Fatty
56
83
  else
57
84
  Fatty::Config.config.dig(:esc_delay)&.to_i
58
85
  end
59
- delay = DEFAULT_ESC_DELAY if delay.nil? || delay <= 0
86
+ delay = DEFAULT_ESC_DELAY if delay.nil? || delay.negative?
60
87
  if ::Curses.respond_to?(:set_escdelay)
61
88
  ::Curses.set_escdelay(delay)
62
89
  else
@@ -136,122 +163,74 @@ module Fatty
136
163
  when "false", "no", "off", "0"
137
164
  false
138
165
  else
139
- truecolor_env?
166
+ Fatty::Env.truecolor_detected?
140
167
  end
141
168
  Fatty.info(
142
169
  "truecolor=#{@truecolor} setting=#{setting.inspect} " \
143
170
  "TERM=#{ENV['TERM'].inspect} COLORTERM=#{ENV['COLORTERM'].inspect} " \
144
171
  "TERM_PROGRAM=#{ENV['TERM_PROGRAM'].inspect} TMUX=#{ENV.key?('TMUX')}",
145
- tag: :themes,
172
+ tag: :theme,
146
173
  )
147
174
  @truecolor
148
175
  end
149
176
 
150
- def truecolor_env?
151
- colorterm = ENV["COLORTERM"].to_s
152
- term = ENV["TERM"].to_s
153
- term_program = ENV["TERM_PROGRAM"].to_s
154
-
155
- colorterm.match?(/truecolor|24bit/i) ||
156
- term.match?(/truecolor|24bit|direct/i) ||
157
- term.match?(/kitty|wezterm|alacritty|ghostty|foot/i) ||
158
- term_program.match?(/kitty|wezterm|alacritty|ghostty|iTerm/i)
159
- end
160
-
161
- # Map a Fatty::Ansi::Style to a curses attribute.
162
- #
163
- # - If style has no explicit fg/bg, keep the themed role pair.
164
- # - If style specifies fg/bg, allocate/init a curses pair on demand.
165
- #
166
- # This is intentionally independent of theme roles; it is for SGR output
167
- # runs inside the output pane.
168
- def ansi_attr(style, fallback_role: :output)
169
- base_pair_id = Fatty::Colors::Pairs::ROLE_TO_PAIR.fetch(fallback_role)
170
- base_attr = ::Curses.color_pair(base_pair_id)
171
-
172
- has_explicit = !(style.fg.nil? && style.bg.nil?)
173
- attr =
174
- if has_explicit
175
- pair_id = ansi_pair_id(style.fg, style.bg, fallback_pair_id: base_pair_id)
176
- ::Curses.color_pair(pair_id)
177
- else
178
- base_attr
179
- end
180
- attr |= ::Curses::A_BOLD if style.bold
181
- attr |= ::Curses::A_UNDERLINE if style.underline
182
- attr |= ::Curses::A_REVERSE if style.reverse
183
- if style.italic && defined?(::Curses::A_ITALIC)
184
- attr |= ::Curses::A_ITALIC
185
- end
186
- if style.strike && defined?(::Curses::A_HORIZONTAL)
187
- attr |= ::Curses::A_HORIZONTAL
188
- end
189
- attr
190
- end
191
-
192
177
  def apply_palette(palette)
178
+ reset_ansi_pairs!
179
+
193
180
  if ::Curses.has_colors?
194
181
  ::Curses.start_color
195
182
  ::Curses.use_default_colors if ::Curses.respond_to?(:use_default_colors)
183
+
196
184
  palette.each_value do |entry|
197
185
  next unless entry[:pair]
198
186
 
199
187
  ::Curses.init_pair(entry[:pair], entry[:fg], entry[:bg])
200
188
  end
201
189
  end
202
- @palette = palette
203
- end
204
-
205
- private
206
-
207
- def ensure_started!
208
- return if @started
209
-
210
- raise "Curses::Context not started"
211
- end
212
-
213
- def close_windows
214
- @output_win&.close
215
- @input_win&.close
216
- @alert_win&.close
217
- @status_win&.close
218
190
 
219
- @output_win = nil
220
- @input_win = nil
221
- @alert_win = nil
222
- @status_win = nil
191
+ @palette = palette
223
192
  end
224
193
 
225
- # Generate a map from [fg, bg] pairs to Cuses pair ids.
226
- def ansi_pair_id(fg, bg, fallback_pair_id:)
227
- has_colors = ::Curses.has_colors?
228
- return fallback_pair_id unless has_colors
194
+ # Return a curses color-pair id for an ANSI foreground/background pair.
195
+ #
196
+ # This owns the dynamic ANSI pair cache. Renderers decide which fg/bg
197
+ # indexes they want; Context decides whether a pair can be allocated.
198
+ def ansi_pair_for(fg, bg, fallback_pair_id:)
199
+ return fallback_pair_id unless ::Curses.has_colors?
229
200
 
230
201
  @ansi_pairs ||= {}
231
202
  @ansi_pair_limit ||= begin
232
- ::Curses.color_pairs
233
- rescue StandardError
234
- 256
235
- end
203
+ ::Curses.color_pairs.to_i
204
+ rescue StandardError
205
+ 256
206
+ end
236
207
 
237
- key = [fg || -1, bg || -1]
208
+ key = [fg.nil? ? -1 : fg.to_i, bg.nil? ? -1 : bg.to_i]
238
209
  cached = @ansi_pairs[key]
239
210
  return cached if cached
240
211
 
241
- # Leave low pair ids for stable theme roles.
242
- start_id = 64
212
+ start_id = Fatty::Colors::Pairs::ROLE_TO_PAIR.values.max + 1
243
213
  @ansi_next_pair_id ||= start_id
244
214
  @ansi_next_pair_id = start_id if @ansi_next_pair_id < start_id
245
215
 
246
- if @ansi_next_pair_id >= @ansi_pair_limit
216
+ if @ansi_pair_limit.positive? && @ansi_next_pair_id >= @ansi_pair_limit
217
+ Fatty.debug(
218
+ "ansi_pair_for exhausted next=#{@ansi_next_pair_id.inspect} " \
219
+ "limit=#{@ansi_pair_limit.inspect} fg=#{fg.inspect} bg=#{bg.inspect} " \
220
+ "fallback=#{fallback_pair_id.inspect}",
221
+ tag: :theme,
222
+ )
247
223
  @ansi_pairs[key] = fallback_pair_id
248
224
  else
249
225
  pair_id = @ansi_next_pair_id
250
226
  @ansi_next_pair_id += 1
251
227
 
252
- fg_i = fg.nil? ? -1 : fg.to_i
253
- bg_i = bg.nil? ? -1 : bg.to_i
254
- ::Curses.init_pair(pair_id, fg_i, bg_i)
228
+ ::Curses.init_pair(
229
+ pair_id,
230
+ fg.nil? ? -1 : fg.to_i,
231
+ bg.nil? ? -1 : bg.to_i,
232
+ )
233
+
255
234
  @ansi_pairs[key] = pair_id
256
235
  end
257
236
 
@@ -260,7 +239,30 @@ module Fatty
260
239
 
261
240
  def reset_ansi_pairs!
262
241
  @ansi_pairs = {}
263
- @ansi_next_pair_id = 1
242
+ @ansi_next_pair_id = nil
243
+ @ansi_pair_limit = nil
244
+ end
245
+
246
+ private
247
+
248
+ # simplecov:disable
249
+
250
+ def ensure_started!
251
+ return if @started
252
+
253
+ raise "Curses::Context not started"
254
+ end
255
+
256
+ def close_windows
257
+ @output_win&.close
258
+ @input_win&.close
259
+ @alert_win&.close
260
+ @status_win&.close
261
+
262
+ @output_win = nil
263
+ @input_win = nil
264
+ @alert_win = nil
265
+ @status_win = nil
264
266
  end
265
267
 
266
268
  def enable_bracketed_paste!
@@ -23,6 +23,13 @@ module Fatty
23
23
  ::Curses::BUTTON3_TRIPLE_CLICKED => :right_triple_clicked
24
24
  }.freeze
25
25
 
26
+ SCROLL_UP_BSTATE = ::Curses::BUTTON4_PRESSED
27
+ SCROLL_DOWN_BSTATE = 0x10000000
28
+ MOUSE_MODIFIER_MASK =
29
+ ::Curses::BUTTON_CTRL |
30
+ ::Curses::BUTTON_ALT |
31
+ ::Curses::BUTTON_SHIFT
32
+
26
33
  ESCAPE_LOOKAHEAD_MS = 0
27
34
 
28
35
  BRACKETED_PASTE_START = "[200~"
@@ -30,32 +37,64 @@ module Fatty
30
37
 
31
38
  attr_reader :context, :key_decoder
32
39
 
33
- def initialize(context:, key_decoder:, poll_ms: 200)
40
+ def initialize(context: Context.new, key_decoder: nil, poll_ms: 200)
34
41
  @context = context
35
- @key_decoder = key_decoder
42
+ @key_decoder = key_decoder || KeyDecoder.new
36
43
  @poll_ms = poll_ms.to_i
37
44
  configure_input_polling!
38
45
  end
39
46
 
40
47
  def next_event
41
- raw = read_raw
48
+ pending_events.shift || read_event
49
+ end
50
+
51
+ def interrupt_pending?
52
+ command = read_event(timeout_ms: 0)
53
+ interrupted = interrupt_command?(command)
54
+
55
+ pending_events << command if command && !interrupted
56
+ interrupted
57
+ end
58
+
59
+ private
60
+
61
+ # simplecov:disable
62
+
63
+ def read_event(timeout_ms: @poll_ms)
64
+ raw = read_raw(timeout_ms:)
42
65
  return unless raw
43
66
 
44
67
  if raw.is_a?(Fatty::MouseEvent)
45
- return [:key, raw]
68
+ Command.session(:active, :key, event: raw)
69
+ elsif raw.is_a?(Array) && raw.first == :paste
70
+ # This :terminal_paste is a terminal bracketed-paste event, not an
71
+ # editor paste action. Treat the pasted bytes as one insert
72
+ # operation instead of decoding them as individual key events.
73
+ Command.session(:active, :terminal_paste, text: raw.last)
74
+ elsif (ev = @key_decoder.decode(raw))
75
+ if ev.key == :resize
76
+ Command.terminal(:resize)
77
+ else
78
+ Command.session(:active, :key, event: ev)
79
+ end
80
+ else
81
+ Fatty.warn("undecoded input: #{raw.inspect}", tag: :input)
82
+ nil
46
83
  end
84
+ end
47
85
 
48
- if raw.is_a?(Array) && raw.first == :paste
49
- return [:cmd, :paste, { text: raw.last }]
50
- end
86
+ def interrupt_command?(command)
87
+ return false unless command&.action == :key
51
88
 
52
- ev = @key_decoder.decode(raw)
53
- return unless ev
89
+ event = command.payload[:event]
90
+ return false unless event.is_a?(Fatty::KeyEvent)
54
91
 
55
- [:key, ev]
92
+ event.ctrl? && [:c, :g].include?(event.key)
56
93
  end
57
94
 
58
- private
95
+ def pending_events
96
+ @pending_events ||= []
97
+ end
59
98
 
60
99
  def window
61
100
  context.input_win
@@ -70,54 +109,98 @@ module Fatty
70
109
  win.timeout = @poll_ms if win.respond_to?(:timeout=)
71
110
  end
72
111
 
73
- def read_raw
112
+ def read_raw(timeout_ms: @poll_ms)
74
113
  return unless window
75
114
 
76
- window.timeout = @poll_ms if window.respond_to?(:timeout=)
115
+ pending = read_pending_raw
116
+ return pending if pending
117
+
118
+ window.timeout = timeout_ms if window.respond_to?(:timeout=)
77
119
 
78
120
  ch = window.getch
79
121
  return if ch == -1
80
122
  return unless ch
81
123
 
124
+ log_keycode(ch)
125
+
82
126
  if ch.is_a?(Integer) && ch == ::Curses::KEY_MOUSE
83
127
  mouse = ::Curses.getmouse
84
128
  Fatty.debug("EventSource#read_raw: bstate=#{mouse&.bstate}", tag: :mouse)
85
129
  return decode_mouse(mouse)
86
130
  end
87
131
 
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
132
+ return ch unless escape_char?(ch)
98
133
 
99
- if ch.is_a?(Integer) && ch == 27
100
- nxt = with_window_timeout(ESCAPE_LOOKAHEAD_MS) { window.getch }
134
+ read_escape_raw(ch)
135
+ end
101
136
 
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
137
+ def read_escape_raw(ch)
138
+ nxt = with_window_timeout(escape_lookahead_ms) { window.getch }
139
+ return ch if nxt == -1 || !nxt
140
+
141
+ if csi_prefix?(nxt)
142
+ suffix = read_escape_suffix
143
+ seq = "[" + suffix
144
+ if seq == BRACKETED_PASTE_START
145
+ [:paste, read_bracketed_paste]
113
146
  else
147
+ push_pending_raw(*suffix.chars.reverse)
148
+ push_pending_raw(nxt)
149
+ ch
150
+ end
151
+ elsif ss3_prefix?(nxt)
152
+ suffix = with_window_timeout(escape_lookahead_ms) { window.getch }
153
+ if suffix == -1 || !suffix
114
154
  [ch, nxt]
155
+ else
156
+ [ch, nxt, suffix]
115
157
  end
116
158
  else
117
- ch
159
+ [ch, nxt]
160
+ end
161
+ end
162
+
163
+ def ss3_prefix?(ch)
164
+ ch == "O".ord || ch == "O"
165
+ end
166
+
167
+ def read_pending_raw
168
+ @pending_raw ||= []
169
+ @pending_raw.shift
170
+ end
171
+
172
+ def push_pending_raw(*values)
173
+ @pending_raw ||= []
174
+ values.each do |value|
175
+ @pending_raw.unshift(value)
118
176
  end
119
177
  end
120
178
 
179
+ def escape_lookahead_ms
180
+ Fatty::Config.config.fetch(:esc_delay, ESCAPE_LOOKAHEAD_MS).to_i
181
+ end
182
+
183
+ def escape_char?(ch)
184
+ ch.is_a?(Integer) && ch == 27
185
+ end
186
+
187
+ def csi_prefix?(ch)
188
+ ch == "[".ord || ch == "["
189
+ end
190
+
191
+ def log_keycode(ch)
192
+ return unless Fatty::Config.config.dig(:log, :tags)&.include?(:keycode)
193
+
194
+ Fatty.debug(
195
+ :curses_getch,
196
+ tag: :keycode,
197
+ ch_class: ch.class.name,
198
+ ch_inspect: ch.inspect,
199
+ ch_int: (ch.is_a?(Integer) ? ch : nil),
200
+ ch_chr: (ch.is_a?(Integer) && ch.between?(0, 255) ? ch.chr : nil),
201
+ )
202
+ end
203
+
121
204
  def with_window_timeout(ms)
122
205
  result = nil
123
206
  win = window
@@ -158,16 +241,21 @@ module Fatty
158
241
  end
159
242
 
160
243
  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?
244
+ button_state = bstate & ~MOUSE_MODIFIER_MASK
245
+ return :scroll_up if (button_state & SCROLL_UP_BSTATE).positive?
246
+ return :scroll_down if (button_state & SCROLL_DOWN_BSTATE).positive?
163
247
 
164
248
  MOUSE_BSTATE_BUTTON_MAP.each do |mask, button|
165
- return button if (bstate & mask).positive?
249
+ return button if (button_state & mask).positive?
166
250
  end
167
251
 
168
252
  nil
169
253
  end
170
254
 
255
+ def mouse_button_pressed?(button_state, const_name)
256
+ ::Curses.const_defined?(const_name) && (button_state & ::Curses.const_get(const_name)).positive?
257
+ end
258
+
171
259
  def read_escape_suffix
172
260
  suffix = +""
173
261
  done = false
@@ -9,9 +9,29 @@ module Fatty
9
9
  # :ctrl, and :meta, in the KeyEvent so that the KeyMap can assign different
10
10
  # actions to the modified keys.
11
11
  class KeyDecoder
12
+ SS3_TO_EVENT = {
13
+ "j" => KeyEvent.new(key: :keypad_multiply),
14
+ "k" => KeyEvent.new(key: :keypad_plus),
15
+ "l" => KeyEvent.new(key: :keypad_comma),
16
+ "m" => KeyEvent.new(key: :keypad_minus),
17
+ "n" => KeyEvent.new(key: :keypad_decimal),
18
+ "o" => KeyEvent.new(key: :keypad_divide),
19
+ "p" => KeyEvent.new(key: :keypad_0),
20
+ "q" => KeyEvent.new(key: :keypad_1),
21
+ "r" => KeyEvent.new(key: :keypad_2),
22
+ "s" => KeyEvent.new(key: :keypad_3),
23
+ "t" => KeyEvent.new(key: :keypad_4),
24
+ "u" => KeyEvent.new(key: :keypad_5),
25
+ "v" => KeyEvent.new(key: :keypad_6),
26
+ "w" => KeyEvent.new(key: :keypad_7),
27
+ "x" => KeyEvent.new(key: :keypad_8),
28
+ "y" => KeyEvent.new(key: :keypad_9),
29
+ "M" => KeyEvent.new(key: :keypad_enter),
30
+ }.freeze
31
+
12
32
  attr_reader :map, :env
13
33
 
14
- def initialize(env:)
34
+ def initialize(env: Env.detect)
15
35
  @env = env
16
36
  @map = {}
17
37
  load_builtin_map
@@ -26,7 +46,11 @@ module Fatty
26
46
  result =
27
47
  case raw
28
48
  when Array
29
- decode_meta(raw)
49
+ if raw.length == 3 && ss3_sequence?(raw)
50
+ decode_ss3(raw)
51
+ else
52
+ decode_meta(raw)
53
+ end
30
54
  else
31
55
  decode_single(raw)
32
56
  end
@@ -36,6 +60,32 @@ module Fatty
36
60
 
37
61
  private
38
62
 
63
+ # simplecov:disable
64
+
65
+ def ss3_sequence?(raw)
66
+ raw[0] == 27 && raw[1] == "O"
67
+ end
68
+
69
+ def decode_ss3(raw)
70
+ _esc, _prefix, suffix = raw
71
+ event = SS3_TO_EVENT[raw_char_to_s(suffix)]
72
+ if event
73
+ KeyEvent.new(
74
+ key: event.key,
75
+ ctrl: event.ctrl?,
76
+ meta: event.meta?,
77
+ shift: event.shift?,
78
+ raw: raw,
79
+ )
80
+ else
81
+ KeyEvent.new(key: raw_char_to_s(suffix).to_sym, raw: raw)
82
+ end
83
+ end
84
+
85
+ def raw_char_to_s(value)
86
+ value.is_a?(Integer) ? value.chr : value.to_s
87
+ end
88
+
39
89
  # Handle the decoding of meta keys which are returned as an Array of the
40
90
  # escape code followed by another key, nxt, which can be either a String
41
91
  # or an Integer.
@@ -143,21 +193,35 @@ module Fatty
143
193
  # different terminal programs process keys.
144
194
  def load_user_config
145
195
  config = Fatty::Config.keydefs
146
- Fatty.info("KeyDecode#load_user_config", config: config, tag: :keycode)
147
- return unless config
196
+ return nil unless config
148
197
 
149
198
  terminal = @env[:terminal]
150
199
  Fatty.info("KeyDecoder#load_user_config: detected terminal `#{terminal}`")
151
200
  Fatty.info("KeyDecoder#load_user_config: only keydefs for `#{terminal}` will be loaded")
152
- section = config.dig(:terminal, terminal.to_sym, :map)
201
+ section = keydef_section(config, terminal.to_s)
153
202
  return unless section
154
203
 
155
204
  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}")
205
+ register_user_keydef(code.to_i, spec)
158
206
  end
159
207
  end
160
208
 
209
+ def register_user_keydef(code, spec)
210
+ @map[Integer(code.to_s)] = KeyEvent.new(**normalize_spec(spec))
211
+ Fatty.debug("KeyDecoder#load_user_config: user keydef: code: #{code} -> #{spec}", tag: :keycode)
212
+ end
213
+
214
+ # Because =fat_config= canonicalizes keys by translating hyphen (-) to
215
+ # underscore (_) so that the keys are valid ruby varaible names, we need
216
+ # to look for that variation of the terminal name to get the right
217
+ # section of the keydefs.yml config Hash.
218
+ def keydef_section(config, terminal)
219
+ config[terminal.to_sym] ||
220
+ config[terminal] ||
221
+ config[terminal.tr("-", "_").to_sym] ||
222
+ config[terminal.tr("-", "_")]
223
+ end
224
+
161
225
  # Add to the @map keydefs as assumed by the curses library, defined in the
162
226
  # constant Fatty::CUSRSES_TO_EVENT. These can be overridden by the the
163
227
  # user config in #load_user_config.
@@ -171,11 +235,13 @@ module Fatty
171
235
 
172
236
  # Make sure the user config Hash uses symbols for its keys. Return the
173
237
  # 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
238
+ def normalize_spec(value)
239
+ unless value.is_a?(Hash)
240
+ raise ArgumentError, "keydef spec must be a Hash, got #{value.class}: #{value.inspect}"
178
241
  end
242
+
243
+ spec = value.transform_keys(&:to_sym)
244
+ spec[:key] = spec[:key].to_sym if spec[:key].is_a?(String)
179
245
  spec
180
246
  end
181
247
  end
data/lib/fatty/env.rb CHANGED
@@ -6,19 +6,22 @@ module Fatty
6
6
 
7
7
  def detect
8
8
  {
9
- os: os,
10
9
  arch: arch,
10
+ os: os,
11
11
  ruby_platform: RUBY_PLATFORM,
12
+ screen: screen?,
13
+ ssh: ssh?,
12
14
  term: ENV["TERM"],
13
15
  terminal: detect_terminal_program,
14
16
  terminal_version: ENV["KONSOLE_VERSION"] || ENV["TERM_PROGRAM_VERSION"],
15
17
  tmux: tmux?,
16
- screen: screen?,
17
- ssh: ssh?,
18
- curses: curses_info,
18
+ truecolor_detected: truecolor_detected?,
19
+ curses: {},
19
20
  }
20
21
  end
21
22
 
23
+ private
24
+
22
25
  # -----------------------
23
26
  # OS / platform
24
27
  # -----------------------
@@ -60,16 +63,29 @@ module Fatty
60
63
  return "ghostty" if ENV["TERM_PROGRAM"]&.downcase == "ghostty"
61
64
  return "alacritty" if ENV.key?("ALACRITTY_LOG")
62
65
  return "terminator" if ENV.key?("TERMINATOR_UUID")
66
+ return "gnome-terminal" if ENV.key?("GNOME_TERMINAL_SCREEN") || ENV.key?("GNOME_TERMINAL_SERVICE")
67
+ return "vte" if ENV.key?("VTE_VERSION")
63
68
 
64
69
  if ENV.key?("TERM_PROGRAM")
65
70
  ENV["TERM_PROGRAM"]&.downcase
66
71
  elsif ENV.key?("TERM")
67
72
  ENV["TERM"]&.downcase
68
73
  else
69
- 'unknown'
74
+ "unknown"
70
75
  end
71
76
  end
72
77
 
78
+ def self.truecolor_detected?
79
+ colorterm = ENV["COLORTERM"].to_s
80
+ term = ENV["TERM"].to_s
81
+ term_program = ENV["TERM_PROGRAM"].to_s
82
+
83
+ colorterm.match?(/truecolor|24bit/i) ||
84
+ term.match?(/truecolor|24bit|direct/i) ||
85
+ term.match?(/kitty|wezterm|alacritty|ghostty|foot/i) ||
86
+ term_program.match?(/kitty|wezterm|alacritty|ghostty|iTerm/i)
87
+ end
88
+
73
89
  def tmux?
74
90
  ENV.key?("TMUX")
75
91
  end
@@ -81,20 +97,5 @@ module Fatty
81
97
  def ssh?
82
98
  ENV.key?("SSH_TTY") || ENV.key?("SSH_CONNECTION")
83
99
  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
100
  end
100
101
  end