textbringer 17 → 19

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/exe/txtb +1 -1
  3. data/lib/textbringer/buffer.rb +37 -3
  4. data/lib/textbringer/commands/buffers.rb +4 -2
  5. data/lib/textbringer/commands/clipboard.rb +21 -6
  6. data/lib/textbringer/commands/completion.rb +133 -0
  7. data/lib/textbringer/commands/ctags.rb +1 -1
  8. data/lib/textbringer/commands/files.rb +11 -1
  9. data/lib/textbringer/commands/help.rb +1 -1
  10. data/lib/textbringer/commands/isearch.rb +4 -10
  11. data/lib/textbringer/commands/ispell.rb +0 -2
  12. data/lib/textbringer/commands/lsp.rb +389 -0
  13. data/lib/textbringer/commands/misc.rb +2 -1
  14. data/lib/textbringer/commands.rb +7 -3
  15. data/lib/textbringer/completion_popup.rb +188 -0
  16. data/lib/textbringer/faces/basic.rb +3 -1
  17. data/lib/textbringer/faces/completion.rb +4 -0
  18. data/lib/textbringer/floating_window.rb +327 -0
  19. data/lib/textbringer/input_methods/skk_input_method.rb +751 -0
  20. data/lib/textbringer/lsp/client.rb +568 -0
  21. data/lib/textbringer/lsp/server_registry.rb +138 -0
  22. data/lib/textbringer/mode.rb +3 -1
  23. data/lib/textbringer/modes/programming_mode.rb +17 -8
  24. data/lib/textbringer/modes/transient_mark_mode.rb +9 -2
  25. data/lib/textbringer/utils.rb +14 -10
  26. data/lib/textbringer/version.rb +1 -1
  27. data/lib/textbringer/window.rb +116 -19
  28. data/lib/textbringer.rb +7 -0
  29. data/sig/lib/textbringer/buffer.rbs +483 -0
  30. data/sig/lib/textbringer/color.rbs +9 -0
  31. data/sig/lib/textbringer/commands/buffers.rbs +93 -0
  32. data/sig/lib/textbringer/commands/clipboard.rbs +17 -0
  33. data/sig/lib/textbringer/commands/completion.rbs +20 -0
  34. data/sig/lib/textbringer/commands/ctags.rbs +11 -0
  35. data/sig/lib/textbringer/commands/dabbrev.rbs +4 -0
  36. data/sig/lib/textbringer/commands/files.rbs +29 -0
  37. data/sig/lib/textbringer/commands/fill.rbs +5 -0
  38. data/sig/lib/textbringer/commands/help.rbs +28 -0
  39. data/sig/lib/textbringer/commands/input_method.rbs +6 -0
  40. data/sig/lib/textbringer/commands/isearch.rbs +38 -0
  41. data/sig/lib/textbringer/commands/ispell.rbs +39 -0
  42. data/sig/lib/textbringer/commands/keyboard_macro.rbs +25 -0
  43. data/sig/lib/textbringer/commands/lsp.rbs +8 -0
  44. data/sig/lib/textbringer/commands/misc.rbs +74 -0
  45. data/sig/lib/textbringer/commands/rectangle.rbs +19 -0
  46. data/sig/lib/textbringer/commands/register.rbs +31 -0
  47. data/sig/lib/textbringer/commands/replace.rbs +17 -0
  48. data/sig/lib/textbringer/commands/server.rbs +31 -0
  49. data/sig/lib/textbringer/commands/ucs_normalize.rbs +9 -0
  50. data/sig/lib/textbringer/commands/windows.rbs +45 -0
  51. data/sig/lib/textbringer/commands.rbs +21 -0
  52. data/sig/lib/textbringer/completion_popup.rbs +40 -0
  53. data/sig/lib/textbringer/controller.rbs +58 -0
  54. data/sig/lib/textbringer/default_output.rbs +7 -0
  55. data/sig/lib/textbringer/errors.rbs +3 -0
  56. data/sig/lib/textbringer/face.rbs +19 -0
  57. data/sig/lib/textbringer/floating_window.rbs +42 -0
  58. data/sig/lib/textbringer/global_minor_mode.rbs +7 -0
  59. data/sig/lib/textbringer/input_method.rbs +28 -0
  60. data/sig/lib/textbringer/input_methods/hangul_input_method.rbs +12 -0
  61. data/sig/lib/textbringer/input_methods/hiragana_input_method.rbs +12 -0
  62. data/sig/lib/textbringer/input_methods/t_code_input_method.rbs +49 -0
  63. data/sig/lib/textbringer/keymap.rbs +33 -0
  64. data/sig/lib/textbringer/lsp/client.rbs +21 -0
  65. data/sig/lib/textbringer/lsp/server_registry.rbs +23 -0
  66. data/sig/lib/textbringer/minor_mode.rbs +12 -0
  67. data/sig/lib/textbringer/mode.rbs +70 -0
  68. data/sig/lib/textbringer/modes/backtrace_mode.rbs +8 -0
  69. data/sig/lib/textbringer/modes/buffer_list_mode.rbs +5 -0
  70. data/sig/lib/textbringer/modes/c_mode.rbs +21 -0
  71. data/sig/lib/textbringer/modes/completion_list_mode.rbs +5 -0
  72. data/sig/lib/textbringer/modes/fundamental_mode.rbs +3 -0
  73. data/sig/lib/textbringer/modes/help_mode.rbs +7 -0
  74. data/sig/lib/textbringer/modes/overwrite_mode.rbs +15 -0
  75. data/sig/lib/textbringer/modes/programming_mode.rbs +14 -0
  76. data/sig/lib/textbringer/modes/ruby_mode.rbs +57 -0
  77. data/sig/lib/textbringer/plugin.rbs +3 -0
  78. data/sig/lib/textbringer/ring.rbs +36 -0
  79. data/sig/lib/textbringer/utils.rbs +95 -0
  80. data/sig/lib/textbringer/window.rbs +183 -0
  81. data/textbringer.gemspec +1 -0
  82. metadata +76 -2
