vimamsa 0.1.23 → 0.1.25

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +32 -0
  3. data/Dockerfile +45 -0
  4. data/custom_example.rb +37 -11
  5. data/docker_cmd.sh +7 -0
  6. data/exe/run_tests.rb +23 -0
  7. data/lib/vimamsa/actions.rb +8 -0
  8. data/lib/vimamsa/buffer.rb +38 -47
  9. data/lib/vimamsa/buffer_changetext.rb +49 -12
  10. data/lib/vimamsa/buffer_list.rb +2 -28
  11. data/lib/vimamsa/conf.rb +30 -0
  12. data/lib/vimamsa/diff_buffer.rb +80 -32
  13. data/lib/vimamsa/editor.rb +54 -67
  14. data/lib/vimamsa/file_finder.rb +6 -2
  15. data/lib/vimamsa/gui.rb +247 -63
  16. data/lib/vimamsa/gui_file_panel.rb +1 -0
  17. data/lib/vimamsa/gui_func_panel.rb +127 -0
  18. data/lib/vimamsa/gui_menu.rb +42 -0
  19. data/lib/vimamsa/gui_select_window.rb +17 -6
  20. data/lib/vimamsa/gui_settings.rb +347 -13
  21. data/lib/vimamsa/gui_sourceview.rb +116 -2
  22. data/lib/vimamsa/gui_text.rb +0 -22
  23. data/lib/vimamsa/hyper_plain_text.rb +1 -0
  24. data/lib/vimamsa/key_actions.rb +30 -29
  25. data/lib/vimamsa/key_binding_tree.rb +85 -3
  26. data/lib/vimamsa/key_bindings_vimlike.rb +4 -0
  27. data/lib/vimamsa/langservp.rb +161 -7
  28. data/lib/vimamsa/macro.rb +54 -7
  29. data/lib/vimamsa/rbvma.rb +2 -0
  30. data/lib/vimamsa/test_framework.rb +137 -0
  31. data/lib/vimamsa/version.rb +1 -1
  32. data/modules/calculator/calculator.rb +318 -0
  33. data/modules/calculator/calculator_info.rb +3 -0
  34. data/modules/terminal/terminal.rb +140 -0
  35. data/modules/terminal/terminal_info.rb +3 -0
  36. data/run_tests.rb +89 -0
  37. data/styles/dark.xml +1 -1
  38. data/styles/molokai_edit.xml +2 -2
  39. data/tests/key_bindings.rb +2 -0
  40. data/tests/test_basic_editing.rb +86 -0
  41. data/tests/test_copy_paste.rb +88 -0
  42. data/tests/test_key_bindings.rb +152 -0
  43. data/tests/test_module_interface.rb +98 -0
  44. data/tests/test_undo.rb +201 -0
  45. data/vimamsa.gemspec +6 -5
  46. metadata +46 -14
@@ -30,14 +30,14 @@ class SelectUpdateWindow
30
30
  end
31
31
 
32
32
  def set_selected_row(rownum)
33
+ count = @model.count
34
+ return if count == 0
33
35
  rownum = 0 if rownum < 0
36
+ rownum = count - 1 if rownum >= count
34
37
  @selected_row = rownum
35
-
36
- if @model.count > 0
37
- path = Gtk::TreePath.new(@selected_row.to_s)
38
- iter = @model.get_iter(path)
39
- @tv.selection.select_iter(iter)
40
- end
38
+ path = Gtk::TreePath.new(@selected_row.to_s)
39
+ iter = @model.get_iter(path)
40
+ @tv.selection.select_iter(iter)
41
41
  end
42
42
 
43
43
  def initialize(main_window, item_list, jump_keys, select_callback, update_callback, opt = {})
@@ -126,6 +126,17 @@ class SelectUpdateWindow
126
126
  @window.destroy
127
127
  # debug iter[1].inspect
128
128
  true
129
+ elsif keyval == Gdk::Keyval::KEY_Delete && @opt[:delete_callback]
130
+ path = Gtk::TreePath.new(@selected_row.to_s)
131
+ iter = @model.get_iter(path)
132
+ next false unless iter
133
+ name = iter[COLUMN_DESCRIPTION]
134
+ refresh = proc {
135
+ update_item_list(@update_callback.call(@entry.text))
136
+ @window.present
137
+ }
138
+ @opt[:delete_callback].call(name, refresh)
139
+ true
129
140
  elsif keyval == Gdk::Keyval::KEY_Escape
130
141
  @window.destroy
