fatty 0.99.8.3 → 0.99.8.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f79cba23bcf8b408c343c3c732d0af6e93bdf677f3492838f3e1a16473bc05fd
4
- data.tar.gz: f3c72c60150347df09d4f8f17238566c78c4ade01bd33b73dffe3db48f462f24
3
+ metadata.gz: 6d2ce5d4499e640b71d333ef143a02f3590aa44514f6ad64c21017ff4eb829f7
4
+ data.tar.gz: 5c9ac04a0c7975c71756c3a9364ab0222e43ec5696150cfed3526b589b3cdd48
5
5
  SHA512:
6
- metadata.gz: a181c65be329a3e0ba0e4084538cee20f25f8250c98e764c9f60ededa9c95af7bda619f28a7ed54d747bbb46efb78ec5460a84263fb5ce4efdf2ba0f2b496e20
7
- data.tar.gz: abafce8dfdfa7446f229b2742b2de1cf9adcfae99e41fb66348e766153efa971bcf13de6239eef5ef2843258381bb6da268f806b2c389fa729a62a5b1703db94
6
+ metadata.gz: a9fab1feaae8bacc7dcda281b9081fed3bab00440445763ab25577399e233321574828b1b014fc3ab9121d35aee69be66dbf2380cc03a16697c4aab4605930cb
7
+ data.tar.gz: 7c6c437ca0b0b3e5ab28a4488a30a770ef3ab127b2467c83b255e9551db7ce40294e469c2e4817f964171bde56847ef6bdfe99e2a936bbcc17c729bd0c7b6226
data/README.md CHANGED
@@ -990,6 +990,11 @@ Alerts are short-lived, non-scrolling messages shown below the input field. They
990
990
 
991
991
  ## Command-line Editing
992
992
 
993
+ Press `M-?` (Alt-?, or Escape followed by ?) to browse the current
994
+ keybindings, including user overrides and action descriptions. The help
995
+ uses the usual pager controls, including search. Press `q` to return to
996
+ the previous output, pager position, and unfinished command line.
997
+
993
998
  `fatty` aims to have a full-featured command-line editor for editing a single line of text that is sent to the `on_accept` proc for the `Terminal`. The default keybindings are based on Emacs keybindings, but they are fully customizable. Implementing `vim`-like keybindings is not currently possible because `fatty` has no notion of editing "modes" as would be required for any kind of a modal editor—at least not yet. Maybe some day.
994
999
 
995
1000
 
data/README.org CHANGED
@@ -855,6 +855,11 @@ uses this area to warn the user of unrecognized key codes and of unbound key
855
855
  presses.
856
856
 
857
857
  ** Command-line Editing
858
+ Press =M-?= (Alt-?, or Escape followed by ?) to browse the current
859
+ keybindings, including user overrides and action descriptions. The help
860
+ uses the usual pager controls, including search. Press =q= to return to
861
+ the previous output, pager position, and unfinished command line.
862
+
858
863
  =fatty= aims to have a full-featured command-line editor for editing a single
859
864
  line of text that is sent to the =on_accept= proc for the =Terminal=. The
860
865
  default keybindings are based on Emacs keybindings, but they are fully
data/help/help.md CHANGED
@@ -60,6 +60,11 @@ Here are the commands builtin to `fatty`
60
60
 
61
61
  ## Keybindings
62
62
 
