mui 0.1.0 → 0.3.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +163 -0
  3. data/CHANGELOG.md +448 -0
  4. data/README.md +309 -6
  5. data/docs/_config.yml +56 -0
  6. data/docs/configuration.md +301 -0
  7. data/docs/getting-started.md +140 -0
  8. data/docs/index.md +55 -0
  9. data/docs/jobs.md +297 -0
  10. data/docs/keybindings.md +229 -0
  11. data/docs/plugins.md +285 -0
  12. data/docs/syntax-highlighting.md +149 -0
  13. data/exe/mui +1 -2
  14. data/lib/mui/autocmd.rb +66 -0
  15. data/lib/mui/buffer.rb +275 -0
  16. data/lib/mui/buffer_word_cache.rb +131 -0
  17. data/lib/mui/buffer_word_completer.rb +77 -0
  18. data/lib/mui/color_manager.rb +136 -0
  19. data/lib/mui/color_scheme.rb +63 -0
  20. data/lib/mui/command_completer.rb +30 -0
  21. data/lib/mui/command_context.rb +90 -0
  22. data/lib/mui/command_history.rb +89 -0
  23. data/lib/mui/command_line.rb +167 -0
  24. data/lib/mui/command_registry.rb +44 -0
  25. data/lib/mui/completion_renderer.rb +84 -0
  26. data/lib/mui/completion_state.rb +58 -0
  27. data/lib/mui/config.rb +58 -0
  28. data/lib/mui/editor.rb +395 -0
  29. data/lib/mui/error.rb +29 -0
  30. data/lib/mui/file_completer.rb +51 -0
  31. data/lib/mui/floating_window.rb +161 -0
  32. data/lib/mui/handler_result.rb +107 -0
  33. data/lib/mui/highlight.rb +22 -0
  34. data/lib/mui/highlighters/base.rb +23 -0
  35. data/lib/mui/highlighters/search_highlighter.rb +27 -0
  36. data/lib/mui/highlighters/selection_highlighter.rb +48 -0
  37. data/lib/mui/highlighters/syntax_highlighter.rb +107 -0
  38. data/lib/mui/input.rb +17 -0
  39. data/lib/mui/insert_completion_renderer.rb +92 -0
  40. data/lib/mui/insert_completion_state.rb +77 -0
  41. data/lib/mui/job.rb +81 -0
  42. data/lib/mui/job_manager.rb +113 -0
  43. data/lib/mui/key_code.rb +30 -0
  44. data/lib/mui/key_handler/base.rb +187 -0
  45. data/lib/mui/key_handler/command_mode.rb +511 -0
  46. data/lib/mui/key_handler/insert_mode.rb +323 -0
  47. data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
  48. data/lib/mui/key_handler/normal_mode.rb +552 -0
  49. data/lib/mui/key_handler/operators/base_operator.rb +134 -0
  50. data/lib/mui/key_handler/operators/change_operator.rb +179 -0
  51. data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
  52. data/lib/mui/key_handler/operators/paste_operator.rb +119 -0
  53. data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
  54. data/lib/mui/key_handler/search_mode.rb +191 -0
  55. data/lib/mui/key_handler/visual_line_mode.rb +20 -0
  56. data/lib/mui/key_handler/visual_mode.rb +402 -0
  57. data/lib/mui/key_handler/window_command.rb +112 -0
  58. data/lib/mui/key_handler.rb +16 -0
  59. data/lib/mui/key_notation_parser.rb +152 -0
  60. data/lib/mui/key_sequence.rb +67 -0
  61. data/lib/mui/key_sequence_buffer.rb +85 -0
  62. data/lib/mui/key_sequence_handler.rb +163 -0
  63. data/lib/mui/key_sequence_matcher.rb +79 -0
  64. data/lib/mui/layout/calculator.rb +15 -0
  65. data/lib/mui/layout/leaf_node.rb +33 -0
  66. data/lib/mui/layout/node.rb +29 -0
  67. data/lib/mui/layout/split_node.rb +132 -0
  68. data/lib/mui/line_renderer.rb +173 -0
  69. data/lib/mui/mode.rb +13 -0
  70. data/lib/mui/mode_manager.rb +186 -0
  71. data/lib/mui/motion.rb +139 -0
  72. data/lib/mui/plugin.rb +35 -0
  73. data/lib/mui/plugin_manager.rb +106 -0
  74. data/lib/mui/register.rb +110 -0
  75. data/lib/mui/screen.rb +103 -0
  76. data/lib/mui/search_completer.rb +50 -0
  77. data/lib/mui/search_input.rb +40 -0
  78. data/lib/mui/search_state.rb +121 -0
  79. data/lib/mui/selection.rb +55 -0
  80. data/lib/mui/status_line_renderer.rb +40 -0
  81. data/lib/mui/syntax/language_detector.rb +106 -0
  82. data/lib/mui/syntax/lexer_base.rb +106 -0
  83. data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
  84. data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
  85. data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
  86. data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
  87. data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
  88. data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
  89. data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
  90. data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
  91. data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -0
  92. data/lib/mui/syntax/token.rb +42 -0
  93. data/lib/mui/syntax/token_cache.rb +91 -0
  94. data/lib/mui/tab_bar_renderer.rb +87 -0
  95. data/lib/mui/tab_manager.rb +96 -0
  96. data/lib/mui/tab_page.rb +35 -0
  97. data/lib/mui/terminal_adapter/base.rb +92 -0
  98. data/lib/mui/terminal_adapter/curses.rb +164 -0
  99. data/lib/mui/terminal_adapter.rb +4 -0
  100. data/lib/mui/themes/default.rb +315 -0
  101. data/lib/mui/undo_manager.rb +83 -0
  102. data/lib/mui/undoable_action.rb +175 -0
  103. data/lib/mui/unicode_width.rb +100 -0
  104. data/lib/mui/version.rb +1 -1
  105. data/lib/mui/window.rb +201 -0
  106. data/lib/mui/window_manager.rb +256 -0
  107. data/lib/mui/wrap_cache.rb +40 -0
  108. data/lib/mui/wrap_helper.rb +84 -0
  109. data/lib/mui.rb +171 -2
  110. metadata +123 -5