131
142
  true
@@ -19,6 +19,14 @@ SETTINGS_DEFS = [
19
19
  :settings => [
20
20
  { :key => [:match, :highlight, :color], :label => "Search highlight color", :type => :string },
21
21
  { :key => [:kbd, :show_prev_action], :label => "Show previous action in toolbar", :type => :bool },
22
+ { :key => [:style_scheme], :label => "Color scheme", :type => :select,
23
+ :options => proc {
24
+ ssm = GtkSource::StyleSchemeManager.new
25
+ ssm.set_search_path(ssm.search_path << ppath("styles/"))
26
+ ssm.scheme_ids.reject { |id| [VIMAMSA_OVERLAY_SCHEME_ID, VIMAMSA_CONTRAST_SCHEME_ID].include?(id) }.sort
27
+ } },
28
+ { :key => [:color_contrast], :label => "Color scheme contrast (1.0 = original)", :type => :float, :min => 0.5, :max => 2.0, :step => 0.05 },
29
+ { :key => [:color_brightness], :label => "Color scheme brightness (0.0 = original)", :type => :float, :min => -0.5, :max => 0.5, :step => 0.01 },
22
30
  ],
23
31
  },
24
32
  {
@@ -27,10 +35,40 @@ SETTINGS_DEFS = [
27
35
  { :key => [:lsp, :enabled], :label => "Enable LSP (Language Server)", :type => :bool },
28
36
  { :key => [:experimental], :label => "Enable experimental features", :type => :bool },
29
37
  { :key => [:macro, :animation_delay], :label => "Macro animation delay (sec)", :type => :float, :min => 0.0, :max => 2.0, :step => 0.0001 },
38
+ { :key => [:paste, :cursor_at_start], :label => "Leave cursor at start of pasted text", :type => :bool },
39
+ ],
40
+ },
41
+ {
42
+ :label => "Files",
43
+ :settings => [
44
+ { :key => ["search_dirs"], :label => "Search directories (one per line)", :type => :string_list },
30
45
  ],
31
46
  },
32
47
  ]
33
48
 
49
+ # Build the full settings definition list, appending a Modules section
50
+ # dynamically from whatever subdirectories exist in modules/.
51
+ def all_settings_defs
52
+ # Build one settings entry per module found in modules/*/.
53
+ # Each module may provide a <name>_info.rb file that defines <name>_info,
54
+ # returning a hash with :name (display label) and :no_restart (bool).
55
+ # Modules without an info file fall back to a capitalized directory name
56
+ # and require a restart to activate.
57
+ module_settings = Dir.glob(ppath("modules/*/")).sort.map do |mod_dir|
58
+ mod_name = File.basename(mod_dir)
59
+ info_file = File.join(mod_dir, "#{mod_name}_info.rb")
60
+ load info_file if File.exist?(info_file)
61
+ info_fn = "#{mod_name}_info"
62
+ info = respond_to?(info_fn, true) ? send(info_fn) : {}
63
+ label = info[:name] || mod_name.split(/[_\-]/).map(&:capitalize).join(" ")
64
+ no_restart = info[:no_restart] || false
65
+ restart_note = no_restart ? "" : " (requires restart)"
66
+ { :key => [:modules, mod_name.to_sym, :enabled], :label => "#{label}#{restart_note}", :type => :bool, :no_restart => no_restart }
67
+ end
68
+ return SETTINGS_DEFS unless module_settings.any?
69
+ SETTINGS_DEFS + [{ :label => "Modules", :settings => module_settings }]
70
+ end
71
+
34
72
  class SettingsDialog
35
73
  def initialize
36
74
  @widgets = {}
@@ -47,7 +85,7 @@ class SettingsDialog
47
85
  notebook = Gtk::Notebook.new
48
86
  outer.append(notebook)
49
87
 
50
- SETTINGS_DEFS.each do |section|
88
+ all_settings_defs.each do |section|
51
89
  grid = Gtk::Grid.new
52
90
  grid.row_spacing = 10
53
91
  grid.column_spacing = 16
@@ -56,16 +94,28 @@ class SettingsDialog
56
94
  grid.margin_start = 12
57
95
  grid.margin_end = 12
58
96
 
59
- section[:settings].each_with_index do |s, row|
97
+ grid_row = 0
98
+ section[:settings].each do |s|
60
99
  label = Gtk::Label.new(s[:label])
61
100
  label.halign = :start
62
101
  label.hexpand = true
63
102
 
