Mercurial Hosting > editor
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) |