comparison editor.luan @ 37:b7ff52d45b9a default tip

copy from luan
author Franklin Schmidt <fschmidt@gmail.com>
date Mon, 21 Apr 2025 13:07:29 -0600
parents 0a8865de3d53
children
comparison
equal deleted inserted replaced
36:0a8865de3d53 37:b7ff52d45b9a
1 local Luan = require "luan:Luan.luan"
2 local error = Luan.error
3 local ipairs = Luan.ipairs or error()
4 local range = Luan.range or error()
5 local stringify = Luan.stringify or error()
6 local String = require "luan:String.luan"
7 local sub_string = String.sub or error()
8 local replace = String.replace or error()
9 local starts_with = String.starts_with or error()
10 local to_number = String.to_number or error()
11 local find = String.find or error()
12 local regex = String.regex or error()
13 local regex_quote = String.regex_quote or error()
14 local regex_quote_replacement = String.regex_quote_replacement or error()
15 local repeated = String.repeated or error()
16 local Io = require "luan:Io.luan"
17 local print = Io.print or error()
18 local new_file = Io.schemes.file or error()
19 local Math = require "luan:Math.luan"
20 local min = Math.min or error()
21 local Swing = require "luan:swing/Swing.luan"
22 local new_frame = require("luan:swing/Frame.luan").new or error()
23 local new_label = require("luan:swing/Label.luan").new or error()
24 local new_text_area = require("luan:swing/Text_area.luan").new or error()
25 local new_scroll_pane = require("luan:swing/Scroll_pane.luan").new or error()
26 local new_text_area_line_numbers = require("luan:swing/Text_area_line_numbers.luan").new or error()
27 local new_menu_bar = require("luan:swing/Menu_bar.luan").new or error()
28 local Menu = require "luan:swing/Menu.luan"
29 local new_menu = Menu.new or error()
30 local separator = Menu.separator or error()
31 local new_menu_item = require("luan:swing/Menu_item.luan").new or error()
32 local new_check_box_menu_item = require("luan:swing/Check_box_menu_item.luan").new or error()
33 local int_to_color = require("luan:swing/Color.luan").int_to_color or error()
34 local Border = require "luan:swing/Border.luan"
35 local create_empty_border = Border.create_empty_border or error()
36 local create_line_border = Border.create_line_border or error()
37 local no_border = Border.no_border or error()
38 local Layout = require "luan:swing/Layout.luan"
39 local new_mig_layout = Layout.new_mig_layout or error()
40 local Option_pane = require "luan:swing/Option_pane.luan"
41 local show_message_dialog = Option_pane.show_message_dialog or error()
42 local show_input_dialog = Option_pane.show_input_dialog or error()
43 local new_dialog = require("luan:swing/Dialog.luan").new or error()
44 local new_panel = require("luan:swing/Component.luan").new_panel or error()
45 local new_button = require("luan:swing/Button.luan").new or error()
46 local new_check_box = require("luan:swing/Check_box.luan").new or error()
47 local new_text_field = require("luan:swing/Text_field.luan").new or error()
48 local Logging = require "luan:logging/Logging.luan"
49 local logger = Logging.logger "editor"
50
51
52 local new_window
53
54 local function action_listener(fn)
55 return function(_)
56 fn()
57 end
58 end
59
60 local function add_menu_bar(window)
61 local document = window.text_area.document
62 local revert = new_menu_item{
63 text = "Revert"
64 enabled = window.has_file
65 action_listener = action_listener(window.revert)
66 }
67 local undo = new_menu_item{
68 text = "Undo"
69 accelerator = "meta Z"
70 action_listener = action_listener(document.undo)
71 }
72 local redo = new_menu_item{
73 text = "Redo"
74 accelerator = "meta shift Z"
75 action_listener = action_listener(document.redo)
76 }
77 local function update_undo_redo()
78 undo.set_enabled(document.can_undo())
79 redo.set_enabled(document.can_redo())
80 end
81 window.update_undo_redo = update_undo_redo -- dont gc
82 update_undo_redo()
83 document.add_undo_listener(update_undo_redo)
84
85 local find_menu_item = new_check_box_menu_item{
86 text = "Find and Replace"
87 accelerator = "meta F"
88 action_listener = function(event)
89 window.show_find_panel(event.source.state)
90 end
91 }
92 window.find_menu_item = find_menu_item
93
94 local menu_bar = new_menu_bar{
95 menus = {
96 new_menu{
97 text = "File"
98 menu_items = {
99 new_menu_item{
100 text = "New File"
101 accelerator = "meta N"
102 action_listener = function(_)
103 new_window()
104 end
105 }
106 new_menu_item{
107 text = "Open..."
108 accelerator = "meta O"
109 action_listener = action_listener(window.open)
110 }
111 new_menu_item{
112 text = "Save"
113 accelerator = "meta S"
114 action_listener = function(_)
115 if window.save() then
116 revert.set_enabled(true)
117 end
118 end
119 }
120 revert
121 }
122 }
123 new_menu{
124 text = "Edit"
125 menu_items = {
126 undo
127 redo
128 separator
129 new_menu_item{
130 text = "Cut"
131 accelerator = "meta X"
132 action_listener = action_listener(window.text_area.cut)
133 }
134 new_menu_item{
135 text = "Copy"
136 accelerator = "meta C"
137 action_listener = action_listener(window.text_area.copy)
138 }
139 new_menu_item{
140 text = "Paste"
141 accelerator = "meta V"
142 action_listener = action_listener(window.text_area.paste)
143 }
144 separator
145 new_menu_item{
146 text = "Indent"
147 accelerator = "meta CLOSE_BRACKET"
148 action_listener = action_listener(window.indent)
149 }
150 new_menu_item{
151 text = "Unindent"
152 accelerator = "meta OPEN_BRACKET"
153 action_listener = action_listener(window.unindent)
154 }
155 separator
156 new_menu_item{
157 text = "Select All"
158 accelerator = "meta A"
159 action_listener = action_listener(window.text_area.select_all)
160 }
161 }
162 }
163 new_menu{
164 text = "Find"
165 menu_items = {
166 find_menu_item
167 new_menu_item{
168 text = "Find Case Insensitive"
169 action_listener = window.find_case_insensitive
170 }
171 new_menu_item{
172 text = "Convert Leading Tabs to Spaces"
173 action_listener = window.tabs_to_spaces
174 }
175 new_menu_item{
176 text = "Convert Leading Spaces to Tabs"
177 action_listener = window.spaces_to_tabs
178 }
179 }
180 }
181 new_menu{
182 text = "View"
183 menu_items = {
184 new_check_box_menu_item{
185 text = "Word Wrap"
186 state = window.text_area.line_wrap
187 action_listener = function(event)
188 window.text_area.line_wrap = event.source.state
189 end
190 }
191 new_check_box_menu_item{
192 text = "Show Whitespace"
193 action_listener = function(event)
194 window.text_area.show_whitespace(event.source.state)
195 end
196 }
197 new_menu_item{
198 text = "Show Cursor Column"
199 action_listener = function(_)
200 show_message_dialog( window.frame, "Cursor Column: "..window.cursor_column() )
201 end
202 }
203 new_menu_item{
204 text = "Goto Line"
205 accelerator = "meta G"
206 action_listener = function(_)
207 local input = show_input_dialog( window.frame, "Goto line" )
208 local line = input and to_number(input)
209 if line ~= nil then
210 window.goto(line)
211 end
212 end
213 }
214 }
215 }
216 }
217 }
218 window.frame.set_menu_bar(menu_bar)
219 end
220
221 local function get_matches(text,s)
222 local r = regex(s)
223 local matches = {}
224 local i = 1
225 local n = #text
226 while i <= n do
227 local j1, j2 = r.find(text,i)
228 if j1 == nil then
229 break
230 end
231 j2 = j2 + 1
232 if j1 == j2 then
233 i = j2 + 1
234 else
235 matches[#matches+1] = { start=j1, end_=j2 }
236 i = j2
237 end
238 end
239 return matches
240 end
241
242 local function make_find_panel(window)
243 local text_area = window.text_area
244 local find_field, replace_field, regex_check_box, output
245 local function find_match(event)
246 --logger.info("action "..event.action)
247 local s = find_field.text
248 if #s == 0 then
249 output.text = ""
250 return
251 end
252 if not regex_check_box.is_selected then
253 s = regex_quote(s)
254 end
255 local matches
256 try
257 matches = get_matches( text_area.text, s )
258 catch e
259 output.text = "Regex error: "..e.get_message()
260 return
261 end
262 local n_matches = #matches
263 if n_matches == 0 then
264 output.text = "0 matches"
265 return
266 end
267 local action = event.action
268 if action == "next" then
269 local _, pos = text_area.get_selection()
270 for i, match in ipairs(matches) do
271 if match.start >= pos then
272 text_area.set_selection( match.start, match.end_ )
273 output.text = i.." of "..n_matches.." matches"
274 return
275 end
276 end
277 local match = matches[1]
278 text_area.set_selection( match.start, match.end_ )
279 output.text = "1 of "..n_matches.." matches; wrapped past end"
280 elseif action == "previous" then
281 local pos = text_area.get_selection()
282 for i in range(n_matches,1,-1) do
283 local match = matches[i]
284 if match.end_ <= pos then
285 text_area.set_selection( match.start, match.end_ )
286 output.text = i.." of "..n_matches.." matches"
287 return
288 end
289 end
290 local match = matches[n_matches]
291 text_area.set_selection( match.start, match.end_ )
292 output.text = n_matches.." of "..n_matches.." matches; wrapped past end"
293 else
294 error(action)
295 end
296 end
297 local function replace_match(event)
298 local find = find_field.text
299 if #find == 0 then
300 output.text = ""
301 return
302 end
303 local replace = replace_field.text
304 if not regex_check_box.is_selected then
305 find = regex_quote(find)
306 replace = regex_quote_replacement(replace)
307 end
308 local r
309 try
310 r = regex(find)
311 catch e
312 output.text = "Regex error: "..e.get_message()
313 return
314 end
315 local new, n
316 local action = event.action
317 if action == "replace" then
318 local selection = text_area.selected_text
319 new, n = r.gsub(selection,replace)
320 if n > 0 then
321 text_area.selected_text = new
322 end
323 elseif action == "replace_all" then
324 local text = text_area.text
325 new, n = r.gsub(text,replace)
326 if n > 0 then
327 text_area.text = new
328 end
329 else
330 error(action)
331 end
332 output.text = n.." replacements"
333 end
334 find_field = new_text_field{
335 constraints = "growx"
336 show_whitespace = true
337 action = "next"
338 action_listener = find_match
339 }
340 replace_field = new_text_field{
341 constraints = "growx"
342 show_whitespace = true
343 action = "replace"
344 action_listener = replace_match
345 }
346 regex_check_box = new_check_box{
347 text = "Use Regex"
348 }
349 output = new_label{
350 constraints = "span"
351 }
352 local find_panel = new_panel{
353 constraints = "growy 0,growx"
354 layout = new_mig_layout("","[][grow][grow 0]")
355 visible = false
356 children = {
357 new_label{
358 constraints = "right"
359 text = "Find:"
360 }
361 find_field
362 new_button{
363 constraints = "grow"
364 text = "Find Next"
365 action = "next"
366 action_listener = find_match
367 }
368 new_button{
369 constraints = "grow,wrap"
370 text = "Find Previous"
371 action = "previous"
372 action_listener = find_match
373 }
374 new_label{
375 constraints = "right"
376 text = "Replace:"
377 }
378 replace_field
379 new_button{
380 constraints = "grow"
381 text = "Replace"
382 tool_tip_text = "Replace matches in selected text"
383 action = "replace"
384 action_listener = replace_match
385 }
386 new_button{
387 constraints = "grow,wrap"
388 text = "Replace All"
389 action = "replace_all"
390 action_listener = replace_match
391 }
392 new_panel{
393 constraints = "span,wrap"
394 layout = new_mig_layout("insets 0,gap 16px")
395 children = {
396 regex_check_box
397 new_button{
398 text = "Learn About Regular Expressions"
399 }
400 }
401 }
402 output
403 }
404 }
405 function window.show_find_panel(visible)
406 find_panel.visible = visible
407 if visible then
408 find_field.request_focus_in_window()
409 end
410 end
411 function window.find_case_insensitive(_)
412 find_panel.visible = true
413 window.find_menu_item.is_selected = true
414 regex_check_box.is_selected = true
415 find_field.text = "(?i)\Q\E"
416 find_field.set_selection(7)
417 find_field.request_focus_in_window()
418 output.text = [[Put search text between "\Q" and "\E"]]
419 end
420 function window.tabs_to_spaces(_)
421 find_panel.visible = true
422 window.find_menu_item.is_selected = true
423 regex_check_box.is_selected = true
424 find_field.text = [[(?m)^(\t*)\t]]
425 local spaces = repeated( " ", text_area.tab_size )
426 replace_field.text = "$1"..spaces
427 output.text = [[Do "Replace All" until 0 replacements]]
428 end
429 function window.spaces_to_tabs(_)
430 find_panel.visible = true
431 window.find_menu_item.is_selected = true
432 regex_check_box.is_selected = true
433 local tab_size = text_area.tab_size
434 find_field.text = `%>(?m)^(( {<%=tab_size%>})*) {<%=tab_size%>}<%`
435 replace_field.text = "$1\t"
436 output.text = [[Do "Replace All" until 0 replacements]]
437 end
438 return find_panel
439 end
440
441 local n_windows = 0
442 local documents = {}
443
444 function new_window(file)
445 local window = {}
446 window.has_file = file~=nil and file.is_file()
447 local text_area = new_text_area{
448 wrap_style_word = true
449 line_wrap = true
450 tab_size = 4
451 font = { family="Monospaced", size=13 }
452 }
453 window.text_area = text_area
454 local title = file and file.canonical().to_string() or "new"
455 if file ~= nil then
456 local document = documents[title]
457 if document == nil then
458 documents[title] = text_area.document
459 else
460 text_area.document = document
461 end
462 if file.is_file() then
463 text_area.text = file.read_text()
464 text_area.document.clear_unedited()
465 end
466 end
467 text_area.set_selection(0)
468 local find_panel = make_find_panel(window)
469 local frame = new_frame{
470 preferred_size = { width=700, height=700 }
471 content_pane = new_panel{
472 layout = new_mig_layout("insets 0,wrap,fill,hidemode 3","","[][grow 0]")
473 children = {
474 new_scroll_pane{
475 constraints = "grow"
476 view = text_area
477 row_header_view = new_text_area_line_numbers{
478 text_area = text_area
479 foreground_color = int_to_color(0x888888)
480 border = create_empty_border(0,8,0,8)
481 }
482 }
483 find_panel
484 }
485 }
486 }
487 window.frame = frame
488 frame.add_close_listener(function()
489 n_windows = n_windows - 1
490 if n_windows == 0 then
491 Luan.exit()
492 end
493 end)
494 local function set_title()
495 local s = title
496 if not text_area.document.is_unedited() then
497 s = s.." *"
498 end
499 frame.title = s
500 end
501 set_title()
502 window.set_title = set_title -- dont gc
503 text_area.document.add_undo_listener(set_title)
504 function window.open()
505 local file_chooser = frame.file_chooser_load()
506 if file ~= nil then
507 file_chooser.directory = file.parent()
508 end
509 file_chooser.visible = true
510 local new_file = file_chooser.file
511 if new_file ~= nil then
512 new_window(new_file)
513 end
514 end
515 function window.save()
516 if file == nil then
517 local file_chooser = frame.file_chooser_save()
518 file_chooser.visible = true
519 file = file_chooser.file
520 if file == nil then
521 return false
522 end
523 title = file.canonical().to_string()
524 frame.title = title
525 documents[title] = text_area.document
526 end
527 file.write_text(text_area.text)
528 text_area.document.set_unedited()
529 return true
530 end
531 function window.revert()
532 local selection = text_area.get_selection()
533 local text = file.read_text()
534 text_area.text = text
535 text_area.set_selection(min(selection,#text+1))
536 text_area.document.set_unedited()
537 end
538 local function selection_lines()
539 local start_seletion, end_selection = text_area.get_selection()
540 local end_ = end_selection == start_seletion and end_selection or end_selection - 1
541 local start_line = text_area.get_line_from_position(start_seletion)
542 local end_line = text_area.get_line_from_position(end_)
543 local start_pos = text_area.get_line_start_position(start_line)
544 local end_pos = text_area.get_line_end_position(end_line)
545 local text = text_area.text
546 text = sub_string(text,start_pos,end_pos-2)
547 return {
548 text = text
549 start_pos = start_pos
550 length = #text
551 lines = end_line - start_line + 1
552 start_seletion = start_seletion
553 end_selection = end_selection
554 }
555 end
556 function window.indent()
557 local r = selection_lines()
558 local text = r.text
559 local start_pos = r.start_pos
560 text = "\t"..replace(text,"\n","\n\t")
561 text_area.replace(start_pos,r.length,text)
562 --logger.info(stringify{text_area.get_selection()})
563 text_area.set_selection( r.start_seletion+1, r.end_selection+r.lines )
564 end
565 function window.unindent()
566 local r = selection_lines()
567 local text = r.text
568 text = "\n"..text
569 local start_seletion = r.start_seletion
570 if starts_with(text,"\n\t") then
571 start_seletion = start_seletion - 1
572 end
573 local len1 = #text
574 text = replace(text,"\n\t","\n")
575 local len2 = #text
576 local end_selection = r.end_selection - (len1 - len2)
577 text = sub_string(text,2)
578 text_area.replace(r.start_pos,r.length,text)
579 text_area.set_selection(start_seletion,end_selection)
580 end
581 function window.cursor_column()
582 local cursor_pos = text_area.get_selection()
583 local line = text_area.get_line_from_position(cursor_pos)
584 local start_line_pos = text_area.get_line_start_position(line)
585 return cursor_pos - start_line_pos + 1
586 end
587 function window.goto(line)
588 local pos = text_area.get_line_start_position(line)
589 text_area.set_selection(pos)
590 end
591 add_menu_bar(window)
592 frame.pack()
593 frame.visible = true
594 text_area.request_focus_in_window()
595 n_windows = n_windows + 1
596 end
597
598 Swing.run(function()
599 local args = Luan.arg
600 if #args == 0 then
601 new_window()
602 else
603 for _, arg in ipairs(args) do
604 local file = new_file(arg)
605 new_window(file)
606 end
607 end
608 end)