64
- widget = make_widget(s)
65
- @widgets[s[:key]] = { :widget => widget, :type => s[:type] }
66
-
67
- grid.attach(label, 0, row, 1, 1)
68
- grid.attach(widget, 1, row, 1, 1)
103
+ if s[:type] == :string_list
104
+ # Label spans both columns; editor on the next row spanning both columns
105
+ grid.attach(label, 0, grid_row, 2, 1)
106
+ grid_row += 1
107
+ cur = get(s[:key])
108
+ container, get_value = build_string_list_editor(cur.is_a?(Array) ? cur : [])
109
+ @widgets[s[:key]] = { :widget => container, :type => s[:type], :get_value => get_value }
110
+ grid.attach(container, 0, grid_row, 2, 1)
111
+ else
112
+ widget = make_widget(s)
113
+ # Store prev_value so save_and_close can detect newly-enabled modules.
114
+ @widgets[s[:key]] = { :widget => widget, :type => s[:type], :no_restart => s[:no_restart], :prev_value => cnf_get(s[:key]) }
115
+ grid.attach(label, 0, grid_row, 1, 1)
116
+ grid.attach(widget, 1, grid_row, 1, 1)
117
+ end
118
+ grid_row += 1
69
119
  end
70
120
 
71
121
  notebook.append_page(grid, Gtk::Label.new(section[:label]))
@@ -97,7 +147,7 @@ class SettingsDialog
97
147
  end
98
148
 
99
149
  def make_widget(s)
100
- cur = get(s[:key])
150
+ cur = cnf_get(s[:key])
101
151
  case s[:type]
102
152
  when :bool
103
153
  w = Gtk::Switch.new
@@ -116,24 +166,122 @@ class SettingsDialog
116
166
  w = Gtk::Entry.new
117
167
  w.text = cur.to_s
118
168
  w
169
+ when :select
170
+ options = s[:options].is_a?(Proc) ? s[:options].call : s[:options]
171
+ cur = cnf_get(s[:key]).to_s
172
+ string_list = Gtk::StringList.new(options)
173
+ w = Gtk::DropDown.new(string_list, nil)
174
+ w.selected = [options.index(cur) || 0, 0].max
175
+ w
176
+ end
177
+ end
178
+
179
+ def build_string_list_editor(paths)
180
+ store = Gtk::ListStore.new(String)
181
+ paths.each { |p| store.append[0] = p }
182
+
183
+ tv = Gtk::TreeView.new(store)
184
+ tv.headers_visible = false
185
+ renderer = Gtk::CellRendererText.new
186
+ renderer.ellipsize = Pango::EllipsizeMode::START
187
+ col = Gtk::TreeViewColumn.new("", renderer, text: 0)
188
+ col.expand = true
189
+ tv.append_column(col)
190
+
191
+ sw = Gtk::ScrolledWindow.new
192
+ sw.set_policy(:automatic, :automatic)
193
+ sw.set_child(tv)
194
+ sw.set_size_request(-1, 120)
195
+ sw.vexpand = true
196
+
197
+ add_btn = Gtk::Button.new(label: "Add…")
198
+ add_btn.signal_connect("clicked") do
199
+ chooser = Gtk::FileChooserDialog.new(
200
+ :title => "Select directory",
201
+ :action => :select_folder,
202
+ :buttons => [["Select", :accept], ["Cancel", :cancel]],
203
+ )
204
+ chooser.set_transient_for(@window)
205
+ chooser.modal = true
206
+ chooser.signal_connect("response") do |dlg, resp|
207
+ if resp == Gtk::ResponseType::ACCEPT
208
+ iter = store.append
209
+ iter[0] = dlg.file.path
210
+ end
211
+ dlg.destroy
212
+ end
213
+ chooser.show
214
+ end
215
+
216
+ remove_btn = Gtk::Button.new(label: "Remove")
217
+ remove_btn.signal_connect("clicked") do
218
+ sel = tv.selection.selected
219
+ store.remove(sel) if sel
119
220
  end
221
+
222
+ btn_box = Gtk::Box.new(:horizontal, 6)
223
+ btn_box.append(add_btn)
224
+ btn_box.append(remove_btn)
225
+
226
+ container = Gtk::Box.new(:vertical, 4)
227
+ container.vexpand = true
228
+ container.append(sw)
229
+ container.append(btn_box)
230
+
231
+ get_value = proc {
232
+ result = []
233
+ store.each { |_m, _path, iter| result << iter[0] }
234
+ result
235
+ }
236
+
237
+ [container, get_value]
120
238
  end