@@ -0,0 +1,389 @@
1
+ module Textbringer
2
+ module Commands
3
+ LSP_DOCUMENT_VERSIONS = {}
4
+ LSP_STATUS = {
5
+ signature_window: nil
6
+ }
7
+
8
+ define_command(:lsp_completion, doc: <<~DOC) do
9
+ Request completion from the Language Server Protocol server.
10
+ Uses M-Tab (Alt+Tab) as the default keybinding.
11
+ DOC
12
+ buffer = Buffer.current
13
+
14
+ client = LSP::ServerRegistry.get_client_for_buffer(buffer)
15
+ unless client
16
+ raise EditorError, "No LSP server configured for this buffer"
17
+ end
18
+
19
+ unless client.running? && client.initialized?
20
+ message("LSP server not ready, please try again")
21
+ return
22
+ end
23
+
24
+ unless client.document_open?(buffer_uri(buffer))
25
+ lsp_open_document(buffer)
26
+ end
27
+
28
+ # Get completion position
29
+ uri = buffer_uri(buffer)
30
+ pos = lsp_position(buffer, buffer.point)
31
+ line = pos[:line]
32
+ character = pos[:character]
33
+
34
+ # Calculate the start point for completion (beginning of current word)
35
+ start_point = buffer.save_point do
36
+ buffer.skip_re_backward(buffer.mode.symbol_pattern)
37
+ buffer.point
38
+ end
39
+
40
+ # Get prefix already typed
41
+ prefix = buffer.substring(start_point, buffer.point)
42
+
43
+ # Determine trigger context: if the character immediately before start_point
44
+ # is a completion trigger character, inform the server so it returns
45
+ # member completions (e.g. after ".").
46
+ trigger_chars = client.server_capabilities
47
+ .dig("completionProvider", "triggerCharacters") || []
48
+ char_before_start = buffer.save_point {
49
+ buffer.goto_char(start_point)
50
+ buffer.point > 0 ? (buffer.backward_char; buffer.char_after) : nil
51
+ }
52
+ context = lsp_completion_context(prefix, trigger_chars, char_before_start)
53
+
54
+ # Request completion
55
+ client.completion(uri: uri, line: line, character: character, context: context) do |items, error|
56
+ if error
57
+ message("LSP completion error: #{error["message"]}")
58
+ elsif items && !items.empty?
59
+ # Sort by sort_text (LSP server already filtered based on position)
60
+ sorted_items = items.sort_by { |item| item[:sort_text] || item[:label] }
61
+
62
+ completion_popup_start(
63
+ items: sorted_items,
64
+ start_point: start_point,
65
+ prefix: prefix
66
+ )
67
+ else
68
+ message("No completions found")
69
+ end
70
+ end
71
+ end
72
+
73
+ define_command(:lsp_signature_help, doc: <<~DOC) do
74
+ Request signature help from the Language Server Protocol server.
75
+ Displays the signature of the function/method at the current cursor
76
+ position in a floating window.
77
+ DOC
78
+ buffer = Buffer.current
79
+
80
+ client = LSP::ServerRegistry.get_client_for_buffer(buffer)
81
+ unless client
82
+ raise EditorError, "No LSP server configured for this buffer"
83
+ end
84
+
85
+ unless client.running? && client.initialized?
86
+ message("LSP server not ready, please try again")
87
+ return
88
+ end
89
+
90
+ unless client.server_capabilities["signatureHelpProvider"]
91
+ message("LSP server does not support signature help")
92
+ return
93
+ end
94
+
95
+ unless client.document_open?(buffer_uri(buffer))
96
+ lsp_open_document(buffer)
97
+ end
98
+
99
+ uri = buffer_uri(buffer)
100
+ pos = lsp_position(buffer, buffer.point)
101
+
102
+ # Determine trigger character from the character before point
103
+ trigger_char = nil
104
+ trigger_chars = client.server_capabilities
105
+ .dig("signatureHelpProvider", "triggerCharacters") || []
106
+ if buffer.point > 0
107
+ char_before = buffer.save_point {
108
+ buffer.backward_char
109
+ buffer.char_after
110
+ }
111
+ trigger_char = char_before if trigger_chars.include?(char_before)
112
+ end
113
+
114
+ context = if trigger_char
115
+ {
116
+ triggerKind: 2, # TriggerCharacter
117
+ triggerCharacter: trigger_char,
118
+ isRetrigger: false
119
+ }
120
+ else
121
+ {
122
+ triggerKind: 1, # Invoked
123
+ isRetrigger: false
124
+ }
125
+ end
126
+
127
+ client.signature_help(uri: uri, line: pos[:line], character: pos[:character], context: context) do |result, error|
128
+ if error
129
+ message("LSP signature help error: #{error["message"]}")
130
+ elsif result && result["signatures"] && !result["signatures"].empty?
131
+ active_index = result["activeSignature"] || 0
132
+ signature = result["signatures"][active_index]
133
+ label = signature["label"] if signature
134
+ if label
135
+ lsp_show_signature_window(label)
136
+ else
137
+ message("No signature information available")
138
+ end
139
+ else
140
+ message("No signature information available")
141
+ end
142
+ end
143
+ end
144
+
145
+ define_command(:lsp_ensure_started, doc: <<~DOC) do
146
+ Start the LSP server for the current buffer if not already running.
147
+ DOC
148
+ buffer = Buffer.current
149
+
150
+ client = LSP::ServerRegistry.get_client_for_buffer(buffer)
151
+ if client
152
+ if client.running?
153
+ message("LSP server already running")
154
+ else
155
+ message("Starting LSP server...")
156
+ end
157
+ else
158
+ message("No LSP server configured for this buffer")
159
+ end
160
+ end
161
+
162
+ define_command(:lsp_stop, doc: <<~DOC) do
163
+ Stop the LSP server for the current buffer.
164
+ DOC
165
+ buffer = Buffer.current
166
+ LSP::ServerRegistry.stop_client_for_buffer(buffer)
167
+ message("LSP server stopped")
168
+ end
169
+
170
+ define_command(:lsp_restart, doc: <<~DOC) do
171
+ Restart the LSP server for the current buffer.
172
+ DOC
173
+ buffer = Buffer.current
174
+ LSP::ServerRegistry.stop_client_for_buffer(buffer)
175
+ client = LSP::ServerRegistry.get_client_for_buffer(buffer)
176
+ if client
177
+ message("LSP server restarting...")
178
+ else
179
+ message("No LSP server configured for this file type")
180
+ end
181
+ end
182
+
183
+ # Helper methods
184
+
185
+ def lsp_completion_context(prefix, trigger_chars, char_before_start)
186
+ if prefix.empty? && trigger_chars.include?(char_before_start)
187
+ { triggerKind: 2, # TriggerCharacter
188
+ triggerCharacter: char_before_start }
189
+ else
190
+ { triggerKind: 1 } # Invoked
191
+ end
192
+ end
193
+
194
+ # Resolve textDocumentSync to a TextDocumentSyncKind integer.
195
+ # The server capability may be an Integer or a TextDocumentSyncOptions Hash.
196
+ # Returns 0 (None), 1 (Full), or 2 (Incremental). Defaults to 2.
197
+ def lsp_text_document_sync_kind(server_capabilities)
198
+ sync = server_capabilities["textDocumentSync"]
199
+ case sync
200
+ when Integer then sync
201
+ when Hash then sync["change"]&.to_i || 2
202
+ else 2
203
+ end
204
+ end
205
+
206
+ # Convert a string's character length to UTF-16 code unit count.
207
+ # LSP positions use UTF-16 offsets by default.
208
+ def lsp_utf16_length(str)
209
+ str.encode("UTF-16LE").bytesize / 2
210
+ end
211
+
212
+ # Compute LSP position (0-based line, UTF-16 character offset)
213
+ # from a buffer position.
214
+ def lsp_position(buffer, pos)
215
+ line, = buffer.pos_to_line_and_column(pos)
216
+ # Get the text from the start of the line to compute UTF-16 offset
217
+ line_start = buffer.save_point do
218
+ buffer.goto_char(pos)
219
+ buffer.beginning_of_line
220
+ buffer.point
221
+ end
222
+ text_on_line = buffer.substring(line_start, pos)
223
+ character = lsp_utf16_length(text_on_line)
224
+ { line: line - 1, character: character }
225
+ end
226
+
227
+ def buffer_uri(buffer)
228
+ if buffer.file_name
229
+ "file://#{buffer.file_name}"
230
+ else
231
+ "untitled:#{buffer.name}"
232
+ end
233
+ end
234
+
235
+ def lsp_open_document(buffer)
236
+ client = LSP::ServerRegistry.get_client_for_buffer(buffer)
237
+ return unless client
238
+ return unless client.running? && client.initialized?
239
+
240
+ uri = buffer_uri(buffer)
241
+ return if client.document_open?(uri)
242
+
243
+ version = 1
244
+ LSP_DOCUMENT_VERSIONS[uri] = version
245
+ language_id = LSP::ServerRegistry.language_id_for_buffer(buffer) || "text"
246
+ client.did_open(
247
+ uri: uri,
248
+ language_id: language_id,
249
+ version: version,
250
+ text: buffer.to_s
251
+ )
252
+ unless buffer[:lsp_hooks_installed]
253
+ lsp_setup_buffer_hooks(buffer, client, uri)
254
+ buffer[:lsp_hooks_installed] = true
255
+ end
256
+ end
257
+
258
+ # Set up buffer hooks for document synchronization
259
+ def lsp_setup_buffer_hooks(buffer, client, uri)
260
+ # Track changes and send updates to LSP server.
261
+ # Sync kind is determined by the server's textDocumentSync capability:
262
+ # 1 = Full (send complete document text on every change)
263
+ # 2 = Incremental (send only the changed range)
264
+ add_hook(:after_change_functions, local: true) do |beg_pos, end_pos, old_text|
265
+ next unless client.running? && client.document_open?(uri)
266
+
267
+ version = LSP_DOCUMENT_VERSIONS[uri] || 0
268
+ version += 1
269
+ LSP_DOCUMENT_VERSIONS[uri] = version
270
+
271
+ sync_kind = lsp_text_document_sync_kind(client.server_capabilities)
272
+
273
+ next if sync_kind == 0 # None: server does not want change notifications
274
+
275
+ if sync_kind == 1
276
+ # Full document sync (e.g. solargraph). Note: buffer.to_s is called
277
+ # on every change; this is expected behavior for Full-sync servers.
278
+ client.did_change(uri: uri, version: version, text: buffer.to_s)
279
+ else
280
+ # Incremental sync
281
+ # Compute start position in LSP coordinates (0-based, UTF-16)
282
+ start_pos = lsp_position(buffer, beg_pos)
283
+
284
+ if old_text.empty?
285
+ # Insertion: old range is empty, new text is the inserted content
286
+ new_text = buffer.substring(beg_pos, end_pos)
287
+ range = { start: start_pos, end: start_pos }
288
+ else
289
+ # Deletion: compute old end position from the deleted text
290
+ newline_count = old_text.count("\n")
291
+ if newline_count == 0
292
+ end_line = start_pos[:line]
293
+ end_char = start_pos[:character] + lsp_utf16_length(old_text)
294
+ else
295
+ end_line = start_pos[:line] + newline_count
296
+ last_newline = old_text.rindex("\n")
297
+ end_char = lsp_utf16_length(old_text[last_newline + 1..])
298
+ end
299
+ range = {
300
+ start: start_pos,
301
+ end: { line: end_line, character: end_char }
302
+ }
303
+ new_text = ""
304
+ end
305
+
306
+ client.did_change(
307
+ uri: uri, version: version,
308
+ text: new_text, range: range
309
+ )
310
+ end
311
+ end
312
+
313
+ # Close document when buffer is killed
314
+ buffer.on_killed do
315
+ if client.running?
316
+ client.did_close(uri: uri)
317
+ LSP_DOCUMENT_VERSIONS.delete(uri)
318
+ end
319
+ end
320
+ end
321
+
322
+ def lsp_show_signature_window(label)
323
+ # Close any existing signature window
324
+ lsp_close_signature_window
325
+
326
+ columns = [[Buffer.display_width(label) + 2, Curses.cols - 2].min, 1].max
327
+ win = FloatingWindow.at_cursor(
328
+ lines: 1,
329
+ columns: columns
330
+ )
331
+ win.buffer.insert(label)
332
+ win.buffer.beginning_of_buffer
333
+ win.show
334
+ LSP_STATUS[:signature_window] = win
335
+
336
+ add_hook(:pre_command_hook, :lsp_signature_pre_command_hook)
337
+ end
338
+
339
+ def lsp_close_signature_window
340
+ win = LSP_STATUS[:signature_window]
341
+ if win && !win.deleted?
342
+ win.close
343
+ end
344
+ LSP_STATUS[:signature_window] = nil
345
+ end
346
+
347
+ def lsp_signature_pre_command_hook
348
+ lsp_close_signature_window
349
+ remove_hook(:pre_command_hook, :lsp_signature_pre_command_hook)
350
+ end
351
+
352
+ # Keybinding: M-Tab for LSP completion
353
+ GLOBAL_MAP.define_key("\M-\t", :lsp_completion)
354
+
355
+ # Keybinding: F1 s for LSP signature help
356
+ GLOBAL_MAP.define_key([:f1, "s"], :lsp_signature_help)
357
+
358
+ # Open document with LSP server when a file is opened
359
+ HOOKS[:find_file_hook].unshift(:lsp_find_file_hook)
360
+
361
+ def lsp_find_file_hook
362
+ lsp_open_document(Buffer.current)
363
+ end
364
+
365
+ # Reopen document when file name changes
366
+ HOOKS[:after_set_visited_file_name_hook].unshift(
367
+ :lsp_after_set_visited_file_name_hook
368
+ )
369
+
370
+ def lsp_after_set_visited_file_name_hook(old_file_name)
371
+ buffer = Buffer.current
372
+
373
+ # Close the old document
374
+ old_uri = old_file_name ? "file://#{old_file_name}" : nil
375
+ return unless old_uri
376
+ client = LSP::ServerRegistry.get_client_for_buffer(buffer)
377
+ if client&.running? && client.document_open?(old_uri)
378
+ client.did_close(uri: old_uri)
379
+ LSP_DOCUMENT_VERSIONS.delete(old_uri)
380
+ end
381
+
382
+ # Reset hooks so they are reinstalled with the new URI
383
+ buffer[:lsp_hooks_installed] = false
384
+
385
+ # Open the new document
386
+ lsp_open_document(buffer)
387
+ end
388
+ end
389
+ end
@@ -55,6 +55,7 @@ module Textbringer
55
55
  result = eval(buffer.substring(b, e), TOPLEVEL_BINDING,
56
56
  "(eval_region)", 1)
