New release.
[rox-edit.git] / EditWindow.py
blob702f7ce7e4a087d33a7d77fba3e021ab9f0ae89f
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')
25 word_wrap = Option('wordwrap', '1')
27 layout_left_margin = Option('layout_left_margin', 2)
28 layout_right_margin = Option('layout_right_margin', 4)
30 layout_before_para = Option('layout_before_para', 0)
31 layout_after_para = Option('layout_after_para', 0)
32 layout_inside_para = Option('layout_inside_para', 0)
33 layout_indent_para = Option('layout_indent_para', 2)
35 set_save_name('Edit')
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),
63 known_codecs = (
64 "iso8859_1", "iso8859_2", "iso8859_3", "iso8859_4", "iso8859_5",
65 "iso8859_6", "iso8859_7", "iso8859_8", "iso8859_9", "iso8859_10",
66 "iso8859_13", "iso8859_14", "iso8859_15",
67 "ascii", "base64_codec", "charmap",
68 "cp037", "cp1006", "cp1026", "cp1140", "cp1250", "cp1251", "cp1252",
69 "cp1253", "cp1254", "cp1255", "cp1256", "cp1257", "cp1258", "cp424",
70 "cp437", "cp500", "cp737", "cp775", "cp850", "cp852", "cp855", "cp856",
71 "cp857", "cp860", "cp861", "cp862", "cp863", "cp864", "cp865", "cp866",
72 "cp869", "cp874", "cp875", "hex_codec",
73 "koi8_r",
74 "latin_1",
75 "mac_cyrillic", "mac_greek", "mac_iceland", "mac_latin2", "mac_roman", "mac_turkish",
76 "mbcs", "quopri_codec", "raw_unicode_escape",
77 "rot_13",
78 "utf_16_be", "utf_16_le", "utf_16", "utf_7", "utf_8", "uu_codec",
79 "zlib_codec"
82 class Abort(Exception):
83 pass
85 class Minibuffer:
86 def setup(self):
87 """Called when the minibuffer is opened."""
89 def key_press(self, kev):
90 """A keypress event in the minibuffer text entry."""
92 def changed(self):
93 """The minibuffer text has changed."""
95 def activate(self):
96 """Return or Enter pressed."""
98 info = 'Press Escape to close the minibuffer.'
100 class DiffLoader(XDSLoader):
101 def __init__(self, window):
102 XDSLoader.__init__(self, ['text/plain'])
103 self.window = window
105 def xds_load_from_file(self, path):
106 self.window.diff(path = path)
108 def xds_load_from_stream(self, name, type, stream):
109 tmp = diff.Tmp(suffix = '-' + (name or 'tmp'))
110 import shutil
111 shutil.copyfileobj(stream, tmp)
112 tmp.seek(0)
113 self.window.diff(path = tmp.name)
115 class EditWindow(rox.Window, XDSLoader, Saveable):
116 def __init__(self, filename = None):
117 rox.Window.__init__(self)
118 XDSLoader.__init__(self, ['text/plain', 'UTF8_STRING'])
119 self.set_default_size(g.gdk.screen_width() * 2 / 3,
120 g.gdk.screen_height() / 2)
122 self.savebox = None
123 self.info_box = None
125 app_options.add_notify(self.update_styles)
127 self.buffer = Buffer()
129 self.text = g.TextView()
130 self.text.set_buffer(self.buffer)
131 self.text.set_size_request(10, 10)
132 self.xds_proxy_for(self.text)
133 self.update_styles()
135 self.insert_mark = self.buffer.get_mark('insert')
136 self.selection_bound_mark = self.buffer.get_mark('selection_bound')
137 start = self.buffer.get_start_iter()
138 self.mark_start = self.buffer.create_mark('mark_start', start, TRUE)
139 self.mark_end = self.buffer.create_mark('mark_end', start, FALSE)
140 self.mark_tmp = self.buffer.create_mark('mark_tmp', start, FALSE)
141 tag = self.buffer.create_tag('marked')
142 tag.set_property('background', 'green')
143 self.marked = 0
145 # When searching, this is where the cursor was when the minibuffer
146 # was opened.
147 start = self.buffer.get_start_iter()
148 self.search_base = self.buffer.create_mark('search_base', start, TRUE)
150 vbox = g.VBox(FALSE)
151 self.add(vbox)
153 tools = g.Toolbar()
154 tools.set_style(g.TOOLBAR_ICONS)
155 vbox.pack_start(tools, FALSE, TRUE, 0)
156 tools.show()
158 self.status_label = g.Label('')
159 tools.append_widget(self.status_label, None, None)
160 tools.insert_stock(g.STOCK_HELP, _('Help'), None, self.help, None, 0)
161 diff = tools.insert_stock('rox-diff', _('Show changes from saved copy.\n'
162 'Or, drop a backup file onto this button to see changes from that.'),
163 None, self.diff, None, 0)
164 DiffLoader(self).xds_proxy_for(diff)
165 tools.insert_stock(g.STOCK_REDO, _('Redo'), None, self.redo, None, 0)
166 tools.insert_stock(g.STOCK_UNDO, _('Undo'), None, self.undo, None, 0)
167 tools.insert_stock(g.STOCK_FIND_AND_REPLACE, _('Replace'), None, self.search_replace, None, 0)
168 tools.insert_stock(g.STOCK_FIND, _('Search'), None, self.search, None, 0)
169 tools.insert_stock(g.STOCK_SAVE, _('Save'), None, self.save, None, 0)
170 tools.insert_stock(g.STOCK_GO_UP, _('Up'), None, self.up, None, 0)
171 tools.insert_stock(g.STOCK_CLOSE, _('Close'), None, self.close, None, 0)
172 # Set minimum size to ignore the label
173 tools.set_size_request(tools.size_request()[0], -1)
175 swin = g.ScrolledWindow()
176 swin.set_policy(g.POLICY_AUTOMATIC, g.POLICY_AUTOMATIC)
177 vbox.pack_start(swin, True, True)
179 swin.add(self.text)
181 self.show_all()
183 # Create the minibuffer
184 self.mini_hbox = g.HBox(FALSE)
185 info = g.Button()
186 info.set_relief(g.RELIEF_NONE)
187 info.unset_flags(g.CAN_FOCUS)
188 image = g.Image()
189 image.set_from_stock(g.STOCK_DIALOG_INFO, size = g.ICON_SIZE_SMALL_TOOLBAR)
190 info.add(image)
191 info.show_all()
192 info.connect('clicked', self.mini_show_info)
194 self.mini_hbox.pack_start(info, FALSE, TRUE, 0)
195 self.mini_label = g.Label('')
196 self.mini_hbox.pack_start(self.mini_label, FALSE, TRUE, 0)
197 self.mini_entry = g.Entry()
198 self.mini_hbox.pack_start(self.mini_entry, TRUE, TRUE, 0)
199 vbox.pack_start(self.mini_hbox, FALSE, TRUE)
200 self.mini_entry.connect('key-press-event', self.mini_key_press)
201 self.mini_entry.connect('changed', self.mini_changed)
203 self.connect('destroy', self.destroyed)
205 self.connect('delete-event', self.delete_event)
206 self.text.grab_focus()
207 self.text.connect('key-press-event', self.key_press)
209 def update_current_line(*unused):
210 cursor = self.buffer.get_iter_at_mark(self.insert_mark)
211 bound = self.buffer.get_iter_at_mark(self.selection_bound_mark)
212 if cursor.compare(bound) == 0:
213 n_lines = self.buffer.get_line_count()
214 self.status_label.set_text(_('Line %s of %d') % (cursor.get_line() + 1, n_lines))
215 else:
216 n_lines = abs(cursor.get_line() - bound.get_line()) + 1
217 if n_lines == 1:
218 n_chars = abs(cursor.get_line_offset() - bound.get_line_offset())
219 if n_chars == 1:
220 bytes = to_utf8(self.buffer.get_text(cursor, bound, False))[0]
221 self.status_label.set_text(_('One character selected (%s)') %
222 ' '.join(map(lambda x: '0x%2x' % ord(x), bytes)))
223 else:
224 self.status_label.set_text(_('%d characters selected') % n_chars)
225 else:
226 self.status_label.set_text(_('%d lines selected') % n_lines)
227 self.buffer.connect('mark-set', update_current_line)
228 self.buffer.connect('changed', update_current_line)
230 if filename:
231 import os.path
232 self.uri = os.path.abspath(filename)
233 else:
234 self.uri = None
235 self.update_title()
237 # Loading might take a while, so get something on the screen
238 # now...
239 g.gdk.flush()
241 if filename:
242 try:
243 self.load_file(filename)
244 if filename != '-':
245 self.save_last_stat = os.stat(filename)
246 self.save_mode = self.save_last_stat.st_mode # for older ROX-Libs
247 except Abort:
248 self.destroy()
249 raise
251 self.buffer.connect('modified-changed', self.update_title)
252 self.buffer.set_modified(FALSE)
254 def button_press(text, event):
255 if event.button != 3:
256 return False
257 menu.popup(self, event)
258 return True
259 self.text.connect('button-press-event', button_press)
260 self.text.connect('popup-menu', lambda text: menu.popup(self, None))
262 menu.attach(self, self)
263 self.buffer.place_cursor(self.buffer.get_start_iter())
264 self.buffer.start_undo_history()
266 def key_press(self, text, kev):
267 if kev.keyval != g.keysyms.Return and kev.keyval != g.keysyms.KP_Enter:
268 return
269 if not auto_indent.int_value:
270 return
271 start = self.buffer.get_iter_at_mark(self.insert_mark)
272 end = start.copy()
273 start.set_line_offset(0)
274 end.forward_to_line_end()
275 line = self.buffer.get_text(start, end, False)
276 indent = ''
277 for x in line:
278 if x in ' \t':
279 indent += x
280 else:
281 break
282 self.buffer.begin_user_action()
283 self.buffer.insert_at_cursor('\n' + indent)
284 self.buffer.end_user_action()
285 return True
287 def destroyed(self, widget):
288 app_options.remove_notify(self.update_styles)
290 def update_styles(self):
291 try:
292 import pango
293 font = pango.FontDescription(default_font.value)
294 bg = g.gdk.color_parse(background_colour.value)
295 fg = g.gdk.color_parse(foreground_colour.value)
297 self.text.set_left_margin(layout_left_margin.int_value)
298 self.text.set_right_margin(layout_right_margin.int_value)
300 self.text.set_pixels_above_lines(layout_before_para.int_value)
301 self.text.set_pixels_below_lines(layout_after_para.int_value)
302 self.text.set_pixels_inside_wrap(layout_inside_para.int_value)
303 self.text.set_indent(layout_indent_para.int_value)
305 if word_wrap.int_value == 1:
306 self.text.set_wrap_mode(g.WRAP_WORD)
307 else:
308 self.text.set_wrap_mode(g.WRAP_NONE)
309 except:
310 rox.report_exception()
311 else:
312 self.text.modify_font(font)
313 self.text.modify_base(g.STATE_NORMAL, bg)
314 self.text.modify_text(g.STATE_NORMAL, fg)
316 def cut(self): self.text.emit('cut_clipboard')
317 def copy(self): self.text.emit('copy_clipboard')
318 def paste(self): self.text.emit('paste_clipboard')
320 def delete_event(self, window, event):
321 if self.buffer.get_modified():
322 self.save(discard = 1)
323 return 1
324 return 0
326 def update_title(self, *unused):
327 title = self.uri or '<Untitled>'
328 if self.buffer.get_modified():
329 title = title + " *"
330 self.set_title(title)
332 def xds_load_from_stream(self, name, t, stream):
333 if t == 'UTF8_STRING':
334 return # Gtk will handle it
335 try:
336 self.insert_data(stream.read())
337 except Abort:
338 pass
340 def get_encoding(self, message):
341 "Returns (encoding, errors), or raises Abort to cancel."
342 box = g.MessageDialog(self, 0, g.MESSAGE_QUESTION, g.BUTTONS_CANCEL, message)
343 box.set_has_separator(FALSE)
345 frame = g.Frame()
346 box.vbox.pack_start(frame, TRUE, TRUE)
347 frame.set_border_width(6)
349 hbox = g.HBox(FALSE, 4)
350 hbox.set_border_width(6)
352 hbox.pack_start(g.Label('Encoding:'), FALSE, TRUE, 0)
353 combo = g.Combo()
354 combo.disable_activate()
355 combo.entry.connect('activate', lambda w: box.activate_default())
356 combo.set_popdown_strings(known_codecs)
357 hbox.pack_start(combo, TRUE, TRUE, 0)
358 ignore_errors = g.CheckButton('Ignore errors')
359 hbox.pack_start(ignore_errors, FALSE, TRUE)
361 frame.add(hbox)
363 box.vbox.show_all()
364 box.add_button(g.STOCK_CONVERT, g.RESPONSE_YES)
365 box.set_default_response(g.RESPONSE_YES)
367 while 1:
368 combo.entry.grab_focus()
370 resp = box.run()
371 if resp != g.RESPONSE_YES:
372 box.destroy()
373 raise Abort
375 if ignore_errors.get_active():
376 errors = 'replace'
377 else:
378 errors = 'strict'
379 encoding = combo.entry.get_text()
380 try:
381 codecs.getdecoder(encoding)
382 break
383 except:
384 rox.alert(_("Unknown encoding '%s'") % encoding)
386 box.destroy()
388 return encoding, errors
390 def insert_data(self, data):
391 import codecs
392 errors = 'strict'
393 encoding = 'utf-8'
394 while 1:
395 decoder = codecs.getdecoder(encoding)
396 try:
397 data = decoder(data, errors)[0]
398 if errors == 'strict':
399 assert '\0' not in data
400 else:
401 if '\0' in data:
402 data = data.replace('\0', '\\0')
403 break
404 except:
405 pass
407 encoding, errors = self.get_encoding(
408 _("Data is not valid %s. Please select the file's encoding. "
409 "Turn on 'ignore errors' to try and load it anyway.")
410 % encoding)
412 self.buffer.begin_user_action()
413 self.buffer.insert_at_cursor(data)
414 self.buffer.end_user_action()
415 return 1
417 def load_file(self, path):
418 try:
419 if path == '-':
420 file = sys.stdin
421 else:
422 file = open(path, 'r')
423 contents = file.read()
424 if path != '-':
425 file.close()
426 self.insert_data(contents)
427 except Abort:
428 raise
429 except:
430 rox.report_exception()
431 raise Abort
433 def close(self, button = None):
434 if self.buffer.get_modified():
435 self.save(discard = 1)
436 else:
437 self.destroy()
439 def discard(self):
440 self.destroy()
442 def up(self, button = None):
443 if self.uri:
444 filer.show_file(self.uri)
445 else:
446 rox.alert(_('File is not saved to disk yet'))
448 def diff(self, button = None, path = None):
449 path = path or self.uri
450 if not path:
451 rox.alert(_('This file has never been saved; nothing to compare it to!\n'
452 'Note: you can drop a file onto the toolbar button to see '
453 'the changes from that file.'))
454 return
455 diff.show_diff(path, self.save_to_stream)
457 def has_selection(self):
458 s, e = self.get_selection_range()
459 return not e.equal(s)
461 def get_marked_range(self):
462 s = self.buffer.get_iter_at_mark(self.mark_start)
463 e = self.buffer.get_iter_at_mark(self.mark_end)
464 if s.compare(e) > 0:
465 return e, s
466 return s, e
468 def get_selection_range(self):
469 s = self.buffer.get_iter_at_mark(self.insert_mark)
470 e = self.buffer.get_iter_at_mark(self.selection_bound_mark)
471 if s.compare(e) > 0:
472 return e, s
473 return s, e
475 def save(self, widget = None, discard = 0):
476 from rox.saving import SaveBox
478 if self.savebox:
479 self.savebox.destroy()
481 if self.has_selection() and not discard:
482 saver = SelectionSaver(self)
483 self.savebox = SaveBox(saver, 'Selection', 'text/plain')
484 self.savebox.connect('destroy', lambda w: saver.destroy())
485 else:
486 uri = self.uri or 'TextFile'
487 self.savebox = SaveBox(self, uri, 'text/plain', discard)
488 self.savebox.show()
490 def help(self, button = None):
491 filer.open_dir(os.path.join(rox.app_dir, 'Help'))
493 def save_to_stream(self, stream):
494 s = self.buffer.get_start_iter()
495 e = self.buffer.get_end_iter()
496 stream.write(self.buffer.get_text(s, e, TRUE))
498 def set_uri(self, uri):
499 self.uri = uri
500 self.buffer.set_modified(FALSE)
501 self.update_title()
503 def new(self):
504 EditWindow()
506 def change_font(self):
507 style = self.text.get_style().copy()
508 style.font = load_font(options.get('edit_font'))
509 self.text.set_style(style)
511 def show_options(self):
512 rox.edit_options()
514 def set_marked(self, start = None, end = None):
515 "Set the marked region (from the selection if no region is given)."
516 self.clear_marked()
517 assert not self.marked
519 buffer = self.buffer
520 if start:
521 assert end
522 else:
523 assert not end
524 start, end = self.get_selection_range()
525 buffer.move_mark(self.mark_start, start)
526 buffer.move_mark(self.mark_end, end)
527 buffer.apply_tag_by_name('marked',
528 buffer.get_iter_at_mark(self.mark_start),
529 buffer.get_iter_at_mark(self.mark_end))
530 self.marked = 1
532 def clear_marked(self):
533 if not self.marked:
534 return
535 self.marked = 0
536 buffer = self.buffer
537 buffer.remove_tag_by_name('marked',
538 buffer.get_iter_at_mark(self.mark_start),
539 buffer.get_iter_at_mark(self.mark_end))
541 def undo(self, widget = None):
542 self.buffer.undo()
544 def redo(self, widget = None):
545 self.buffer.redo()
547 def goto(self, widget = None):
548 from goto import Goto
549 self.set_minibuffer(Goto())
551 def search(self, widget = None):
552 from search import Search
553 self.set_minibuffer(Search())
555 def search_replace(self, widget = None):
556 from search import Replace
557 Replace(self).show()
559 def set_mini_label(self, label):
560 self.mini_label.set_text(label)
562 def set_minibuffer(self, minibuffer):
563 assert minibuffer is None or isinstance(minibuffer, Minibuffer)
565 self.minibuffer = None
567 if minibuffer:
568 self.mini_entry.set_text('')
569 self.minibuffer = minibuffer
570 minibuffer.setup(self)
571 self.mini_entry.grab_focus()
572 self.mini_hbox.show_all()
573 else:
574 self.mini_hbox.hide()
575 self.text.grab_focus()
577 def mini_key_press(self, entry, kev):
578 if kev.keyval == g.keysyms.Escape:
579 self.set_minibuffer(None)
580 return 1
581 if kev.keyval == g.keysyms.Return or kev.keyval == g.keysyms.KP_Enter:
582 self.minibuffer.activate()
583 return 1
585 return self.minibuffer.key_press(kev)
587 def mini_changed(self, entry):
588 if not self.minibuffer:
589 return
590 self.minibuffer.changed()
592 def mini_show_info(self, *unused):
593 assert self.minibuffer
594 if self.info_box:
595 self.info_box.destroy()
596 self.info_box = g.MessageDialog(self, 0, g.MESSAGE_INFO, g.BUTTONS_OK,
597 self.minibuffer.info)
598 self.info_box.set_title(_('Minibuffer help'))
599 def destroy(box):
600 self.info_box = None
601 self.info_box.connect('destroy', destroy)
602 self.info_box.show()
603 self.info_box.connect('response', lambda w, r: w.destroy())
605 def process_selected(self, process):
606 """Calls process(line) on each line in the selection, or each line in the file
607 if there is no selection. If the result is not None, the text is replaced."""
608 self.buffer.begin_user_action()
609 self._process_selected(process)
610 self.buffer.end_user_action()
612 def _process_selected(self, process):
613 if self.has_selection():
614 def get_end():
615 start, end = self.get_selection_range()
616 if start.compare(end) > 0:
617 return start
618 return end
619 start, end = self.get_selection_range()
620 if start.compare(end) > 0:
621 start = end
622 else:
623 def get_end():
624 return self.buffer.get_end_iter()
625 start = self.buffer.get_start_iter()
626 end = get_end()
628 while start.compare(end) <= 0:
629 line_end = start.copy()
630 line_end.forward_to_line_end()
631 if line_end.compare(end) >= 0:
632 line_end = end
633 line = self.buffer.get_text(start, line_end, False)
634 new = process(line)
635 if new is not None:
636 self.buffer.move_mark(self.mark_tmp, start)
637 self.buffer.insert(line_end, new)
638 start = self.buffer.get_iter_at_mark(self.mark_tmp)
639 line_end = start.copy()
640 line_end.forward_chars(len(line))
641 self.buffer.delete(start, line_end)
643 start = self.buffer.get_iter_at_mark(self.mark_tmp)
644 end = get_end()
645 if not start.forward_line(): break
647 class SelectionSaver(Saveable):
648 def __init__(self, window):
649 self.window = window
650 window.set_marked()
652 def save_to_stream(self, stream):
653 s, e = self.window.get_marked_range()
654 stream.write(self.window.buffer.get_text(s, e, TRUE))
656 def destroy(self):
657 # Called when savebox is remove. Get rid of the selection marker
658 self.window.clear_marked()