121
239
 
122
240
  def save_and_close
123
241
  @widgets.each do |key, info|
124
242
  val = case info[:type]
125
- when :bool then info[:widget].active?
126
- when :int then info[:widget].value.to_i
127
- when :float then info[:widget].value.to_f
128
- when :string then info[:widget].text
243
+ when :bool then info[:widget].active?
244
+ when :int then info[:widget].value.to_i
245
+ when :float then info[:widget].value.to_f
246
+ when :string then info[:widget].text
247
+ when :string_list then info[:get_value].call
248
+ when :select then info[:widget].selected_item&.string
129
249
  end
130
- set(key, val)
250
+ cnf_set(key, val) unless val.nil?
131
251
  end
132
252
  save_settings_to_file
133
253
  gui_refresh_font
254
+ gui_refresh_style_scheme
255
+ activate_no_restart_modules
134
256
  @window.destroy
135
257
  end
136
258
 
259
+ # For modules that support live activation (no_restart: true), apply enable
260
+ # and disable transitions immediately without requiring a restart.
261
+ # Modules that were already in the target state when the dialog opened are
262
+ # skipped (prev_value matches new value), so we never double-init or double-disable.
263
+ def activate_no_restart_modules
264
+ @widgets.each do |key, info|
265
+ next unless key.is_a?(Array) && key[0] == :modules && key[2] == :enabled
266
+ next unless info[:no_restart] # module requires restart — skip
267
+ mod_name = key[1].to_s
268
+ was_enabled = info[:prev_value]
269
+ now_enabled = cnf_get(key)
270
+ if !was_enabled && now_enabled
271
+ # Toggled on: load the module file and call <name>_init.
272
+ main_file = ppath("modules/#{mod_name}/#{mod_name}.rb")
273
+ next unless File.exist?(main_file)
274
+ load main_file
275
+ init_fn = "#{mod_name}_init"
276
+ send(init_fn) if respond_to?(init_fn, true)
277
+ elsif was_enabled && !now_enabled
278
+ # Toggled off: call <name>_disable to unregister actions and bindings.
279
+ disable_fn = "#{mod_name}_disable"
280
+ send(disable_fn) if respond_to?(disable_fn, true)
281
+ end
282
+ end
283
+ end
284
+
137
285
  def run
138
286
  @window.show
139
287
  end
@@ -143,6 +291,192 @@ def show_settings_dialog
143
291
  SettingsDialog.new.run
144
292
  end
145
293
 
