Added ability to bring up a diff of the changes to the saved version of the
[rox-edit.git] / EditWindow.py
blob5b79c27851f8525457b929dd46d19b784621df72
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
10 FALSE = g.FALSE
11 TRUE = g.TRUE
13 from rox.Menu import Menu, set_save_name
15 default_font = Option('default_font', 'serif')
17 background_colour = Option('background', '#fff')
18 foreground_colour = Option('foreground', '#000')
20 auto_indent = Option('autoindent', '1')
22 layout_left_margin = Option('layout_left_margin', 2)
23 layout_right_margin = Option('layout_right_margin', 4)
25 layout_before_para = Option('layout_before_para', 0)
26 layout_after_para = Option('layout_after_para', 0)
27 layout_inside_para = Option('layout_inside_para', 0)
28 layout_indent_para = Option('layout_indent_para', 2)
30 set_save_name('Edit')
32 try:
33 menu = Menu('main', [
34 (_('/File'), '', '<Branch>'),
35 (_('/File/') + _('Save'), 'save', '<StockItem>', '<Ctrl>S', g.STOCK_SAVE),
36 (_('/File/') + _('Open Parent'), 'up', '<StockItem>', '', g.STOCK_GO_UP),
37 (_('/File/') + _('Show Changes'), 'diff', ''),
38 (_('/File/') + _('Close'), 'close', '<StockItem>', '', g.STOCK_CLOSE),
39 (_('/File/'), '', '<Separator>'),
40 (_('/File/') + _('New'), 'new', '<StockItem>', '', g.STOCK_NEW),
42 (_('/Edit'), '', '<Branch>'),
43 (_('/Edit/') + _('Cut'), 'cut', '<StockItem>', '<Ctrl>X', g.STOCK_CUT),
44 (_('/Edit/') + _('Copy'), 'copy', '<StockItem>', '<Ctrl>C', g.STOCK_COPY),
45 (_('/Edit/') + _('Paste'), 'paste', '<StockItem>', '<Ctrl>V', g.STOCK_PASTE),
46 (_('/Edit/'), '', '<Separator>'),
47 (_('/Edit/') + _('Undo'), 'undo', '<StockItem>', '<Ctrl>Z', g.STOCK_UNDO),
48 (_('/Edit/') + _('Redo'), 'redo', '<StockItem>', '<Ctrl>Y', g.STOCK_REDO),
49 (_('/Edit/'), '', '<Separator>'),
50 (_('/Edit/') + _('Search...'), 'search', '<StockItem>', 'F4', g.STOCK_FIND),
51 (_('/Edit/') + _('Search and Replace....'), 'search_replace',
52 '<StockItem>', '<Ctrl>F4', g.STOCK_FIND_AND_REPLACE),
53 (_('/Edit/') + _('Goto line...'), 'goto', '<StockItem>', 'F5', g.STOCK_JUMP_TO),
55 (_('/Options'), 'show_options', '<StockItem>', '', g.STOCK_PROPERTIES),
56 (_('/Help'), 'help', '<StockItem>', 'F1', g.STOCK_HELP),
58 except ValueError:
59 rox.croak(_('Edit requires ROX-Lib2 1.9.8 or later'))
61 known_codecs = (
62 "iso8859_1", "iso8859_2", "iso8859_3", "iso8859_4", "iso8859_5",
63 "iso8859_6", "iso8859_7", "iso8859_8", "iso8859_9", "iso8859_10",
64 "iso8859_13", "iso8859_14", "iso8859_15",
65 "ascii", "base64_codec", "charmap",
66 "cp037", "cp1006", "cp1026", "cp1140", "cp1250", "cp1251", "cp1252",
67 "cp1253", "cp1254", "cp1255", "cp1256", "cp1257", "cp1258", "cp424",
68 "cp437", "cp500", "cp737", "cp775", "cp850", "cp852", "cp855", "cp856",
69 "cp857", "cp860", "cp861", "cp862", "cp863", "cp864", "cp865", "cp866",
70 "cp869", "cp874", "cp875", "hex_codec",
71 "koi8_r",
72 "latin_1",
73 "mac_cyrillic", "mac_greek", "mac_iceland", "mac_latin2", "mac_roman", "mac_turkish",
74 "mbcs", "quopri_codec", "raw_unicode_escape",
75 "rot_13",
76 "utf_16_be", "utf_16_le", "utf_16", "utf_7", "utf_8", "uu_codec",
77 "zlib_codec"
80 class Abort(Exception):
81 pass
83 class Minibuffer:
84 def setup(self):
85 """Called when the minibuffer is opened."""
87 def key_press(self, kev):
88 """A keypress event in the minibuffer text entry."""
90 def changed(self):
91 """The minibuffer text has changed."""
93 def activate(self):
94 """Return or Enter pressed."""
96 info = 'Press Escape to close the minibuffer.'
98 class EditWindow(rox.Window, XDSLoader, Saveable):
99 def __init__(self, filename = None):
100 rox.Window.__init__(self)
101 XDSLoader.__init__(self, ['text/plain', 'UTF8_STRING'])
102 self.set_default_size(g.gdk.screen_width() * 2 / 3,
103 g.gdk.screen_height() / 2)
105 self.savebox = None
106 self.info_box = None
108 app_options.add_notify(self.update_styles)
110 self.buffer = Buffer()
112 self.text = g.TextView()
113 self.text.set_buffer(self.buffer)
114 self.text.set_size_request(10, 10)
115 self.xds_proxy_for(self.text)
116 self.text.set_wrap_mode(g.WRAP_WORD)
117 self.update_styles()
119 self.insert_mark = self.buffer.get_mark('insert')
120 self.selection_bound_mark = self.buffer.get_mark('selection_bound')
121 start = self.buffer.get_start_iter()
122 self.mark_start = self.buffer.create_mark('mark_start', start, TRUE)
123 self.mark_end = self.buffer.create_mark('mark_end', start, FALSE)
124 self.mark_tmp = self.buffer.create_mark('mark_tmp', start, FALSE)
125 tag = self.buffer.create_tag('marked')
126 tag.set_property('background', 'green')
127 self.marked = 0
129 # When searching, this is where the cursor was when the minibuffer
130 # was opened.
131 start = self.buffer.get_start_iter()
132 self.search_base = self.buffer.create_mark('search_base', start, TRUE)
134 vbox = g.VBox(FALSE)
135 self.add(vbox)
137 tools = g.Toolbar()
138 tools.set_style(g.TOOLBAR_ICONS)
139 vbox.pack_start(tools, FALSE, TRUE, 0)
140 tools.show()
142 tools.insert_stock(g.STOCK_HELP, _('Help'), None, self.help, None, 0)
143 tools.insert_stock(g.STOCK_REDO, _('Redo'), None, self.redo, None, 0)
144 tools.insert_stock(g.STOCK_UNDO, _('Undo'), None, self.undo, None, 0)
145 tools.insert_stock(g.STOCK_FIND_AND_REPLACE, _('Replace'), None, self.search_replace, None, 0)
146 tools.insert_stock(g.STOCK_FIND, _('Search'), None, self.search, None, 0)
147 tools.insert_stock(g.STOCK_SAVE, _('Save'), None, self.save, None, 0)
148 tools.insert_stock(g.STOCK_GO_UP, _('Up'), None, self.up, None, 0)
149 tools.insert_stock(g.STOCK_CLOSE, _('Close'), None, self.close, None, 0)
151 swin = g.ScrolledWindow()
152 swin.set_policy(g.POLICY_NEVER, g.POLICY_AUTOMATIC)
153 vbox.pack_start(swin, True, True)
155 swin.add(self.text)
157 self.show_all()
159 # Create the minibuffer
160 self.mini_hbox = g.HBox(FALSE)
161 info = g.Button()
162 info.set_relief(g.RELIEF_NONE)
163 info.unset_flags(g.CAN_FOCUS)
164 image = g.Image()
165 image.set_from_stock(g.STOCK_DIALOG_INFO, size = g.ICON_SIZE_SMALL_TOOLBAR)
166 info.add(image)
167 info.show_all()
168 info.connect('clicked', self.mini_show_info)
170 self.mini_hbox.pack_start(info, FALSE, TRUE, 0)
171 self.mini_label = g.Label('')
172 self.mini_hbox.pack_start(self.mini_label, FALSE, TRUE, 0)
173 self.mini_entry = g.Entry()
174 self.mini_hbox.pack_start(self.mini_entry, TRUE, TRUE, 0)
175 vbox.pack_start(self.mini_hbox, FALSE, TRUE)
176 self.mini_entry.connect('key-press-event', self.mini_key_press)
177 self.mini_entry.connect('changed', self.mini_changed)
179 self.connect('destroy', self.destroyed)
181 self.connect('delete-event', self.delete_event)
182 self.text.grab_focus()
183 self.text.connect('key-press-event', self.key_press)
185 if filename:
186 import os.path
187 self.uri = os.path.abspath(filename)
188 else:
189 self.uri = None
190 self.update_title()
192 # Loading might take a while, so get something on the screen
193 # now...
194 g.gdk.flush()
196 if filename:
197 try:
198 self.load_file(filename)
199 if filename != '-':
200 self.save_mode = os.stat(filename).st_mode
201 except Abort:
202 self.destroy()
203 raise
205 self.buffer.connect('modified-changed', self.update_title)
206 self.buffer.set_modified(FALSE)
208 def button_press(text, event):
209 if event.button != 3:
210 return False
211 menu.popup(self, event)
212 return True
213 self.text.connect('button-press-event', button_press)
214 self.text.connect('popup-menu', lambda text: menu.popup(self, None))
216 menu.attach(self, self)
217 self.buffer.place_cursor(self.buffer.get_start_iter())
218 self.buffer.start_undo_history()
220 def key_press(self, text, kev):
221 if kev.keyval != g.keysyms.Return and kev.keyval != g.keysyms.KP_Enter:
222 return
223 if not auto_indent.int_value:
224 return
225 start = self.buffer.get_iter_at_mark(self.insert_mark)
226 end = start.copy()
227 start.set_line_offset(0)
228 end.forward_to_line_end()
229 line = self.buffer.get_text(start, end, False)
230 indent = ''
231 for x in line:
232 if x in ' \t':
233 indent += x
234 else:
235 break
236 self.buffer.begin_user_action()
237 self.buffer.insert_at_cursor('\n' + indent)
238 self.buffer.end_user_action()
239 return True
241 def destroyed(self, widget):
242 app_options.remove_notify(self.update_styles)
244 def update_styles(self):
245 try:
246 import pango
247 font = pango.FontDescription(default_font.value)
248 bg = g.gdk.color_parse(background_colour.value)
249 fg = g.gdk.color_parse(foreground_colour.value)
251 self.text.set_left_margin(layout_left_margin.int_value)
252 self.text.set_right_margin(layout_right_margin.int_value)
254 self.text.set_pixels_above_lines(layout_before_para.int_value)
255 self.text.set_pixels_below_lines(layout_after_para.int_value)
256 self.text.set_pixels_inside_wrap(layout_inside_para.int_value)
257 self.text.set_indent(layout_indent_para.int_value)
258 except:
259 rox.report_exception()
260 else:
261 self.text.modify_font(font)
262 self.text.modify_base(g.STATE_NORMAL, bg)
263 self.text.modify_text(g.STATE_NORMAL, fg)
265 def cut(self): self.text.emit('cut_clipboard')
266 def copy(self): self.text.emit('copy_clipboard')
267 def paste(self): self.text.emit('paste_clipboard')
269 def delete_event(self, window, event):
270 if self.buffer.get_modified():
271 self.save(discard = 1)
272 return 1
273 return 0
275 def update_title(self, *unused):
276 title = self.uri or '<Untitled>'
277 if self.buffer.get_modified():
278 title = title + " *"
279 self.set_title(title)
281 def xds_load_from_stream(self, name, t, stream):
282 if t == 'UTF8_STRING':
283 return # Gtk will handle it
284 try:
285 self.insert_data(stream.read())
286 except Abort:
287 pass
289 def get_encoding(self, message):
290 "Returns (encoding, errors), or raises Abort to cancel."
291 import codecs
293 box = g.MessageDialog(self, 0, g.MESSAGE_QUESTION, g.BUTTONS_CANCEL, message)
294 box.set_has_separator(FALSE)
296 frame = g.Frame()
297 box.vbox.pack_start(frame, TRUE, TRUE)
298 frame.set_border_width(6)
300 hbox = g.HBox(FALSE, 4)
301 hbox.set_border_width(6)
303 hbox.pack_start(g.Label('Encoding:'), FALSE, TRUE, 0)
304 combo = g.Combo()
305 combo.disable_activate()
306 combo.entry.connect('activate', lambda w: box.activate_default())
307 combo.set_popdown_strings(known_codecs)
308 hbox.pack_start(combo, TRUE, TRUE, 0)
309 ignore_errors = g.CheckButton('Ignore errors')
310 hbox.pack_start(ignore_errors, FALSE, TRUE)
312 frame.add(hbox)
314 box.vbox.show_all()
315 box.add_button(g.STOCK_CONVERT, g.RESPONSE_YES)
316 box.set_default_response(g.RESPONSE_YES)
318 while 1:
319 combo.entry.grab_focus()
321 resp = box.run()
322 if resp != g.RESPONSE_YES:
323 box.destroy()
324 raise Abort
326 if ignore_errors.get_active():
327 errors = 'replace'
328 else:
329 errors = 'strict'
330 encoding = combo.entry.get_text()
331 try:
332 codecs.getdecoder(encoding)
333 break
334 except:
335 rox.alert(_("Unknown encoding '%s'") % encoding)
337 box.destroy()
339 return encoding, errors
341 def insert_data(self, data):
342 import codecs
343 errors = 'strict'
344 encoding = 'utf-8'
345 while 1:
346 decoder = codecs.getdecoder(encoding)
347 try:
348 data = decoder(data, errors)[0]
349 if errors == 'strict':
350 assert '\0' not in data
351 else:
352 if '\0' in data:
353 data = data.replace('\0', '\\0')
354 break
355 except:
356 pass
358 encoding, errors = self.get_encoding(
359 _("Data is not valid %s. Please select the file's encoding. "
360 "Turn on 'ignore errors' to try and load it anyway.")
361 % encoding)
363 self.buffer.begin_user_action()
364 self.buffer.insert_at_cursor(data)
365 self.buffer.end_user_action()
366 return 1
368 def load_file(self, path):
369 try:
370 if path == '-':
371 file = sys.stdin
372 else:
373 file = open(path, 'r')
374 contents = file.read()
375 if path != '-':
376 file.close()
377 self.insert_data(contents)
378 except Abort:
379 raise
380 except:
381 rox.report_exception()
382 raise Abort
384 def close(self, button = None):
385 if self.buffer.get_modified():
386 self.save(discard = 1)
387 else:
388 self.destroy()
390 def discard(self):
391 self.destroy()
393 def up(self, button = None):
394 if self.uri:
395 filer.show_file(self.uri)
396 else:
397 rox.alert(_('File is not saved to disk yet'))
399 def diff(self, button = None):
400 if not self.uri:
401 rox.alert(_('This file has never been saved; nothing to compare it to!'))
402 return
403 import diff
404 diff.show_diff(self.uri, self.save_to_stream)
406 def has_selection(self):
407 s, e = self.get_selection_range()
408 return not e.equal(s)
410 def get_marked_range(self):
411 s = self.buffer.get_iter_at_mark(self.mark_start)
412 e = self.buffer.get_iter_at_mark(self.mark_end)
413 if s.compare(e) > 0:
414 return e, s
415 return s, e
417 def get_selection_range(self):
418 s = self.buffer.get_iter_at_mark(self.insert_mark)
419 e = self.buffer.get_iter_at_mark(self.selection_bound_mark)
420 if s.compare(e) > 0:
421 return e, s
422 return s, e
424 def save(self, widget = None, discard = 0):
425 from rox.saving import SaveBox
427 if self.savebox:
428 self.savebox.destroy()
430 if self.has_selection() and not discard:
431 saver = SelectionSaver(self)
432 self.savebox = SaveBox(saver, 'Selection', 'text/plain')
433 self.savebox.connect('destroy', lambda w: saver.destroy())
434 else:
435 uri = self.uri or 'TextFile'
436 self.savebox = SaveBox(self, uri, 'text/plain', discard)
437 self.savebox.show()
439 def help(self, button = None):
440 filer.open_dir(os.path.join(rox.app_dir, 'Help'))
442 def save_to_stream(self, stream):
443 s = self.buffer.get_start_iter()
444 e = self.buffer.get_end_iter()
445 stream.write(self.buffer.get_text(s, e, TRUE))
447 def set_uri(self, uri):
448 self.uri = uri
449 self.buffer.set_modified(FALSE)
450 self.update_title()
452 def new(self):
453 EditWindow()
455 def change_font(self):
456 style = self.text.get_style().copy()
457 style.font = load_font(options.get('edit_font'))
458 self.text.set_style(style)
460 def show_options(self):
461 rox.edit_options()
463 def set_marked(self, start = None, end = None):
464 "Set the marked region (from the selection if no region is given)."
465 self.clear_marked()
466 assert not self.marked
468 buffer = self.buffer
469 if start:
470 assert end
471 else:
472 assert not end
473 start, end = self.get_selection_range()
474 buffer.move_mark(self.mark_start, start)
475 buffer.move_mark(self.mark_end, end)
476 buffer.apply_tag_by_name('marked',
477 buffer.get_iter_at_mark(self.mark_start),
478 buffer.get_iter_at_mark(self.mark_end))
479 self.marked = 1
481 def clear_marked(self):
482 if not self.marked:
483 return
484 self.marked = 0
485 buffer = self.buffer
486 buffer.remove_tag_by_name('marked',
487 buffer.get_iter_at_mark(self.mark_start),
488 buffer.get_iter_at_mark(self.mark_end))
490 def undo(self, widget = None):
491 self.buffer.undo()
493 def redo(self, widget = None):
494 self.buffer.redo()
496 def goto(self, widget = None):
497 from goto import Goto
498 self.set_minibuffer(Goto())
500 def search(self, widget = None):
501 from search import Search
502 self.set_minibuffer(Search())
504 def search_replace(self, widget = None):
505 from search import Replace
506 Replace(self).show()
508 def set_mini_label(self, label):
509 self.mini_label.set_text(label)
511 def set_minibuffer(self, minibuffer):
512 assert minibuffer is None or isinstance(minibuffer, Minibuffer)
514 self.minibuffer = None
516 if minibuffer:
517 self.mini_entry.set_text('')
518 self.minibuffer = minibuffer
519 minibuffer.setup(self)
520 self.mini_entry.grab_focus()
521 self.mini_hbox.show_all()
522 else:
523 self.mini_hbox.hide()
524 self.text.grab_focus()
526 def mini_key_press(self, entry, kev):
527 if kev.keyval == g.keysyms.Escape:
528 self.set_minibuffer(None)
529 return 1
530 if kev.keyval == g.keysyms.Return or kev.keyval == g.keysyms.KP_Enter:
531 self.minibuffer.activate()
532 return 1
534 return self.minibuffer.key_press(kev)
536 def mini_changed(self, entry):
537 if not self.minibuffer:
538 return
539 self.minibuffer.changed()
541 def mini_show_info(self, *unused):
542 assert self.minibuffer
543 if self.info_box:
544 self.info_box.destroy()
545 self.info_box = g.MessageDialog(self, 0, g.MESSAGE_INFO, g.BUTTONS_OK,
546 self.minibuffer.info)
547 self.info_box.set_title(_('Minibuffer help'))
548 def destroy(box):
549 self.info_box = None
550 self.info_box.connect('destroy', destroy)
551 self.info_box.show()
552 self.info_box.connect('response', lambda w, r: w.destroy())
554 def process_selected(self, process):
555 """Calls process(line) on each line in the selection, or each line in the file
556 if there is no selection. If the result is not None, the text is replaced."""
557 self.buffer.begin_user_action()
558 self._process_selected(process)
559 self.buffer.end_user_action()
561 def _process_selected(self, process):
562 if self.has_selection():
563 def get_end():
564 start, end = self.get_selection_range()
565 if start.compare(end) > 0:
566 return start
567 return end
568 start, end = self.get_selection_range()
569 if start.compare(end) > 0:
570 start = end
571 else:
572 def get_end():
573 return self.buffer.get_end_iter()
574 start = self.buffer.get_start_iter()
575 end = get_end()
577 while start.compare(end) <= 0:
578 line_end = start.copy()
579 line_end.forward_to_line_end()
580 if line_end.compare(end) >= 0:
581 line_end = end
582 line = self.buffer.get_text(start, line_end, False)
583 new = process(line)
584 if new is not None:
585 self.buffer.move_mark(self.mark_tmp, start)
586 self.buffer.insert(line_end, new)
587 start = self.buffer.get_iter_at_mark(self.mark_tmp)
588 line_end = start.copy()
589 line_end.forward_chars(len(line))
590 self.buffer.delete(start, line_end)
592 start = self.buffer.get_iter_at_mark(self.mark_tmp)
593 end = get_end()
594 if not start.forward_line(): break
596 class SelectionSaver(Saveable):
597 def __init__(self, window):
598 self.window = window
599 window.set_marked()
601 def save_to_stream(self, stream):
602 s, e = self.window.get_marked_range()
603 stream.write(self.window.buffer.get_text(s, e, TRUE))
605 def destroy(self):
606 # Called when savebox is remove. Get rid of the selection marker
607 self.window.clear_marked()