Added line number display (also shows details of the current selection, if any).
[rox-edit.git] / EditWindow.py
blob804a25e62c55ec19c77cd81a45b266e4a9039280
1 import rox
2 from rox import g, filer, app_options
3 import sys
4 from rox.loading import XDSLoader
5 from rox.options import Option
6 from buffer import Buffer
7 from rox.saving import Saveable
8 import os
9 import diff
10 import codecs
12 to_utf8 = codecs.getencoder('utf-8')
14 FALSE = g.FALSE
15 TRUE = g.TRUE
17 from rox.Menu import Menu, set_save_name
19 default_font = Option('default_font', 'serif')
21 background_colour = Option('background', '#fff')
22 foreground_colour = Option('foreground', '#000')
24 auto_indent = Option('autoindent', '1')
26 layout_left_margin = Option('layout_left_margin', 2)
27 layout_right_margin = Option('layout_right_margin', 4)
29 layout_before_para = Option('layout_before_para', 0)
30 layout_after_para = Option('layout_after_para', 0)
31 layout_inside_para = Option('layout_inside_para', 0)
32 layout_indent_para = Option('layout_indent_para', 2)
34 set_save_name('Edit')
36 try:
37 menu = Menu('main', [
38 (_('/File'), '', '<Branch>'),
39 (_('/File/') + _('Save'), 'save', '<StockItem>', '<Ctrl>S', g.STOCK_SAVE),
40 (_('/File/') + _('Open Parent'), 'up', '<StockItem>', '', g.STOCK_GO_UP),
41 (_('/File/') + _('Show Changes'), 'diff', '<StockItem>', '', 'rox-diff'),
42 (_('/File/') + _('Close'), 'close', '<StockItem>', '', g.STOCK_CLOSE),
43 (_('/File/'), '', '<Separator>'),
44 (_('/File/') + _('New'), 'new', '<StockItem>', '', g.STOCK_NEW),
46 (_('/Edit'), '', '<Branch>'),
47 (_('/Edit/') + _('Cut'), 'cut', '<StockItem>', '<Ctrl>X', g.STOCK_CUT),
48 (_('/Edit/') + _('Copy'), 'copy', '<StockItem>', '<Ctrl>C', g.STOCK_COPY),
49 (_('/Edit/') + _('Paste'), 'paste', '<StockItem>', '<Ctrl>V', g.STOCK_PASTE),
50 (_('/Edit/'), '', '<Separator>'),
51 (_('/Edit/') + _('Undo'), 'undo', '<StockItem>', '<Ctrl>Z', g.STOCK_UNDO),
52 (_('/Edit/') + _('Redo'), 'redo', '<StockItem>', '<Ctrl>Y', g.STOCK_REDO),
53 (_('/Edit/'), '', '<Separator>'),
54 (_('/Edit/') + _('Search...'), 'search', '<StockItem>', 'F4', g.STOCK_FIND),
55 (_('/Edit/') + _('Search and Replace....'), 'search_replace',
56 '<StockItem>', '<Ctrl>F4', g.STOCK_FIND_AND_REPLACE),
57 (_('/Edit/') + _('Goto line...'), 'goto', '<StockItem>', 'F5', g.STOCK_JUMP_TO),
59 (_('/Options'), 'show_options', '<StockItem>', '', g.STOCK_PROPERTIES),
60 (_('/Help'), 'help', '<StockItem>', 'F1', g.STOCK_HELP),
62 except ValueError:
63 rox.croak(_('Edit requires ROX-Lib2 1.9.8 or later'))
65 known_codecs = (
66 "iso8859_1", "iso8859_2", "iso8859_3", "iso8859_4", "iso8859_5",
67 "iso8859_6", "iso8859_7", "iso8859_8", "iso8859_9", "iso8859_10",
68 "iso8859_13", "iso8859_14", "iso8859_15",
69 "ascii", "base64_codec", "charmap",
70 "cp037", "cp1006", "cp1026", "cp1140", "cp1250", "cp1251", "cp1252",
71 "cp1253", "cp1254", "cp1255", "cp1256", "cp1257", "cp1258", "cp424",
72 "cp437", "cp500", "cp737", "cp775", "cp850", "cp852", "cp855", "cp856",
73 "cp857", "cp860", "cp861", "cp862", "cp863", "cp864", "cp865", "cp866",
74 "cp869", "cp874", "cp875", "hex_codec",
75 "koi8_r",
76 "latin_1",
77 "mac_cyrillic", "mac_greek", "mac_iceland", "mac_latin2", "mac_roman", "mac_turkish",
78 "mbcs", "quopri_codec", "raw_unicode_escape",
79 "rot_13",
80 "utf_16_be", "utf_16_le", "utf_16", "utf_7", "utf_8", "uu_codec",
81 "zlib_codec"
84 class Abort(Exception):
85 pass
87 class Minibuffer:
88 def setup(self):
89 """Called when the minibuffer is opened."""
91 def key_press(self, kev):
92 """A keypress event in the minibuffer text entry."""
94 def changed(self):
95 """The minibuffer text has changed."""
97 def activate(self):
98 """Return or Enter pressed."""
100 info = 'Press Escape to close the minibuffer.'
102 class DiffLoader(XDSLoader):
103 def __init__(self, window):
104 XDSLoader.__init__(self, ['text/plain'])
105 self.window = window
107 def xds_load_from_file(self, path):
108 self.window.diff(path = path)
110 def xds_load_from_stream(self, name, type, stream):
111 tmp = diff.Tmp(suffix = '-' + (name or 'tmp'))
112 import shutil
113 shutil.copyfileobj(stream, tmp)
114 tmp.seek(0)
115 self.window.diff(path = tmp.name)
117 class EditWindow(rox.Window, XDSLoader, Saveable):
118 def __init__(self, filename = None):
119 rox.Window.__init__(self)
120 XDSLoader.__init__(self, ['text/plain', 'UTF8_STRING'])
121 self.set_default_size(g.gdk.screen_width() * 2 / 3,
122 g.gdk.screen_height() / 2)
124 self.savebox = None
125 self.info_box = None
127 app_options.add_notify(self.update_styles)
129 self.buffer = Buffer()
131 self.text = g.TextView()
132 self.text.set_buffer(self.buffer)
133 self.text.set_size_request(10, 10)
134 self.xds_proxy_for(self.text)
135 self.text.set_wrap_mode(g.WRAP_WORD)
136 self.update_styles()
138 self.insert_mark = self.buffer.get_mark('insert')
139 self.selection_bound_mark = self.buffer.get_mark('selection_bound')
140 start = self.buffer.get_start_iter()
141 self.mark_start = self.buffer.create_mark('mark_start', start, TRUE)
142 self.mark_end = self.buffer.create_mark('mark_end', start, FALSE)
143 self.mark_tmp = self.buffer.create_mark('mark_tmp', start, FALSE)
144 tag = self.buffer.create_tag('marked')
145 tag.set_property('background', 'green')
146 self.marked = 0
148 # When searching, this is where the cursor was when the minibuffer
149 # was opened.
150 start = self.buffer.get_start_iter()
151 self.search_base = self.buffer.create_mark('search_base', start, TRUE)
153 vbox = g.VBox(FALSE)
154 self.add(vbox)
156 tools = g.Toolbar()
157 tools.set_style(g.TOOLBAR_ICONS)
158 vbox.pack_start(tools, FALSE, TRUE, 0)
159 tools.show()
161 self.status_label = g.Label('')
162 tools.append_widget(self.status_label, None, None)
163 tools.insert_stock(g.STOCK_HELP, _('Help'), None, self.help, None, 0)
164 diff = tools.insert_stock('rox-diff', _('Show changes from saved copy.\n'
165 'Or, drop a backup file onto this button to see changes from that.'),
166 None, self.diff, None, 0)
167 DiffLoader(self).xds_proxy_for(diff)
168 tools.insert_stock(g.STOCK_REDO, _('Redo'), None, self.redo, None, 0)
169 tools.insert_stock(g.STOCK_UNDO, _('Undo'), None, self.undo, None, 0)
170 tools.insert_stock(g.STOCK_FIND_AND_REPLACE, _('Replace'), None, self.search_replace, None, 0)
171 tools.insert_stock(g.STOCK_FIND, _('Search'), None, self.search, None, 0)
172 tools.insert_stock(g.STOCK_SAVE, _('Save'), None, self.save, None, 0)
173 tools.insert_stock(g.STOCK_GO_UP, _('Up'), None, self.up, None, 0)
174 tools.insert_stock(g.STOCK_CLOSE, _('Close'), None, self.close, None, 0)
175 # Set minimum size to ignore the label
176 tools.set_size_request(tools.size_request()[0], -1)
178 swin = g.ScrolledWindow()
179 swin.set_policy(g.POLICY_NEVER, g.POLICY_AUTOMATIC)
180 vbox.pack_start(swin, True, True)
182 swin.add(self.text)
184 self.show_all()
186 # Create the minibuffer
187 self.mini_hbox = g.HBox(FALSE)
188 info = g.Button()
189 info.set_relief(g.RELIEF_NONE)
190 info.unset_flags(g.CAN_FOCUS)
191 image = g.Image()
192 image.set_from_stock(g.STOCK_DIALOG_INFO, size = g.ICON_SIZE_SMALL_TOOLBAR)
193 info.add(image)
194 info.show_all()
195 info.connect('clicked', self.mini_show_info)
197 self.mini_hbox.pack_start(info, FALSE, TRUE, 0)
198 self.mini_label = g.Label('')
199 self.mini_hbox.pack_start(self.mini_label, FALSE, TRUE, 0)
200 self.mini_entry = g.Entry()
201 self.mini_hbox.pack_start(self.mini_entry, TRUE, TRUE, 0)
202 vbox.pack_start(self.mini_hbox, FALSE, TRUE)
203 self.mini_entry.connect('key-press-event', self.mini_key_press)
204 self.mini_entry.connect('changed', self.mini_changed)
206 self.connect('destroy', self.destroyed)
208 self.connect('delete-event', self.delete_event)
209 self.text.grab_focus()
210 self.text.connect('key-press-event', self.key_press)
212 def update_current_line(*unused):
213 cursor = self.buffer.get_iter_at_mark(self.insert_mark)
214 bound = self.buffer.get_iter_at_mark(self.selection_bound_mark)
215 if cursor.compare(bound) == 0:
216 n_lines = self.buffer.get_line_count()
217 self.status_label.set_text(_('Line %s of %d') % (cursor.get_line() + 1, n_lines))
218 else:
219 n_lines = abs(cursor.get_line() - bound.get_line()) + 1
220 if n_lines == 1:
221 n_chars = abs(cursor.get_line_offset() - bound.get_line_offset())
222 if n_chars == 1:
223 bytes = to_utf8(self.buffer.get_text(cursor, bound, False))[0]
224 self.status_label.set_text(_('One character selected (%s)') %
225 ' '.join(map(lambda x: '0x%2x' % ord(x), bytes)))
226 else:
227 self.status_label.set_text('%d characters selected' % n_chars)
228 else:
229 self.status_label.set_text('%d lines selected' % n_lines)
230 self.buffer.connect('mark-set', update_current_line)
231 self.buffer.connect('changed', update_current_line)
233 if filename:
234 import os.path
235 self.uri = os.path.abspath(filename)
236 else:
237 self.uri = None
238 self.update_title()
240 # Loading might take a while, so get something on the screen
241 # now...
242 g.gdk.flush()
244 if filename:
245 try:
246 self.load_file(filename)
247 if filename != '-':
248 self.save_mode = os.stat(filename).st_mode
249 except Abort:
250 self.destroy()
251 raise
253 self.buffer.connect('modified-changed', self.update_title)
254 self.buffer.set_modified(FALSE)
256 def button_press(text, event):
257 if event.button != 3:
258 return False
259 menu.popup(self, event)
260 return True
261 self.text.connect('button-press-event', button_press)
262 self.text.connect('popup-menu', lambda text: menu.popup(self, None))
264 menu.attach(self, self)
265 self.buffer.place_cursor(self.buffer.get_start_iter())
266 self.buffer.start_undo_history()
268 def key_press(self, text, kev):
269 if kev.keyval != g.keysyms.Return and kev.keyval != g.keysyms.KP_Enter:
270 return
271 if not auto_indent.int_value:
272 return
273 start = self.buffer.get_iter_at_mark(self.insert_mark)
274 end = start.copy()
275 start.set_line_offset(0)
276 end.forward_to_line_end()
277 line = self.buffer.get_text(start, end, False)
278 indent = ''
279 for x in line:
280 if x in ' \t':
281 indent += x
282 else:
283 break
284 self.buffer.begin_user_action()
285 self.buffer.insert_at_cursor('\n' + indent)
286 self.buffer.end_user_action()
287 return True
289 def destroyed(self, widget):
290 app_options.remove_notify(self.update_styles)
292 def update_styles(self):
293 try:
294 import pango
295 font = pango.FontDescription(default_font.value)
296 bg = g.gdk.color_parse(background_colour.value)
297 fg = g.gdk.color_parse(foreground_colour.value)
299 self.text.set_left_margin(layout_left_margin.int_value)
300 self.text.set_right_margin(layout_right_margin.int_value)
302 self.text.set_pixels_above_lines(layout_before_para.int_value)
303 self.text.set_pixels_below_lines(layout_after_para.int_value)
304 self.text.set_pixels_inside_wrap(layout_inside_para.int_value)
305 self.text.set_indent(layout_indent_para.int_value)
306 except:
307 rox.report_exception()
308 else:
309 self.text.modify_font(font)
310 self.text.modify_base(g.STATE_NORMAL, bg)
311 self.text.modify_text(g.STATE_NORMAL, fg)
313 def cut(self): self.text.emit('cut_clipboard')
314 def copy(self): self.text.emit('copy_clipboard')
315 def paste(self): self.text.emit('paste_clipboard')
317 def delete_event(self, window, event):
318 if self.buffer.get_modified():
319 self.save(discard = 1)
320 return 1
321 return 0
323 def update_title(self, *unused):
324 title = self.uri or '<Untitled>'
325 if self.buffer.get_modified():
326 title = title + " *"
327 self.set_title(title)
329 def xds_load_from_stream(self, name, t, stream):
330 if t == 'UTF8_STRING':
331 return # Gtk will handle it
332 try:
333 self.insert_data(stream.read())
334 except Abort:
335 pass
337 def get_encoding(self, message):
338 "Returns (encoding, errors), or raises Abort to cancel."
339 box = g.MessageDialog(self, 0, g.MESSAGE_QUESTION, g.BUTTONS_CANCEL, message)
340 box.set_has_separator(FALSE)
342 frame = g.Frame()
343 box.vbox.pack_start(frame, TRUE, TRUE)
344 frame.set_border_width(6)
346 hbox = g.HBox(FALSE, 4)
347 hbox.set_border_width(6)
349 hbox.pack_start(g.Label('Encoding:'), FALSE, TRUE, 0)
350 combo = g.Combo()
351 combo.disable_activate()
352 combo.entry.connect('activate', lambda w: box.activate_default())
353 combo.set_popdown_strings(known_codecs)
354 hbox.pack_start(combo, TRUE, TRUE, 0)
355 ignore_errors = g.CheckButton('Ignore errors')
356 hbox.pack_start(ignore_errors, FALSE, TRUE)
358 frame.add(hbox)
360 box.vbox.show_all()
361 box.add_button(g.STOCK_CONVERT, g.RESPONSE_YES)
362 box.set_default_response(g.RESPONSE_YES)
364 while 1:
365 combo.entry.grab_focus()
367 resp = box.run()
368 if resp != g.RESPONSE_YES:
369 box.destroy()
370 raise Abort
372 if ignore_errors.get_active():
373 errors = 'replace'
374 else:
375 errors = 'strict'
376 encoding = combo.entry.get_text()
377 try:
378 codecs.getdecoder(encoding)
379 break
380 except:
381 rox.alert(_("Unknown encoding '%s'") % encoding)
383 box.destroy()
385 return encoding, errors
387 def insert_data(self, data):
388 import codecs
389 errors = 'strict'
390 encoding = 'utf-8'
391 while 1:
392 decoder = codecs.getdecoder(encoding)
393 try:
394 data = decoder(data, errors)[0]
395 if errors == 'strict':
396 assert '\0' not in data
397 else:
398 if '\0' in data:
399 data = data.replace('\0', '\\0')
400 break
401 except:
402 pass
404 encoding, errors = self.get_encoding(
405 _("Data is not valid %s. Please select the file's encoding. "
406 "Turn on 'ignore errors' to try and load it anyway.")
407 % encoding)
409 self.buffer.begin_user_action()
410 self.buffer.insert_at_cursor(data)
411 self.buffer.end_user_action()
412 return 1
414 def load_file(self, path):
415 try:
416 if path == '-':
417 file = sys.stdin
418 else:
419 file = open(path, 'r')
420 contents = file.read()
421 if path != '-':
422 file.close()
423 self.insert_data(contents)
424 except Abort:
425 raise
426 except:
427 rox.report_exception()
428 raise Abort
430 def close(self, button = None):
431 if self.buffer.get_modified():
432 self.save(discard = 1)
433 else:
434 self.destroy()
436 def discard(self):
437 self.destroy()
439 def up(self, button = None):
440 if self.uri:
441 filer.show_file(self.uri)
442 else:
443 rox.alert(_('File is not saved to disk yet'))
445 def diff(self, button = None, path = None):
446 path = path or self.uri
447 if not path:
448 rox.alert(_('This file has never been saved; nothing to compare it to!\n'
449 'Note: you can drop a file onto the toolbar button to see '
450 'the changes from that file.'))
451 return
452 diff.show_diff(path, self.save_to_stream)
454 def has_selection(self):
455 s, e = self.get_selection_range()
456 return not e.equal(s)
458 def get_marked_range(self):
459 s = self.buffer.get_iter_at_mark(self.mark_start)
460 e = self.buffer.get_iter_at_mark(self.mark_end)
461 if s.compare(e) > 0:
462 return e, s
463 return s, e
465 def get_selection_range(self):
466 s = self.buffer.get_iter_at_mark(self.insert_mark)
467 e = self.buffer.get_iter_at_mark(self.selection_bound_mark)
468 if s.compare(e) > 0:
469 return e, s
470 return s, e
472 def save(self, widget = None, discard = 0):
473 from rox.saving import SaveBox
475 if self.savebox:
476 self.savebox.destroy()
478 if self.has_selection() and not discard:
479 saver = SelectionSaver(self)
480 self.savebox = SaveBox(saver, 'Selection', 'text/plain')
481 self.savebox.connect('destroy', lambda w: saver.destroy())
482 else:
483 uri = self.uri or 'TextFile'
484 self.savebox = SaveBox(self, uri, 'text/plain', discard)
485 self.savebox.show()
487 def help(self, button = None):
488 filer.open_dir(os.path.join(rox.app_dir, 'Help'))
490 def save_to_stream(self, stream):
491 s = self.buffer.get_start_iter()
492 e = self.buffer.get_end_iter()
493 stream.write(self.buffer.get_text(s, e, TRUE))
495 def set_uri(self, uri):
496 self.uri = uri
497 self.buffer.set_modified(FALSE)
498 self.update_title()
500 def new(self):
501 EditWindow()
503 def change_font(self):
504 style = self.text.get_style().copy()
505 style.font = load_font(options.get('edit_font'))
506 self.text.set_style(style)
508 def show_options(self):
509 rox.edit_options()
511 def set_marked(self, start = None, end = None):
512 "Set the marked region (from the selection if no region is given)."
513 self.clear_marked()
514 assert not self.marked
516 buffer = self.buffer
517 if start:
518 assert end
519 else:
520 assert not end
521 start, end = self.get_selection_range()
522 buffer.move_mark(self.mark_start, start)
523 buffer.move_mark(self.mark_end, end)
524 buffer.apply_tag_by_name('marked',
525 buffer.get_iter_at_mark(self.mark_start),
526 buffer.get_iter_at_mark(self.mark_end))
527 self.marked = 1
529 def clear_marked(self):
530 if not self.marked:
531 return
532 self.marked = 0
533 buffer = self.buffer
534 buffer.remove_tag_by_name('marked',
535 buffer.get_iter_at_mark(self.mark_start),
536 buffer.get_iter_at_mark(self.mark_end))
538 def undo(self, widget = None):
539 self.buffer.undo()
541 def redo(self, widget = None):
542 self.buffer.redo()
544 def goto(self, widget = None):
545 from goto import Goto
546 self.set_minibuffer(Goto())
548 def search(self, widget = None):
549 from search import Search
550 self.set_minibuffer(Search())
552 def search_replace(self, widget = None):
553 from search import Replace
554 Replace(self).show()
556 def set_mini_label(self, label):
557 self.mini_label.set_text(label)
559 def set_minibuffer(self, minibuffer):
560 assert minibuffer is None or isinstance(minibuffer, Minibuffer)
562 self.minibuffer = None
564 if minibuffer:
565 self.mini_entry.set_text('')
566 self.minibuffer = minibuffer
567 minibuffer.setup(self)
568 self.mini_entry.grab_focus()
569 self.mini_hbox.show_all()
570 else:
571 self.mini_hbox.hide()
572 self.text.grab_focus()
574 def mini_key_press(self, entry, kev):
575 if kev.keyval == g.keysyms.Escape:
576 self.set_minibuffer(None)
577 return 1
578 if kev.keyval == g.keysyms.Return or kev.keyval == g.keysyms.KP_Enter:
579 self.minibuffer.activate()
580 return 1
582 return self.minibuffer.key_press(kev)
584 def mini_changed(self, entry):
585 if not self.minibuffer:
586 return
587 self.minibuffer.changed()
589 def mini_show_info(self, *unused):
590 assert self.minibuffer
591 if self.info_box:
592 self.info_box.destroy()
593 self.info_box = g.MessageDialog(self, 0, g.MESSAGE_INFO, g.BUTTONS_OK,
594 self.minibuffer.info)
595 self.info_box.set_title(_('Minibuffer help'))
596 def destroy(box):
597 self.info_box = None
598 self.info_box.connect('destroy', destroy)
599 self.info_box.show()
600 self.info_box.connect('response', lambda w, r: w.destroy())
602 def process_selected(self, process):
603 """Calls process(line) on each line in the selection, or each line in the file
604 if there is no selection. If the result is not None, the text is replaced."""
605 self.buffer.begin_user_action()
606 self._process_selected(process)
607 self.buffer.end_user_action()
609 def _process_selected(self, process):
610 if self.has_selection():
611 def get_end():
612 start, end = self.get_selection_range()
613 if start.compare(end) > 0:
614 return start
615 return end
616 start, end = self.get_selection_range()
617 if start.compare(end) > 0:
618 start = end
619 else:
620 def get_end():
621 return self.buffer.get_end_iter()
622 start = self.buffer.get_start_iter()
623 end = get_end()
625 while start.compare(end) <= 0:
626 line_end = start.copy()
627 line_end.forward_to_line_end()
628 if line_end.compare(end) >= 0:
629 line_end = end
630 line = self.buffer.get_text(start, line_end, False)
631 new = process(line)
632 if new is not None:
633 self.buffer.move_mark(self.mark_tmp, start)
634 self.buffer.insert(line_end, new)
635 start = self.buffer.get_iter_at_mark(self.mark_tmp)
636 line_end = start.copy()
637 line_end.forward_chars(len(line))
638 self.buffer.delete(start, line_end)
640 start = self.buffer.get_iter_at_mark(self.mark_tmp)
641 end = get_end()
642 if not start.forward_line(): break
644 class SelectionSaver(Saveable):
645 def __init__(self, window):
646 self.window = window
647 window.set_marked()
649 def save_to_stream(self, stream):
650 s, e = self.window.get_marked_range()
651 stream.write(self.window.buffer.get_text(s, e, TRUE))
653 def destroy(self):
654 # Called when savebox is remove. Get rid of the selection marker
655 self.window.clear_marked()