294
+ VIMAMSA_OVERLAY_SCHEME_ID = "vimamsa_overlay"
295
+ VIMAMSA_CONTRAST_SCHEME_ID = "vimamsa_contrast"
296
+
297
+ # ── Color utilities ────────────────────────────────────────────────────────────
298
+
299
+ def hex_to_rgb(hex)
300
+ hex = hex.delete("#")
301
+ hex = hex.chars.map { |c| c * 2 }.join if hex.length == 3
302
+ [hex[0, 2].to_i(16), hex[2, 2].to_i(16), hex[4, 2].to_i(16)]
303
+ end
304
+
305
+ def rgb_to_hsl(r, g, b)
306
+ r, g, b = r / 255.0, g / 255.0, b / 255.0
307
+ max, min = [r, g, b].max, [r, g, b].min
308
+ l = (max + min) / 2.0
309
+ if max == min
310
+ [0.0, 0.0, l]
311
+ else
312
+ d = max - min
313
+ s = l > 0.5 ? d / (2.0 - max - min) : d / (max + min)
314
+ h = case max
315
+ when r then ((g - b) / d + (g < b ? 6 : 0)) / 6.0
316
+ when g then ((b - r) / d + 2) / 6.0
317
+ else ((r - g) / d + 4) / 6.0
318
+ end
319
+ [h, s, l]
320
+ end
321
+ end
322
+
323
+ def hsl_to_rgb(h, s, l)
324
+ return Array.new(3, (l * 255).round) if s == 0
325
+ q = l < 0.5 ? l * (1 + s) : l + s - l * s
326
+ p = 2 * l - q
327
+ [h + 1 / 3.0, h, h - 1 / 3.0].map do |t|
328
+ t += 1 if t < 0; t -= 1 if t > 1
329
+ v = if t < 1 / 6.0 then p + (q - p) * 6 * t
330
+ elsif t < 1 / 2.0 then q
331
+ elsif t < 2 / 3.0 then p + (q - p) * (2 / 3.0 - t) * 6
332
+ else p
333
+ end
334
+ (v * 255).round.clamp(0, 255)
335
+ end
336
+ end
337
+
338
+ # Adjust contrast and brightness of a hex color.
339
+ # contrast: scales lightness around 0.5 (>1 increases separation, <1 reduces it).
340
+ # brightness: fixed lightness offset applied after contrast (-1.0..1.0, 0 = no change).
341
+ # Chroma is preserved by rescaling saturation to compensate for gamut narrowing at
342
+ # extreme lightness values, preventing warm colors from washing out.
343
+ def adjust_hex_color(hex, contrast, brightness)
344
+ r, g, b = hex_to_rgb(hex)
345
+ h, s, l = rgb_to_hsl(r, g, b)
346
+ new_l = (0.5 + (l - 0.5) * contrast + brightness).clamp(0.0, 1.0)
347
+ old_chroma = s * [l, 1.0 - l].min * 2.0
348
+ max_new_chroma = [new_l, 1.0 - new_l].min * 2.0
349
+ new_s = max_new_chroma > 0 ? [old_chroma / max_new_chroma, 1.0].min : 0.0
350
+ nr, ng, nb = hsl_to_rgb(h, new_s, new_l)
351
+ "#%02X%02X%02X" % [nr, ng, nb]
352
+ end
353
+
354
+ # ── Scheme XML processing ──────────────────────────────────────────────────────
355
+
356
+ # Find the XML file on disk for a given scheme id.
357
+ def find_scheme_xml_path(scheme_id)
358
+ ssm = GtkSource::StyleSchemeManager.new
359
+ ssm.set_search_path(ssm.search_path << ppath("styles/"))
360
+ ssm.search_path.each do |dir|
361
+ next unless Dir.exist?(dir)
362
+ Dir[File.join(dir, "*.xml")].each do |f|
363
+ return f if File.read(f).include?("id=\"#{scheme_id}\"")
364
+ rescue StandardError
365
+ end
366
+ end
367
+ nil
368
+ end
369
+
370
+ # Apply contrast and brightness to every hex color value in an XML string.
371
+ def apply_contrast_to_xml(xml, contrast, brightness)
372
+ xml.gsub(/#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}\b/) do |hex|
373
+ adjust_hex_color(hex, contrast, brightness)
374
+ end
375
+ end
376
+
377
+ # If contrast != 1.0, generate a contrast-adjusted copy of base_scheme_id and
378
+ # return its scheme id. Otherwise return base_scheme_id unchanged.
379
+ def apply_contrast_transformation(base_scheme_id, contrast, brightness)
380
+ return base_scheme_id if contrast == 1.0 && brightness == 0.0
381
+ xml_path = find_scheme_xml_path(base_scheme_id)
382
+ return base_scheme_id if xml_path.nil?
383
+
384
+ adjusted = apply_contrast_to_xml(IO.read(xml_path), contrast, brightness)
385
+ adjusted = adjusted.sub(/(<style-scheme\b[^>]*)id="[^"]*"/, "\\1id=\"#{VIMAMSA_CONTRAST_SCHEME_ID}\"")
386
+ adjusted = adjusted.sub(/(<style-scheme\b[^>]*)name="[^"]*"/, "\\1name=\"#{VIMAMSA_CONTRAST_SCHEME_ID}\"")
387
+ adjusted = adjusted.sub(/\s*parent-scheme="[^"]*"/, "") # avoid inheritance chains
388
+ IO.write(ppath("styles/_vimamsa_contrast.xml"), adjusted)
389
+ VIMAMSA_CONTRAST_SCHEME_ID
390
+ end
391
+
392
+ # ── Overlay scheme ─────────────────────────────────────────────────────────────
393
+
394
+ # Return foreground attribute XML fragment for a style name from a scheme object.
395
+ # Falls back to default_fg if the scheme doesn't define it.
396
+ def style_fg_attr(sty, style_name, default_fg = nil)
397
+ fg = sty&.get_style(style_name)&.foreground
398
+ fg = default_fg if fg.nil? || fg.empty?
399
+ fg ? " foreground=\"#{fg}\"" : ""
400
+ end
401
+
402
+ # Generate a GtkSourceView style scheme that inherits from base_scheme_id
403
+ # (optionally contrast-adjusted) and overlays Vimamsa-specific heading/
404
+ # hyperlink styles on top. Written to styles/_vimamsa_overlay.xml.
405
+ # Foreground colors are read from the parent scheme so they are preserved.
406
+ def generate_vimamsa_overlay(base_scheme_id)
407
+ contrast = (cnf_get([:color_contrast]) || 1.0).to_f
408
+ brightness = (cnf_get([:color_brightness]) || 0.0).to_f
409
+ parent_id = apply_contrast_transformation(base_scheme_id, contrast, brightness)
410
+
411
+ # Load the parent scheme to read existing foreground colors for headings/links
412
+ ssm = GtkSource::StyleSchemeManager.new
413
+ ssm.set_search_path(ssm.search_path << ppath("styles/"))
414
+ parent_sty = ssm.get_scheme(parent_id)
415
+
416
+ xml = <<~XML
417
+ <?xml version="1.0"?>
418
+ <style-scheme id="#{VIMAMSA_OVERLAY_SCHEME_ID}" name="#{VIMAMSA_OVERLAY_SCHEME_ID}" version="1.0" parent-scheme="#{parent_id}">
419
+ <style name="def:title" scale="2.0" bold="true"#{style_fg_attr(parent_sty, "def:title")}/>
420
+ <style name="def:hyperlink" bold="true"#{style_fg_attr(parent_sty, "def:hyperlink", "#4FC3F7")}/>
421
+ <style name="def:heading0" scale="2.0" bold="true"#{style_fg_attr(parent_sty, "def:heading0")}/>
422
+ <style name="def:heading1" scale="1.75" bold="true"#{style_fg_attr(parent_sty, "def:heading1")}/>
423
+ <style name="def:heading2" scale="1.5" bold="true"#{style_fg_attr(parent_sty, "def:heading2")}/>
424
+ <style name="def:heading3" scale="1.25" bold="true"#{style_fg_attr(parent_sty, "def:heading3")}/>
425
+ <style name="def:heading4" scale="1.175" bold="true"#{style_fg_attr(parent_sty, "def:heading4")}/>
426
+ <style name="def:heading5" scale="1.1" bold="true"#{style_fg_attr(parent_sty, "def:heading5")}/>
427
+ <style name="def:heading6" scale="1.0" bold="true"#{style_fg_attr(parent_sty, "def:heading6")}/>
428
+ <style name="def:bold" bold="true"/>
429
+ </style-scheme>
430
+ XML
431
+ user_styles_dir = File.expand_path("~/.config/vimamsa/styles")
432
+ FileUtils.mkdir_p(user_styles_dir)
433
+ IO.write(File.join(user_styles_dir, "_vimamsa_overlay.xml"), xml)
434
+ end
435
+
436
+ # Build a StyleSchemeManager with the project styles dir appended,
437
+ # apply the overlay on top of the user's chosen base scheme,
438
+ # and return the overlay scheme object.
439
+ def load_vimamsa_scheme
440
+ base_id = cnf.style_scheme! || "molokai_edit"
441
+ generate_vimamsa_overlay(base_id)
442
+ user_styles_dir = File.expand_path("~/.config/vimamsa/styles")
443
+ ssm = GtkSource::StyleSchemeManager.new
444
+ ssm.set_search_path(ssm.search_path << ppath("styles/") << user_styles_dir)
445
+ ssm.get_scheme(VIMAMSA_OVERLAY_SCHEME_ID)
446
+ end
447
+
448
+ # Returns true if the given GtkSource::StyleScheme has a light background.
449
+ # Reads the background color of the "text" style and computes relative luminance.
450
+ def scheme_is_light?(sty)
451
+ text_style = sty.get_style("text")
452
+ return false if text_style.nil?
453
+ bg = text_style.background
454
+ return false if bg.nil? || !bg.start_with?("#")
455
+ hex = bg.delete("#")
456
+ hex = hex.chars.map { |c| c * 2 }.join if hex.length == 3
457
+ return false unless hex.length == 6
458
+ r = hex[0, 2].to_i(16) / 255.0
459
+ g = hex[2, 2].to_i(16) / 255.0
460
+ b = hex[4, 2].to_i(16) / 255.0
461
+ luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
462
+ luminance > 0.5
463
+ end
464
+
465
+ # Apply dark/light GTK preference to match the given style scheme.
466
+ def gui_apply_color_mode(sty)
467
+ Gtk::Settings.default.gtk_application_prefer_dark_theme = !scheme_is_light?(sty)
468
+ end
469
+
470
+ def gui_refresh_style_scheme
471
+ return unless $vmag
472
+ sty = load_vimamsa_scheme
473
+ return if sty.nil?
474
+ gui_apply_color_mode(sty)
475
+ for _k, view in $vmag.buffers
476
+ view.buffer.style_scheme = sty
477
+ end
478
+ end
479
+
146
480
  def gui_refresh_font
