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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class ISearchSession < Session
5
+ action_on :session
6
+
7
+ attr_reader :field, :direction
8
+
9
+ def id = :isearch
10
+
11
+ DEFAULT_ISEARCH_HISTORY_FILE = File.expand_path("~/.fatty_search_history")
12
+ DEFAULT_ISEARCH_HISTORY_MAX = 200
13
+
14
+ def initialize(direction: :forward, last_pattern: nil, history: nil)
15
+ super(keymap: Keymaps.emacs, views: [])
16
+
17
+ @direction = direction.to_sym
18
+ @failed = false
19
+ @last_text = nil
20
+ @last_pattern = last_pattern.to_s
21
+
22
+ @field = Fatty::InputField.new(
23
+ prompt: Prompt.new { isearch_prompt },
24
+ history: history,
25
+ history_kind: :search_string,
26
+ # ? history_ctx: -> { { session: "pager_isearch" } },
27
+ )
28
+ end
29
+
30
+ #########################################################################################
31
+ # Framework and Session Hooks
32
+ #########################################################################################
33
+
34
+ def keymap_contexts
35
+ [:isearch, :text, :terminal]
36
+ end
37
+
38
+ def view(screen:, renderer:)
39
+ row = screen.output_rect.rows - 1
40
+
41
+ ::Curses.curs_set(1)
42
+ renderer.render_pager_field(@field, row: row, role: :search_input)
43
+ renderer.restore_output_cursor(@field, row: row)
44
+ end
45
+
46
+ ############################################################################################
47
+ # Actions
48
+ ############################################################################################
49
+
50
+ desc "Return the line so far as the search string"
51
+ action :isearch_accept do
52
+ accept!
53
+ end
54
+
55
+ desc "Quit the I-Search session"
56
+ action :isearch_cancel do
57
+ cancel!
58
+ end
59
+
60
+ desc "Move to the next matching text"
61
+ action :isearch_next do
62
+ step_next!
63
+ end
64
+
65
+ desc "Move to the prior matching text"
66
+ action :isearch_prev do
67
+ step_prev!
68
+ end
69
+
70
+ private
71
+
72
+ def update_cmd(name, payload)
73
+ cmds = []
74
+ case name
75
+ when :isearch_set_failed
76
+ @failed = !!payload[:failed]
77
+ @field.prompt = isearch_prompt
78
+ end
79
+ cmds
80
+ end
81
+
82
+ def action_env(event:)
83
+ ActionEnvironment.new(
84
+ session: self,
85
+ counter: counter,
86
+ event: event,
87
+ buffer: @field.buffer,
88
+ field: @field,
89
+ )
90
+ end
91
+
92
+ def handle_action(action, args, event:)
93
+ env = action_env(event: event)
94
+
95
+ if Fatty::Actions.lookup(action)&.fetch(:on) == :session
96
+ Fatty::Actions.call(action, env, *args)
97
+ else
98
+ @field.act_on(action, *args, env: env)
99
+ maybe_preview!
100
+ end
101
+ rescue Fatty::ActionError
102
+ []
103
+ end
104
+
105
+ def isearch_prompt
106
+ base = @failed ? "Failing I-search: " : "I-search: "
107
+ arrow = @direction == :backward ? "↑ " : "↓ "
108
+ arrow + base
109
+ end
110
+
111
+ def accept!
112
+ pattern = @field.accept_line.to_s
113
+ cmds = []
114
+ cmds << [:terminal, :send_modal_owner, [:cmd, :pager_isearch_commit, { pattern: pattern, direction: @direction }]]
115
+ cmds << [:terminal, :pop_modal]
116
+ cmds
117
+ end
118
+
119
+ def cancel!
120
+ [[:terminal, :send_modal_owner, [:cmd, :pager_isearch_cancel, {}]], [:terminal, :pop_modal]]
121
+ end
122
+
123
+ def step_next!
124
+ @direction = :forward
125
+ cmds = []
126
+ cmds.concat(prefill_last_pattern_if_empty!)
127
+ cmds << [:terminal, :send_modal_owner, [:cmd, :pager_isearch_step, { direction: @direction }]]
128
+ cmds
129
+ end
130
+
131
+ def step_prev!
132
+ @direction = :backward
133
+ cmds = []
134
+ cmds.concat(prefill_last_pattern_if_empty!)
135
+ cmds << [:terminal, :send_modal_owner, [:cmd, :pager_isearch_step, { direction: @direction }]]
136
+ cmds
137
+ end
138
+
139
+ def prefill_last_pattern_if_empty!
140
+ current = @field.buffer.text.to_s
141
+ pat = @last_pattern.to_s
142
+ return [] unless current.empty?
143
+ return [] if pat.strip.empty?
144
+
145
+ env = ActionEnvironment.new(
146
+ session: self,
147
+ counter: counter,
148
+ event: nil,
149
+ buffer: @field.buffer,
150
+ field: @field,
151
+ )
152
+
153
+ # Replace buffer contents with the last pattern, then preview immediately.
154
+ @field.act_on(:replace, pat, env: env)
155
+ maybe_preview!
156
+ end
157
+
158
+ def maybe_preview!
159
+ text = @field.buffer.text.to_s
160
+ return [] if @last_text == text
161
+
162
+ @last_text = text.dup
163
+ [
164
+ [
165
+ :terminal,
166
+ :send_modal_owner,
167
+ [:cmd, :pager_isearch_update, { pattern: text, direction: @direction }],
168
+ ]
169
+ ]
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Fatty
6
+ class KeyTestSession < Session
7
+ def id = :keytest
8
+
9
+ def initialize(owner: nil)
10
+ super(keymap: nil, views: [])
11
+ @owner = owner
12
+ end
13
+
14
+ def init(terminal:)
15
+ super
16
+ @suggestions = []
17
+ @owner ||= terminal.focused_session
18
+ show_quit_status
19
+ force_scrolling_output!
20
+ append <<~TEXT
21
+ Key test mode
22
+
23
+ Press keys to inspect Fatty's decoding and binding resolution.
24
+ Press q, ESC, or C-g to leave key test mode.
25
+
26
+ TEXT
27
+ []
28
+ end
29
+
30
+ def update_key(ev)
31
+ if quit_key?(ev)
32
+ append "Leaving key test mode.\n\n"
33
+ return [[:terminal, :pop_modal]]
34
+ end
35
+
36
+ # append " TERM: #{terminal_name}\n"
37
+ append ev.report
38
+ snippet = ev.suggested_snippet(terminal_name)
39
+ append snippet + "\n"
40
+ @suggestions << snippet unless snippet.empty?
41
+ show_quit_status
42
+ []
43
+ end
44
+
45
+ def view(screen:, renderer:)
46
+ # Intentionally blank. The underlying shell session renders the output
47
+ # pane; this modal only captures keys and appends diagnostic text.
48
+ nil
49
+ end
50
+
51
+ def close
52
+ write_suggestions!
53
+ restore_owner_pager!
54
+ terminal.clear_status if terminal.respond_to?(:clear_status)
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ def append(text)
61
+ return unless @owner&.respond_to?(:append_output)
62
+
63
+ force_scrolling_output!
64
+
65
+ before = @owner.output.lines.length
66
+ @owner.append_output(text.to_s, follow: false)
67
+
68
+ height = @owner.viewport.height.to_i
69
+ added = @owner.output.lines.length - before
70
+
71
+ if added >= height
72
+ @owner.viewport.top = before
73
+ @owner.viewport.clamp!(@owner.output.lines)
74
+ else
75
+ @owner.viewport.page_bottom(@owner.output.lines)
76
+ end
77
+
78
+ terminal.renderer.reset_frame_cache if terminal.respond_to?(:renderer)
79
+ end
80
+
81
+ def force_scrolling_output!
82
+ if @owner&.respond_to?(:pager)
83
+ @owner.pager.paging_to_scrolling
84
+ end
85
+ end
86
+
87
+ def quit_key?(ev)
88
+ ev.key == :q ||
89
+ ev.key == :escape ||
90
+ (ev.key == :g && ev.ctrl?)
91
+ end
92
+
93
+ def terminal_name
94
+ env =
95
+ if terminal.respond_to?(:env)
96
+ terminal.env
97
+ else
98
+ terminal.instance_variable_get(:@env)
99
+ end
100
+
101
+ env && env[:terminal] || ENV.fetch("TERM", "unknown")
102
+ end
103
+
104
+ def show_quit_status
105
+ terminal.warn("KeyTest — press q, ESC, or C-g to quit (TERM: #{terminal_name})")
106
+ end
107
+
108
+ def write_suggestions!
109
+ suggestions = @suggestions.uniq
110
+ return if suggestions.empty?
111
+
112
+ path = File.join(Dir.tmpdir, "fatty-keytest-#{Time.now.strftime('%Y%m%d-%H%M%S')}.yml")
113
+
114
+ File.write(path, suggestion_file_text(suggestions))
115
+
116
+ append <<~TEXT
117
+ =======================================================
118
+ KeyTest suggestions written to:
119
+ #{path}
120
+
121
+ TEXT
122
+
123
+ terminal.good("KeyTest suggestions written to #{path}")
124
+ end
125
+
126
+ def suggestion_file_text(suggestions)
127
+ out = <<~TEXT
128
+ # Fatty keytest suggestions
129
+ # Generated at #{Time.now}
130
+
131
+ KEYNAMES
132
+
133
+ Known/common key names for keydefs.yml/keybindings.yml:
134
+ TEXT
135
+
136
+ Fatty::Curses.valid_keynames.each do |name|
137
+ out << " #{name}\n"
138
+ end
139
+
140
+ out << <<~TEXT
141
+
142
+ Printable keys are also valid key names, for example:
143
+ a
144
+ y
145
+ ]
146
+ /
147
+ Use ctrl/meta/shift flags in keybindings.yml for modifiers.
148
+
149
+ NOTE: You can also define additional key names in keydefs.yml.
150
+ For example: if your terminal reports code 652 for the Pause key:
151
+
152
+ terminal:
153
+ tmux:
154
+ map:
155
+ 652:
156
+ key: pause
157
+
158
+ Then you can bind it to an action in keybindings.yml:
159
+
160
+ - key: pause
161
+ action: your_action
162
+
163
+ ACTIONS
164
+
165
+ Valid action names for keybindings.yml:
166
+
167
+ NOTE: Fatty also supports user-defined actions. The extension mechanism is still
168
+ evolving; see README.md for the current plugin/action registration details.
169
+ TEXT
170
+
171
+ Fatty::Actions.catalog_by_target.each do |target, names|
172
+ out << "\n"
173
+ out << "#{target} actions:\n"
174
+
175
+ names.each do |name|
176
+ out << " #{name}\n"
177
+ end
178
+ end
179
+
180
+ out << <<~TEXT
181
+
182
+ CONTEXTS
183
+
184
+ Valid contexts for keybindings.yml:\n
185
+ TEXT
186
+
187
+ Fatty::KeyMap.valid_contexts.each do |name|
188
+ out << " #{name}\n"
189
+ end
190
+
191
+ out << <<~TEXT
192
+ If context is omitted, the default is #{Fatty::KeyMap::DEFAULT_CONTEXT}.
193
+
194
+ MOUSE EVENTS
195
+
196
+ Mouse events may also be bound in keybindings.yml, for example:
197
+
198
+ - mouse: <BUTTONNAME>
199
+ context: paging
200
+ action: page_up
201
+
202
+ Where <BUTTONNAME> is one of:
203
+ scroll_up
204
+ scroll_down
205
+ left_pressed,
206
+ left_released,
207
+ left_clicked,
208
+ left_double_clicked,
209
+ left_triple_clicked,
210
+ middle_pressed,
211
+ middle_released,
212
+ middle_clicked,
213
+ middle_double_clicked,
214
+ middle_triple_clicked,
215
+ right_pressed,
216
+ right_released,
217
+ right_clicked,
218
+ right_double_clicked,
219
+ right_triple_clicked
220
+
221
+ All of which may also be combined with modifiers: ctrl, meta, and shift.
222
+
223
+ TEXT
224
+ out << "\n"
225
+ out << suggestions.join("\n\n")
226
+ out << "\n"
227
+ out
228
+ end
229
+
230
+ def restore_owner_pager!
231
+ if @owner&.respond_to?(:pager)
232
+ @owner.pager.quit_paging
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class ModalSession < Session
5
+ attr_reader :win
6
+ private attr_writer :win
7
+
8
+ def close
9
+ Fatty.debug("#{self.class}#close: object_id=#{object_id}", tag: :session)
10
+ old_win = win
11
+ self.win = nil
12
+ safely_close_window(old_win)
13
+ nil
14
+ end
15
+
16
+ def handle_resize
17
+ rebuild_windows!
18
+ []
19
+ end
20
+
21
+ def rebuild_windows!
22
+ old_win = win
23
+ self.win = nil
24
+ safely_close_window(old_win)
25
+
26
+ cols = ::Curses.cols
27
+ rows = ::Curses.lines
28
+ width, height = geometry(cols: cols, rows: rows)
29
+ x = (cols - width) / 2
30
+ y = (rows - height) / 2
31
+ self.win = ::Curses::Window.new(height, width, y, x)
32
+ nil
33
+ end
34
+
35
+ # Return the outer width and height of the window for this modal,
36
+ # including any padding and borders.
37
+ def geometry(cols:, rows:)
38
+ raise NotImplementedError, "#{self.class} must implement #geometry"
39
+ end
40
+
41
+ # Common helpers for computing geometry
42
+
43
+ def max_width(cols:, margin:, min_width:)
44
+ [cols - (margin * 2), min_width].max
45
+ end
46
+
47
+ def max_height(rows:, margin:, min_height:)
48
+ [rows - (margin * 2), min_height].max
49
+ end
50
+
51
+ def clamp_width(width, max_width:, hard_max: nil)
52
+ width = [width, max_width].min
53
+ width = [width, hard_max].min if hard_max
54
+ width
55
+ end
56
+
57
+ def clamp_height(height, max_height:, min_height:)
58
+ height.clamp(min_height, max_height)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class OutputSession < Session
5
+ attr_reader :output, :viewport, :pager, :pager_field
6
+
7
+ def initialize(keymap: nil, views: [])
8
+ super(keymap: keymap, views: views)
9
+ @output = Fatty::OutputBuffer.new(max_lines: 500_000)
10
+ @viewport = Fatty::Viewport.new(height: 10)
11
+ mode = Fatty::Config.config.dig(:output, :mode)&.to_sym || :paging
12
+ @default_output_mode = mode
13
+ @pager = Fatty::Pager.new(output: @output, viewport: @viewport, mode: mode)
14
+ @pager_field = Fatty::InputField.new(prompt: -> { pager_status_prompt })
15
+ end
16
+
17
+ def append_output(text, follow: true)
18
+ ntrim = @output.append(text.to_s)
19
+ @pager.on_append(ntrim: ntrim)
20
+
21
+ # When callers request follow behavior (scrolling mode),
22
+ # keep the viewport at the bottom unless paging is actively paused.
23
+ if follow && !@pager.paused?
24
+ case @pager.mode
25
+ when :scrolling
26
+ @viewport.page_bottom(@output.lines)
27
+ end
28
+ end
29
+ ntrim
30
+ end
31
+
32
+ def reset_output!
33
+ @output.lines.clear
34
+ @viewport.reset
35
+ end
36
+
37
+ def resize_output!
38
+ was_at_bottom = pager.at_bottom?
39
+ @viewport.height = terminal.screen.output_rect.rows
40
+ pager.preserve_after_resize!(was_at_bottom: was_at_bottom)
41
+ end
42
+
43
+ def reset_for_command!
44
+ reset_output!
45
+ mode = @default_output_mode # Fatty::Config.config.dig(:output, :mode)&.to_sym || :paging
46
+ @pager.reset!(mode: mode)
47
+ end
48
+
49
+ # True when the pager is currently holding the screen (i.e., paging mode is
50
+ # active and the output is paused).
51
+ def pager_active?
52
+ @pager.reserve_prompt_row?
53
+ end
54
+
55
+ # When the pager is active, the last output row is reserved for the pager
56
+ # InputField. This returns the viewport to use for rendering the output
57
+ # content itself.
58
+ def pager_viewport
59
+ if pager_active? && @viewport.height > 1
60
+ Fatty::Viewport.new(top: @viewport.top, height: @viewport.height - 1)
61
+ else
62
+ @viewport
63
+ end
64
+ end
65
+
66
+ def pager_status_prompt
67
+ total = @output.lines.length
68
+ visible_h = pager_active? ? pager.page_height : @viewport.height
69
+ bottom = [@viewport.top + visible_h, total].min
70
+ pct =
71
+ if total.positive?
72
+ ((bottom * 100.0) / total).round
73
+ end
74
+
75
+ search = pager.search_label
76
+ search = " [#{search}]" if search && !search.empty?
77
+ if pct
78
+ " #{pager.nav_arrow} --More-- #{bottom}/#{total} (#{pct}%)#{search} "
79
+ else
80
+ " #{pager.nav_arrow} --More-- #{bottom}#{search} "
81
+ end
82
+ end
83
+
84
+ def reset_pager!
85
+ # Start the next command from the bottom of existing scrollback.
86
+ @viewport.page_bottom(@output.lines)
87
+ @pager.reset!(total_lines: @output.lines.length, mode: @default_output_mode)
88
+ end
89
+
90
+ def follow_output!
91
+ pager.end_of_output
92
+ []
93
+ end
94
+
95
+ def page_output_from_top!
96
+ pager.page_top
97
+ []
98
+ end
99
+
100
+ def page_output_from_bottom!
101
+ pager.page_bottom
102
+ []
103
+ end
104
+ end
105
+ end