63
+ Press `M-?` (Alt-?, or Escape followed by ?) to browse the current
64
+ keybindings, including user overrides and action descriptions. Use the
65
+ usual pager controls to scroll and search; `q` restores the previous output
66
+ and unfinished command line.
67
+
63
68
  The following tables explain the keybindings available in \`fatty\` in different contexts. Named keys are indicated by \`:name\` and key categories, such as \`<digits>\` are indicated with brackets.
64
69
 
65
70
 
@@ -37,6 +37,10 @@ module Fatty
37
37
  POPUP_FRAME = 33
38
38
  POPUP_COUNTS = 34
39
39
 
40
+ MARKDOWN_H1 = 35
41
+ MARKDOWN_H2 = 36
42
+ MARKDOWN_TABLE_HEADER = 37
43
+
40
44
  # Role -> pair id mapping for iteration.
41
45
  ROLE_TO_PAIR = {
42
46
  output: OUTPUT,
@@ -69,6 +73,10 @@ module Fatty
69
73
  popup_input: POPUP_INPUT,
70
74
  popup_frame: POPUP_FRAME,
71
75
  popup_counts: POPUP_COUNTS,
76
+
77
+ markdown_h1: MARKDOWN_H1,
78
+ markdown_h2: MARKDOWN_H2,
79
+ markdown_table_header: MARKDOWN_TABLE_HEADER,
72
80
  }.freeze
73
81
  end
74
82
  end
@@ -81,7 +81,7 @@ markdown_h1:
81
81
  attrs: [bold]
82
82
 
83
83
  markdown_h2:
84
- fg: "#00d7ff"
84
+ fg: "#ffff00"
85
85
  bg: "#000000"
86
86
  attrs: [bold]
87
87
 
@@ -2,6 +2,10 @@ name: nordic
2
2
  inherit: null
3
3
  markdown_code_theme: monokai
4
4
 
5
+ markdown_h2:
6
+ fg: "#88c0d0"
7
+ attrs: [bold]
8
+
5
9
  output:
6
10
  fg: "#d8dee9"
7
11
  bg: "#2e3440"
@@ -2,6 +2,10 @@ name: solarized_dark
2
2
  inherit: null
3
3
  markdown_code_theme: solarized_dark
4
4
 
5
+ markdown_h2:
6
+ fg: "#b58900"
7
+ attrs: [bold]
8
+
5
9
  output:
6
10
  fg: "#839496"
7
11
  bg: "#002b36"
@@ -155,6 +155,7 @@ module Fatty
155
155
 
156
156
  ::Curses.reset_prog_mode
157
157
  ::Curses.refresh
158
+ touch_windows!
158
159
  enable_bracketed_paste!
159
160
  nil
160
161
  end
@@ -172,6 +173,14 @@ module Fatty
172
173
  @started = false
173
174
  end
174
175
 
176
+ def touch_windows!
177
+ [@output_win, @status_win, @input_win, @alert_win].each do |win|
178
+ win.touch if win&.respond_to?(:touch)
179
+ end
180
+ ::Curses.stdscr.touch if ::Curses.respond_to?(:stdscr) && ::Curses.stdscr.respond_to?(:touch)
181
+ nil
182
+ end
183
+
175
184
  # Map a Fatty::Ansi::Style to a curses attribute.
176
185
  #
177
186
  # - If style has no explicit fg/bg, we keep the themed role pair.
data/lib/fatty/help.rb CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  module Fatty
4
4
  module Help
5
+ CONTEXT_DESCRIPTIONS = {
6
+ text: "The text context supplies the shared editing keys for moving the cursor, " \
7
+ "deleting text, undoing edits, and copying or pasting. It is active while editing " \
8
+ "the command line, prompts, search queries, and popup filters. The input context " \
9
+ "below adds command-line-specific keys; other text fields add their own context. " \
10
+ "Those more specific bindings take precedence over text bindings.",
11
+ input: "The input context is active while you compose a command at the main prompt. " \
12
+ "It adds command history, completion, and submission to the shared text editing " \
13
+ "keys above. Fatty checks input bindings first, then text, then terminal. " \
14
+ "These input bindings do not apply to other prompts or search fields, or while paging output.",
15
+ terminal: "The terminal context supplies shared controls available from command input, " \
16
+ "the pager, prompts, searches, and popups. It is checked after the other active " \
17
+ "contexts, so these keys work throughout the normal interface unless a more " \
18
+ "specific context binds the same key. Special modes such as key testing handle " \
19
+ "keys separately.",
20
+ paging: "The paging context is active while you browse output, including this help. " \
21
+ "Its keys scroll, jump, search, narrow the displayed lines, or leave the pager. " \
22
+ "Printable keys act as pager commands rather than editing the command line. " \
23
+ "Terminal bindings remain available when paging does not bind the same key.",
24
+ popup: "The popup context is active in menus and selection lists. Its keys move " \
25
+ "among items, accept a choice, or cancel. Text bindings edit the popup's filter, " \
26
+ "while popup bindings take precedence for navigation and selection. Terminal " \
27
+ "bindings are checked last.",
28
+ popup_multi: "The popup_multi context adds selection controls to popups that let " \
29
+ "you choose multiple items. Its bindings are checked before popup, text, and " \
30
+ "terminal bindings. By default, Space toggles the current item instead of " \
31
+ "inserting a space into the filter.",
32
+ prompt: "The prompt context is active when Fatty asks you to enter an answer in " \
33
+ "a prompt. It supplies acceptance, cancellation, and prompt-history controls. " \
34
+ "The shared text bindings edit your answer; terminal bindings are checked last. " \
35
+ "The main command line uses input instead of this context.",
36
+ search: "The search context is active while you enter a pager search query. It " \
37
+ "provides search history, acceptance and cancellation, match navigation, and " \
38
+ "switching between literal text and regular expressions. Text bindings edit " \
39
+ "the query, and terminal bindings are checked last. After leaving the search " \
40
+ "field, paging bindings control the output again.",
41
+ isearch: "The isearch context is active during incremental pager search, where " \
42
+ "the match updates as you type. Its keys step through matches, recall search " \
43
+ "history, accept the search, or cancel and restore the earlier view. Text " \
44
+ "bindings edit the query, and terminal bindings are checked last.",
45
+ }.freeze
46
+ private_constant :CONTEXT_DESCRIPTIONS
47
+
5
48
  def self.path
6
49
  File.expand_path("../../help/help.md", __dir__)
7
50
  end
@@ -9,5 +52,78 @@ module Fatty
9
52
  def self.text
10
53
  File.read(path)
11
54
  end
55
+
56
+ def self.keybindings(keymap, width: 83)
57
+ lines = ["# Current keybindings", "",
58
+ "Fatty is the terminal user-interface library used by this application. " \
59
+ "It provides command-line editing, output paging and search, and interactive prompts and menus.", "",
60
+ "Use the pager to browse or search; quit paging to return.", "",
61
+ "The tables below show the loaded keybindings for each context, including user overrides. " \
62
+ "Keys use Emacs-like notation: C- means hold Control, M- means hold Alt (also called Meta), " \
63
+ "and C-M- means hold both. Shift may be indicated by the final key name, as in M-R, " \
64
+ "or by an explicit S- prefix, as in M-S-r. Both examples mean Alt-Shift-R. " \
65
+ "Printable characters insert text in text-entry contexts.", "",
66
+ "The Action column gives the name of the Fatty action bound to each key. " \
67
+ "These names are useful when customizing keybindings or working on Fatty itself."]
68
+ key_width, action_width, description_width = keybinding_column_widths(width)
69
+ keymap.bindings.sort_by { |context, _| [[:text, :input].index(context) || 2, context.to_s] }.each do |context, bindings|
70
+ description = CONTEXT_DESCRIPTIONS.fetch(context) do
71
+ "This context is supplied by the application or a custom keymap. Its bindings " \
72
+ "apply when the application activates it; the application determines how " \
73
+ "it combines with other contexts."
74
+ end
75
+ lines << "" << "## In the #{context} context:" << "" << description << ""
76
+ lines << "| Key | Action | Description |" << "| --- | --- | --- |"
77
+ entries = bindings.map do |gesture, binding|
78
+ key = gesture.is_a?(MouseGesture) ? gesture.button : gesture.key
79
+ label = KeyEvent.key_to_str(key: key, ctrl: gesture.ctrl, meta: gesture.meta, shift: gesture.shift)
80
+ action, *args = Array(binding)
81
+ description = Actions.lookup(action)&.fetch(:doc)
82
+ action_text = ([action] + args.map(&:inspect)).join(" ")
83
+ [label, action_text, description]
84
+ end
85
+ entries.sort_by(&:first).each do |label, action, description|
86
+ keys = wrap_cell(label, key_width)
87
+ actions = wrap_cell(action, action_width)
88
+ descriptions = wrap_cell(description, description_width)
89
+ [keys.length, actions.length, descriptions.length].max.times do |index|
90
+ lines << "| #{escape_cell(keys[index])} | #{escape_cell(actions[index])} | #{escape_cell(descriptions[index])} |"
91
+ end
92
+ end
93
+ end
94
+ lines.join("\n")
95
+ end
96
+
97
+ def self.keybinding_column_widths(width)
98
+ width = [width, 83].min
99
+ key_width = [[width / 6, 12].min, 3].max
100
+ action_width = [[width - 12 - key_width - 11, 27].min, 6].max
101
+ [key_width, action_width, [width - 12 - key_width - action_width, 11].max]
102
+ end
103
+
104
+ def self.wrap_cell(text, width)
105
+ lines = []
106
+ line = +""
107
+ text.to_s.split.each do |word|
108
+ if !line.empty? && line.length + word.length + 1 > width
109
+ lines << line
110
+ line = +""
111
+ end
112
+ while word.length > width
113
+ lines << word.slice!(0, width)
114
+ end
115
+ line << " " unless line.empty?
116
+ line << word
117
+ end
118
+ lines << line unless line.empty?
119
+ lines
120
+ end
121
+ private_class_method :wrap_cell
122
+
123
+ def self.escape_cell(text)
124
+ # Entities keep punctuation keys (especially pipes) inside their cells.
125
+ text.to_s.each_char.map { |char| char.match?(/[[:alnum:] ]/) ? char : "&##{char.ord};" }.join
126
+ end
127
+ private_class_method :escape_cell
12
128
  end
13
129
  end
@@ -519,9 +519,13 @@ module Fatty
519
519
  end
520
520
 
521
521
  def autosuggestion_suffix
522
- return "" unless autosuggestion_visible?
522
+ suggestion = autosuggestion.to_s
523
+ text = buffer.text.to_s
524
+ return "" if suggestion.empty?
525
+ return "" unless suggestion.start_with?(text)
526
+ return "" if suggestion == text
523
527
 
524
- autosuggestion.to_s.delete_prefix(buffer.text.to_s)
528
+ suggestion.delete_prefix(text)
525
529
  end
526
530
 
527
531
  def accept_autosuggestion!
@@ -4,6 +4,7 @@ module Fatty
4
4
  module Keymaps
5
5
  def self.emacs
6
6
  map = KeyMap.new
7
+ map.bind(context: :terminal, key: :'?', meta: true, action: :show_keybindings)
7
8
 
8
9
  # Motion
9
10
  map.bind(key: :f, ctrl: true, action: :move_right)
@@ -7,12 +7,14 @@ module Fatty
7
7
  class AnsiRenderer < Redcarpet::Render::Base
8
8
  HARD_BREAK = "\uE000"
9
9
 
10
- def initialize(width: 80, palette: nil, theme: nil, truecolor: false)
10
+ def initialize(width: 80, palette: nil, theme: nil, truecolor: false, table_widths: nil, table_borders: false)
11
11
  super()
12
12
  @width = width.to_i
13
13
  @palette = palette || {}
14
14
  @theme = theme || {}
15
15
  @truecolor = truecolor
16
+ @table_widths = table_widths
17
+ @table_borders = table_borders
16
18
  end
17
19
 
18
20
  def block_code(code, language)
@@ -37,6 +39,10 @@ module Fatty
37
39
  text
38
40
  end
39
41
 
42
+ def entity(text)
43
+ CGI.unescapeHTML(text.to_s)
44
+ end
45
+
40
46
  def raw_html(html)
41
47
  text = CGI.unescapeHTML(html.to_s)
42
48
  render_inline_html(text)
@@ -186,6 +192,10 @@ module Fatty
186
192
  end
187
193
  end
188
194
 
195
+ if @table_borders
196
+ rendered.unshift(render_table_separator(widths, joints: ["┌", "┬", "┐"]))
197
+ rendered << render_table_separator(widths, joints: ["└", "┴", "┘"])
198
+ end
189
199
  rendered.join("\n") + "\n\n"
190
200
  end
191
201
 
@@ -207,7 +217,7 @@ module Fatty
207
217
  end
208
218
  end
209
219
 
210
- widths
220
+ widths.each_with_index.map { |width, index| [width, Array(@table_widths)[index].to_i].max }
211
221
  end
212
222
 
213
223
  def render_table_row_cells(row, widths, header: false)
@@ -220,12 +230,12 @@ module Fatty
220
230
  " │ #{cells.join(' │ ')} │"
221
231
  end
222
232
 
223
- def render_table_separator(widths)
233
+ def render_table_separator(widths, joints: @table_borders ? ["├", "┼", "┤"] : [TABLE_BAR, TABLE_BAR, TABLE_BAR])
224
234
  parts = widths.map do |width|
225
235
  TABLE_DASH * (width + 2)
226
236
  end
227
237
 
228
- " #{TABLE_BAR}#{parts.join(TABLE_BAR)}#{TABLE_BAR}"
238
+ " #{joints[0]}#{parts.join(joints[1])}#{joints[2]}"
229
239
  end
230
240
 
231
241
  def pad_visible(text, width)
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Fatty
4
4
  module Markdown
5
- def self.render(text, width: 80, palette: nil, theme: nil, truecolor: false) #
5
+ def self.render(text, width: 80, palette: nil, theme: nil, truecolor: false, table_widths: nil, table_borders: false)
6
6
  markdown = Redcarpet::Markdown.new(
7
- Fatty::AnsiRenderer.new(width: width, palette: palette, theme: theme, truecolor: truecolor),
7
+ Fatty::AnsiRenderer.new(width: width, palette: palette, theme: theme, truecolor: truecolor,
8
+ table_widths: table_widths, table_borders: table_borders),
8
9
  tables: true,
9
10
  fenced_code_blocks: true,
10
11
  autolink: true,
@@ -523,27 +523,16 @@ module Fatty
523
523
  base_attr = pair_attr(:output, fallback: ::Curses::A_NORMAL)
524
524
  win.attrset(base_attr)
525
525
  win.scrl(delta)
526
- if delta.positive?
527
- start_y = height - delta
528
- start_y = 0 if start_y < 0
529
-
530
- (start_y...height).each do |y|
531
- visible_line = lines[y]
532
- draw_output_row(
533
- win,
534
- line: visible_line.text,
535
- fragments: visible_line.fragments,
536
- y: y,
537
- abs_line: visible_line.number - 1,
538
- highlights: highlights,
539
- )
526
+ exposed_rows =
527
+ if delta.positive?
528
+ [height - delta, 0].max...height
529
+ else
530
+ 0...[-delta, height].min
540
531
  end
541
- else
542
- count = -delta
543
- count = height if count > height
544
532
 
545
- (0...count).each do |y|
546
- visible_line = lines[y]
533
+ exposed_rows.each do |y|
534
+ visible_line = lines[y]
535
+ if visible_line
547
536
  draw_output_row(
548
537
  win,
549
538
  line: visible_line.text,
@@ -552,6 +541,11 @@ module Fatty
552
541
  abs_line: visible_line.number - 1,
553
542
  highlights: highlights,
554
543
  )
544
+ else
545
+ win.setpos(y, 0)
546
+ win.attrset(base_attr)
547
+ win.addstr("~")
548
+ win.clrtoeol
555
549
  end
556
550
  end
557
551
  stage_window(win)
@@ -624,15 +618,16 @@ module Fatty
624
618
  win.bkgdset(base_attr) if win.respond_to?(:bkgdset)
625
619
  win.erase
626
620
 
627
- lines.each_with_index do |visible_line, y|
621
+ viewport.height.times do |y|
622
+ visible_line = lines[y]
628
623
  draw_output_row(
629
624
  win,
630
- line: visible_line.text,
631
- fragments: visible_line.fragments,
625
+ line: visible_line ? visible_line.text : "~",
626
+ fragments: visible_line&.fragments,
632
627
  y: y,
633
- abs_line: visible_line.number - 1,
628
+ abs_line: visible_line ? visible_line.number - 1 : nil,
634
629
  highlights: highlights,
635
- line_number: visible_line.number,
630
+ line_number: visible_line&.number,
636
631
  line_number_width: line_number_width,
637
632
  )
638
633
  end
@@ -510,7 +510,7 @@ module Fatty
510
510
 
511
511
  rect.rows.times do |y|
512
512
  visible_line = lines[y]
513
- text = visible_line&.text.to_s
513
+ text = visible_line ? visible_line.text : (y < viewport.height ? "~" : "")
514
514
  abs_line = visible_line ? visible_line.number - 1 : nil
515
515
  ranges = highlight_ranges_for_line(highlights, abs_line)
516
516
  segments = []
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class KeybindingsSession < OutputSession
5
+ def init(terminal:)
6
+ super
7
+ width = [screen.output_rect.cols, 83].min
8
+ text = Markdown.render(
9
+ Help.keybindings(keymap, width: width),
10
+ width: [width, 80].min,
11
+ table_widths: Help.keybinding_column_widths(width),
12
+ table_borders: true,
13
+ )
14
+ # Keep Markdown roles semantic so theme changes recolor existing help.
15
+ sections = text.lines.chunk { |line| help_role(line.chomp) }
16
+ [
17
+ Command.session(id, :resize),
18
+ *sections.map { |role, lines| Command.session(id, :append, text: lines.join, role: role, follow: false) },
19
+ Command.session(id, :set_mode, mode: :paging),
20
+ ]
21
+ end
22
+
23
+ def update(command)
24
+ return [Command.terminal(:pop_modal)] if command.action == :quit_paging
25
+
26
+ super
27
+ end
28
+
29
+ def handle_resize
30
+ [Command.session(id, :resize)]
31
+ end
32
+
33
+ def show_keybindings
34
+ []
35
+ end
36
+
37
+ private
38
+
39
+ def help_role(line)
40
+ if line == "Current keybindings"
41
+ :markdown_h1
42
+ elsif keymap.contexts.any? { |context| line == "In the #{context} context:" }
43
+ :markdown_h2
44
+ elsif line.start_with?(" │ Key ")
45
+ :markdown_table_header
46
+ else
47
+ :output
48
+ end
49
+ end
50
+
51
+ def keymap_contexts
52
+ [:paging, :terminal]
53
+ end
54
+
55
+ def apply_action(action, args, event:)
56
+ return [Command.terminal(:pop_modal)] if action == :quit_paging
57
+
58
+ super
59
+ end
60
+ end
61
+ end
@@ -24,6 +24,7 @@ module Fatty
24
24
  @line_numbers = false
25
25
  @narrow_query = nil
26
26
  @visible_lines = nil
27
+ @pending_command_reset = false
27
28
  mode = Fatty::Config.config.dig(:output, :mode)&.to_sym || :paging
28
29
  @default_output_mode = mode
29
30
  @pager = Fatty::Pager.new(output: @output, viewport: @viewport, mode: mode, lines: -> { visible_lines })
@@ -52,6 +53,7 @@ module Fatty
52
53
  []
53
54
  end
54
55
  when :append
56
+ prepare_for_command_output!
55
57
  case payload[:mode]
56
58
  when :scrolling
57
59
  pager.toggle_paging_mode if pager.mode == :paging
@@ -86,11 +88,15 @@ module Fatty
86
88
  resize_output!
87
89
  []
88
90
  when :begin_command
89
- reset_for_command!
90
- pager.begin_command!(anchor: output.lines.length)
91
+ @narrow_query = nil
92
+ invalidate_visible_lines!
93
+ @pending_command_reset = true
91
94
  []
92
95
  when :finish_command
93
- pager.finish_command!
96
+ unless @pending_command_reset
97
+ pager.finish_command!
98
+ end
99
+ @pending_command_reset = false
94
100
  []
95
101
  when :quit_paging
96
102
  pager.quit
@@ -509,6 +515,14 @@ module Fatty
509
515
  @pager.reset!(mode: mode)
510
516
  end
511
517
 
518
+ def prepare_for_command_output!
519
+ return unless @pending_command_reset
520
+
521
+ reset_for_command!
522
+ pager.begin_command!(anchor: output.lines.length)
523
+ @pending_command_reset = false
524
+ end
525
+
512
526
  # When the pager is active, the last output row is reserved for the pager
513
527
  # InputField. This returns the viewport to use for rendering the output
514
528
  # content itself.
data/lib/fatty/session.rb CHANGED
@@ -69,6 +69,11 @@ module Fatty
69
69
  Command.terminal(:cycle_theme)
70
70
  end
71
71
 
72
+ desc "Display current keybindings in a temporary pager"
73
+ action :show_keybindings do
74
+ Command.terminal(:push_modal, session: KeybindingsSession.new(keymap: keymap), owner: self)
75
+ end
76
+
72
77
  desc "Choose a theme from a popup"
73
78
  action :choose_theme do
74
79
  Command.terminal(:choose_theme)
@@ -168,6 +173,7 @@ require_relative "session/popup_session"
168
173
  require_relative "session/search_session"
169
174
  require_relative "session/isearch_session"
170
175
  require_relative "session/output_session"
176
+ require_relative "session/keybindings_session"
171
177
  require_relative "session/shell_session"
172
178
  require_relative 'session/prompt_session'
173
179
  require_relative 'session/keytest_session'
@@ -97,6 +97,7 @@ module Fatty
97
97
  @last_render_time = nil
98
98
  @restore_cursor_after_render = true
99
99
  @render_count = 0
100
+ @deferred_render = false
100
101
  @deferred_count = 0
101
102
  end
102
103
 
@@ -149,6 +150,8 @@ module Fatty
149
150
  text: warning,
150
151
  ),
151
152
  )
153
+ elsif (hint = keybinding_help_hint)
154
+ apply_command(Command.session(:alert, :show, role: :info, text: hint))
152
155
  end
153
156
 
154
157
  Fatty.debug("@sessions: #{@sessions.map(&:id).join('==')}")
@@ -161,6 +164,9 @@ module Fatty
161
164
  loop_count = 0
162
165
  while @running
163
166
  loop_count += 1
167
+ deferred_render = @deferred_render
168
+ @deferred_render = false
169
+ render_frame if deferred_render
164
170
  @session_dirty = false
165
171
  @immediate_render = false
166
172
  if (cmd = event_source.next_event)
@@ -194,8 +200,11 @@ module Fatty
194
200
  yield
195
201
  ensure
196
202
  ctx.resume
203
+ reconcile_terminal_size!
197
204
  renderer.invalidate!
205
+ renderer.clear_physical_screen! if renderer.context.truecolor
198
206
  render_frame
207
+ @deferred_render = true
199
208
  end
200
209
 
201
210
  # A command is either bound for this Terminal or it's meant to be
@@ -253,8 +262,8 @@ module Fatty
253
262
  focused_session&.view
254
263
  status_session.view
255
264
  alert_session.view
256
- if (top = @modal_stack.last)
257
- top[:session].view
265
+ @modal_stack.each do |entry|
266
+ entry[:session].view
258
267
  end
259
268
  if @restore_cursor_after_render
260
269
  restore_active_cursor
@@ -457,8 +466,8 @@ module Fatty
457
466
  renderer.screen = screen
458
467
  apply_command(Command.session(:status, :resize))
459
468
  apply_command(Command.session(:focused, :resize))
460
- if (top = @modal_stack.last)
461
- apply_command(Command.session(top[:session].id, :resize))
469
+ @modal_stack.each do |entry|
470
+ apply_command(Command.session(entry[:session].id, :resize))
462
471
  end
463
472
  renderer.sync_backgrounds! if renderer.context.truecolor
464
473
  renderer.invalidate!
@@ -470,9 +479,9 @@ module Fatty
470
479
  command.action == :resize
471
480
  end
472
481
 
473
- def handle_resize
474
- rows = ::Curses.lines
475
- cols = ::Curses.cols
482
+ def handle_resize(rows: nil, cols: nil)
483
+ rows ||= ::Curses.lines
484
+ cols ||= ::Curses.cols
476
485
  size = [rows, cols]
477
486
 
478
487
  return [] if size == @last_handled_resize_size
@@ -509,8 +518,8 @@ module Fatty
509
518
  out.resize_output! if out.respond_to?(:resize_output!)
510
519
  end
511
520
 
512
- if (top = @modal_stack.last)
513
- session = top[:session]
521
+ @modal_stack.each do |entry|
522
+ session = entry[:session]
514
523
  cmds = session.handle_resize
515
524
  apply_commands(cmds)
516
525
  end
@@ -520,6 +529,22 @@ module Fatty
520
529
  @inside_resize_term = false if did_resize_term
521
530
  end
522
531
 
532
+ def reconcile_terminal_size!
533
+ return unless @screen && @ctx
534
+
535
+ rows, cols = STDOUT.winsize
536
+ return if rows.to_i <= 0 || cols.to_i <= 0
537
+
538
+ if [rows, cols] == [@screen.rows, @screen.cols]
539
+ @ctx.apply_layout(@screen)
540
+ renderer.screen = @screen
541
+ else
542
+ handle_resize(rows:, cols:)
543
+ end
544
+ rescue SystemCallError, IOError
545
+ nil
546
+ end
547
+
523
548
  # --- Initialization ---------------------------------------------------------
524
549
 
525
550
  #
@@ -527,6 +552,23 @@ module Fatty
527
552
  # Curses, the Logger, installing key definitions and mappings, and
528
553
  # installing themes.
529
554
  #
555
+ def keybinding_help_hint
556
+ map = shell_session&.keymap
557
+ return unless map
558
+
559
+ gesture = map.bindings.values.flat_map(&:keys).uniq.find do |key|
560
+ next unless key.is_a?(KeyGesture)
561
+
562
+ event = KeyEvent.new(key: key.key, ctrl: key.ctrl, meta: key.meta, shift: key.shift)
563
+ binding = map.resolve(event, contexts: [:input, :text, :terminal])
564
+ Array(binding).first == :show_keybindings
565
+ end
566
+ return unless gesture
567
+
568
+ label = KeyEvent.key_to_str(key: gesture.key, ctrl: gesture.ctrl, meta: gesture.meta, shift: gesture.shift)
569
+ "Press #{label} for keybinding help."
570
+ end
571
+
530
572
  def preflight!
531
573
  Fatty::Config.configure_app(
532
574
  app_name: @app_name,
data/lib/fatty/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fatty
4
- VERSION = "0.99.8.3"
4
+ VERSION = "0.99.8.5"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fatty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.99.8.3
4
+ version: 0.99.8.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel E. Doherty
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-08 00:00:00.000000000 Z
10
+ date: 2026-10-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: curses
@@ -224,6 +224,7 @@ files:
224
224
  - lib/fatty/session.rb
225
225
  - lib/fatty/session/alert_session.rb
226
226
  - lib/fatty/session/isearch_session.rb
227
+ - lib/fatty/session/keybindings_session.rb
227
228
  - lib/fatty/session/keytest_session.rb
228
229
  - lib/fatty/session/modal_session.rb
229
230
  - lib/fatty/session/output_session.rb