57
57
  message(result.inspect)
58
+ buffer.deactivate_mark
58
59
  result
59
60
  end
60
61
 
@@ -303,7 +304,7 @@ module Textbringer
303
304
  buffer.insert(s)
304
305
  Window.redisplay
305
306
  rescue EOFError
306
- throw(:finish)
307
+ throw(:finish) if output.eof? && error.eof?
307
308
  rescue Errno::EAGAIN, Errno::EWOULDBLOCK
308
309
  Window.redisplay
309
310
  next
@@ -2,7 +2,11 @@ require "open3"
2
2
  require "io/wait"
3
3
 
4
4
  module Textbringer
5
- Command = Struct.new(:name, :block, :doc)
5
+ class Command < Data.define(:name, :block, :doc, :source_location_proc)
6
+ def source_location
7
+ source_location_proc&.call || block.source_location
8
+ end
9
+ end
6
10
 
7
11
  module Commands
8
12
  include Utils
@@ -21,11 +25,11 @@ module Textbringer
21
25
  @command_table[name.intern]
22
26
  end
23
27
 
24
- def define_command(name, doc: "No documentation", &block)
28
+ def define_command(name, doc: "No documentation", source_location_proc: nil, &block)
25
29
  name = name.intern
26
30
  Commands.send(:define_method, name, &block)
