Allow drops to the toolbar diff button.
[rox-edit/bju.git] / EditWindow.py
bloba2ec7b54e5b82480ea6abb1a148782df9193b180
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
11 FALSE = g.FALSE
12 TRUE = g.TRUE
14 from rox.Menu import Menu, set_save_name
16 default_font = Option('default_font', 'serif')
18 background_colour = Option('background', '#fff')
19 foreground_colour = Option('foreground', '#000')
21 auto_indent = Option('autoindent', '1')
23 layout_left_margin = Option('layout_left_margin', 2)
24 layout_right_margin = Option('layout_right_margin', 4)
26 layout_before_para = Option('layout_before_para', 0)
27 layout_after_para = Option('layout_after_para', 0)
28 layout_inside_para = Option('layout_inside_para', 0)
29 layout_indent_para = Option('layout_indent_para', 2)
31 set_save_name('Edit')
33 try:
34 menu = Menu('main', [
35 (_('/File'), '', '<Branch>'),
36 (_('/File/') + _('Save'), 'save', '<StockItem>', '<Ctrl>S', g.STOCK_SAVE),
37 (_('/File/') + _('Open Parent'), 'up', '<StockItem>', '', g.STOCK_GO_UP),
38 (_('/File/') + _('Show Changes'), 'diff', '<StockItem>', '', 'rox-diff'),
39 (_('/File/') + _('Close'), 'close', '<StockItem>', '', g.STOCK_CLOSE),
40 (_('/File/'), '', '<Separator>'),
41 (_('/File/') + _('New'), 'new', '<StockItem>', '', g.STOCK_NEW),
43 (_('/Edit'), '', '<Branch>'),
44 (_('/Edit/') + _('Cut'), 'cut', '<StockItem>', '<Ctrl>X', g.STOCK_CUT),
45 (_('/Edit/') + _('Copy'), 'copy', '<StockItem>', '<Ctrl>C', g.STOCK_COPY),
46 (_('/Edit/') + _('Paste'), 'paste', '<StockItem>', '<Ctrl>V', g.STOCK_PASTE),
47 (_('/Edit/'), '', '<Separator>'),
48 (_('/Edit/') + _('Undo'), 'undo', '<StockItem>', '<Ctrl>Z', g.STOCK_UNDO),
49 (_('/Edit/') + _('Redo'), 'redo', '<StockItem>', '<Ctrl>Y', g.STOCK_REDO),
50 (_('/Edit/'), '', '<Separator>'),
51 (_('/Edit/') + _('Search...'), 'search', '<StockItem>', 'F4', g.STOCK_FIND),
52 (_('/Edit/') + _('Search and Replace....'), 'search_replace',
53 '<StockItem>', '<Ctrl>F4', g.STOCK_FIND_AND_REPLACE),
54 (_('/Edit/') + _('Goto line...'), 'goto', '<StockItem>', 'F5', g.STOCK_JUMP_TO),
56 (_('/Options'), 'show_options', '<StockItem>', '', g.STOCK_PROPERTIES),
57 (_('/Help'), 'help', '<StockItem>', 'F1', g.STOCK_HELP),
59 except ValueError:
60 rox.croak(_('Edit requires ROX-Lib2 1.9.8 or later'))
62 known_codecs = (
63 "iso8859_1", "iso8859_2", "iso8859_3", "iso8859_4", "iso8859_5",
64 "iso8859_6", "iso8859_7", "iso8859_8", "iso8859_9", "iso8859_10",
65 "iso8859_13", "iso8859_14", "iso8859_15",
66 "ascii", "base64_codec", "charmap",
67 "cp037", "cp1006", "cp1026", "cp1140", "cp1250", "cp1251", "cp1252",
68 "cp1253", "cp1254", "cp1255", "cp1256", "cp1257", "cp1258", "cp424",
69 "cp437", "cp500", "cp737", "cp775", "cp850", "cp852", "cp855", "cp856",
70 "cp857", "cp860", "cp861", "cp862", "cp863", "cp864", "cp865", "cp866",
71 "cp869", "cp874", "cp875", "hex_codec",
72 "koi8_r",
73 "latin_1",
74 "mac_cyrillic", "mac_greek", "mac_iceland", "mac_latin2", "mac_roman", "mac_turkish",
75 "mbcs", "quopri_codec", "raw_unicode_escape",
76 "rot_13",
77 "utf_16_be", "utf_16_le", "utf_16", "utf_7", "utf_8", "uu_codec",
78 "zlib_codec"
81 class Abort(Exception):
82 pass
84 class Minibuffer:
85 def setup(self):
86 """Called when the minibuffer is opened."""
88 def key_press(self, kev):
89 """A keypress event in the minibuffer text entry."""
91 def changed(self):
92 """The minibuffer text has changed."""
94 def activate(self):
95 """Return or Enter pressed."""
97 info = 'Press Escape to close the minibuffer.'
99 class DiffLoader(XDSLoader):
100 def __init__(self, window):
101 XDSLoader.__init__(self, ['text/plain'])
102 self.window = window
104 def xds_load_from_file(self, path):
105 self.window.diff(path = path)
107 def xds_load_from_stream(self, name, type, stream):
108 tmp = diff.Tmp(suffix = '-' + (name or 'tmp'))
109 import shutil
110 shutil.copyfileobj(stream, tmp)
111 tmp.seek(0)
112 self.window.diff(path = tmp.name)
114 class EditWindow(rox.Window, XDSLoader, Saveable):
115 def __init__(self, filename = None):
116 rox.Window.__init__(self)
117 XDSLoader.__init__(self, ['text/plain', 'UTF8_STRING'])
118 self.set_default_size(g.gdk.screen_width() * 2 / 3,
119 g.gdk.screen_height() / 2)
121 self.savebox = None
122 self.info_box = None
124 app_options.add_notify(self.update_styles)
126 self.buffer = Buffer()
128 self.text = g.TextView()
129 self.text.set_buffer(self.buffer)
130 self.text.set_size_request(10, 10)
131 self.xds_proxy_for(self.text)
132 self.text.set_wrap_mode(g.WRAP_WORD)
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 tools.insert_stock(g.STOCK_HELP, _('Help'), None, self.help, None, 0)
159 diff = tools.insert_stock('rox-diff', _('Show changes from saved copy.\n'
160 'Or, drop a backup file onto this button to see changes from that.'),
161 None, self.diff, None, 0)
162 DiffLoader(self).xds_proxy_for(diff)
163 tools.insert_stock(g.STOCK_REDO, _('Redo'), None, self.redo, None, 0)
164 tools.insert_stock(g.STOCK_UNDO, _('Undo'), None, self.undo, None, 0)
165 tools.insert_stock(g.STOCK_FIND_AND_REPLACE, _('Replace'), None, self.search_replace, None, 0)
166 tools.insert_stock(g.STOCK_FIND, _('Search'), None, self.search, None, 0)
167 tools.insert_stock(g.STOCK_SAVE, _('Save'), None, self.save, None, 0)
168 tools.insert_stock(g.STOCK_GO_UP, _('Up'), None, self.up, None, 0)
169 tools.insert_stock(g.STOCK_CLOSE, _('Close'), None, self.close, None, 0)
171 swin = g.ScrolledWindow()
172 swin.set_policy(g.POLICY_NEVER, g.POLICY_AUTOMATIC)
173 vbox.pack_start(swin, True, True)
175 swin.add(self.text)
177 self.show_all()
179 # Create the minibuffer
180 self.mini_hbox = g.HBox(FALSE)
181 info = g.Button()
182 info.set_relief(g.RELIEF_NONE)
183 info.unset_flags(g.CAN_FOCUS)
184 image = g.Image()
185 image.set_from_stock(g.STOCK_DIALOG_INFO, size = g.ICON_SIZE_SMALL_TOOLBAR)
186 info.add(image)
187 info.show_all()
188 info.connect('clicked', self.mini_show_info)
190 self.mini_hbox.pack_start(info, FALSE, TRUE, 0)
191 self.mini_label = g.Label('')
192 self.mini_hbox.pack_start(self.mini_label, FALSE, TRUE, 0)
193 self.mini_entry = g.Entry()
194 self.mini_hbox.pack_start(self.mini_entry, TRUE, TRUE, 0)
195 vbox.pack_start(self.mini_hbox, FALSE, TRUE)
196 self.mini_entry.connect('key-press-event', self.mini_key_press)
197 self.mini_entry.connect('changed', self.mini_changed)
199 self.connect('destroy', self.destroyed)
201 self.connect('delete-event', self.delete_event)
202 self.text.grab_focus()
203 self.text.connect('key-press-event', self.key_press)
205 if filename:
206 import os.path
207 self.uri = os.path.abspath(filename)
208 else:
209 self.uri = None
210 self.update_title()
212 # Loading might take a while, so get something on the screen
213 # now...
214 g.gdk.flush()
216 if filename:
217 try:
218 self.load_file(filename)
219 if filename != '-':
220 self.save_mode = os.stat(filename).st_mode
221 except Abort:
222 self.destroy()
223 raise
225 self.buffer.connect('modified-changed', self.update_title)
226 self.buffer.set_modified(FALSE)
228 def button_press(text, event):
229 if event.button != 3:
230 return False
231 menu.popup(self, event)
232 return True
233 self.text.connect('button-press-event', button_press)
234 self.text.connect('popup-menu', lambda text: menu.popup(self, None))
236 menu.attach(self, self)
237 self.buffer.place_cursor(self.buffer.get_start_iter())
238 self.buffer.start_undo_history()
240 def key_press(self, text, kev):
241 if kev.keyval != g.keysyms.Return and kev.keyval != g.keysyms.KP_Enter:
242 return
243 if not auto_indent.int_value:
244 return
245 start = self.buffer.get_iter_at_mark(self.insert_mark)
246 end = start.copy()
247 start.set_line_offset(0)
248 end.forward_to_line_end()
249 line = self.buffer.get_text(start, end, False)
250 indent = ''
251 for x in line:
252 if x in ' \t':
253 indent += x
254 else:
255 break
256 self.buffer.begin_user_action()
257 self.buffer.insert_at_cursor('\n' + indent)
258 self.buffer.end_user_action()
259 return True
261 def destroyed(self, widget):
262 app_options.remove_notify(self.update_styles)
264 def update_styles(self):
265 try:
266 import pango
267 font = pango.FontDescription(default_font.value)
268 bg = g.gdk.color_parse(background_colour.value)
269 fg = g.gdk.color_parse(foreground_colour.value)
271 self.text.set_left_margin(layout_left_margin.int_value)
272 self.text.set_right_margin(layout_right_margin.int_value)
274 self.text.set_pixels_above_lines(layout_before_para.int_value)
275 self.text.set_pixels_below_lines(layout_after_para.int_value)
276 self.text.set_pixels_inside_wrap(layout_inside_para.int_value)
277 self.text.set_indent(layout_indent_para.int_value)
278 except:
279 rox.report_exception()
280 else:
281 self.text.modify_font(font)
282 self.text.modify_base(g.STATE_NORMAL, bg)
283 self.text.modify_text(g.STATE_NORMAL, fg)
285 def cut(self): self.text.emit('cut_clipboard')
286 def copy(self): self.text.emit('copy_clipboard')
287 def paste(self): self.text.emit('paste_clipboard')
289 def delete_event(self, window, event):
290 if self.buffer.get_modified():
291 self.save(discard = 1)
292 return 1
293 return 0
295 def update_title(self, *unused):
296 title = self.uri or '<Untitled>'
297 if self.buffer.get_modified():
298 title = title + " *"
299 self.set_title(title)
301 def xds_load_from_stream(self, name, t, stream):
302 if t == 'UTF8_STRING':
303 return # Gtk will handle it
304 try:
305 self.insert_data(stream.read())
306 except Abort:
307 pass
309 def get_encoding(self, message):
310 "Returns (encoding, errors), or raises Abort to cancel."
311 import codecs
313 box = g.MessageDialog(self, 0, g.MESSAGE_QUESTION, g.BUTTONS_CANCEL, message)
314 box.set_has_separator(FALSE)
316 frame = g.Frame()
317 box.vbox.pack_start(frame, TRUE, TRUE)
318 frame.set_border_width(6)
320 hbox = g.HBox(FALSE, 4)
321 hbox.set_border_width(6)
323 hbox.pack_start(g.Label('Encoding:'), FALSE, TRUE, 0)
324 combo = g.Combo()
325 combo.disable_activate()
326 combo.entry.connect('activate', lambda w: box.activate_default())
327 combo.set_popdown_strings(known_codecs)
328 hbox.pack_start(combo, TRUE, TRUE, 0)
329 ignore_errors = g.CheckButton('Ignore errors')
330 hbox.pack_start(ignore_errors, FALSE, TRUE)
332 frame.add(hbox)
334 box.vbox.show_all()
335 box.add_button(g.STOCK_CONVERT, g.RESPONSE_YES)
336 box.set_default_response(g.RESPONSE_YES)
338 while 1:
339 combo.entry.grab_focus()
341 resp = box.run()
342 if resp != g.RESPONSE_YES:
343 box.destroy()
344 raise Abort
346 if ignore_errors.get_active():
347 errors = 'replace'
348 else:
349 errors = 'strict'
350 encoding = combo.entry.get_text()
351 try:
352 codecs.getdecoder(encoding)
353 break
354 except:
355 rox.alert(_("Unknown encoding '%s'") % encoding)
357 box.destroy()
359 return encoding, errors
361 def insert_data(self, data):
362 import codecs
363 errors = 'strict'
364 encoding = 'utf-8'
365 while 1:
366 decoder = codecs.getdecoder(encoding)
367 try:
368 data = decoder(data, errors)[0]
369 if errors == 'strict':
370 assert '\0' not in data
371 else:
372 if '\0' in data:
373 data = data.replace('\0', '\\0')
374 break
375 except:
376 pass
378 encoding, errors = self.get_encoding(
379 _("Data is not valid %s. Please select the file's encoding. "
380 "Turn on 'ignore errors' to try and load it anyway.")
381 % encoding)
383 self.buffer.begin_user_action()
384 self.buffer.insert_at_cursor(data)
385 self.buffer.end_user_action()
386 return 1
388 def load_file(self, path):
389 try:
390 if path == '-':
391 file = sys.stdin
392 else:
393 file = open(path, 'r')
394 contents = file.read()
395 if path != '-':
396 file.close()
397 self.insert_data(contents)
398 except Abort:
399 raise
400 except:
401 rox.report_exception()
402 raise Abort
404 def close(self, button = None):
405 if self.buffer.get_modified():
406 self.save(discard = 1)
407 else:
408 self.destroy()
410 def discard(self):
411 self.destroy()
413 def up(self, button = None):
414 if self.uri:
415 filer.show_file(self.uri)
416 else:
417 rox.alert(_('File is not saved to disk yet'))
419 def diff(self, button = None, path = None):
420 path = path or self.uri
421 if not path:
422 rox.alert(_('This file has never been saved; nothing to compare it to!\n'
423 'Note: you can drop a file onto the toolbar button to see '
424 'the changes from that file.'))
425 return
426 diff.show_diff(path, self.save_to_stream)
428 def has_selection(self):
429 s, e = self.get_selection_range()
430 return not e.equal(s)
432 def get_marked_range(self):
433 s = self.buffer.get_iter_at_mark(self.mark_start)
434 e = self.buffer.get_iter_at_mark(self.mark_end)
435 if s.compare(e) > 0:
436 return e, s
437 return s, e
439 def get_selection_range(self):
440 s = self.buffer.get_iter_at_mark(self.insert_mark)
441 e = self.buffer.get_iter_at_mark(self.selection_bound_mark)
442 if s.compare(e) > 0:
443 return e, s
444 return s, e
446 def save(self, widget = None, discard = 0):
447 from rox.saving import SaveBox
449 if self.savebox:
450 self.savebox.destroy()
452 if self.has_selection() and not discard:
453 saver = SelectionSaver(self)
454 self.savebox = SaveBox(saver, 'Selection', 'text/plain')
455 self.savebox.connect('destroy', lambda w: saver.destroy())
456 else:
457 uri = self.uri or 'TextFile'
458 self.savebox = SaveBox(self, uri, 'text/plain', discard)
459 self.savebox.show()
461 def help(self, button = None):
462 filer.open_dir(os.path.join(rox.app_dir, 'Help'))
464 def save_to_stream(self, stream):
465 s = self.buffer.get_start_iter()
466 e = self.buffer.get_end_iter()
467 stream.write(self.buffer.get_text(s, e, TRUE))
469 def set_uri(self, uri):
470 self.uri = uri
471 self.buffer.set_modified(FALSE)
472 self.update_title()
474 def new(self):
475 EditWindow()
477 def change_font(self):
478 style = self.text.get_style().copy()
479 style.font = load_font(options.get('edit_font'))
480 self.text.set_style(style)
482 def show_options(self):
483 rox.edit_options()
485 def set_marked(self, start = None, end = None):
486 "Set the marked region (from the selection if no region is given)."
487 self.clear_marked()
488 assert not self.marked
490 buffer = self.buffer
491 if start:
492 assert end
493 else:
494 assert not end
495 start, end = self.get_selection_range()
496 buffer.move_mark(self.mark_start, start)
497 buffer.move_mark(self.mark_end, end)
498 buffer.apply_tag_by_name('marked',
499 buffer.get_iter_at_mark(self.mark_start),
500 buffer.get_iter_at_mark(self.mark_end))
501 self.marked = 1
503 def clear_marked(self):
504 if not self.marked:
505 return
506 self.marked = 0
507 buffer = self.buffer
508 buffer.remove_tag_by_name('marked',
509 buffer.get_iter_at_mark(self.mark_start),
510 buffer.get_iter_at_mark(self.mark_end))
512 def undo(self, widget = None):
513 self.buffer.undo()
515 def redo(self, widget = None):
516 self.buffer.redo()
518 def goto(self, widget = None):
519 from goto import Goto
520 self.set_minibuffer(Goto())
522 def search(self, widget = None):
523 from search import Search
524 self.set_minibuffer(Search())
526 def search_replace(self, widget = None):
527 from search import Replace
528 Replace(self).show()
530 def set_mini_label(self, label):
531 self.mini_label.set_text(label)
533 def set_minibuffer(self, minibuffer):
534 assert minibuffer is None or isinstance(minibuffer, Minibuffer)
536 self.minibuffer = None
538 if minibuffer:
539 self.mini_entry.set_text('')
540 self.minibuffer = minibuffer
541 minibuffer.setup(self)
542 self.mini_entry.grab_focus()
543 self.mini_hbox.show_all()
544 else:
545 self.mini_hbox.hide()
546 self.text.grab_focus()
548 def mini_key_press(self, entry, kev):
549 if kev.keyval == g.keysyms.Escape:
550 self.set_minibuffer(None)
551 return 1
552 if kev.keyval == g.keysyms.Return or kev.keyval == g.keysyms.KP_Enter:
553 self.minibuffer.activate()
554 return 1
556 return self.minibuffer.key_press(kev)
558 def mini_changed(self, entry):
559 if not self.minibuffer:
560 return
561 self.minibuffer.changed()
563 def mini_show_info(self, *unused):
564 assert self.minibuffer
565 if self.info_box:
566 self.info_box.destroy()
567 self.info_box = g.MessageDialog(self, 0, g.MESSAGE_INFO, g.BUTTONS_OK,
568 self.minibuffer.info)
569 self.info_box.set_title(_('Minibuffer help'))
570 def destroy(box):
571 self.info_box = None
572 self.info_box.connect('destroy', destroy)
573 self.info_box.show()
574 self.info_box.connect('response', lambda w, r: w.destroy())
576 def process_selected(self, process):
577 """Calls process(line) on each line in the selection, or each line in the file
578 if there is no selection. If the result is not None, the text is replaced."""
579 self.buffer.begin_user_action()
580 self._process_selected(process)
581 self.buffer.end_user_action()
583 def _process_selected(self, process):
584 if self.has_selection():
585 def get_end():
586 start, end = self.get_selection_range()
587 if start.compare(end) > 0:
588 return start
589 return end
590 start, end = self.get_selection_range()
591 if start.compare(end) > 0:
592 start = end
593 else:
594 def get_end():
595 return self.buffer.get_end_iter()
596 start = self.buffer.get_start_iter()
597 end = get_end()
599 while start.compare(end) <= 0:
600 line_end = start.copy()
601 line_end.forward_to_line_end()
602 if line_end.compare(end) >= 0:
603 line_end = end
604 line = self.buffer.get_text(start, line_end, False)
605 new = process(line)
606 if new is not None:
607 self.buffer.move_mark(self.mark_tmp, start)
608 self.buffer.insert(line_end, new)
609 start = self.buffer.get_iter_at_mark(self.mark_tmp)
610 line_end = start.copy()
611 line_end.forward_chars(len(line))
612 self.buffer.delete(start, line_end)
614 start = self.buffer.get_iter_at_mark(self.mark_tmp)
615 end = get_end()
616 if not start.forward_line(): break
618 class SelectionSaver(Saveable):
619 def __init__(self, window):
620 self.window = window
621 window.set_marked()
623 def save_to_stream(self, stream):
624 s, e = self.window.get_marked_range()
625 stream.write(self.window.buffer.get_text(s, e, TRUE))
627 def destroy(self):
628 # Called when savebox is remove. Get rid of the selection marker
629 self.window.clear_marked()