147
481
  return unless $vmag
148
482
  provider = Gtk::CssProvider.new
@@ -45,8 +45,90 @@ class VSourceView < GtkSource::View
45
45
  @curpos_mark = nil
46
46
  end
47
47
 
48
+ # Replace each character of every «word with * so the GTK buffer never
49
+ # exposes the real content. Length is preserved → buffer offsets stay valid.
50
+ def mask_for_display(str)
51
+ str.gsub(/(?<=«)\S+/) { |w| "*" * w.length }
52
+ end
53
+
48
54
  def set_content(str)
49
- self.buffer.set_text(str)
55
+ if @bufo&.lang == "hyperplaintext"
56
+ self.buffer.set_text(mask_for_display(str))
57
+ else
58
+ self.buffer.set_text(str)
59
+ end
60
+ end
61
+
62
+ # Sync the GTK buffer to mask_for_display(@bufo.to_s).
63
+ # Handles both directions:
64
+ # - masks unmasked «word regions (e.g. after typing a new «)
65
+ # - unmasks orphaned *** spans left behind when « is deleted
66
+ def remask_gtk_buffer
67
+ return unless @bufo&.lang == "hyperplaintext"
68
+ ruby_text = @bufo.to_s
69
+ expected = mask_for_display(ruby_text)
70
+ gtk_text = buffer.text
71
+ return if expected == gtk_text
72
+ return if expected.length != gtk_text.length # sanity check
73
+
74
+ # Find the smallest enclosing differing range and fix it in one edit.
75
+ i = 0
76
+ i += 1 while i < expected.length && expected[i] == gtk_text[i]
77
+ j = expected.length - 1
78
+ j -= 1 while j >= i && expected[j] == gtk_text[j]
79
+
80
+ s_iter = buffer.get_iter_at(:offset => i)
81
+ e_iter = buffer.get_iter_at(:offset => j + 1)
82
+ buffer.delete(s_iter, e_iter)
83
+ buffer.insert(buffer.get_iter_at(:offset => i), expected[i..j])
84
+ end
85
+
86
+ # Returns [word, word_start, word_end] if +pos+ (Ruby-buffer offset) is
87
+ # inside a «word region, nil otherwise.
88
+ def masked_word_at_cursor(pos)
89
+ text = @bufo.to_s
90
+ return nil unless text.include?("«")
91
+ text.scan(/«(\S+)/) do
92
+ word = $1
93
+ word_start = $~.begin(0) + 1 # first char after «
94
+ word_end = word_start + word.length
95
+ return [word, word_start, word_end] if pos >= word_start && pos <= word_end
96
+ end
97
+ nil
98
+ end
99
+
100
+ # Replace the masked (***) span with the real word from the Ruby buffer.
101
+ def unmask_gtk_region(word, word_start, word_end)
102
+ s_iter = buffer.get_iter_at(:offset => word_start)
103
+ e_iter = buffer.get_iter_at(:offset => word_end)
104
+ buffer.delete(s_iter, e_iter)
105
+ buffer.insert(buffer.get_iter_at(:offset => word_start), word)
106
+ end
107
+
108
+ # When in insert mode with cursor on a «word, expose the real text in the GTK
109
+ # buffer so the user can see/edit it. Re-mask when cursor leaves or mode changes.
110
+ def update_cursor_unmask
111
+ return unless @bufo&.lang == "hyperplaintext"
112
+ ctype = vma.kbd.get_cursor_type
113
+ pos = @bufo.pos
114
+
115
+ if ctype == :insert
116
+ result = masked_word_at_cursor(pos)
117
+ if result
118
+ word, word_start, word_end = result
119
+ return if @unmasked_range == [word_start, word_end] # already showing this word
120
+ remask_gtk_buffer if @unmasked_range # re-mask previous region
121
+ unmask_gtk_region(word, word_start, word_end)
122
+ @unmasked_range = [word_start, word_end]
123
+ return
124
+ end
125
+ end
126
+
127
+ # Not insert mode, or cursor is not over a masked word — ensure everything masked
128
+ if @unmasked_range
129
+ @unmasked_range = nil
130
+ remask_gtk_buffer
131
+ end
50
132
  end