27
31
  Commands.send(:module_function, name)
28
- Commands.command_table[name] = Command.new(name, block, doc)
32
+ Commands.command_table[name] = Command.new(name, block, doc, source_location_proc)
29
33
  name
30
34
  end
31
35
  module_function :define_command
@@ -0,0 +1,188 @@
1
+ module Textbringer
2
+ class CompletionPopup
3
+ MAX_VISIBLE_ITEMS = 10
4
+ MIN_WIDTH = 20
5
+ MAX_WIDTH = 60
6
+
7
+ attr_reader :items, :selected_index, :start_point
8
+
9
+ def self.instance
10
+ @instance ||= new
11
+ end
12
+
13
+ def initialize
14
+ @floating_window = nil
15
+ @items = []
16
+ @selected_index = 0
17
+ @start_point = nil
18
+ @prefix = ""
19
+ end
20
+
21
+ def show(items:, start_point:, prefix: "")
22
+ @items = items
23
+ @start_point = start_point
24
+ @prefix = prefix
25
+ @selected_index = 0
26
+
27
+ return if @items.empty?
28
+
29
+ create_or_update_window
30
+ render
31
+ @floating_window.show
32
+ end
33
+
34
+ def hide
35
+ @floating_window&.hide
36
+ end
37
+
38
+ def close
39
+ if @floating_window
40
+ @floating_window.close
41
+ @floating_window = nil
42
+ end
43
+ @items = []
44
+ @selected_index = 0
45
+ @start_point = nil
46
+ @prefix = ""
47
+ end
48
+
49
+ def visible?
50
+ @floating_window&.visible? || false
51
+ end
52
+
53
+ def select_next
54
+ return unless visible? && !@items.empty?
55
+ @selected_index = (@selected_index + 1) % @items.size
56
+ render
57
+ @floating_window.redisplay
58
+ end
59
+
60
+ def select_previous
61
+ return unless visible? && !@items.empty?
62
+ @selected_index = (@selected_index - 1) % @items.size
63
+ render
64
+ @floating_window.redisplay
65
+ end
66
+
67
+ def accept
68
+ return nil unless visible? && !@items.empty?
69
+ item = current_item
70
+ close
71
+ item
72
+ end
73
+
74
+ def cancel
75
+ close
76
+ nil
77
+ end
78
+
79
+ def current_item
80
+ return nil if @items.empty?
81
+ @items[@selected_index]
82
+ end
83
+
84
+ private
85
+
86
+ def create_or_update_window
87
+ lines = visible_item_count
88
+ columns = calculate_width
89
+
90
+ if @floating_window && !@floating_window.deleted?
91
+ @floating_window.resize(lines, columns)
92
+ y, x = FloatingWindow.calculate_cursor_position(lines, columns, Window.current)
93
+ @floating_window.move_to(y: y, x: x)
94
+ else
95
+ @floating_window = FloatingWindow.at_cursor(
96
+ lines: lines,
97
+ columns: columns,
98
+ face: :completion_popup,
99
+ current_line_face: :completion_popup_selected
100
+ )
101
+ end
102
+ end
103
+
104
+ def visible_item_count
105
+ [@items.size, MAX_VISIBLE_ITEMS].min
106
+ end
107
+
108
+ def calculate_width
109
+ max_label_width = @items.map { |item| display_width(item[:label]) }.max || 0
110
+ max_detail_width = @items.map { |item|
111
+ item[:detail] ? display_width(item[:detail]) + 2 : 0
112
+ }.max || 0
113
+
114
+ width = max_label_width + max_detail_width + 2 # padding
115
+ [[width, MIN_WIDTH].max, MAX_WIDTH].min
116
+ end
117
+
118
+ def display_width(str)
119
+ return 0 unless str
120
+ Buffer.display_width(str)
121
+ end
122
+
123
+ def render
124
+ buffer = @floating_window.buffer
125
+ buffer.read_only = false
126
+ begin
127
+ buffer.clear
128
+
129
+ # Calculate visible range with scroll
130
+ visible_count = visible_item_count
131
+ scroll_offset = calculate_scroll_offset(visible_count)
132
+
133
+ visible_items = @items[scroll_offset, visible_count]
134
+ visible_items.each_with_index do |item, index|
135
+ line = format_item(item)
136
+ buffer.insert(line)
137
+ buffer.insert("\n")
138
+ end
139
+
140
+ # Go to the selected line so current_line_face highlights it
141
+ relative_index = @selected_index - scroll_offset
142
+ buffer.goto_line(relative_index + 1)
143
+ buffer.beginning_of_line
144
+ ensure
145
+ buffer.read_only = true
146
+ end
147
+ end
148
+
149
+ def calculate_scroll_offset(visible_count)
150
+ if @selected_index < visible_count
151
+ 0
152
+ else
153
+ @selected_index - visible_count + 1
154
+ end
155
+ end
156
+
157
+ def format_item(item)
158
+ label = item[:label] || ""
159
+ detail = item[:detail]
160
+
161
+ # Build the display string
162
+ result = label
163
+ if detail
164
+ result = "#{label} #{detail}"
165
+ end
166
+
167
+ # Truncate if too long
168
+ width = calculate_width
169
+ if display_width(result) > width
170
+ result = truncate_to_width(result, width - 1) + "…"
171
+ end
172
+
173
+ result
174
+ end
175
+
176
+ def truncate_to_width(str, max_width)
177
+ result = +""
178
+ current_width = 0
179
+ str.each_char do |char|
180
+ char_width = Buffer.display_width(char)
181
+ break if current_width + char_width > max_width
182
+ result << char
183
+ current_width += char_width
184
+ end
185
+ result
186
+ end
187
+ end
188
+ end
@@ -2,5 +2,7 @@ module Textbringer
2
2
  Face.define :mode_line, reverse: true
3
3
  Face.define :link, foreground: "blue", bold: true
4
4
  Face.define :control
5
- Face.define :region, background: "blue"
5
+ Face.define :region, background: "blue", foreground: "white"
6
+ Face.define :isearch, background: "yellow", foreground: "black"
7
+ Face.define :floating_window, background: "cyan", foreground: "black"
6
8
  end
@@ -0,0 +1,4 @@
1
+ module Textbringer
2
+ Face.define :completion_popup, background: "white", foreground: "black"
3
+ Face.define :completion_popup_selected, background: "blue", foreground: "white"
4
+ end