data/lib/mui/editor.rb ADDED
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Main editor class that coordinates all components
5
+ class Editor
6
+ attr_reader :tab_manager, :undo_manager, :autocmd, :command_registry, :job_manager, :color_scheme, :floating_window,
7
+ :insert_completion_state, :key_sequence_handler
8
+ attr_accessor :message, :running
9
+
10
+ def initialize(file_path = nil, adapter: TerminalAdapter::Curses.new, load_config: true)
11
+ Mui.load_config if load_config
12
+
13
+ @adapter = adapter
14
+ @color_manager = ColorManager.new
15
+ @adapter.color_resolver = @color_manager
16
+ @color_scheme = load_color_scheme
17
+ @screen = Screen.new(adapter: @adapter, color_manager: @color_manager)
18
+ @input = Input.new(adapter: @adapter)
19
+ @buffer = Buffer.new
20
+ @buffer.load(file_path) if file_path
21
+
22
+ @tab_manager = TabManager.new(@screen, color_scheme: @color_scheme)
23
+ initial_tab = @tab_manager.add
24
+ initial_tab.window_manager.add_window(@buffer)
25
+
26
+ @tab_bar_renderer = TabBarRenderer.new(@tab_manager, color_scheme: @color_scheme)
27
+
28
+ @command_line = CommandLine.new
29
+ @completion_renderer = CompletionRenderer.new(@screen, @color_scheme)
30
+ @insert_completion_renderer = InsertCompletionRenderer.new(@screen, @color_scheme)
31
+ @floating_window = FloatingWindow.new(@color_scheme)
32
+ @message = nil
33
+ @running = true
34
+
35
+ @undo_manager = UndoManager.new
36
+ @buffer.undo_manager = @undo_manager
37
+
38
+ @autocmd = Autocmd.new
39
+ @command_registry = CommandRegistry.new
40
+ @job_manager = JobManager.new(autocmd: @autocmd)
41
+ @insert_completion_state = InsertCompletionState.new
42
+
43
+ # Install and load plugins via bundler/inline
44
+ Mui.plugin_manager.install_and_load
45
+
46
+ # Load plugin autocmds
47
+ load_plugin_autocmds
48
+
49
+ # Initialize key sequence handler for multi-key mappings
50
+ @key_sequence_handler = KeySequenceHandler.new(Mui.config)
51
+ @key_sequence_handler.rebuild_keymaps
52
+
53
+ @mode_manager = ModeManager.new(
54
+ window: @tab_manager,
55
+ buffer: @buffer,
56
+ command_line: @command_line,
57
+ undo_manager: @undo_manager,
58
+ editor: self,
59
+ key_sequence_handler: @key_sequence_handler
60
+ )
61
+
62
+ # Trigger BufEnter event
63
+ trigger_autocmd(:BufEnter)
64
+ end
65
+
66
+ def window_manager
67
+ @tab_manager.window_manager
68
+ end
69
+
70
+ def window
71
+ @tab_manager.active_window
72
+ end
73
+
74
+ def buffer
75
+ window.buffer
76
+ end
77
+
78
+ def mode
79
+ @mode_manager.mode
80
+ end
81
+
82
+ def selection
83
+ @mode_manager.selection
84
+ end
85
+
86
+ def register
87
+ @mode_manager.register
88
+ end
89
+
90
+ def run
91
+ while @running
92
+ process_job_results
93
+ check_key_sequence_timeout
94
+ update_window_size
95
+ render
96
+ key = @input.read_nonblock
97
+ if key
98
+ handle_key(key)
99
+ else
100
+ sleep 0.01 # CPU usage optimization
101
+ end
102
+ end
103
+ ensure
104
+ @adapter.close
105
+ end
106
+
107
+ # Open a scratch buffer (read-only) with the given content
108
+ def open_scratch_buffer(name, content)
109
+ scratch_buffer = Buffer.new(name)
110
+ scratch_buffer.content = content
111
+ scratch_buffer.readonly = true
112
+
113
+ # Split horizontally and show the scratch buffer
114
+ window_manager.split_horizontal(scratch_buffer)
115
+ end
116
+
117
+ # Update existing scratch buffer or create new one
118
+ def update_or_create_scratch_buffer(name, content)
119
+ existing = find_scratch_buffer(name)
120
+
121
+ if existing
122
+ existing.content = content
123
+ focus_buffer(existing)
124
+ else
125
+ open_scratch_buffer(name, content)
126
+ end
127
+ end
128
+
129
+ # Find a scratch buffer by name across all tabs and windows
130
+ def find_scratch_buffer(name)
131
+ @tab_manager.tabs.each do |tab|
132
+ tab.window_manager.windows.each do |win|
133
+ return win.buffer if win.buffer.name == name && win.buffer.readonly?
134
+ end
135
+ end
136
+ nil
137
+ end
138
+
139
+ # Focus a specific buffer by switching to its window
140
+ def focus_buffer(target_buffer)
141
+ @tab_manager.tabs.each_with_index do |tab, tab_index|
142
+ tab.window_manager.windows.each do |win|
143
+ next unless win.buffer == target_buffer
144
+
145
+ @tab_manager.go_to(tab_index)
146
+ tab.window_manager.focus_window(win)
147
+ return true
148
+ end
149
+ end
150
+ false
151
+ end
152
+
153
+ # Suspend UI for running external interactive commands (e.g., fzf)
154
+ def suspend_ui
155
+ @adapter.suspend
156
+ yield
157
+ ensure
158
+ @adapter.resume
159
+ end
160
+
161
+ def handle_key(key)
162
+ @message = nil
163
+
164
+ # Close floating window on Escape or any key input (except scroll keys if we add them)
165
+ if @floating_window.visible
166
+ @floating_window.hide
167
+ return if key == KeyCode::ESCAPE
168
+ end
169
+
170
+ old_window = window
171
+ old_buffer = old_window&.buffer
172
+ old_modified = old_buffer&.modified
173
+ result = @mode_manager.current_handler.handle(key)
174
+ apply_result(result)
175
+
176
+ current_window = window
177
+ return unless current_window # Guard against nil window (e.g., after closing last tab)
178
+
179
+ current_buffer = current_window.buffer
180
+
181
+ # Trigger BufEnter if buffer changed (window focus change)
182
+ trigger_autocmd(:BufEnter) if current_buffer != old_buffer
183
+
184
+ # Trigger TextChanged if buffer was modified
185
+ trigger_autocmd(:TextChanged) if (current_buffer.modified && !old_modified) || buffer_content_changed?
186
+ end
187
+
188
+ # Trigger autocmd event with current context
189
+ def trigger_autocmd(event)
190
+ context = CommandContext.new(editor: self, buffer:, window:)
191
+ @autocmd.trigger(event, context)
192
+ end
193
+
194
+ # Show a floating window with content at the cursor position
195
+ def show_floating(content, max_width: nil, max_height: nil)
196
+ # Position below cursor
197
+ row = window.screen_cursor_y + 1
198
+ col = window.screen_cursor_x
199
+
200
+ @floating_window.show(
201
+ content,
202
+ row:,
203
+ col:,
204
+ max_width: max_width || (@screen.width / 2),
205
+ max_height: max_height || 10
206
+ )
207
+ end
208
+
209
+ # Hide the floating window
210
+ def hide_floating
211
+ @floating_window.hide
212
+ end
213
+
214
+ # Start insert mode completion with LSP items
215
+ def start_insert_completion(items, prefix: "")
216
+ @insert_completion_state.start(items, prefix:)
217
+ end
218
+
219
+ # Check if insert completion is active
220
+ def insert_completion_active?
221
+ @insert_completion_state.active?
222
+ end
223
+
224
+ private
225
+
226
+ def buffer_content_changed?
227
+ # Track if content actually changed (for TextChanged event)
228
+ # Use change_count instead of hash for O(1) performance
229
+ @last_change_count ||= buffer.change_count
230
+ current_count = buffer.change_count
231
+ changed = @last_change_count != current_count
232
+ @last_change_count = current_count
233
+ changed
234
+ end
235
+
236
+ def update_window_size
237
+ window_manager.update_layout(y_offset: tab_bar_height)
238
+ end
239
+
240
+ def render
241
+ @screen.clear
242
+
243
+ @tab_bar_renderer.render(@screen, 0)
244
+
245
+ window.ensure_cursor_visible
246
+ window_manager.render_all(
247
+ @screen,
248
+ selection: @mode_manager.selection,
249
+ search_state: @mode_manager.search_state
250
+ )
251
+
252
+ render_status_area
253
+
254
+ # Position cursor based on current mode
255
+ if @mode_manager.mode == Mode::COMMAND
256
+ # In command mode, cursor is on the command line (after ":" + buffer position)
257
+ @screen.move_cursor(@screen.height - 1, 1 + @command_line.cursor_pos)
258
+ elsif [Mode::SEARCH_FORWARD, Mode::SEARCH_BACKWARD].include?(@mode_manager.mode)
259
+ # In search mode, cursor is on the search input line (after "/" or "?" + pattern)
260
+ @screen.move_cursor(@screen.height - 1, 1 + @mode_manager.search_input.buffer.length)
261
+ else
262
+ # In other modes, cursor is in the editor window
263
+ @screen.move_cursor(window.screen_cursor_y, window.screen_cursor_x)
264
+ end
265
+ @screen.refresh
266
+ end
267
+
268
+ def tab_bar_height
269
+ @tab_bar_renderer.height
270
+ end
271
+
272
+ def render_status_area
273
+ status_text = case @mode_manager.mode
274
+ when Mode::COMMAND
275
+ @command_line.to_s
276
+ when Mode::INSERT
277
+ @message || "-- INSERT --"
278
+ when Mode::VISUAL
279
+ @message || "-- VISUAL --"
280
+ when Mode::VISUAL_LINE
281
+ @message || "-- VISUAL LINE --"
282
+ when Mode::SEARCH_FORWARD, Mode::SEARCH_BACKWARD
283
+ @mode_manager.search_input.to_s
284
+ else
285
+ @message || "-- NORMAL --"
286
+ end
287
+
288
+ status_line = status_text.ljust(@screen.width)
289
+ style = @color_scheme[:command_line]
290
+ @screen.put_with_style(@screen.height - 1, 0, status_line, style)
291
+
292
+ # Render completion popup based on mode
293
+ if @mode_manager.mode == Mode::COMMAND
294
+ render_completion_popup
295
+ elsif [Mode::SEARCH_FORWARD, Mode::SEARCH_BACKWARD].include?(@mode_manager.mode)
296
+ render_search_completion_popup
297
+ elsif @mode_manager.mode == Mode::INSERT
298
+ render_insert_completion_popup
299
+ end
300
+
301
+ # Render floating window if visible
302
+ @floating_window.render(@screen)
303
+ end
304
+
305
+ def render_search_completion_popup
306
+ completion_state = @mode_manager.current_handler.completion_state
307
+ return unless completion_state&.active?
308
+
309
+ # Popup appears above the search line, starting after "/" or "?"
310
+ base_row = @screen.height - 1
311
+ base_col = 1 # After the prompt
312
+ @completion_renderer.render(completion_state, base_row, base_col)
313
+ end
314
+
315
+ def render_completion_popup
316
+ completion_state = @mode_manager.current_handler.completion_state
317
+ return unless completion_state&.active?
318
+
319
+ # Popup appears above the command line, starting after the ":"
320
+ base_row = @screen.height - 1
321
+ base_col = 1 # After the ":"
322
+ @completion_renderer.render(completion_state, base_row, base_col)
323
+ end
324
+
325
+ def render_insert_completion_popup
326
+ return unless @insert_completion_state&.active?
327
+
328
+ # Popup appears below the cursor
329
+ @insert_completion_renderer.render(
330
+ @insert_completion_state,
331
+ window.screen_cursor_y,
332
+ window.screen_cursor_x
333
+ )
334
+ end
335
+
336
+ def apply_result(result)
337
+ @mode_manager.transition(result)
338
+ @message = result.message if result.message
339
+ @running = false if result.quit?
340
+ end
341
+
342
+ def load_color_scheme
343
+ scheme_name = Mui.config.get(:colorscheme)
344
+ Themes.send(scheme_name.to_sym)
345
+ rescue NoMethodError
346
+ Themes.mui
347
+ end
348
+
349
+ def load_plugin_autocmds
350
+ Mui.config.autocmds.each do |event, handlers|
351
+ handlers.each do |h|
352
+ @autocmd.register(event, pattern: h[:pattern], &h[:handler])
353
+ end
354
+ end
355
+ end
356
+
357
+ def process_job_results
358
+ @job_manager.poll
359
+ end
360
+
361
+ def check_key_sequence_timeout
362
+ return unless @key_sequence_handler.pending?
363
+
364
+ mode_symbol = mode_to_symbol(@mode_manager.mode)
365
+ result = @key_sequence_handler.check_timeout(mode_symbol)
366
+
367
+ return unless result
368
+
369
+ type, data = result
370
+ case type
371
+ when KeySequenceHandler::RESULT_HANDLED
372
+ execute_keymap_handler(data)
373
+ when KeySequenceHandler::RESULT_PASSTHROUGH
374
+ # Re-process the passthrough key
375
+ handle_key(data) if data
376
+ end
377
+ end
378
+
379
+ def mode_to_symbol(mode)
380
+ case mode
381
+ when Mode::NORMAL then :normal
382
+ when Mode::INSERT then :insert
383
+ when Mode::VISUAL, Mode::VISUAL_LINE then :visual
384
+ when Mode::COMMAND then :command
385
+ when Mode::SEARCH_FORWARD, Mode::SEARCH_BACKWARD then :search
386
+ else :normal
387
+ end
388
+ end
389
+
390
+ def execute_keymap_handler(handler)
391
+ context = CommandContext.new(editor: self, buffer:, window:)
392
+ handler.call(context)
393
+ end
394
+ end
395
+ end
data/lib/mui/error.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class Error < StandardError; end
5
+
6
+ # Raised when a subclass does not override a required method
7
+ class MethodNotOverriddenError < Error
8
+ def initialize(method_name)
9
+ super("Subclass must implement ##{method_name}")
10
+ end
11
+ end
12
+
13
+ # Raised when a plugin operation fails
14
+ class PluginError < Error; end
15
+
16
+ # Raised when a plugin is not found
17
+ class PluginNotFoundError < PluginError
18
+ def initialize(plugin_name)
19
+ super("Plugin '#{plugin_name}' not found")
20
+ end
21
+ end
22
+
23
+ # Raised when an unknown command is executed
24
+ class UnknownCommandError < Error
25
+ def initialize(command_name)
26
+ super("Unknown command: #{command_name}")
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Provides file path completion
5
+ class FileCompleter
6
+ def complete(partial_path)
7
+ return list_current_directory if partial_path.empty?
8
+
9
+ dir, prefix = split_path(partial_path)
10
+ entries = list_directory(dir)
11
+
12
+ entries.select { |entry| entry.start_with?(prefix) }
13
+ .map { |entry| join_path(dir, entry) }
14
+ .map { |path| format_path(path) }
15
+ end
16
+
17
+ private
18
+
19
+ def split_path(path)
20
+ if path.end_with?("/")
21
+ [path, ""]
22
+ else
23
+ dir = File.dirname(path)
24
+ dir = "" if dir == "."
25
+ [dir, File.basename(path)]
26
+ end
27
+ end
28
+
29
+ def list_directory(dir)
30
+ target = dir.empty? ? "." : dir
31
+ return [] unless Dir.exist?(target)
32
+
33
+ Dir.entries(target)
34
+ .reject { |e| e.start_with?(".") }
35
+ .sort
36
+ end
37
+
38
+ def list_current_directory
39
+ list_directory("").map { |entry| format_path(entry) }
40
+ end
41
+
42
+ def join_path(dir, entry)
43
+ dir.empty? ? entry : File.join(dir, entry)
44
+ end
45
+
46
+ def format_path(path)
47
+ full_path = path.start_with?("/") ? path : File.join(".", path)
48
+ File.directory?(full_path) ? "#{path}/" : path
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # A floating window (popup) for displaying temporary content like hover info
5
+ class FloatingWindow
6
+ attr_reader :content, :row, :col, :width, :height
7
+ attr_accessor :visible
8
+
9
+ def initialize(color_scheme)
10
+ @color_scheme = color_scheme
11
+ @content = []
12
+ @row = 0
13
+ @col = 0
14
+ @width = 0
15
+ @height = 0
16
+ @visible = false
17
+ @scroll_offset = 0
18
+ end
19
+
20
+ # Show the floating window with content at the specified position
21
+ def show(content, row:, col:, max_width: nil, max_height: nil)
22
+ @content = normalize_content(content)
23
+ @row = row
24
+ @col = col
25
+ @max_width = max_width
26
+ @max_height = max_height
27
+ @scroll_offset = 0
28
+ calculate_dimensions
29
+ @visible = true
30
+ end
31
+
32
+ # Hide the floating window
33
+ def hide
34
+ @visible = false
35
+ @content = []
36
+ end
37
+
38
+ # Scroll content up
39
+ def scroll_up
40
+ @scroll_offset = [@scroll_offset - 1, 0].max if @visible
41
+ end
42
+
43
+ # Scroll content down
44
+ def scroll_down
45
+ max_offset = [@content.length - @height, 0].max
46
+ @scroll_offset = [@scroll_offset + 1, max_offset].min if @visible
47
+ end
48
+
49
+ # Render the floating window to the screen
50
+ def render(screen)
51
+ return unless @visible
52
+ return if @content.empty?
53
+
54
+ # Adjust position to fit within screen bounds
55
+ adjusted_row, adjusted_col = adjust_position(screen)
56
+
57
+ # Draw border and content
58
+ draw_border(screen, adjusted_row, adjusted_col)
59
+ draw_content(screen, adjusted_row, adjusted_col)
60
+ end
61
+
62
+ private
63
+
64
+ def normalize_content(content)
65
+ case content
66
+ when String
67
+ content.split("\n")
68
+ when Array
69
+ content.flat_map { |line| line.to_s.split("\n") }
70
+ else
71
+ [content.to_s]
72
+ end
73
+ end
74
+
75
+ def calculate_dimensions
76
+ return if @content.empty?
77
+
78
+ # Calculate content dimensions
79
+ content_width = @content.map { |line| UnicodeWidth.string_width(line) }.max || 0
80
+ content_height = @content.length
81
+
82
+ # Apply max constraints (+2 for border)
83
+ @width = content_width + 2
84
+ @width = [@width, @max_width].min if @max_width
85
+
86
+ @height = content_height + 2
87
+ @height = [@height, @max_height].min if @max_height
88
+ end
89
+
90
+ def adjust_position(screen)
91
+ row = @row
92
+ col = @col
93
+
94
+ # Adjust horizontal position
95
+ col = screen.width - @width if col + @width > screen.width
96
+ col = [col, 0].max
97
+
98
+ # Adjust vertical position - prefer below cursor, but go above if not enough space
99
+ if row + @height > screen.height
100
+ # Try above the original position
101
+ row = @row - @height
102
+ end
103
+ row = [row, 0].max
104
+
105
+ [row, col]
106
+ end
107
+
108
+ def draw_border(screen, row, col)
109
+ style = @color_scheme[:floating_window] || @color_scheme[:completion_popup]
110
+
111
+ # Top border
112
+ top_border = "┌#{"─" * (@width - 2)}┐"
113
+ screen.put_with_style(row, col, top_border, style)
114
+
115
+ # Side borders
116
+ inner_height = @height - 2
117
+ inner_height.times do |i|
118
+ screen.put_with_style(row + 1 + i, col, "│", style)
119
+ screen.put_with_style(row + 1 + i, col + @width - 1, "│", style)
120
+ end
121
+
122
+ # Bottom border
123
+ bottom_border = "└#{"─" * (@width - 2)}┘"
124
+ screen.put_with_style(row + @height - 1, col, bottom_border, style)
125
+ end
126
+
127
+ def draw_content(screen, row, col)
128
+ style = @color_scheme[:floating_window] || @color_scheme[:completion_popup]
129
+ inner_width = @width - 2
130
+ inner_height = @height - 2
131
+
132
+ inner_height.times do |i|
133
+ line_index = @scroll_offset + i
134
+ line = @content[line_index] || ""
135
+
136
+ # Truncate line if needed
137
+ display_line = truncate_to_width(line, inner_width)
138
+ padded_line = display_line.ljust(inner_width)
139
+
140
+ screen.put_with_style(row + 1 + i, col + 1, padded_line, style)
141
+ end
142
+ end
143
+
144
+ def truncate_to_width(text, max_width)
145
+ return text if UnicodeWidth.string_width(text) <= max_width
146
+
147
+ result = ""
148
+ current_width = 0
149
+
150
+ text.each_char do |char|
151
+ char_width = UnicodeWidth.char_width(char)
152
+ break if current_width + char_width > max_width
153
+
154
+ result += char
155
+ current_width += char_width
156
+ end
157
+
158
+ result
159
+ end
160
+ end
161
+ end