51
133
 
52
134
  def gutter_width()
@@ -228,6 +310,7 @@ class VSourceView < GtkSource::View
228
310
 
229
311
  click.signal_connect "pressed" do |gesture, n_press, x, y, z|
230
312
  debug "SourceView, GestureClick released x=#{x} y=#{y}"
313
+ vma.gui.instance_variable_set(:@kbd_passthrough, false)
231
314
 
232
315
  if buf.visual_mode?
233
316
  buf.end_visual_mode
@@ -484,6 +567,7 @@ class VSourceView < GtkSource::View
484
567
  end
485
568
  end
486
569
  if any_change
570
+ remask_gtk_buffer
487
571
  #TODO: only when necessary
488
572
  self.set_cursor_pos(pos)
489
573
  end
@@ -608,7 +692,7 @@ class VSourceView < GtkSource::View
608
692
  self.style_context.add_provider(prov)
609
693
  @cursor_prov = prov
610
694
  end
611
- @ctype == ctype
695
+ @ctype = ctype
612
696
  end
613
697
  end
614
698
 
@@ -666,6 +750,8 @@ class VSourceView < GtkSource::View
666
750
  self.cursor_visible = false
667
751
  self.cursor_visible = true
668
752
  end
753
+
754
+ update_cursor_unmask
669
755
  end #end draw_cursor
