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,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class ActionError < StandardError; end
5
+
6
+ module Actions
7
+ @defs = {} # { Symbol => { owner:, on:, doc:, method: } }
8
+
9
+ def self.names
10
+ @defs.keys.sort
11
+ end
12
+
13
+ def self.valid_names
14
+ names.map(&:to_s)
15
+ end
16
+
17
+ def self.catalog
18
+ names.map do |name|
19
+ defn = @defs[name]
20
+ {
21
+ name: name.to_s,
22
+ doc: defn[:doc],
23
+ on: defn[:on].to_s,
24
+ method: defn[:method].to_s,
25
+ }
26
+ end
27
+ end
28
+
29
+ def self.catalog_by_target
30
+ @defs
31
+ .group_by { |_name, defn| defn[:on].to_s }
32
+ .sort
33
+ .to_h do |target, entries|
34
+ names = entries.map { |name, _defn| name.to_s }.sort
35
+ [target, names]
36
+ end
37
+ end
38
+
39
+ def self.snapshot
40
+ @defs.dup
41
+ end
42
+
43
+ def self.restore(snapshot)
44
+ @defs = snapshot
45
+ end
46
+
47
+ def self.reset!
48
+ @defs.clear
49
+ end
50
+
51
+ def self.defs
52
+ @defs
53
+ end
54
+
55
+ def self.lookup(name)
56
+ @defs[name.to_sym]
57
+ end
58
+
59
+ def self.register(name, owner:, on:, method_name: name, doc: nil)
60
+ arg_str = "Action.register: name: #{name}, on: #{on}, method_name: #{method_name}, doc: #{doc}"
61
+ Fatty.debug("Action.register(#{arg_str})", tag: :action)
62
+ key = name.to_sym
63
+ raise ActionError, "action already registered for #{key}" if @defs.key?(key)
64
+
65
+ @defs[key] = {
66
+ owner: owner,
67
+ on: on.to_sym,
68
+ doc: doc,
69
+ method: method_name.to_sym,
70
+ }
71
+ end
72
+
73
+ def self.call(name, env, *args, **kwargs)
74
+ arg_str = "name: #{name}, env: #{env}, args: #{args}, kwargs: #{kwargs}"
75
+ Fatty.debug("Action.call(#{arg_str})", tag: :action)
76
+ key = name.to_sym
77
+ defn = @defs[key] or raise ActionError, "Unknown action: #{key}"
78
+
79
+ target = env.public_send(defn[:on])
80
+ raise ActionError, "env.#{defn[:on]} is nil for action #{key}" unless target
81
+
82
+ # Inject count: from env.counter when the target accepts it.
83
+ if env.counter&.active? && !kwargs.key?(:count)
84
+ meth = target.method(defn[:method])
85
+ params = meth.parameters
86
+
87
+ accepts_count =
88
+ params.any? { |(kind, pname)| kind == :key && pname == :count } ||
89
+ params.any? { |(kind, _pname)| kind == :keyrest }
90
+
91
+ if accepts_count
92
+ kwargs = kwargs.merge(count: env.counter.consume(default: 1))
93
+ end
94
+ end
95
+
96
+ target.public_send(defn[:method], *args, **kwargs)
97
+ end
98
+
99
+ def self.registered?(name)
100
+ @defs.key?(name.to_sym)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ # For holding the environment in which an action is executed
5
+ class ActionEnvironment
6
+ attr_accessor :session, :event, :counter, :buffer, :field, :pager
7
+
8
+ def initialize(
9
+ session: nil,
10
+ terminal: nil,
11
+ counter: nil,
12
+ event: nil,
13
+ buffer: nil,
14
+ field: nil,
15
+ pager: nil
16
+ )
17
+ @session = session
18
+ @terminal = terminal
19
+ @counter = counter
20
+ @event = event
21
+ @buffer = buffer
22
+ @field = field
23
+ @pager = pager
24
+ end
25
+
26
+ def terminal
27
+ session&.terminal
28
+ end
29
+
30
+ def to_s
31
+ parts = []
32
+ parts << "session: #{session}" if session
33
+ parts << "terminal: #{terminal}" if terminal
34
+ parts << "counter: #{counter}" if counter
35
+ parts << "event: #{event.to_s[0..90]}" if event
36
+ parts << "buffer: #{buffer}" if buffer
37
+ parts << "field: #{field}" if field
38
+ parts << "pager: #{pager}" if pager
39
+ parts.join('; ')
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ module Actionable
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ # Optional override; by default infer slot from class name (InputBuffer -> :buffer)
11
+ def action_on(sym = nil)
12
+ @default_action_target = sym&.to_sym
13
+ end
14
+
15
+ def default_action_target
16
+ @default_action_target || infer_action_target
17
+ end
18
+
19
+ def desc(text)
20
+ @__next_action_doc = text.to_s
21
+ end
22
+
23
+ def next_action_doc
24
+ doc = @__next_action_doc
25
+ @__next_action_doc = nil
26
+ doc
27
+ end
28
+
29
+ # Define instance method AND register as bindable action.
30
+ #
31
+ # Examples:
32
+ # action :bol { @cursor = 0 }
33
+ # action :insert { |str| ... }
34
+ # action :backward_char, to: :move_left # alias action name to a method
35
+ #
36
+ def action(name, on: default_action_target, to: name, doc: nil, &block)
37
+ name = name.to_sym
38
+ on = on.to_sym
39
+ to = to.to_sym
40
+
41
+ # Prefer desc() if present, else doc: kwarg
42
+ doc ||= next_action_doc
43
+
44
+ if block
45
+ # define the underlying method (usually same as name)
46
+ define_method(to, &block)
47
+ elsif name != to && !method_defined?(name)
48
+ # If this is an alias (action name differs from method), define the alias method
49
+
50
+ # Prefer a real alias if the target method already exists; otherwise define a delegator.
51
+ if method_defined?(to)
52
+ alias_method name, to
53
+ else
54
+ define_method(name) { |*args, &blk| public_send(to, *args, &blk) }
55
+ end
56
+ end
57
+ Fatty::Actions.register(name, owner: self, on: on, method_name: to, doc: doc)
58
+ end
59
+
60
+ private
61
+
62
+ def infer_action_target
63
+ base = name.split("::").last
64
+ return :buffer if base == "InputBuffer"
65
+ return :field if base == "InputField"
66
+ return :terminal if base == "Terminal"
67
+ return :pager if base == "Pager"
68
+
69
+ base.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class Alert
5
+ DETAIL_ORDER = %i[terminal key ctrl meta shift].freeze
6
+
7
+ attr_reader :level, :message, :details
8
+
9
+ # Return a new Alert object
10
+ #
11
+ # @param message [String]
12
+ # @param level [:info, :warn, :error]
13
+ # @param details [Hash|String]
14
+ # @param sticky [Boolean]
15
+ # @return [Alert]
16
+ def initialize(message:, level: :info, details: nil, sticky: false)
17
+ @message = message
18
+ @level = level.to_sym
19
+ @details = details
20
+ @sticky = !!sticky
21
+ end
22
+
23
+ # Return a new Alert object at level info
24
+ #
25
+ # @param msg [String]
26
+ # @return [Alert] with level info
27
+ def self.info(msg)
28
+ new(message: msg, level: :info)
29
+ end
30
+
31
+ # Return a new Alert object at level warn
32
+ #
33
+ # @param msg [String]
34
+ # @return [Alert] with level warn
35
+ def self.warn(msg)
36
+ new(message: msg, level: :warn)
37
+ end
38
+
39
+ # Return a new Alert object at level error
40
+ #
41
+ # @param msg [String]
42
+ # @return [Alert] with level error
43
+ def self.error(msg)
44
+ new(message: msg, level: :error)
45
+ end
46
+
47
+ # Translate the "level" to a "role" used by the renderers. The returned
48
+ # roles are "composite" roles in the resolver in that they take the
49
+ # background ot the alert panel and apply a foreground color based on
50
+ # severity.
51
+ # @param level [:info, :warn, :error]
52
+ # @return [Symbol] composite or semantic role
53
+ # used by renderer (e.g., alert_good, :alert_info, :alert_warn, :alert_error)
54
+ def role
55
+ case level
56
+ when :good then :alert_good
57
+ when :warn then :alert_warn
58
+ when :error then :alert_error
59
+ else :alert_info
60
+ end
61
+ end
62
+
63
+ # Build a string version of the Alert suitable for display to the user.
64
+ def format
65
+ icon =
66
+ case level
67
+ when :warn then "⚠"
68
+ when :error then "✖"
69
+ else "ℹ"
70
+ end
71
+ msg = message.to_s
72
+ details_str = ""
73
+ if details.respond_to?(:empty?) ? !details.empty? : !!details
74
+ details_str = if details.is_a?(Hash)
75
+ " (" +
76
+ DETAIL_ORDER.filter_map { |k| "#{k}=#{details[k]}" if details.key?(k) }.join(" ") +
77
+ ")"
78
+ else
79
+ " (#{details})"
80
+ end
81
+ end
82
+ "#{icon} #{msg}#{details_str}"
83
+ end
84
+
85
+ # Return whether this Alert is sticky, meaning that it should not be
86
+ # cleared until a key is presses or another Alert displayed
87
+ #
88
+ # @return [true, false] is this Alert sticky?
89
+ def sticky?
90
+ @sticky
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ module Ansi
5
+ class Renderer
6
+ CSI = "\e["
7
+
8
+ def initialize(io: nil)
9
+ @io = io
10
+ end
11
+
12
+ def render_line(row:, col:, width:, text:, role:, palette:)
13
+ spec = palette&.[](role)
14
+ return unless spec
15
+
16
+ msg = text.to_s.tr("\r\n", " ")
17
+ msg = Fatty::Ansi.truncate_visible(msg, width)
18
+ visible = Fatty::Ansi.visible_length(msg)
19
+ pad = width - visible
20
+ sgr = sgr_for_spec(spec)
21
+ padding = pad.positive? ? " " * pad : ""
22
+ write_ansi("#{CSI}#{row + 1};#{col + 1}H", sgr, msg, sgr, padding, reset)
23
+ end
24
+
25
+ def render_segments_line(row:, col:, width:, segments:, palette:, fill_role: :output)
26
+ rendered = +""
27
+ visible = 0
28
+ segments.each do |seg|
29
+ spec = palette&.[](seg[:role])
30
+ next unless spec
31
+
32
+ remaining = width - visible
33
+ break if remaining <= 0
34
+
35
+ text = Fatty::Ansi.truncate_visible(seg[:text].to_s, remaining)
36
+ next if text.empty?
37
+
38
+ visible += Fatty::Ansi.visible_length(text)
39
+
40
+ sgr =
41
+ if seg[:style]
42
+ sgr_for_style(seg[:style], fallback_spec: spec)
43
+ else
44
+ sgr_for_spec(spec)
45
+ end
46
+
47
+ rendered << sgr
48
+ rendered << text
49
+ end
50
+
51
+ if visible < width
52
+ spec = palette&.[](fill_role)
53
+ rendered << sgr_for_spec(spec) if spec
54
+ rendered << (" " * (width - visible))
55
+ end
56
+ write_ansi("#{CSI}#{row + 1};#{col + 1}H", rendered, reset)
57
+ end
58
+
59
+ def write_ansi(*parts)
60
+ text = parts.join
61
+
62
+ if defined?(::Curses) && ::Curses.respond_to?(:putp)
63
+ ::Curses.putp(text)
64
+ else
65
+ io.write(text)
66
+ io.flush
67
+ end
68
+
69
+ nil
70
+ end
71
+
72
+ def io
73
+ @io || $stdout
74
+ end
75
+
76
+ private
77
+
78
+ def save_cursor
79
+ "#{CSI}s"
80
+ end
81
+
82
+ def restore_cursor
83
+ "#{CSI}u"
84
+ end
85
+
86
+ def reset
87
+ "#{CSI}0m"
88
+ end
89
+
90
+ def move_to(row, col)
91
+ "#{CSI}#{row + 1};#{col + 1}H"
92
+ end
93
+
94
+ def sgr_for_spec(spec)
95
+ fg = spec[:fg_rgb]
96
+ bg = spec[:bg_rgb]
97
+ codes = []
98
+
99
+ Array(spec[:attrs]).each do |attr|
100
+ case attr.to_sym
101
+ when :bold
102
+ codes << "1"
103
+ when :dim
104
+ codes << "2"
105
+ when :underline
106
+ codes << "4"
107
+ when :reverse
108
+ if fg && bg
109
+ fg, bg = bg, fg
110
+ else
111
+ codes << "7"
112
+ end
113
+ end
114
+ end
115
+
116
+ parts = [reset]
117
+ parts << "#{CSI}#{codes.join(';')}m" unless codes.empty?
118
+ parts << "#{CSI}38;2;#{fg[0]};#{fg[1]};#{fg[2]}m" if fg
119
+ parts << "#{CSI}48;2;#{bg[0]};#{bg[1]};#{bg[2]}m" if bg
120
+
121
+ return reset if parts.empty?
122
+
123
+ parts.join
124
+ end
125
+
126
+ def sgr_for_style(style, fallback_spec:)
127
+ fg = rgb_for_style_color(style.fg, fallback: fallback_spec[:fg_rgb])
128
+ bg = rgb_for_style_color(style.bg, fallback: fallback_spec[:bg_rgb])
129
+
130
+ codes = []
131
+ codes << "1" if style.bold
132
+ codes << "3" if style.italic
133
+ codes << "4" if style.underline
134
+ codes << "9" if style.strike
135
+
136
+ if style.reverse
137
+ if fg && bg
138
+ fg, bg = bg, fg
139
+ else
140
+ codes << "7"
141
+ end
142
+ end
143
+
144
+ parts = [reset]
145
+ parts << "#{CSI}#{codes.join(';')}m" unless codes.empty?
146
+ parts << "#{CSI}38;2;#{fg[0]};#{fg[1]};#{fg[2]}m" if fg
147
+ parts << "#{CSI}48;2;#{bg[0]};#{bg[1]};#{bg[2]}m" if bg
148
+
149
+ parts.join
150
+ end
151
+
152
+ def rgb_for_style_color(color, fallback:)
153
+ case color
154
+ when Array
155
+ color
156
+ when Integer
157
+ if color.between?(0, 15)
158
+ Fatty::Color::ANSI_RGB[color]
159
+ else
160
+ Fatty::Color.xterm_rgb_for_index(color)
161
+ end
162
+ else
163
+ fallback
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end