Icon for window.
[rox-edit.git] / EditWindow.py
blob98b1cfbcfd8f13575a65faa08f2d75542da8c809
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(rox.Window, XDSLoader, Saveable):
97 def __init__(self, filename = None):
98 rox.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 self.text = g.TextView()
111 self.text.set_buffer(self.buffer)
112 self.text.set_size_request(10, 10)
113 self.xds_proxy_for(self.text)
114 self.text.set_wrap_mode(g.WRAP_WORD)
115 self.update_styles()
117 self.insert_mark = self.buffer.get_mark('insert')
118 self.selection_bound_mark = self.buffer.get_mark('selection_bound')
119 start = self.buffer.get_start_iter()
120 self.mark_start = self.buffer.create_mark('mark_start', start, TRUE)
121 self.mark_end = self.buffer.create_mark('mark_end', start, FALSE)
122 self.mark_tmp = self.buffer.create_mark('mark_tmp', start, FALSE)
123 tag = self.buffer.create_tag('marked')
124 tag.set_property('background', 'green')
125 self.marked = 0
127 # When searching, this is where the cursor was when the minibuffer
128 # was opened.
129 start = self.buffer.get_start_iter()
130 self.search_base = self.buffer.create_mark('search_base', start, TRUE)
132 vbox = g.VBox(FALSE)
133 self.add(vbox)
135 tools = g.Toolbar()
136 tools.set_style(g.TOOLBAR_ICONS)
137 vbox.pack_start(tools, FALSE, TRUE, 0)
138 tools.show()
140 tools.insert_stock(g.STOCK_HELP, 'Help', None, self.help, None, 0)
141 tools.insert_stock(g.STOCK_REDO, 'Redo', None, self.redo, None, 0)
142 tools.insert_stock(g.STOCK_UNDO, 'Undo', None, self.undo, None, 0)
143 tools.insert_stock(g.STOCK_FIND_AND_REPLACE, 'Replace', None, self.search_replace, None, 0)
144 tools.insert_stock(g.STOCK_FIND, 'Search', None, self.search, None, 0)
145 tools.insert_stock(g.STOCK_SAVE, 'Save', None, self.save, None, 0)
146 tools.insert_stock(g.STOCK_GO_UP, 'Up', None, self.up, None, 0)
147 tools.insert_stock(g.STOCK_CLOSE, 'Close', None, self.close, None, 0)
149 swin = g.ScrolledWindow()
150 swin.set_policy(g.POLICY_NEVER, g.POLICY_AUTOMATIC)
151 vbox.pack_start(swin, True, True)
153 swin.add(self.text)
155 self.show_all()
157 # Create the minibuffer
158 self.mini_hbox = g.HBox(FALSE)
159 info = g.Button()
160 info.set_relief(g.RELIEF_NONE)
161 info.unset_flags(g.CAN_FOCUS)
162 image = g.Image()
163 image.set_from_stock(g.STOCK_DIALOG_INFO, size = g.ICON_SIZE_SMALL_TOOLBAR)
164 info.add(image)
165 info.show_all()
166 info.connect('clicked', self.mini_show_info)
168 self.mini_hbox.pack_start(info, FALSE, TRUE, 0)
169 self.mini_label = g.Label('')
170 self.mini_hbox.pack_start(self.mini_label, FALSE, TRUE, 0)
171 self.mini_entry = g.Entry()
172 self.mini_hbox.pack_start(self.mini_entry, TRUE, TRUE, 0)
173 vbox.pack_start(self.mini_hbox, FALSE, TRUE)
174 self.mini_entry.connect('key-press-event', self.mini_key_press)
175 self.mini_entry.connect('changed', self.mini_changed)
177 self.connect('destroy', self.destroyed)
179 self.connect('delete-event', self.delete_event)
180 self.text.grab_focus()
181 self.text.connect('key-press-event', self.key_press)
183 if filename:
184 import os.path
185 self.uri = os.path.abspath(filename)
186 else:
187 self.uri = None
188 self.update_title()
190 # Loading might take a while, so get something on the screen
191 # now...
192 g.gdk.flush()
194 if filename:
195 try:
196 self.load_file(filename)
197 if filename != '-':
198 self.save_mode = os.stat(filename).st_mode
199 except Abort:
200 self.destroy()
201 raise
203 self.buffer.connect('modified-changed', self.update_title)
204 self.buffer.set_modified(FALSE)
206 self.text.connect('button-press-event', self.button_press)
208 menu.attach(self, self)
209 self.buffer.place_cursor(self.buffer.get_start_iter())
210 self.buffer.start_undo_history()
212 def key_press(self, text, kev):
213 if kev.keyval != g.keysyms.Return and kev.keyval != g.keysyms.KP_Enter:
214 return
215 if not auto_indent.int_value:
216 return
217 start = self.buffer.get_iter_at_mark(self.insert_mark)
218 end = start.copy()
219 start.set_line_offset(0)
220 end.forward_to_line_end()
221 line = self.buffer.get_text(start, end, False)
222 indent = ''
223 for x in line:
224 if x in ' \t':
225 indent += x
226 else:
227 break
228 self.buffer.begin_user_action()
229 self.buffer.insert_at_cursor('\n' + indent)
230 self.buffer.end_user_action()
231 return True
233 def destroyed(self, widget):
234 app_options.remove_notify(self.update_styles)
236 def update_styles(self):
237 try:
238 import pango
239 font = pango.FontDescription(default_font.value)
240 bg = g.gdk.color_parse(background_colour.value)
241 fg = g.gdk.color_parse(foreground_colour.value)
243 self.text.set_left_margin(layout_left_margin.int_value)
244 self.text.set_right_margin(layout_right_margin.int_value)
246 self.text.set_pixels_above_lines(layout_before_para.int_value)
247 self.text.set_pixels_below_lines(layout_after_para.int_value)
248 self.text.set_pixels_inside_wrap(layout_inside_para.int_value)
249 self.text.set_indent(layout_indent_para.int_value)
250 except:
251 rox.report_exception()
252 else:
253 self.text.modify_font(font)
254 self.text.modify_base(g.STATE_NORMAL, bg)
255 self.text.modify_text(g.STATE_NORMAL, fg)
257 def cut(self): self.text.emit('cut_clipboard')
258 def copy(self): self.text.emit('copy_clipboard')
259 def paste(self): self.text.emit('paste_clipboard')
261 def button_press(self, text, event):
262 if event.button != 3:
263 return 0
264 menu.popup(self, event)
265 return 1
267 def delete_event(self, window, event):
268 if self.buffer.get_modified():
269 self.save(discard = 1)
270 return 1
271 return 0
273 def update_title(self, *unused):
274 title = self.uri or '<Untitled>'
275 if self.buffer.get_modified():
276 title = title + " *"
277 self.set_title(title)
279 def xds_load_from_stream(self, name, t, stream):
280 if t == 'UTF8_STRING':
281 return # Gtk will handle it
282 try:
283 self.insert_data(stream.read())
284 except Abort:
285 pass
287 def get_encoding(self, message):
288 "Returns (encoding, errors), or raises Abort to cancel."
289 import codecs
291 box = g.MessageDialog(self, 0, g.MESSAGE_QUESTION, g.BUTTONS_CANCEL, message)
292 box.set_has_separator(FALSE)
294 frame = g.Frame()
295 box.vbox.pack_start(frame, TRUE, TRUE)
296 frame.set_border_width(6)
298 hbox = g.HBox(FALSE, 4)
299 hbox.set_border_width(6)
301 hbox.pack_start(g.Label('Encoding:'), FALSE, TRUE, 0)
302 combo = g.Combo()
303 combo.disable_activate()
304 combo.entry.connect('activate', lambda w: box.activate_default())
305 combo.set_popdown_strings(known_codecs)
306 hbox.pack_start(combo, TRUE, TRUE, 0)
307 ignore_errors = g.CheckButton('Ignore errors')
308 hbox.pack_start(ignore_errors, FALSE, TRUE)
310 frame.add(hbox)
312 box.vbox.show_all()
313 box.add_button(g.STOCK_CONVERT, g.RESPONSE_YES)
314 box.set_default_response(g.RESPONSE_YES)
316 while 1:
317 combo.entry.grab_focus()
319 resp = box.run()
320 if resp != g.RESPONSE_YES:
321 box.destroy()
322 raise Abort
324 if ignore_errors.get_active():
325 errors = 'replace'
326 else:
327 errors = 'strict'
328 encoding = combo.entry.get_text()
329 try:
330 codecs.getdecoder(encoding)
331 break
332 except:
333 rox.alert("Unknown encoding '%s'" % encoding)
335 box.destroy()
337 return encoding, errors
339 def insert_data(self, data):
340 import codecs
341 errors = 'strict'
342 encoding = 'utf-8'
343 while 1:
344 decoder = codecs.getdecoder(encoding)
345 try:
346 data = decoder(data, errors)[0]
347 if errors == 'strict':
348 assert '\0' not in data
349 else:
350 if '\0' in data:
351 data = data.replace('\0', '\\0')
352 break
353 except:
354 pass
356 encoding, errors = self.get_encoding(
357 "Data is not valid %s. Please select the file's encoding."
358 "Turn on 'ignore errors' to try and load it anyway." % encoding)
360 self.buffer.begin_user_action()
361 self.buffer.insert_at_cursor(data)
362 self.buffer.end_user_action()
363 return 1
365 def load_file(self, path):
366 try:
367 if path == '-':
368 file = sys.stdin
369 else:
370 file = open(path, 'r')
371 contents = file.read()
372 if path != '-':
373 file.close()
374 self.insert_data(contents)
375 except Abort:
376 raise
377 except:
378 rox.report_exception()
379 raise Abort
381 def close(self, button = None):
382 if self.buffer.get_modified():
383 self.save(discard = 1)
384 else:
385 self.destroy()
387 def discard(self):
388 self.destroy()
390 def up(self, button = None):
391 if self.uri:
392 filer.show_file(self.uri)
393 else:
394 rox.alert("File is not saved to disk yet")
396 def has_selection(self):
397 s, e = self.get_selection_range()
398 return not e.equal(s)
400 def get_marked_range(self):
401 s = self.buffer.get_iter_at_mark(self.mark_start)
402 e = self.buffer.get_iter_at_mark(self.mark_end)
403 if s.compare(e) > 0:
404 return e, s
405 return s, e
407 def get_selection_range(self):
408 s = self.buffer.get_iter_at_mark(self.insert_mark)
409 e = self.buffer.get_iter_at_mark(self.selection_bound_mark)
410 if s.compare(e) > 0:
411 return e, s
412 return s, e
414 def save(self, widget = None, discard = 0):
415 from rox.saving import SaveBox
417 if self.savebox:
418 self.savebox.destroy()
420 if self.has_selection() and not discard:
421 saver = SelectionSaver(self)
422 self.savebox = SaveBox(saver, 'Selection', 'text/plain')
423 self.savebox.connect('destroy', lambda w: saver.destroy())
424 else:
425 uri = self.uri or 'TextFile'
426 self.savebox = SaveBox(self, uri, 'text/plain', discard)
427 self.savebox.show()
429 def help(self, button = None):
430 filer.open_dir(rox.app_dir + '/Help')
432 def save_to_stream(self, stream):
433 s = self.buffer.get_start_iter()
434 e = self.buffer.get_end_iter()
435 stream.write(self.buffer.get_text(s, e, TRUE))
437 def set_uri(self, uri):
438 self.uri = uri
439 self.buffer.set_modified(FALSE)
440 self.update_title()
442 def new(self):
443 EditWindow()
445 def change_font(self):
446 style = self.text.get_style().copy()
447 style.font = load_font(options.get('edit_font'))
448 self.text.set_style(style)
450 def show_options(self):
451 rox.edit_options()
453 def set_marked(self, start = None, end = None):
454 "Set the marked region (from the selection if no region is given)."
455 self.clear_marked()
456 assert not self.marked
458 buffer = self.buffer
459 if start:
460 assert end
461 else:
462 assert not end
463 start, end = self.get_selection_range()
464 buffer.move_mark(self.mark_start, start)
465 buffer.move_mark(self.mark_end, end)
466 buffer.apply_tag_by_name('marked',
467 buffer.get_iter_at_mark(self.mark_start),
468 buffer.get_iter_at_mark(self.mark_end))
469 self.marked = 1
471 def clear_marked(self):
472 if not self.marked:
473 return
474 self.marked = 0
475 buffer = self.buffer
476 buffer.remove_tag_by_name('marked',
477 buffer.get_iter_at_mark(self.mark_start),
478 buffer.get_iter_at_mark(self.mark_end))
480 def undo(self, widget = None):
481 self.buffer.undo()
483 def redo(self, widget = None):
484 self.buffer.redo()
486 def goto(self, widget = None):
487 from goto import Goto
488 self.set_minibuffer(Goto())
490 def search(self, widget = None):
491 from search import Search
492 self.set_minibuffer(Search())
494 def search_replace(self, widget = None):
495 from search import Replace
496 Replace(self).show()
498 def set_mini_label(self, label):
499 self.mini_label.set_text(label)
501 def set_minibuffer(self, minibuffer):
502 assert minibuffer is None or isinstance(minibuffer, Minibuffer)
504 self.minibuffer = None
506 if minibuffer:
507 self.mini_entry.set_text('')
508 self.minibuffer = minibuffer
509 minibuffer.setup(self)
510 self.mini_entry.grab_focus()
511 self.mini_hbox.show_all()
512 else:
513 self.mini_hbox.hide()
514 self.text.grab_focus()
516 def mini_key_press(self, entry, kev):
517 if kev.keyval == g.keysyms.Escape:
518 self.set_minibuffer(None)
519 return 1
520 if kev.keyval == g.keysyms.Return or kev.keyval == g.keysyms.KP_Enter:
521 self.minibuffer.activate()
522 return 1
524 return self.minibuffer.key_press(kev)
526 def mini_changed(self, entry):
527 if not self.minibuffer:
528 return
529 self.minibuffer.changed()
531 def mini_show_info(self, *unused):
532 assert self.minibuffer
533 if self.info_box:
534 self.info_box.destroy()
535 self.info_box = g.MessageDialog(self, 0, g.MESSAGE_INFO, g.BUTTONS_OK,
536 self.minibuffer.info)
537 self.info_box.set_title('Minibuffer help')
538 def destroy(box):
539 self.info_box = None
540 self.info_box.connect('destroy', destroy)
541 self.info_box.show()
542 self.info_box.connect('response', lambda w, r: w.destroy())
544 def process_selected(self, process):
545 """Calls process(line) on each line in the selection, or each line in the file
546 if there is no selection. If the result is not None, the text is replaced."""
547 self.buffer.begin_user_action()
548 self._process_selected(process)
549 self.buffer.end_user_action()
551 def _process_selected(self, process):
552 if self.has_selection():
553 def get_end():
554 start, end = self.get_selection_range()
555 if start.compare(end) > 0:
556 return start
557 return end
558 start, end = self.get_selection_range()
559 if start.compare(end) > 0:
560 start = end
561 else:
562 def get_end():
563 return self.buffer.get_end_iter()
564 start = self.buffer.get_start_iter()
565 end = get_end()
567 while start.compare(end) <= 0:
568 line_end = start.copy()
569 line_end.forward_to_line_end()
570 if line_end.compare(end) >= 0:
571 line_end = end
572 line = self.buffer.get_text(start, line_end, False)
573 new = process(line)
574 if new is not None:
575 self.buffer.move_mark(self.mark_tmp, start)
576 self.buffer.insert(line_end, new)
577 start = self.buffer.get_iter_at_mark(self.mark_tmp)
578 line_end = start.copy()
579 line_end.forward_chars(len(line))
580 self.buffer.delete(start, line_end)
582 start = self.buffer.get_iter_at_mark(self.mark_tmp)
583 end = get_end()
584 if not start.forward_line(): break
586 class SelectionSaver(Saveable):
587 def __init__(self, window):
588 self.window = window
589 window.set_marked()
591 def save_to_stream(self, stream):
592 s, e = self.window.get_marked_range()
593 stream.write(self.window.buffer.get_text(s, e, TRUE))
595 def destroy(self):
596 # Called when savebox is remove. Get rid of the selection marker
597 self.window.clear_marked()