670
756
 
671
757
  CONTEXT_MENU_ITEMS = [
@@ -691,14 +777,42 @@ class VSourceView < GtkSource::View
691
777
  end
692
778
  end
693
779
  end
780
+
781
+ unless vma.gui.app.lookup_action("ctx_open_link")
782
+ act = Gio::SimpleAction.new("ctx_open_link")
783
+ vma.gui.app.add_action(act)
784
+ act.signal_connect("activate") do
785
+ next unless @context_link
786
+ if is_url(@context_link)
787
+ open_url(@context_link)
788
+ else
789
+ fn = hpt_check_cur_word(@context_link)
790
+ open_existing_file(fn) if fn
791
+ end
792
+ end
793
+ end
794
+
694
795
  @context_menu = Gtk::PopoverMenu.new
695
796
  @context_menu.set_parent(self)
696
797
  @context_menu.has_arrow = false
697
798
  end
698
799
 
800
+ def link_at(x, y)
801
+ bx, by = window_to_buffer_coords(:widget, x.to_i, y.to_i)
802
+ iter = get_iter_at_location(bx, by)
803
+ return nil unless iter
804
+ word, _range = @bufo.get_word_in_pos(iter.offset, boundary: :space)
805
+ return nil unless word
806
+ return word if is_url(word)
807
+ return word if word.match?(/⟦.+⟧/)
808
+ nil
809
+ end
810
+
699
811
  def show_context_menu(x, y)
700
812
  init_context_menu if @context_menu.nil?
701
813
  menu = Gio::Menu.new
814
+ @context_link = link_at(x, y)
815
+ menu.append("Open link", "app.ctx_open_link") if @context_link
702
816
  CONTEXT_MENU_ITEMS.each { |label, action_id| menu.append(label, "app.ctx_#{action_id}") }
703
817
  if @bufo.selection_active?
704
818
  CONTEXT_MENU_ITEMS_SELECTION.each { |label, action_id| menu.append(label, "app.ctx_#{action_id}") }
@@ -24,26 +24,4 @@ module Gui
24
24
  self.hilight_range(bf, r, tag: tag)
25
25
  }
26
26
  end
27
-
28
- def self.highlight_match_old(bf, str, color: "#aa0000ff")
29
- vbuf = bf.view.buffer
30
- r = Regexp.new(Regexp.escape(str), Regexp::IGNORECASE)
31
-
32
- hlparts = []
33
-
34
- tt = vma.gui.view.buffer.tag_table.lookup("highlight_match_tag")
35
- if tt.nil?
36
- tt = vma.gui.view.buffer.create_tag("highlight_match_tag")
37
- end
38
-
39
- tt.weight = 650
40
- tt.foreground = color
41
-
42
- ind = scan_indexes(bf, r)
43
- ind.each { |x|
44
- itr = vbuf.get_iter_at(:offset => x)
45
- itr2 = vbuf.get_iter_at(:offset => x + str.size)
46
- vbuf.apply_tag(tt, itr, itr2)
47
- }
48
- end
49
27
  end
@@ -133,3 +133,4 @@ def hpt_scan_images(bf = nil)
133
133
 
134
134
  # vma.gui.delex.run #TODO:gtk4
135
135
  end
136
+