Cope slightly better with binary data.
[rox-edit.git] / EditWindow.py
blobb485b53a1f68e12f30443f6638dd756ee20182b8
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
9 FALSE = g.FALSE
10 TRUE = g.TRUE
12 from rox.Menu import Menu, set_save_name
14 default_font = Option('default_font', 'serif')
16 background_colour = Option('background', '#fff')
17 foreground_colour = Option('foreground', '#000')
19 auto_indent = Option('autoindent', '1')
21 layout_left_margin = Option('layout_left_margin', 2)
22 layout_right_margin = Option('layout_right_margin', 4)
24 layout_before_para = Option('layout_before_para', 0)
25 layout_after_para = Option('layout_after_para', 0)
26 layout_inside_para = Option('layout_inside_para', 0)
27 layout_indent_para = Option('layout_indent_para', 2)
29 set_save_name('Edit')
31 try:
32 menu = Menu('main', [
33 ('/File', '', '<Branch>'),
34 ('/File/Save', 'save', '<StockItem>', '<Ctrl>S', g.STOCK_SAVE),
35 ('/File/Open Parent', 'up', '<StockItem>', '', g.STOCK_GO_UP),
36 ('/File/Close', 'close', '<StockItem>', '', g.STOCK_CLOSE),
37 ('/File/', '', '<Separator>'),
38 ('/File/New', 'new', '<StockItem>', '', g.STOCK_NEW),
40 ('/Edit', '', '<Branch>'),
41 ('/Edit/Cut', 'cut', '<StockItem>', '<Ctrl>X', g.STOCK_CUT),
42 ('/Edit/Copy', 'copy', '<StockItem>', '<Ctrl>C', g.STOCK_COPY),
43 ('/Edit/Pase', 'paste', '<StockItem>', '<Ctrl>V', g.STOCK_PASTE),
44 ('/Edit/', '', '<Separator>'),
45 ('/Edit/Undo', 'undo', '<StockItem>', '<Ctrl>Z', g.STOCK_UNDO),
46 ('/Edit/Redo', 'redo', '<StockItem>', '<Ctrl>Y', g.STOCK_REDO),
47 ('/Edit/', '', '<Separator>'),
48 ('/Edit/Search...', 'search', '<StockItem>', 'F4', g.STOCK_FIND),
49 ('/Edit/Search and Replace....', 'search_replace',
50 '<StockItem>', '<Ctrl>F4', g.STOCK_FIND_AND_REPLACE),
51 ('/Edit/Goto line...', 'goto', '<StockItem>', 'F5', g.STOCK_JUMP_TO),
53 ('/Options', 'show_options', '<StockItem>', '', g.STOCK_PROPERTIES),
54 ('/Help', 'help', '<StockItem>', 'F1', g.STOCK_HELP),
56 except ValueError:
57 rox.croak('Edit requires ROX-Lib2 1.9.8 or later')
59 known_codecs = (
60 "iso8859_1", "iso8859_2", "iso8859_3", "iso8859_4", "iso8859_5",
61 "iso8859_6", "iso8859_7", "iso8859_8", "iso8859_9", "iso8859_10",
62 "iso8859_13", "iso8859_14", "iso8859_15",
63 "ascii", "base64_codec", "charmap",
64 "cp037", "cp1006", "cp1026", "cp1140", "cp1250", "cp1251", "cp1252",
65 "cp1253", "cp1254", "cp1255", "cp1256", "cp1257", "cp1258", "cp424",
66 "cp437", "cp500", "cp737", "cp775", "cp850", "cp852", "cp855", "cp856",
67 "cp857", "cp860", "cp861", "cp862", "cp863", "cp864", "cp865", "cp866",
68 "cp869", "cp874", "cp875", "hex_codec",
69 "koi8_r",
70 "latin_1",
71 "mac_cyrillic", "mac_greek", "mac_iceland", "mac_latin2", "mac_roman", "mac_turkish",
72 "mbcs", "quopri_codec", "raw_unicode_escape",
73 "rot_13",
74 "utf_16_be", "utf_16_le", "utf_16", "utf_7", "utf_8", "uu_codec",
75 "zlib_codec"
78 class Abort(Exception):
79 pass
81 class Minibuffer:
82 def setup(self):
83 """Called when the minibuffer is opened."""
85 def key_press(self, kev):
86 """A keypress event in the minibuffer text entry."""
88 def changed(self):
89 """The minibuffer text has changed."""
91 def activate(self):
92 """Return or Enter pressed."""
94 info = 'Press Escape to close the minibuffer.'
96 class EditWindow(g.Window, XDSLoader, Saveable):
97 def __init__(self, filename = None):
98 g.Window.__init__(self)
99 XDSLoader.__init__(self, ['text/plain', 'UTF8_STRING'])
100 self.set_default_size(g.gdk.screen_width() * 2 / 3,
101 g.gdk.screen_height() / 2)
103 self.savebox = None
104 self.info_box = None
106 app_options.add_notify(self.update_styles)
108 self.buffer = Buffer()
110 scrollbar = g.VScrollbar()
111 self.v_adj = scrollbar.get_adjustment()
112 self.text = g.TextView()
113 self.text.set_buffer(self.buffer)
114 self.text.set_scroll_adjustments(None, self.v_adj)
115 self.text.set_size_request(10, 10)
116 self.xds_proxy_for(self.text)
117 self.text.set_wrap_mode(g.WRAP_WORD)
118 self.update_styles()
120 self.insert_mark = self.buffer.get_mark('insert')
121 self.selection_bound_mark = self.buffer.get_mark('selection_bound')
122 start = self.buffer.get_start_iter()
123 self.mark_start = self.buffer.create_mark('mark_start', start, TRUE)
124 self.mark_end = self.buffer.create_mark('mark_end', start, FALSE)
125 self.mark_tmp = self.buffer.create_mark('mark_tmp', start, FALSE)
126 tag = self.buffer.create_tag('marked')
127 tag.set_property('background', 'green')
128 self.marked = 0
130 # When searching, this is where the cursor was when the minibuffer
131 # was opened.
132 start = self.buffer.get_start_iter()
133 self.search_base = self.buffer.create_mark('search_base', start, TRUE)
135 vbox = g.VBox(FALSE)
136 self.add(vbox)
138 tools = g.Toolbar()
139 tools.set_style(g.TOOLBAR_ICONS)
140 vbox.pack_start(tools, FALSE, TRUE, 0)
141 tools.show()
143 tools.insert_stock(g.STOCK_HELP, 'Help', None, self.help, None, 0)
144 tools.insert_stock(g.STOCK_REDO, 'Redo', None, self.redo, None, 0)
145 tools.insert_stock(g.STOCK_UNDO, 'Undo', None, self.undo, None, 0)
146 tools.insert_stock(g.STOCK_FIND_AND_REPLACE, 'Replace', None, self.search_replace, None, 0)
147 tools.insert_stock(g.STOCK_FIND, 'Search', None, self.search, None, 0)
148 tools.insert_stock(g.STOCK_SAVE, 'Save', None, self.save, None, 0)
149 tools.insert_stock(g.STOCK_GO_UP, 'Up', None, self.up, None, 0)
150 tools.insert_stock(g.STOCK_CLOSE, 'Close', None, self.close, None, 0)
152 hbox = g.HBox(FALSE) # View + Minibuffer + Scrollbar
153 vbox.pack_start(hbox, TRUE, TRUE)
155 inner_vbox = g.VBox(FALSE) # View + Minibuffer
156 hbox.pack_start(inner_vbox, TRUE, TRUE)
157 inner_vbox.pack_start(self.text, TRUE, TRUE)
158 hbox.pack_start(scrollbar, FALSE, TRUE)
160 self.show_all()
162 # Create the minibuffer
163 self.mini_hbox = g.HBox(FALSE)
164 info = g.Button()
165 info.set_relief(g.RELIEF_NONE)
166 info.unset_flags(g.CAN_FOCUS)
167 image = g.Image()
168 image.set_from_stock(g.STOCK_DIALOG_INFO, size = g.ICON_SIZE_SMALL_TOOLBAR)
169 info.add(image)
170 info.show_all()
171 info.connect('clicked', self.mini_show_info)
173 self.mini_hbox.pack_start(info, FALSE, TRUE, 0)
174 self.mini_label = g.Label('')
175 self.mini_hbox.pack_start(self.mini_label, FALSE, TRUE, 0)
176 self.mini_entry = g.Entry()
177 self.mini_hbox.pack_start(self.mini_entry, TRUE, TRUE, 0)
178 inner_vbox.pack_start(self.mini_hbox, FALSE, TRUE)
179 self.mini_entry.connect('key-press-event', self.mini_key_press)
180 self.mini_entry.connect('changed', self.mini_changed)
182 rox.toplevel_ref()
183 self.connect('destroy', self.destroyed)
185 self.connect('delete-event', self.delete_event)
186 self.text.grab_focus()
187 self.text.connect('key-press-event', self.key_press)
189 if filename:
190 import os.path
191 self.uri = os.path.abspath(filename)
192 else:
193 self.uri = None
194 self.update_title()
196 # Loading might take a while, so get something on the screen
197 # now...
198 g.gdk.flush()
200 if filename:
201 try:
202 self.load_file(filename)
203 if filename != '-':
204 self.save_mode = os.stat(filename).st_mode
205 except Abort:
206 self.destroy()
207 raise
209 self.buffer.connect('modified-changed', self.update_title)
210 self.buffer.set_modified(FALSE)
212 self.text.connect('button-press-event', self.button_press)
213 self.text.connect('scroll-event', self.scroll_event)
215 menu.attach(self, self)
216 self.buffer.place_cursor(self.buffer.get_start_iter())
217 self.buffer.start_undo_history()
219 def key_press(self, text, kev):
220 if kev.keyval != g.keysyms.Return and kev.keyval != g.keysyms.KP_Enter:
221 return
222 if not auto_indent.int_value:
223 return
224 start = self.buffer.get_iter_at_mark(self.insert_mark)
225 end = start.copy()
226 start.set_line_offset(0)
227 end.forward_to_line_end()
228 line = self.buffer.get_text(start, end, False)
229 indent = ''
230 for x in line:
231 if x in ' \t':
232 indent += x
233 else:
234 break
235 self.buffer.begin_user_action()
236 self.buffer.insert_at_cursor('\n' + indent)
237 self.buffer.end_user_action()
238 return True
240 def destroyed(self, widget):
241 app_options.remove_notify(self.update_styles)
242 rox.toplevel_unref()
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 button_press(self, text, event):
270 if event.button != 3:
271 return 0
272 menu.popup(self, event)
273 return 1
275 def scroll_event(self, text, event):
276 dir = event.direction
277 new = self.v_adj.value
278 if dir == g.gdk.SCROLL_UP:
279 new -= 32
280 elif dir == g.gdk.SCROLL_DOWN:
281 new += 32
282 self.v_adj.set_value(new)
284 def delete_event(self, window, event):
285 if self.buffer.get_modified():
286 self.save(discard = 1)
287 return 1
288 return 0
290 def update_title(self, *unused):
291 title = self.uri or '<Untitled>'
292 if self.buffer.get_modified():
293 title = title + " *"
294 self.set_title(title)
296 def xds_load_from_stream(self, name, t, stream):
297 if t == 'UTF8_STRING':
298 return # Gtk will handle it
299 try:
300 self.insert_data(stream.read())
301 except Abort:
302 pass
304 def get_encoding(self, message):
305 "Returns (encoding, errors), or raises Abort to cancel."
306 import codecs
308 box = g.MessageDialog(self, 0, g.MESSAGE_QUESTION, g.BUTTONS_CANCEL, message)
309 box.set_has_separator(FALSE)
311 frame = g.Frame()
312 box.vbox.pack_start(frame, TRUE, TRUE)
313 frame.set_border_width(6)
315 hbox = g.HBox(FALSE, 4)
316 hbox.set_border_width(6)
318 hbox.pack_start(g.Label('Encoding:'), FALSE, TRUE, 0)
319 combo = g.Combo()
320 combo.disable_activate()
321 combo.entry.connect('activate', lambda w: box.activate_default())
322 combo.set_popdown_strings(known_codecs)
323 hbox.pack_start(combo, TRUE, TRUE, 0)
324 ignore_errors = g.CheckButton('Ignore errors')
325 hbox.pack_start(ignore_errors, FALSE, TRUE)
327 frame.add(hbox)
329 box.vbox.show_all()
330 box.add_button(g.STOCK_CONVERT, g.RESPONSE_YES)
331 box.set_default_response(g.RESPONSE_YES)
333 while 1:
334 combo.entry.grab_focus()
336 resp = box.run()
337 if resp != g.RESPONSE_YES:
338 box.destroy()
339 raise Abort
341 if ignore_errors.get_active():
342 errors = 'replace'
343 else:
344 errors = 'strict'
345 encoding = combo.entry.get_text()
346 try:
347 codecs.getdecoder(encoding)
348 break
349 except:
350 rox.alert("Unknown encoding '%s'" % encoding)
352 box.destroy()
354 return encoding, errors
356 def insert_data(self, data):
357 import codecs
358 errors = 'strict'
359 encoding = 'utf-8'
360 while 1:
361 decoder = codecs.getdecoder(encoding)
362 try:
363 data = decoder(data, errors)[0]
364 if errors == 'strict':
365 assert '\0' not in data
366 else:
367 if '\0' in data:
368 data = data.replace('\0', '\\0')
369 break
370 except:
371 pass
373 encoding, errors = self.get_encoding(
374 "Data is not valid %s. Please select the file's encoding."
375 "Turn on 'ignore errors' to try and load it anyway." % encoding)
377 self.buffer.begin_user_action()
378 self.buffer.insert_at_cursor(data)
379 self.buffer.end_user_action()
380 return 1
382 def load_file(self, path):
383 try:
384 if path == '-':
385 file = sys.stdin
386 else:
387 file = open(path, 'r')
388 contents = file.read()
389 if path != '-':
390 file.close()
391 self.insert_data(contents)
392 except Abort:
393 raise
394 except:
395 rox.report_exception()
396 raise Abort
398 def close(self, button = None):
399 if self.buffer.get_modified():
400 self.save(discard = 1)
401 else:
402 self.destroy()
404 def discard(self):
405 self.destroy()
407 def up(self, button = None):
408 if self.uri:
409 filer.show_file(self.uri)
410 else:
411 rox.alert("File is not saved to disk yet")
413 def has_selection(self):
414 s, e = self.get_selection_range()
415 return not e.equal(s)
417 def get_marked_range(self):
418 s = self.buffer.get_iter_at_mark(self.mark_start)
419 e = self.buffer.get_iter_at_mark(self.mark_end)
420 if s.compare(e) > 0:
421 return e, s
422 return s, e
424 def get_selection_range(self):
425 s = self.buffer.get_iter_at_mark(self.insert_mark)
426 e = self.buffer.get_iter_at_mark(self.selection_bound_mark)
427 if s.compare(e) > 0:
428 return e, s
429 return s, e
431 def save(self, widget = None, discard = 0):
432 from rox.saving import SaveBox
434 if self.savebox:
435 self.savebox.destroy()
437 if self.has_selection() and not discard:
438 saver = SelectionSaver(self)
439 self.savebox = SaveBox(saver, 'Selection', 'text/plain')
440 self.savebox.connect('destroy', lambda w: saver.destroy())
441 else:
442 uri = self.uri or 'TextFile'
443 self.savebox = SaveBox(self, uri, 'text/plain', discard)
444 self.savebox.show()
446 def help(self, button = None):
447 filer.open_dir(rox.app_dir + '/Help')
449 def save_to_stream(self, stream):
450 s = self.buffer.get_start_iter()
451 e = self.buffer.get_end_iter()
452 stream.write(self.buffer.get_text(s, e, TRUE))
454 def set_uri(self, uri):
455 self.uri = uri
456 self.buffer.set_modified(FALSE)
457 self.update_title()
459 def new(self):
460 EditWindow()
462 def change_font(self):
463 style = self.text.get_style().copy()
464 style.font = load_font(options.get('edit_font'))
465 self.text.set_style(style)
467 def show_options(self):
468 rox.edit_options()
470 def set_marked(self, start = None, end = None):
471 "Set the marked region (from the selection if no region is given)."
472 self.clear_marked()
473 assert not self.marked
475 buffer = self.buffer
476 if start:
477 assert end
478 else:
479 assert not end
480 start, end = self.get_selection_range()
481 buffer.move_mark(self.mark_start, start)
482 buffer.move_mark(self.mark_end, end)
483 buffer.apply_tag_by_name('marked',
484 buffer.get_iter_at_mark(self.mark_start),
485 buffer.get_iter_at_mark(self.mark_end))
486 self.marked = 1
488 def clear_marked(self):
489 if not self.marked:
490 return
491 self.marked = 0
492 buffer = self.buffer
493 buffer.remove_tag_by_name('marked',
494 buffer.get_iter_at_mark(self.mark_start),
495 buffer.get_iter_at_mark(self.mark_end))
497 def undo(self, widget = None):
498 self.buffer.undo()
500 def redo(self, widget = None):
501 self.buffer.redo()
503 def goto(self, widget = None):
504 from goto import Goto
505 self.set_minibuffer(Goto())
507 def search(self, widget = None):
508 from search import Search
509 self.set_minibuffer(Search())
511 def search_replace(self, widget = None):
512 from search import Replace
513 Replace(self).show()
515 def set_mini_label(self, label):
516 self.mini_label.set_text(label)
518 def set_minibuffer(self, minibuffer):
519 assert minibuffer is None or isinstance(minibuffer, Minibuffer)
521 self.minibuffer = None
523 if minibuffer:
524 self.mini_entry.set_text('')
525 self.minibuffer = minibuffer
526 minibuffer.setup(self)
527 self.mini_entry.grab_focus()
528 self.mini_hbox.show_all()
529 else:
530 self.mini_hbox.hide()
531 self.text.grab_focus()
533 def mini_key_press(self, entry, kev):
534 if kev.keyval == g.keysyms.Escape:
535 self.set_minibuffer(None)
536 return 1
537 if kev.keyval == g.keysyms.Return or kev.keyval == g.keysyms.KP_Enter:
538 self.minibuffer.activate()
539 return 1
541 return self.minibuffer.key_press(kev)
543 def mini_changed(self, entry):
544 if not self.minibuffer:
545 return
546 self.minibuffer.changed()
548 def mini_show_info(self, *unused):
549 assert self.minibuffer
550 if self.info_box:
551 self.info_box.destroy()
552 self.info_box = g.MessageDialog(self, 0, g.MESSAGE_INFO, g.BUTTONS_OK,
553 self.minibuffer.info)
554 self.info_box.set_title('Minibuffer help')
555 def destroy(box):
556 self.info_box = None
557 self.info_box.connect('destroy', destroy)
558 self.info_box.show()
559 self.info_box.connect('response', lambda w, r: w.destroy())
561 def process_selected(self, process):
562 """Calls process(line) on each line in the selection, or each line in the file
563 if there is no selection. If the result is not None, the text is replaced."""
564 self.buffer.begin_user_action()
565 self._process_selected(process)
566 self.buffer.end_user_action()
568 def _process_selected(self, process):
569 if self.has_selection():
570 def get_end():
571 start, end = self.get_selection_range()
572 if start.compare(end) > 0:
573 return start
574 return end
575 start, end = self.get_selection_range()
576 if start.compare(end) > 0:
577 start = end
578 else:
579 def get_end():
580 return self.buffer.get_end_iter()
581 start = self.buffer.get_start_iter()
582 end = get_end()
584 while start.compare(end) <= 0:
585 line_end = start.copy()
586 line_end.forward_to_line_end()
587 if line_end.compare(end) >= 0:
588 line_end = end
589 line = self.buffer.get_text(start, line_end, False)
590 new = process(line)
591 if new is not None:
592 self.buffer.move_mark(self.mark_tmp, start)
593 self.buffer.insert(line_end, new)
594 start = self.buffer.get_iter_at_mark(self.mark_tmp)
595 line_end = start.copy()
596 line_end.forward_chars(len(line))
597 self.buffer.delete(start, line_end)
599 start = self.buffer.get_iter_at_mark(self.mark_tmp)
600 end = get_end()
601 if not start.forward_line(): break
603 class SelectionSaver(Saveable):
604 def __init__(self, window):
605 self.window = window
606 window.set_marked()
608 def save_to_stream(self, stream):
609 s, e = self.window.get_marked_range()
610 stream.write(self.window.buffer.get_text(s, e, TRUE))
612 def destroy(self):
613 # Called when savebox is remove. Get rid of the selection marker
614 self.window.clear_marked()