1# Copyright 2008-2021 Jaap Karssenberg <jaap.karssenberg@gmail.com> 2 3'''This module contains the main text editor widget. 4It includes all classes needed to display and edit a single page as well 5as related dialogs like the dialogs to insert images, links etc. 6 7The main widget accessed by the rest of the application is the 8L{PageView} class. This wraps a L{TextView} widget which actually 9shows the page. The L{TextBuffer} class is the data model used by the 10L{TextView}. 11 12@todo: for documentation group functions in more logical order 13''' 14 15 16 17import logging 18 19from gi.repository import GObject 20from gi.repository import GLib 21from gi.repository import Gtk 22from gi.repository import Gdk 23from gi.repository import GdkPixbuf 24from gi.repository import Pango 25 26import re 27import string 28import weakref 29import functools 30import zim.datetimetz as datetime 31 32import zim.formats 33import zim.errors 34 35from zim.fs import File, Dir, normalize_file_uris, FilePath, adapt_from_newfs 36from zim.errors import Error 37from zim.config import String, Float, Integer, Boolean, Choice, ConfigManager 38from zim.notebook import Path, interwiki_link, HRef, PageNotFoundError 39from zim.notebook.operations import NotebookState, ongoing_operation 40from zim.parsing import link_type, Re 41from zim.formats import heading_to_anchor, get_format, increase_list_iter, \ 42 ParseTree, ElementTreeModule, OldParseTreeBuilder, \ 43 BULLET, CHECKED_BOX, UNCHECKED_BOX, XCHECKED_BOX, TRANSMIGRATED_BOX, MIGRATED_BOX, LINE, OBJECT, \ 44 HEADING, LISTITEM, BLOCK_LEVEL 45from zim.formats.wiki import url_re, match_url 46from zim.actions import get_gtk_actiongroup, action, toggle_action, get_actions, \ 47 ActionClassMethod, ToggleActionClassMethod, initialize_actiongroup 48from zim.gui.widgets import \ 49 Dialog, FileDialog, QuestionDialog, ErrorDialog, \ 50 IconButton, MenuButton, BrowserTreeView, InputEntry, \ 51 ScrolledWindow, \ 52 rotate_pixbuf, populate_popup_add_separator, strip_boolean_result, \ 53 widget_set_css 54from zim.gui.applications import OpenWithMenu, open_url, open_file, edit_config_file 55from zim.gui.clipboard import Clipboard, SelectionClipboard, \ 56 textbuffer_register_serialize_formats 57from zim.gui.insertedobjects import \ 58 InsertedObjectWidget, UnknownInsertedObject, UnknownInsertedImageObject, \ 59 POSITION_BEGIN, POSITION_END 60from zim.signals import callback 61from zim.formats import get_dumper 62from zim.formats.wiki import Dumper as WikiDumper 63from zim.plugins import PluginManager 64 65from .editbar import EditBar 66 67 68logger = logging.getLogger('zim.gui.pageview') 69 70 71MAX_PAGES_UNDO_STACK = 10 #: Keep this many pages in a queue to keep ref and thus undostack alive 72 73 74class LineSeparator(InsertedObjectWidget): 75 '''Class to create a separation line.''' 76 77 def __init__(self): 78 InsertedObjectWidget.__init__(self) 79 widget = Gtk.Box() 80 widget.get_style_context().add_class(Gtk.STYLE_CLASS_BACKGROUND) 81 widget.set_size_request(-1, 3) 82 self.add(widget) 83 84 85def is_line(line): 86 '''Function used for line autoformatting.''' 87 length = len(line) 88 return (line == '-' * length) and (length >= 3) 89 90 91STOCK_CHECKED_BOX = 'zim-checked-box' 92STOCK_UNCHECKED_BOX = 'zim-unchecked-box' 93STOCK_XCHECKED_BOX = 'zim-xchecked-box' 94STOCK_MIGRATED_BOX = 'zim-migrated-box' 95STOCK_TRANSMIGRATED_BOX = 'zim-transmigrated-box' 96 97bullet_types = { 98 CHECKED_BOX: STOCK_CHECKED_BOX, 99 UNCHECKED_BOX: STOCK_UNCHECKED_BOX, 100 XCHECKED_BOX: STOCK_XCHECKED_BOX, 101 MIGRATED_BOX: STOCK_MIGRATED_BOX, 102 TRANSMIGRATED_BOX: STOCK_TRANSMIGRATED_BOX, 103} 104 105# reverse dict 106bullets = {} 107for bullet in bullet_types: 108 bullets[bullet_types[bullet]] = bullet 109 110autoformat_bullets = { 111 '*': BULLET, 112 '[]': UNCHECKED_BOX, 113 '[ ]': UNCHECKED_BOX, 114 '[*]': CHECKED_BOX, 115 '[x]': XCHECKED_BOX, 116 '[>]': MIGRATED_BOX, 117 '[<]': TRANSMIGRATED_BOX, 118 '()': UNCHECKED_BOX, 119 '( )': UNCHECKED_BOX, 120 '(*)': CHECKED_BOX, 121 '(x)': XCHECKED_BOX, 122 '(>)': MIGRATED_BOX, 123 '(<)': TRANSMIGRATED_BOX, 124} 125 126BULLETS = (BULLET, UNCHECKED_BOX, CHECKED_BOX, XCHECKED_BOX, MIGRATED_BOX, TRANSMIGRATED_BOX) 127CHECKBOXES = (UNCHECKED_BOX, CHECKED_BOX, XCHECKED_BOX, MIGRATED_BOX, TRANSMIGRATED_BOX) 128 129NUMBER_BULLET = '#.' # Special case for autonumbering 130is_numbered_bullet_re = re.compile('^(\d+|\w|#)\.$') 131 #: This regular expression is used to test whether a bullet belongs to a numbered list or not 132 133# Check the (undocumented) list of constants in Gtk.keysyms to see all names 134KEYVALS_HOME = list(map(Gdk.keyval_from_name, ('Home', 'KP_Home'))) 135KEYVALS_ENTER = list(map(Gdk.keyval_from_name, ('Return', 'KP_Enter', 'ISO_Enter'))) 136KEYVALS_BACKSPACE = list(map(Gdk.keyval_from_name, ('BackSpace',))) 137KEYVALS_TAB = list(map(Gdk.keyval_from_name, ('Tab', 'KP_Tab'))) 138KEYVALS_LEFT_TAB = list(map(Gdk.keyval_from_name, ('ISO_Left_Tab',))) 139 140# ~ CHARS_END_OF_WORD = (' ', ')', '>', '.', '!', '?') 141CHARS_END_OF_WORD = ('\t', ' ', ')', '>', ';') 142KEYVALS_END_OF_WORD = list(map( 143 Gdk.unicode_to_keyval, list(map(ord, CHARS_END_OF_WORD)))) + KEYVALS_TAB 144 145KEYVALS_ASTERISK = ( 146 Gdk.unicode_to_keyval(ord('*')), Gdk.keyval_from_name('KP_Multiply')) 147KEYVALS_SLASH = ( 148 Gdk.unicode_to_keyval(ord('/')), Gdk.keyval_from_name('KP_Divide')) 149KEYVALS_GT = (Gdk.unicode_to_keyval(ord('>')),) 150KEYVALS_SPACE = (Gdk.unicode_to_keyval(ord(' ')),) 151 152KEYVAL_ESC = Gdk.keyval_from_name('Escape') 153KEYVAL_POUND = Gdk.unicode_to_keyval(ord('#')) 154 155# States that influence keybindings - we use this to explicitly 156# exclude other states. E.g. MOD2_MASK seems to be set when either 157# numlock or fn keys are active, resulting in keybindings failing 158KEYSTATES = Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.META_MASK | Gdk.ModifierType.SHIFT_MASK | Gdk.ModifierType.MOD1_MASK 159 160MENU_ACTIONS = ( 161 # name, stock id, label 162 ('insert_new_file_menu', None, _('New _Attachment')), # T: Menu title 163) 164 165COPY_FORMATS = zim.formats.list_formats(zim.formats.TEXT_FORMAT) 166 167ui_preferences = ( 168 # key, type, category, label, default 169 ('show_edit_bar', 'bool', 'Interface', 170 _('Show edit bar along bottom of editor'), True), 171 # T: option in preferences dialog 172 ('follow_on_enter', 'bool', 'Interface', 173 _('Use the <Enter> key to follow links\n(If disabled you can still use <Alt><Enter>)'), True), 174 # T: option in preferences dialog 175 ('read_only_cursor', 'bool', 'Interface', 176 _('Show the cursor also for pages that can not be edited'), False), 177 # T: option in preferences dialog 178 ('autolink_camelcase', 'bool', 'Editing', 179 _('Automatically turn "CamelCase" words into links'), True), 180 # T: option in preferences dialog 181 ('autolink_files', 'bool', 'Editing', 182 _('Automatically turn file paths into links'), True), 183 # T: option in preferences dialog 184 ('autoselect', 'bool', 'Editing', 185 _('Automatically select the current word when you apply formatting'), True), 186 # T: option in preferences dialog 187 ('unindent_on_backspace', 'bool', 'Editing', 188 _('Unindent on <BackSpace>\n(If disabled you can still use <Shift><Tab>)'), True), 189 # T: option in preferences dialog 190 ('cycle_checkbox_type', 'bool', 'Editing', 191 _('Repeated clicking a checkbox cycles through the checkbox states'), True), 192 # T: option in preferences dialog 193 ('recursive_indentlist', 'bool', 'Editing', 194 _('(Un-)indenting a list item also changes any sub-items'), True), 195 # T: option in preferences dialog 196 ('recursive_checklist', 'bool', 'Editing', 197 _('Checking a checkbox also changes any sub-items'), False), 198 # T: option in preferences dialog 199 ('auto_reformat', 'bool', 'Editing', 200 _('Reformat wiki markup on the fly'), False), 201 # T: option in preferences dialog 202 ('copy_format', 'choice', 'Editing', 203 _('Default format for copying text to the clipboard'), 'Text', COPY_FORMATS), 204 # T: option in preferences dialog 205 ('file_templates_folder', 'dir', 'Editing', 206 _('Folder with templates for attachment files'), '~/Templates'), 207 # T: option in preferences dialog 208) 209 210_is_zim_tag = lambda tag: hasattr(tag, 'zim_type') 211_is_indent_tag = lambda tag: _is_zim_tag(tag) and tag.zim_type == 'indent' 212_is_not_indent_tag = lambda tag: _is_zim_tag(tag) and tag.zim_type != 'indent' 213_is_heading_tag = lambda tag: hasattr(tag, 'zim_tag') and tag.zim_tag == 'h' 214_is_pre_tag = lambda tag: hasattr(tag, 'zim_tag') and tag.zim_tag == 'pre' 215_is_pre_or_code_tag = lambda tag: hasattr(tag, 'zim_tag') and tag.zim_tag in ('pre', 'code') 216_is_line_based_tag = lambda tag: _is_indent_tag(tag) or _is_heading_tag(tag) or _is_pre_tag(tag) 217_is_not_line_based_tag = lambda tag: not _is_line_based_tag(tag) 218_is_style_tag = lambda tag: _is_zim_tag(tag) and tag.zim_type == 'style' 219_is_not_style_tag = lambda tag: not (_is_zim_tag(tag) and tag.zim_type == 'style') 220_is_link_tag = lambda tag: _is_zim_tag(tag) and tag.zim_type == 'link' 221_is_not_link_tag = lambda tag: not (_is_zim_tag(tag) and tag.zim_type == 'link') 222_is_tag_tag = lambda tag: _is_zim_tag(tag) and tag.zim_type == 'tag' 223_is_not_tag_tag = lambda tag: not (_is_zim_tag(tag) and tag.zim_type == 'tag') 224_is_inline_nesting_tag = lambda tag: _is_zim_tag(tag) and tag.zim_tag in TextBuffer._nesting_style_tags or tag.zim_type == 'link' 225_is_non_nesting_tag = lambda tag: hasattr(tag, 'zim_tag') and tag.zim_tag in ('pre', 'code', 'tag') 226_is_link_tag_without_href = lambda tag: _is_link_tag(tag) and not tag.zim_attrib['href'] 227 228PIXBUF_CHR = '\uFFFC' 229 230# Minimal distance from mark to window border after scroll_to_mark() 231SCROLL_TO_MARK_MARGIN = 0.2 232 233# Regexes used for autoformatting 234heading_re = Re(r'^(={2,7})\s*(.*?)(\s=+)?$') 235 236link_to_page_re = Re(r'''( 237 [\w\.\-\(\)]*(?: :[\w\.\-\(\)]{2,} )+ (?: : | \#\w[\w_-]+)? 238 | \+\w[\w\.\-\(\)]+(?: :[\w\.\-\(\)]{2,} )* (?: : | \#\w[\w_-]+)? 239)$''', re.X | re.U) 240 # e.g. namespace:page or +subpage, but not word without ':' or '+' 241 # optionally followed by anchor id 242 # links with only anchor id or page (without ':' or '+') and achor id are matched by 'link_to_anchor_re' 243 244interwiki_re = Re(r'\w[\w\+\-\.]+\?\w\S+$', re.U) # name?page, where page can be any url style 245 246file_re = Re(r'''( 247 ~/[^/\s] 248 | ~[^/\s]*/ 249 | \.\.?/ 250 | /[^/\s] 251)\S*$''', re.X | re.U) # ~xxx/ or ~name/xxx or ../xxx or ./xxx or /xxx 252 253markup_re = [ 254 # All ending in "$" to match last sequence on end-of-word 255 # the group captures the content to keep 256 ('style-strong', re.compile(r'\*\*(.*)\*\*$')), 257 ('style-emphasis', re.compile(r'\/\/(.*)\/\/$')), 258 ('style-mark', re.compile(r'__(.*)__$')), 259 ('style-code', re.compile(r'\'\'(.*)\'\'$')), 260 ('style-strike', re.compile(r'~~(.*)~~$')), 261 ('style-sup', re.compile(r'(?<=\w)\^\{(\S*)}$')), 262 ('style-sup', re.compile(r'(?<=\w)\^(\S*)$')), 263 ('style-sub', re.compile(r'(?<=\w)_\{(\S*)}$')), 264] 265 266link_to_anchor_re = Re(r'^([\w\.\-\(\)]*#\w[\w_-]+)$', re.U) # before the "#" can be a page name, needs to match logic in 'link_to_page_re' 267 268anchor_re = Re(r'^(##\w[\w_-]+)$', re.U) 269 270tag_re = Re(r'^(@\w+)$', re.U) 271 272twoletter_re = re.compile(r'[^\W\d]{2}', re.U) # match letters but not numbers - not non-alphanumeric and not number 273 274 275def camelcase(word): 276 # To be CamelCase, a word needs to start uppercase, followed 277 # by at least one lower case, followed by at least one uppercase. 278 # As a result: 279 # - CamelCase needs at least 3 characters 280 # - first char needs to be upper case 281 # - remainder of the text needs to be mixed case 282 if len(word) < 3 \ 283 or not str.isalpha(word) \ 284 or not str.isupper(word[0]) \ 285 or str.islower(word[1:]) \ 286 or str.isupper(word[1:]): 287 return False 288 289 # Now do detailed check and check indeed lower case followed by 290 # upper case and exclude e.g. "AAbb" 291 # Also check that check that string does not contain letters that 292 # are neither upper or lower case (e.g. some Arabic letters) 293 upper = list(map(str.isupper, word)) 294 lower = list(map(str.islower, word)) 295 if not all(upper[i] or lower[i] for i in range(len(word))): 296 return False 297 298 count = 0 299 for i in range(1, len(word)): 300 if not upper[i - 1] and upper[i]: 301 return True 302 else: 303 return False 304 305 306def increase_list_bullet(bullet): 307 '''Like L{increase_list_iter()}, but handles bullet string directly 308 @param bullet: a numbered list bullet, e.g. C{"1."} 309 @returns: the next bullet, e.g. C{"2."} or C{None} 310 ''' 311 next = increase_list_iter(bullet.rstrip('.')) 312 if next: 313 return next + '.' 314 else: 315 return None 316 317 318class AsciiString(String): 319 320 # pango doesn't like unicode attributes 321 322 def check(self, value): 323 value = String.check(self, value) 324 if isinstance(value, str): 325 return str(value) 326 else: 327 return value 328 329 330 331class ConfigDefinitionConstant(String): 332 333 def __init__(self, default, group, prefix): 334 self.group = group 335 self.prefix = prefix 336 String.__init__(self, default=default) 337 338 def check(self, value): 339 value = String.check(self, value) 340 if isinstance(value, str): 341 value = value.upper() 342 for prefix in (self.prefix, self.prefix.split('_', 1)[1]): 343 # e.g. PANGO_WEIGHT_BOLD --> BOLD but also WEIGHT_BOLD --> BOLD 344 if value.startswith(prefix): 345 value = value[len(prefix):] 346 value = value.lstrip('_') 347 348 if hasattr(self.group, value): 349 return getattr(self.group, value) 350 else: 351 raise ValueError('No such constant: %s_%s' % (self.prefix, value)) 352 else: 353 return value 354 355 def tostring(self, value): 356 if hasattr(value, 'value_name'): 357 return value.value_name 358 else: 359 return str(value) 360 361 362 363class UserActionContext(object): 364 '''Context manager to wrap actions in proper user-action signals 365 366 This class used for the L{TextBuffer.user_action} attribute 367 368 This allows syntax like:: 369 370 with buffer.user_action: 371 buffer.insert(...) 372 373 instead off:: 374 375 buffer.begin_user_action() 376 buffer.insert(...) 377 buffer.end_user_action() 378 379 By wrapping actions in this "user-action" block the 380 L{UndoStackManager} will see it as a single action and make it 381 undo-able in a single step. 382 ''' 383 384 def __init__(self, buffer): 385 self.buffer = buffer 386 387 def __enter__(self): 388 self.buffer.begin_user_action() 389 390 def __exit__(self, *a): 391 self.buffer.end_user_action() 392 393 394GRAVITY_RIGHT = 'right' 395GRAVITY_LEFT = 'left' 396 397class SaveCursorContext(object): 398 '''Context manager used by L{TextBuffer.tmp_cursor()} 399 400 This allows syntax like:: 401 402 with buffer.tmp_cursor(iter): 403 # some manipulation using iter as cursor position 404 405 # old cursor position restored 406 407 Basically it keeps a mark for the old cursor and restores it 408 after exiting the context. 409 ''' 410 411 def __init__(self, buffer, iter=None, gravity=GRAVITY_LEFT): 412 self.buffer = buffer 413 self.iter = iter 414 self.mark = None 415 self.gravity = gravity 416 417 def __enter__(self): 418 buffer = self.buffer 419 cursor = buffer.get_iter_at_mark(buffer.get_insert()) 420 self.mark = buffer.create_mark(None, cursor, left_gravity=(self.gravity == GRAVITY_LEFT)) 421 if self.iter: 422 buffer.place_cursor(self.iter) 423 424 def __exit__(self, *a): 425 buffer = self.buffer 426 iter = buffer.get_iter_at_mark(self.mark) 427 buffer.place_cursor(iter) 428 buffer.delete_mark(self.mark) 429 430 431def image_file_get_dimensions(file_path): 432 """ 433 Replacement for GdkPixbuf.Pixbuf.get_file_info 434 @return (width, height) in pixels 435 or None if file does not exist or failed to load 436 """ 437 438 # Let GTK try reading the file 439 _, width, height = GdkPixbuf.Pixbuf.get_file_info(file_path) 440 if width > 0 and height > 0: 441 return (width, height) 442 443 # Fallback to Pillow 444 try: 445 from PIL import Image # load Pillow only if necessary 446 with Image.open(file_path) as img_pil: 447 return (img_pil.width, img_pil.height) 448 except: 449 return None 450 451 452def image_file_load_pixels(file, width_override=-1, height_override=-1): 453 """ 454 Replacement for GdkPixbuf.Pixbuf.new_from_file_at_size(file.path, w, h) 455 When file does not exist or fails to load, this throws exceptions. 456 """ 457 458 if not file.exists(): 459 # if the file does not exist, no need to make the effort of trying to read it 460 raise FileNotFoundError(file.path) 461 462 b_size_override = width_override > 0 or height_override > 0 463 464 # Let GTK try reading the file 465 try: 466 if b_size_override: 467 pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(file.path, width_override, height_override) 468 else: 469 pixbuf = GdkPixbuf.Pixbuf.new_from_file(file.path) 470 471 pixbuf = rotate_pixbuf(pixbuf) 472 473 except: 474 logger.debug('GTK failed to read image, using Pillow fallback: %s', file.path) 475 476 from PIL import Image # load Pillow only if necessary 477 478 with Image.open(file.path) as img_pil: 479 480 # resize if a specific size was requested 481 if b_size_override: 482 if height_override <= 0: 483 height_override = int(img_pil.height * width_override / img_pil.width) 484 if width_override <= 0: 485 width_override = int(img_pil.width * height_override / img_pil.height) 486 487 logger.debug('PIL resizing %s %s', width_override, height_override) 488 img_pil = img_pil.resize((width_override, height_override)) 489 490 # check if there is an alpha channel 491 if img_pil.mode == 'RGB': 492 has_alpha = False 493 elif img_pil.mode == 'RGBA': 494 has_alpha = True 495 else: 496 raise ValueError('Pixel format {fmt} can not be converted to Pixbuf for image {p}'.format( 497 fmt = img_pil.mode, p = file.path, 498 )) 499 500 # convert to GTK pixbuf 501 data_gtk = GLib.Bytes.new_take(img_pil.tobytes()) 502 503 pixbuf = GdkPixbuf.Pixbuf.new_from_bytes( 504 data = data_gtk, 505 colorspace = GdkPixbuf.Colorspace.RGB, 506 has_alpha = has_alpha, 507 # GTK docs: "Currently only RGB images with 8 bits per sample are supported" 508 # https://developer.gnome.org/gdk-pixbuf/stable/gdk-pixbuf-Image-Data-in-Memory.html#gdk-pixbuf-new-from-bytes 509 bits_per_sample = 8, 510 width = img_pil.width, 511 height = img_pil.height, 512 rowstride = img_pil.width * (4 if has_alpha else 3), 513 ) 514 515 return pixbuf 516 517 518class TextBuffer(Gtk.TextBuffer): 519 '''Data model for the editor widget 520 521 This sub-class of C{Gtk.TextBuffer} manages the contents of 522 the L{TextView} widget. It has an internal data model that allows 523 to manipulate the formatted text by cursor positions. It manages 524 images, links, bullet lists etc. The methods L{set_parsetree()} 525 and L{get_parsetree()} can exchange the formatted text as a 526 L{ParseTree} object which can be parsed by the L{zim.formats} 527 modules. 528 529 Styles 530 ====== 531 532 Formatting styles like bold, italic etc. as well as functional 533 text objects like links and tags are represented by C{Gtk.TextTags}. 534 For static styles these TextTags have the same name as the style. 535 For links and tag anonymous TextTags are used. Be aware though that 536 not all TextTags in the model are managed by us, e.g. gtkspell 537 uses it's own tags. TextTags that are managed by us have an 538 additional attribute C{zim_type} which gives the format type 539 for this tag. All TextTags without this attribute are not ours. 540 All TextTags that have a C{zim_type} attribute also have an 541 C{zim_attrib} attribute, which can be either C{None} or contain 542 some properties, like the C{href} property for a link. See the 543 parsetree documentation for what properties to expect. 544 545 The buffer keeps an internal state for what tags should be applied 546 to new text and applies these automatically when text is inserted. 547 E.g. when you place the cursor at the end of a bold area and 548 start typing the new text will be bold as well. However when you 549 move to the beginning of the area it will not be bold. 550 551 One limitation is that the current code supposes only one format 552 style can be applied to a part of text at the same time. This 553 means you can not overlap e.g. bold and italic styles. But it 554 makes the code simpler because we only deal with one style at a 555 time. 556 557 Images 558 ====== 559 560 Embedded images and icons are handled by C{GdkPixbuf.Pixbuf} object. 561 Again the ones that are handled by us have the extry C{zim_type} and 562 C{zim_attrib} attributes. 563 564 Lists 565 ===== 566 567 As far as this class is concerned bullet and checkbox lists are just 568 a number of lines that start with a bullet (checkboxes are rendered 569 with small images or icons, but are also considered bullets). 570 There is some logic to keep list formatting nicely but it only 571 applies to one line at a time. For functionality affecting a list 572 as a whole see the L{TextBufferList} class. 573 574 @todo: The buffer needs a reference to the notebook and page objects 575 for the text that is being shown to make sure that e.g. serializing 576 links works correctly. Check if we can get rid of page and notebook 577 here and just put provide them as arguments when needed. 578 579 @cvar tag_styles: This dict defines the formatting styles supported 580 by the editor. The style properties are overruled by the values 581 from the X{style.conf} config file. 582 583 @ivar notebook: The L{Notebook} object 584 @ivar page: The L{Page} object 585 @ivar user_action: A L{UserActionContext} context manager 586 @ivar finder: A L{TextFinder} for this buffer 587 588 @signal: C{begin-insert-tree (interactive)}: 589 Emitted at the begin of a complex insert, c{interactive} is boolean flag 590 @signal: C{end-insert-tree ()}: 591 Emitted at the end of a complex insert 592 @signal: C{textstyle-changed (style)}: 593 Emitted when textstyle at the cursor changes, gets the list of text styles or None. 594 @signal: C{link-clicked ()}: 595 Emitted when a link is clicked; for example within a table cell 596 @signal: C{undo-save-cursor (iter)}: 597 emitted in some specific case where the undo stack should 598 lock the current cursor position 599 @signal: C{insert-objectanchor (achor)}: emitted when an object 600 is inserted, should trigger L{TextView} to attach a widget 601 602 @todo: document tag styles that are supported 603 ''' 604 605 # We rely on the priority of gtk TextTags to sort links before styles, 606 # and styles before indenting. Since styles are initialized on init, 607 # while indenting tags are created when needed, indenting tags always 608 # have the higher priority. By explicitly lowering the priority of new 609 # link tags to zero we keep those tags on the lower endof the scale. 610 611 612 # define signals we want to use - (closure type, return type and arg types) 613 __gsignals__ = { 614 'insert-text': 'override', 615 'begin-insert-tree': (GObject.SignalFlags.RUN_LAST, None, (bool,)), 616 'end-insert-tree': (GObject.SignalFlags.RUN_LAST, None, ()), 617 'textstyle-changed': (GObject.SignalFlags.RUN_LAST, None, (object,)), 618 'undo-save-cursor': (GObject.SignalFlags.RUN_LAST, None, (object,)), 619 'insert-objectanchor': (GObject.SignalFlags.RUN_LAST, None, (object,)), 620 'link-clicked': (GObject.SignalFlags.RUN_LAST, None, (object,)), 621 } 622 623 # style attributes 624 pixels_indent = 30 #: pixels indent for a single indent level 625 bullet_icon_size = Gtk.IconSize.MENU #: constant for icon size of checkboxes etc. 626 627 #: text styles supported by the editor 628 tag_styles = { 629 'h1': {'weight': Pango.Weight.BOLD, 'scale': 1.15**4}, 630 'h2': {'weight': Pango.Weight.BOLD, 'scale': 1.15**3}, 631 'h3': {'weight': Pango.Weight.BOLD, 'scale': 1.15**2}, 632 'h4': {'weight': Pango.Weight.ULTRABOLD, 'scale': 1.15}, 633 'h5': {'weight': Pango.Weight.BOLD, 'scale': 1.15, 'style': Pango.Style.ITALIC}, 634 'h6': {'weight': Pango.Weight.BOLD, 'scale': 1.15}, 635 'emphasis': {'style': Pango.Style.ITALIC}, 636 'strong': {'weight': Pango.Weight.BOLD}, 637 'mark': {'background': 'yellow'}, 638 'strike': {'strikethrough': True, 'foreground': 'grey'}, 639 'code': {'family': 'monospace'}, 640 'pre': {'family': 'monospace', 'wrap-mode': Gtk.WrapMode.NONE}, 641 'sub': {'rise': -3500, 'scale': 0.7}, 642 'sup': {'rise': 7500, 'scale': 0.7}, 643 'link': {'foreground': 'blue'}, 644 'tag': {'foreground': '#ce5c00'}, 645 'indent': {}, 646 'bullet-list': {}, 647 'numbered-list': {}, 648 'unchecked-checkbox': {}, 649 'checked-checkbox': {}, 650 'xchecked-checkbox': {}, 651 'migrated-checkbox': {}, 652 'transmigrated-checkbox': {}, 653 'find-highlight': {'background': 'magenta', 'foreground': 'white'}, 654 'find-match': {'background': '#38d878', 'foreground': 'white'} 655 } 656 657 #: tags that can be mapped to named TextTags 658 _static_style_tags = ( 659 # The order determines order of nesting, and order of formatting 660 # Indent-tags will be inserted before headings 661 # Link-tags and tag-tags will be inserted before "pre" and "code" 662 # search for "set_priority()" and "get_priority()" to see impact 663 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 664 'emphasis', 'strong', 'mark', 'strike', 'sub', 'sup', 665 'pre', 'code', 666 ) 667 _static_tag_before_links = 'sup' # link will be inserted with this prio +1 668 _static_tag_after_tags = 'pre' # link will be inserted with this prio 669 670 #: tags that can nest in any order 671 _nesting_style_tags = ( 672 'emphasis', 'strong', 'mark', 'strike', 'sub', 'sup', 673 ) 674 675 tag_attributes = { 676 'weight': ConfigDefinitionConstant(None, Pango.Weight, 'PANGO_WEIGHT'), 677 'scale': Float(None), 678 'style': ConfigDefinitionConstant(None, Pango.Style, 'PANGO_STYLE'), 679 'background': AsciiString(None), 680 'paragraph-background': AsciiString(None), 681 'foreground': AsciiString(None), 682 'strikethrough': Boolean(None), 683 'font': AsciiString(None), 684 'family': AsciiString(None), 685 'wrap-mode': ConfigDefinitionConstant(None, Gtk.WrapMode, 'GTK_WRAP'), 686 'indent': Integer(None), 687 'underline': ConfigDefinitionConstant(None, Pango.Underline, 'PANGO_UNDERLINE'), 688 'linespacing': Integer(None), 689 'wrapped-lines-linespacing': Integer(None), 690 'rise': Integer(None), 691 } #: Valid properties for a style in tag_styles 692 693 def __init__(self, notebook, page, parsetree=None): 694 '''Constructor 695 696 @param notebook: a L{Notebook} object 697 @param page: a L{Page} object 698 @param parsetree: optional L{ParseTree} object, if given this will 699 initialize the buffer content *before* initializing the undostack 700 ''' 701 GObject.GObject.__init__(self) 702 self.notebook = notebook 703 self.page = page 704 self._insert_tree_in_progress = False 705 self._deleted_editmode_mark = None 706 self._deleted_line_end = False 707 self._check_renumber = [] 708 self._renumbering = False 709 self.user_action = UserActionContext(self) 710 self.finder = TextFinder(self) 711 self.showing_template = False 712 713 for name in self._static_style_tags: 714 tag = self.create_tag('style-' + name, **self.tag_styles[name]) 715 tag.zim_type = 'style' 716 if name in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'): 717 # This is needed to get proper output in get_parse_tree 718 tag.zim_tag = 'h' 719 tag.zim_attrib = {'level': int(name[1])} 720 else: 721 tag.zim_tag = name 722 tag.zim_attrib = None 723 724 self._editmode_tags = [] 725 726 textbuffer_register_serialize_formats(self, notebook, page) 727 728 self.connect('delete-range', self.__class__.do_pre_delete_range) 729 self.connect_after('delete-range', self.__class__.do_post_delete_range) 730 731 if parsetree is not None: 732 # Do this *before* initializing the undostack 733 self.set_parsetree(parsetree) 734 self.set_modified(False) 735 736 self.undostack = UndoStackManager(self) 737 738 #~ def do_begin_user_action(self): 739 #~ print('>>>> USER ACTION') 740 #~ pass 741 742 @property 743 def hascontent(self): 744 if self.showing_template: 745 return False 746 else: 747 start, end = self.get_bounds() 748 return not start.equal(end) 749 750 def do_end_user_action(self): 751 #print('<<<< USER ACTION') 752 if self._deleted_editmode_mark is not None: 753 self.delete_mark(self._deleted_editmode_mark) 754 self._deleted_editmode_mark = None 755 756 if True: # not self._renumbering: 757 lines = list(self._check_renumber) 758 # copy to avoid infinite loop when updating bullet triggers new delete 759 self._renumbering = True 760 for line in lines: 761 self.renumber_list(line) 762 # This flag means we deleted a line, and now we need 763 # to check if the numbering is still valid. 764 # It is delayed till here because this logic only applies 765 # to interactive actions. 766 self._renumbering = False 767 self._check_renumber = [] 768 769 def clear(self): 770 self.delete(*self.get_bounds()) 771 if self._deleted_editmode_mark is not None: 772 self.delete_mark(self._deleted_editmode_mark) 773 self._deleted_editmode_mark = None 774 self._editmode_tags = [] 775 776 def get_insert_iter(self): 777 '''Get a C{Gtk.TextIter} for the current cursor position''' 778 return self.get_iter_at_mark(self.get_insert()) 779 780 def tmp_cursor(self, iter=None, gravity=GRAVITY_LEFT): 781 '''Get a L{SaveCursorContext} object 782 783 @param iter: a C{Gtk.TextIter} for the new (temporary) cursor 784 position 785 @param gravity: give mark left or right "gravity" compared to new 786 inserted text, default is "left" which means new text goes after the 787 cursor position 788 ''' 789 return SaveCursorContext(self, iter, gravity) 790 791 def set_parsetree(self, tree, showing_template=False): 792 '''Load a new L{ParseTree} in the buffer 793 794 This method replaces any content in the buffer with the new 795 parser tree. 796 797 @param tree: a L{ParseTree} object 798 @param showing_template: if C{True} the C{tree} represents a template 799 and not actual page content (yet) 800 ''' 801 with self.user_action: 802 self.clear() 803 self.insert_parsetree_at_cursor(tree) 804 805 self.showing_template = showing_template # Set after modifying! 806 807 def insert_parsetree(self, iter, tree, interactive=False): 808 '''Insert a L{ParseTree} in the buffer 809 810 This method inserts a parsetree at a specific place in the 811 buffer. 812 813 @param iter: a C{Gtk.TextIter} for the insert position 814 @param tree: a L{ParseTree} object 815 @param interactive: Boolean which determines how current state 816 in the buffer is handled. If not interactive we break any 817 existing tags and insert the tree, otherwise we insert using the 818 formatting tags that that are present at iter. 819 820 For example when a parsetree is inserted because the user pastes 821 content from the clipboard C{interactive} should be C{True}. 822 ''' 823 with self.tmp_cursor(iter): 824 self.insert_parsetree_at_cursor(tree, interactive) 825 826 def append_parsetree(self, tree, interactive=False): 827 '''Append a L{ParseTree} to the buffer 828 829 Like L{insert_parsetree()} but inserts at the end of the current buffer. 830 ''' 831 self.insert_parsetree(self.get_end_iter(), tree, interactive) 832 833 def insert_parsetree_at_cursor(self, tree, interactive=False): 834 '''Insert a L{ParseTree} in the buffer 835 836 Like L{insert_parsetree()} but inserts at the current cursor 837 position. 838 839 @param tree: a L{ParseTree} object 840 @param interactive: Boolean which determines how current state 841 in the buffer is handled. 842 ''' 843 #print('INSERT AT CURSOR', tree.tostring()) 844 tree.resolve_images(self.notebook, self.page) 845 846 # Check tree 847 root = tree._etree.getroot() # HACK - switch to new interface ! 848 assert root.tag == 'zim-tree' 849 raw = root.attrib.get('raw') 850 if isinstance(raw, str): 851 raw = (raw != 'False') 852 853 # Check if we are at a bullet or checkbox line 854 iter = self.get_iter_at_mark(self.get_insert()) 855 if not raw and iter.starts_line() \ 856 and not tree.get_ends_with_newline(): 857 bullet = self._get_bullet_at_iter(iter) 858 if bullet: 859 self._iter_forward_past_bullet(iter, bullet) 860 self.place_cursor(iter) 861 862 # Prepare 863 startoffset = iter.get_offset() 864 if not interactive: 865 self._editmode_tags = [] 866 tree.decode_urls() 867 868 if self._deleted_editmode_mark is not None: 869 self.delete_mark(self._deleted_editmode_mark) 870 self._deleted_editmode_mark = None 871 872 # Actual insert 873 modified = self.get_modified() 874 try: 875 self.emit('begin-insert-tree', interactive) 876 if root.text: 877 self.insert_at_cursor(root.text) 878 self._insert_element_children(root, raw=raw) 879 880 # Fix partial tree inserts 881 startiter = self.get_iter_at_offset(startoffset) 882 if not startiter.starts_line(): 883 self._do_lines_merged(startiter) 884 885 enditer = self.get_iter_at_mark(self.get_insert()) 886 if not enditer.ends_line(): 887 self._do_lines_merged(enditer) 888 889 # Fix text direction of indent tags 890 for line in range(startiter.get_line(), enditer.get_line() + 1): 891 iter = self.get_iter_at_line(line) 892 tags = list(filter(_is_indent_tag, iter.get_tags())) 893 if tags: 894 dir = self._find_base_dir(line) 895 if dir == 'RTL': 896 bullet = self.get_bullet(line) 897 level = self.get_indent(line) 898 self._set_indent(line, level, bullet, dir=dir) 899 # else pass, LTR is the default 900 except: 901 # Try to recover buffer state before raising 902 self.update_editmode() 903 startiter = self.get_iter_at_offset(startoffset) 904 enditer = self.get_iter_at_mark(self.get_insert()) 905 self.delete(startiter, enditer) 906 self.set_modified(modified) 907 self.emit('end-insert-tree') 908 raise 909 else: 910 # Signal the tree that was inserted 911 self.update_editmode() 912 startiter = self.get_iter_at_offset(startoffset) 913 enditer = self.get_iter_at_mark(self.get_insert()) 914 self.emit('end-insert-tree') 915 916 def do_begin_insert_tree(self, interactive): 917 self._insert_tree_in_progress = True 918 919 def do_end_insert_tree(self): 920 self._insert_tree_in_progress = False 921 self.emit('textstyle-changed', self.get_textstyles()) 922 923 # emitting textstyle-changed is skipped while loading the tree 924 925 def _insert_element_children(self, node, list_level=-1, list_type=None, list_start='0', raw=False, textstyles=[]): 926 # FIXME should load list_level from cursor position 927 #~ list_level = get_indent --- with bullets at indent 0 this is not bullet proof... 928 list_iter = list_start 929 930 def set_indent(level, bullet=None): 931 # Need special set_indent() function here because the normal 932 # function affects the whole line. THis has unwanted side 933 # effects when we e.g. paste a multi-line tree in the 934 # middle of a indented line. 935 # In contrast to the normal set_indent we treat level=None 936 # and level=0 as different cases. 937 self._editmode_tags = list(filter(_is_not_indent_tag, self._editmode_tags)) 938 if level is None: 939 return # Nothing more to do 940 941 iter = self.get_insert_iter() 942 if not iter.starts_line(): 943 # Check existing indent - may have bullet type while we have not 944 tags = list(filter(_is_indent_tag, self.iter_get_zim_tags(iter))) 945 if len(tags) > 1: 946 logger.warn('BUG: overlapping indent tags') 947 if tags and int(tags[0].zim_attrib['indent']) == level: 948 self._editmode_tags.append(tags[0]) 949 return # Re-use tag 950 951 tag = self._get_indent_tag(level, bullet) 952 # We don't set the LTR / RTL direction here 953 # instead we update all indent tags after the full 954 # insert is done. 955 self._editmode_tags.append(tag) 956 957 def force_line_start(): 958 # Inserts a newline if we are not at the beginning of a line 959 # makes pasting a tree halfway in a line more sane 960 if not raw: 961 iter = self.get_iter_at_mark(self.get_insert()) 962 if not iter.starts_line(): 963 self.insert_at_cursor('\n') 964 965 for element in iter(node): 966 if element.tag in ('p', 'div'): 967 # No force line start here on purpose 968 if 'indent' in element.attrib: 969 set_indent(int(element.attrib['indent'])) 970 else: 971 set_indent(None) 972 973 if element.text: 974 self.insert_at_cursor(element.text) 975 976 self._insert_element_children(element, list_level=list_level, raw=raw, textstyles=textstyles) # recurs 977 978 set_indent(None) 979 elif element.tag in ('ul', 'ol'): 980 start = element.attrib.get('start') 981 if 'indent' in element.attrib: 982 level = int(element.attrib['indent']) 983 else: 984 level = list_level + 1 985 self._insert_element_children(element, list_level=level, list_type=element.tag, list_start=start, raw=raw, 986 textstyles=textstyles) # recurs 987 set_indent(None) 988 elif element.tag == 'li': 989 force_line_start() 990 991 if 'indent' in element.attrib: 992 list_level = int(element.attrib['indent']) 993 elif list_level < 0: 994 list_level = 0 # We skipped the <ul> - raw tree ? 995 996 if list_type == 'ol': 997 bullet = list_iter + '.' 998 list_iter = increase_list_iter(list_iter) 999 elif 'bullet' in element.attrib and element.attrib['bullet'] != '*': 1000 bullet = element.attrib['bullet'] 1001 else: 1002 bullet = BULLET # default to '*' 1003 1004 set_indent(list_level, bullet) 1005 self._insert_bullet_at_cursor(bullet, raw=raw) 1006 1007 if element.text: 1008 self.insert_at_cursor(element.text) 1009 1010 self._insert_element_children(element, list_level=list_level, raw=raw, textstyles=textstyles) # recurs 1011 set_indent(None) 1012 1013 if not raw: 1014 self.insert_at_cursor('\n') 1015 1016 elif element.tag == 'link': 1017 self.set_textstyles(textstyles) # reset Needed for interactive insert tree after paste 1018 tag = self._create_link_tag('', **element.attrib) 1019 self._editmode_tags = list(filter(_is_not_link_tag, self._editmode_tags)) + [tag] 1020 linkstartpos = self.get_insert_iter().get_offset() 1021 if element.text: 1022 self.insert_at_cursor(element.text) 1023 self._insert_element_children(element, list_level=list_level, raw=raw, 1024 textstyles=textstyles) # recurs 1025 linkstart = self.get_iter_at_offset(linkstartpos) 1026 text = linkstart.get_text(self.get_insert_iter()) 1027 if element.attrib['href'] and text != element.attrib['href']: 1028 # same logic in _create_link_tag, but need to check text after all child elements inserted 1029 tag.zim_attrib['href'] = element.attrib['href'] 1030 else: 1031 tag.zim_attrib['href'] = None 1032 self._editmode_tags.pop() 1033 elif element.tag == 'tag': 1034 self.set_textstyles(textstyles) # reset Needed for interactive insert tree after paste 1035 self.insert_tag_at_cursor(element.text, **element.attrib) 1036 elif element.tag == 'anchor': 1037 self.set_textstyles(textstyles) 1038 self.insert_anchor_at_cursor(element.attrib['name']) 1039 elif element.tag == 'img': 1040 file = element.attrib['_src_file'] 1041 self.insert_image_at_cursor(file, **element.attrib) 1042 elif element.tag == 'pre': 1043 if 'indent' in element.attrib: 1044 set_indent(int(element.attrib['indent'])) 1045 self.set_textstyles([element.tag]) 1046 if element.text: 1047 self.insert_at_cursor(element.text) 1048 self.set_textstyles(None) 1049 set_indent(None) 1050 elif element.tag == 'table': 1051 if 'indent' in element.attrib: 1052 set_indent(int(element.attrib['indent'])) 1053 self.insert_table_element_at_cursor(element) 1054 set_indent(None) 1055 elif element.tag == 'line': 1056 anchor = LineSeparatorAnchor() 1057 self.insert_objectanchor_at_cursor(anchor) 1058 1059 elif element.tag == 'object': 1060 if 'indent' in element.attrib: 1061 set_indent(int(element.attrib['indent'])) 1062 self.insert_object_at_cursor(element.attrib, element.text) 1063 set_indent(None) 1064 else: 1065 # Text styles 1066 flushed = False 1067 if element.tag == 'h': 1068 force_line_start() 1069 tag = 'h' + str(element.attrib['level']) 1070 self.set_textstyles([tag]) 1071 if element.text: 1072 self.insert_at_cursor(element.text) 1073 flushed = True 1074 self._insert_element_children(element, list_level=list_level, raw=raw, 1075 textstyles=[tag]) # recurs 1076 elif element.tag in self._static_style_tags: 1077 self.set_textstyles(textstyles + [element.tag]) 1078 if element.text: 1079 self.insert_at_cursor(element.text) 1080 flushed = True 1081 self._insert_element_children(element, list_level=list_level, raw=raw, 1082 textstyles=textstyles + [element.tag]) # recurs 1083 elif element.tag == '_ignore_': 1084 # raw tree from undo can contain these 1085 self._insert_element_children(element, list_level=list_level, raw=raw, textstyles=textstyles) # recurs 1086 else: 1087 logger.debug("Unknown tag : %s, %s, %s", element.tag, 1088 element.attrib, element.text) 1089 assert False, 'Unknown tag: %s' % element.tag 1090 1091 if element.text and not flushed: 1092 self.insert_at_cursor(element.text) 1093 1094 self.set_textstyles(textstyles) 1095 1096 if element.tail: 1097 self.insert_at_cursor(element.tail) 1098 1099 #region Links 1100 1101 def insert_link(self, iter, text, href, **attrib): 1102 '''Insert a link into the buffer 1103 1104 @param iter: a C{Gtk.TextIter} for the insert position 1105 @param text: the text for the link as string 1106 @param href: the target (URL, pagename) of the link as string 1107 @param attrib: any other link attributes 1108 ''' 1109 with self.tmp_cursor(iter): 1110 self.insert_link_at_cursor(text, href, **attrib) 1111 1112 def insert_link_at_cursor(self, text, href=None, **attrib): 1113 '''Insert a link into the buffer 1114 1115 Like insert_link() but inserts at the current cursor position 1116 1117 @param text: the text for the link as string 1118 @param href: the target (URL, pagename) of the link as string 1119 @param attrib: any other link attributes 1120 ''' 1121 if self._deleted_editmode_mark is not None: 1122 self.delete_mark(self._deleted_editmode_mark) 1123 self._deleted_editmode_mark = None 1124 1125 tag = self._create_link_tag(text, href, **attrib) 1126 self._editmode_tags = list(filter(_is_not_link_tag, self._editmode_tags)) + [tag] 1127 self.insert_at_cursor(text) 1128 self._editmode_tags = self._editmode_tags[:-1] 1129 1130 def _create_link_tag(self, text, href, **attrib): 1131 '''Creates an anonymouse TextTag for a link''' 1132 # These are created after __init__, so higher priority for Formatting 1133 # properties than any of the _static_style_tags 1134 if isinstance(href, File): 1135 href = href.uri 1136 assert isinstance(href, str) or href is None 1137 1138 tag = self.create_tag(None, **self.tag_styles['link']) 1139 tag.zim_type = 'link' 1140 tag.zim_tag = 'link' 1141 tag.zim_attrib = attrib 1142 if href == text or not href or href.isspace(): 1143 tag.zim_attrib['href'] = None 1144 else: 1145 tag.zim_attrib['href'] = href 1146 1147 prio_tag = self.get_tag_table().lookup('style-' + self._static_tag_before_links) 1148 tag.set_priority(prio_tag.get_priority()+1) 1149 1150 return tag 1151 1152 def get_link_tag(self, iter): 1153 '''Get the C{Gtk.TextTag} for a link at a specific position, if any 1154 1155 @param iter: a C{Gtk.TextIter} 1156 @returns: a C{Gtk.TextTag} if there is a link at C{iter}, 1157 C{None} otherwise 1158 ''' 1159 # Explicitly left gravity, otherwise position behind the link 1160 # would also be considered part of the link. Position before the 1161 # link is included here. 1162 for tag in sorted(iter.get_tags(), key=lambda i: i.get_priority()): 1163 if hasattr(tag, 'zim_type') and tag.zim_type == 'link': 1164 return tag 1165 else: 1166 return None 1167 1168 def get_link_text(self, iter): 1169 tag = self.get_link_tag(iter) 1170 return self.get_tag_text(iter, tag) if tag else None 1171 1172 def get_link_data(self, iter, raw=False): 1173 '''Get the link attributes for a link at a specific position, if any 1174 1175 @param iter: a C{Gtk.TextIter} 1176 @returns: a dict with link properties if there is a link 1177 at C{iter}, C{None} otherwise 1178 ''' 1179 tag = self.get_link_tag(iter) 1180 1181 if tag: 1182 link = tag.zim_attrib.copy() 1183 if link['href'] is None: 1184 if raw: 1185 link['href'] = '' 1186 else: 1187 # Copy text content as href 1188 start, end = self.get_tag_bounds(iter, tag) 1189 link['href'] = start.get_text(end) 1190 return link 1191 else: 1192 return None 1193 1194 #endregion 1195 1196 #region TextTags 1197 1198 def get_tag(self, iter, type): 1199 '''Get the C{Gtk.TextTag} for a zim type at a specific position, if any 1200 1201 @param iter: a C{Gtk.TextIter} 1202 @param type: the zim type to look for ('style', 'link', 'tag', 'indent', 'anchor') 1203 @returns: a C{Gtk.TextTag} if there is a tag at C{iter}, 1204 C{None} otherwise 1205 ''' 1206 for tag in iter.get_tags(): 1207 if hasattr(tag, 'zim_type') and tag.zim_type == type: 1208 return tag 1209 else: 1210 return None 1211 1212 def get_tag_bounds(self, iter, tag): 1213 start = iter.copy() 1214 if not start.begins_tag(tag): 1215 start.backward_to_tag_toggle(tag) 1216 end = iter.copy() 1217 if not end.ends_tag(tag): 1218 end.forward_to_tag_toggle(tag) 1219 return start, end 1220 1221 def get_tag_text(self, iter, tag): 1222 start, end = self.get_tag_bounds(iter, tag) 1223 return start.get_text(end) 1224 1225 #endregion 1226 1227 #region Tags 1228 1229 def insert_tag(self, iter, text, **attrib): 1230 '''Insert a tag into the buffer 1231 1232 Insert a tag in the buffer (not a TextTag, but a tag 1233 like "@foo") 1234 1235 @param iter: a C{Gtk.TextIter} object 1236 @param text: The text for the tag 1237 @param attrib: any other tag attributes 1238 ''' 1239 with self.tmp_cursor(iter): 1240 self.insert_tag_at_cursor(text, **attrib) 1241 1242 def insert_tag_at_cursor(self, text, **attrib): 1243 '''Insert a tag into the buffer 1244 1245 Like C{insert_tag()} but inserts at the current cursor position 1246 1247 @param text: The text for the tag 1248 @param attrib: any other tag attributes 1249 ''' 1250 if self._deleted_editmode_mark is not None: 1251 self.delete_mark(self._deleted_editmode_mark) 1252 self._deleted_editmode_mark = None 1253 1254 tag = self._create_tag_tag(text, **attrib) 1255 self._editmode_tags = \ 1256 [t for t in self._editmode_tags if not _is_non_nesting_tag(t)] + [tag] 1257 self.insert_at_cursor(text) 1258 self._editmode_tags = self._editmode_tags[:-1] 1259 1260 def _create_tag_tag(self, text, **attrib): 1261 '''Creates an anonymous TextTag for a tag''' 1262 # These are created after __init__, so higher priority for Formatting 1263 # properties than any of the _static_style_tags 1264 tag = self.create_tag(None, **self.tag_styles['tag']) 1265 tag.zim_type = 'tag' 1266 tag.zim_tag = 'tag' 1267 tag.zim_attrib = attrib 1268 tag.zim_attrib['name'] = None 1269 1270 prio_tag = self.get_tag_table().lookup('style-' + self._static_tag_after_tags) 1271 tag.set_priority(prio_tag.get_priority()) 1272 1273 return tag 1274 1275 def get_tag_tag(self, iter): 1276 '''Get the C{Gtk.TextTag} for a tag at a specific position, if any 1277 1278 @param iter: a C{Gtk.TextIter} 1279 @returns: a C{Gtk.TextTag} if there is a tag at C{iter}, 1280 C{None} otherwise 1281 ''' 1282 # Explicitly left gravity, otherwise position behind the tag 1283 # would also be considered part of the tag. Position before the 1284 # tag is included here. 1285 for tag in iter.get_tags(): 1286 if hasattr(tag, 'zim_type') and tag.zim_type == 'tag': 1287 return tag 1288 else: 1289 return None 1290 1291 def get_tag_data(self, iter): 1292 '''Get the attributes for a tag at a specific position, if any 1293 1294 @param iter: a C{Gtk.TextIter} 1295 @returns: a dict with tag properties if there is a link 1296 at C{iter}, C{None} otherwise 1297 ''' 1298 tag = self.get_tag_tag(iter) 1299 1300 if tag: 1301 attrib = tag.zim_attrib.copy() 1302 # Copy text content as name 1303 start = iter.copy() 1304 if not start.begins_tag(tag): 1305 start.backward_to_tag_toggle(tag) 1306 end = iter.copy() 1307 if not end.ends_tag(tag): 1308 end.forward_to_tag_toggle(tag) 1309 attrib['name'] = start.get_text(end).lstrip('@').strip() 1310 return attrib 1311 else: 1312 return None 1313 1314 #endregion 1315 1316 #region Anchors 1317 1318 def insert_anchor(self, iter, name, **attrib): 1319 '''Insert a "link anchor" with id C{name} at C{iter}''' 1320 widget = Gtk.HBox() # Need *some* widget here... 1321 pixbuf = widget.render_icon('zim-pilcrow', self.bullet_icon_size) 1322 pixbuf.zim_type = 'anchor' 1323 pixbuf.zim_attrib = attrib 1324 pixbuf.zim_attrib['name'] = name 1325 self.insert_pixbuf(iter, pixbuf) 1326 1327 def insert_anchor_at_cursor(self, name): 1328 '''Insert a "link anchor" with id C{name}''' 1329 iter = self.get_iter_at_mark(self.get_insert()) 1330 self.insert_anchor(iter, name) 1331 1332 def get_anchor_data(self, iter): 1333 pixbuf = iter.get_pixbuf() 1334 if pixbuf and hasattr(pixbuf, 'zim_type') and pixbuf.zim_type == 'anchor': 1335 return pixbuf.zim_attrib.copy() 1336 else: 1337 return None 1338 1339 def get_anchor_or_object_id(self, iter): 1340 # anchor or image 1341 pixbuf = iter.get_pixbuf() 1342 if pixbuf and hasattr(pixbuf, 'zim_type') and pixbuf.zim_type == 'anchor': 1343 return pixbuf.zim_attrib.get('name', None) 1344 elif pixbuf and hasattr(pixbuf, 'zim_type') and pixbuf.zim_type == 'image': 1345 return pixbuf.zim_attrib.get('id', None) 1346 1347 # object? 1348 anchor = iter.get_child_anchor() 1349 if anchor and isinstance(anchor, PluginInsertedObjectAnchor): 1350 object_type = anchor.objecttype 1351 object_model = anchor.objectmodel 1352 attrib, _ = object_type.data_from_model(object_model) 1353 return attrib.get('id', None) 1354 1355 def iter_anchors_for_range(self, start, end): 1356 iter = start.copy() 1357 match = iter.forward_search(PIXBUF_CHR, 0, limit=end) 1358 while match: 1359 iter, mend = match 1360 name = self.get_anchor_or_object_id(iter) 1361 if name: 1362 yield (iter.copy(), name) 1363 match = mend.forward_search(PIXBUF_CHR, 0, limit=end) 1364 1365 def get_anchor_for_location(self, iter): 1366 '''Returns an anchor name that refers to C{iter} or the same line 1367 Uses C{iter} to return id of explicit anchor on the same line closest 1368 to C{iter}. If no explicit anchor is found and C{iter} is within a heading 1369 line, the implicit anchor for the heading is returned. 1370 @param iter: the location to refer to 1371 @returns: an anchor name if any anchor object or heading is found, else C{None} 1372 ''' 1373 return self.get_anchor_or_object_id(iter) \ 1374 or self._get_close_anchor_or_object_id(iter) \ 1375 or self._get_implict_anchor_if_heading(iter) 1376 1377 def _get_close_anchor_or_object_id(self, iter): 1378 line_start = iter.copy() if iter.starts_line() else self.get_iter_at_line(iter.get_line()) 1379 line_end = line_start.copy() 1380 line_end.forward_line() 1381 line_offset = iter.get_line_offset() 1382 anchors = [ 1383 (abs(myiter.get_line_offset() - line_offset), name) 1384 for myiter, name in self.iter_anchors_for_range(line_start, line_end) 1385 ] 1386 if anchors: 1387 anchors.sort() 1388 return anchors[0][1] 1389 else: 1390 return None 1391 1392 def _get_implict_anchor_if_heading(self, iter): 1393 text = self._get_heading_text(iter) 1394 return heading_to_anchor(text) if text else None 1395 1396 def _get_heading_text(self, iter): 1397 line_start = iter.copy() if iter.starts_line() else self.get_iter_at_line(iter.get_line()) 1398 is_heading = any(filter(_is_heading_tag, line_start.get_tags())) 1399 if not is_heading: 1400 return None 1401 1402 line_end = line_start.copy() 1403 line_end.forward_line() 1404 return line_start.get_text(line_end) 1405 1406 #endregion 1407 1408 #region Images 1409 1410 def insert_image(self, iter, file, src, **attrib): 1411 '''Insert an image in the buffer 1412 1413 @param iter: a C{Gtk.TextIter} for the insert position 1414 @param file: a L{File} object or a file path or URI 1415 @param src: the file path the show to the user 1416 1417 If the image is e.g. specified in the page source as a relative 1418 link, C{file} should give the absolute path the link resolves 1419 to, while C{src} gives the relative path. 1420 1421 @param attrib: any other image properties 1422 ''' 1423 #~ If there is a property 'alt' in attrib we try to set a tooltip. 1424 #~ ''' 1425 if isinstance(file, str): 1426 file = File(file) 1427 try: 1428 pixbuf = image_file_load_pixels(file, int(attrib.get('width', -1)), int(attrib.get('height', -1))) 1429 except: 1430 #~ logger.exception('Could not load image: %s', file) 1431 logger.warn('No such image: %s', file) 1432 widget = Gtk.HBox() # Need *some* widget here... 1433 pixbuf = widget.render_icon(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.DIALOG) 1434 pixbuf = pixbuf.copy() # need unique instance to set zim_attrib 1435 1436 pixbuf.zim_type = 'image' 1437 pixbuf.zim_attrib = attrib 1438 pixbuf.zim_attrib['src'] = src 1439 pixbuf.zim_attrib['_src_file'] = file 1440 self.insert_pixbuf(iter, pixbuf) 1441 1442 def insert_image_at_cursor(self, file, src, **attrib): 1443 '''Insert an image in the buffer 1444 1445 Like L{insert_image()} but inserts at the current cursor 1446 position 1447 1448 @param file: a L{File} object or a file path or URI 1449 @param src: the file path the show to the user 1450 @param attrib: any other image properties 1451 ''' 1452 iter = self.get_iter_at_mark(self.get_insert()) 1453 self.insert_image(iter, file, src, **attrib) 1454 1455 def get_image_data(self, iter): 1456 '''Get the attributes for an image at a specific position, if any 1457 1458 @param iter: a C{Gtk.TextIter} object 1459 @returns: a dict with image properties or C{None} 1460 ''' 1461 pixbuf = iter.get_pixbuf() 1462 if pixbuf and hasattr(pixbuf, 'zim_type') and pixbuf.zim_type == 'image': 1463 return pixbuf.zim_attrib.copy() 1464 else: 1465 return None 1466 1467 #endregion 1468 1469 #region Objects 1470 1471 def insert_object_at_cursor(self, attrib, data): 1472 '''Inserts a custom object in the page 1473 @param attrib: dict with object attributes 1474 @param data: string data of object 1475 ''' 1476 try: 1477 objecttype = PluginManager.insertedobjects[attrib['type']] 1478 except KeyError: 1479 if attrib['type'].startswith('image+'): 1480 # Fallback for backward compatibility of image generators < zim 0.70 1481 objecttype = UnknownInsertedImageObject() 1482 else: 1483 objecttype = UnknownInsertedObject() 1484 1485 model = objecttype.model_from_data(self.notebook, self.page, attrib, data) 1486 self.insert_object_model_at_cursor(objecttype, model) 1487 1488 def insert_object_model_at_cursor(self, objecttype, model): 1489 from zim.plugins.tableeditor import TableViewObjectType # XXX 1490 1491 model.connect('changed', lambda o: self.set_modified(True)) 1492 1493 if isinstance(objecttype, TableViewObjectType): 1494 anchor = TableAnchor(objecttype, model) 1495 else: 1496 anchor = PluginInsertedObjectAnchor(objecttype, model) 1497 1498 self.insert_objectanchor_at_cursor(anchor) 1499 1500 def insert_table_element_at_cursor(self, element): 1501 try: 1502 obj = PluginManager.insertedobjects['table'] 1503 except KeyError: 1504 # HACK - if table plugin is not loaded - show table as plain text 1505 tree = ParseTree(element) 1506 lines = get_dumper('wiki').dump(tree) 1507 self.insert_object_at_cursor({'type': 'table'}, ''.join(lines)) 1508 else: 1509 model = obj.model_from_element(element.attrib, element) 1510 model.connect('changed', lambda o: self.set_modified(True)) 1511 1512 anchor = TableAnchor(obj, model) 1513 self.insert_objectanchor_at_cursor(anchor) 1514 1515 def insert_objectanchor_at_cursor(self, anchor): 1516 iter = self.get_insert_iter() 1517 self.insert_objectanchor(iter, anchor) 1518 1519 def insert_objectanchor(self, iter, anchor): 1520 self.insert_child_anchor(iter, anchor) 1521 self.emit('insert-objectanchor', anchor) 1522 1523 def get_objectanchor_at_cursor(self): 1524 iter = self.get_insert_iter() 1525 return self.get_object_achor(iter) 1526 1527 def get_objectanchor(self, iter): 1528 anchor = iter.get_child_anchor() 1529 if anchor and isinstance(anchor, InsertedObjectAnchor): 1530 return anchor 1531 else: 1532 return None 1533 1534 def list_objectanchors(self): 1535 start, end = self.get_bounds() 1536 match = start.forward_search(PIXBUF_CHR, 0) 1537 while match: 1538 start, end = match 1539 anchor = start.get_child_anchor() 1540 if anchor and isinstance(anchor, InsertedObjectAnchor): 1541 yield anchor 1542 match = end.forward_search(PIXBUF_CHR, 0) 1543 1544 #endregion 1545 1546 #region Bullets 1547 1548 def set_bullet(self, line, bullet, indent=None): 1549 '''Sets the bullet type for a line 1550 1551 Replaces any bullet that may already be present on the line. 1552 Set bullet C{None} to remove any bullet at this line. 1553 1554 @param line: the line number 1555 @param bullet: the bullet type, one of:: 1556 BULLET 1557 UNCHECKED_BOX 1558 CHECKED_BOX 1559 XCHECKED_BOX 1560 MIGRATED_BOX 1561 TRANSMIGRATED_BOX 1562 NUMBER_BULLET 1563 None 1564 or a numbered bullet, like C{"1."} 1565 @param indent: optional indent to set after inserting the bullet, 1566 but before renumbering 1567 ''' 1568 if bullet == NUMBER_BULLET: 1569 indent = self.get_indent(line) 1570 _, prev = self._search_bullet(line, indent, -1) 1571 if prev and is_numbered_bullet_re.match(prev): 1572 bullet = increase_list_bullet(prev) 1573 else: 1574 bullet = '1.' 1575 1576 with self.user_action: 1577 self._replace_bullet(line, bullet) 1578 if indent is not None: 1579 self.set_indent(line, indent) 1580 if bullet and is_numbered_bullet_re.match(bullet): 1581 self.renumber_list(line) 1582 1583 def _replace_bullet(self, line, bullet): 1584 indent = self.get_indent(line) 1585 with self.tmp_cursor(gravity=GRAVITY_RIGHT): 1586 iter = self.get_iter_at_line(line) 1587 bound = iter.copy() 1588 self.iter_forward_past_bullet(bound) 1589 self.delete(iter, bound) 1590 # Will trigger do_delete_range, which will update indent tag 1591 1592 if not bullet is None: 1593 iter = self.get_iter_at_line(line) 1594 self.place_cursor(iter) # update editmode 1595 1596 insert = self.get_insert_iter() 1597 assert insert.starts_line(), 'BUG: bullet not at line start' 1598 1599 # Turning into list item removes heading 1600 if not insert.ends_line(): 1601 end = insert.copy() 1602 end.forward_to_line_end() 1603 self.smart_remove_tags(_is_heading_tag, insert, end) 1604 1605 # TODO: convert 'pre' to 'code' ? 1606 1607 self._insert_bullet_at_cursor(bullet) 1608 1609 #~ self.update_indent_tag(line, bullet) 1610 self._set_indent(line, indent, bullet) 1611 1612 def _insert_bullet_at_cursor(self, bullet, raw=False): 1613 '''Insert a bullet plus a space at the cursor position. 1614 If 'raw' is True the space will be omitted and the check that 1615 cursor position must be at the start of a line will not be 1616 enforced. 1617 1618 External interface should use set_bullet(line, bullet) 1619 instead of calling this method directly. 1620 ''' 1621 assert bullet in BULLETS or is_numbered_bullet_re.match(bullet), 'Bullet: >>%s<<' % bullet 1622 if self._deleted_editmode_mark is not None: 1623 self.delete_mark(self._deleted_editmode_mark) 1624 self._deleted_editmode_mark = None 1625 1626 orig_editmode_tags = self._editmode_tags 1627 if not raw: 1628 insert = self.get_insert_iter() 1629 assert insert.starts_line(), 'BUG: bullet not at line start' 1630 1631 # Temporary clear non indent tags during insert 1632 self._editmode_tags = list(filter(_is_indent_tag, self._editmode_tags)) 1633 1634 if not self._editmode_tags: 1635 # Without indent get_parsetree will not recognize 1636 # the icon as a bullet item. This will mess up 1637 # undo stack. If 'raw' we assume indent tag is set 1638 # already. 1639 dir = self._find_base_dir(insert.get_line()) 1640 tag = self._get_indent_tag(0, bullet, dir=dir) 1641 self._editmode_tags.append(tag) 1642 1643 with self.user_action: 1644 if bullet == BULLET: 1645 if raw: 1646 self.insert_at_cursor('\u2022') 1647 else: 1648 self.insert_at_cursor('\u2022 ') 1649 elif bullet in bullet_types: 1650 # Insert icon 1651 stock = bullet_types[bullet] 1652 widget = Gtk.HBox() # Need *some* widget here... 1653 pixbuf = widget.render_icon(stock, self.bullet_icon_size) 1654 if pixbuf is None: 1655 logger.warn('Could not find icon: %s', stock) 1656 pixbuf = widget.render_icon(Gtk.STOCK_MISSING_IMAGE, self.bullet_icon_size) 1657 pixbuf.zim_type = 'icon' 1658 pixbuf.zim_attrib = {'stock': stock} 1659 self.insert_pixbuf(self.get_insert_iter(), pixbuf) 1660 1661 if not raw: 1662 self.insert_at_cursor(' ') 1663 else: 1664 # Numbered 1665 if raw: 1666 self.insert_at_cursor(bullet) 1667 else: 1668 self.insert_at_cursor(bullet + ' ') 1669 1670 self._editmode_tags = orig_editmode_tags 1671 1672 def renumber_list(self, line): 1673 '''Renumber list from this line downward 1674 1675 This method is called when the user just typed a new bullet or 1676 when we suspect the user deleted some line(s) that are part 1677 of a numbered list. Typically there is no need to call this 1678 method directly, but it is exposed for testing. 1679 1680 It implements the following rules: 1681 1682 - If there is a numered list item above on the same level, number down 1683 from there 1684 - Else if the line itself has a numbered bullet (and thus is top of a 1685 numbered list) number down 1686 - Stop renumbering at the end of the list, or when a non-numeric bullet 1687 is encountered on the same list level 1688 1689 @param line: line number to start updating 1690 ''' 1691 indent = self.get_indent(line) 1692 bullet = self.get_bullet(line) 1693 if bullet is None or not is_numbered_bullet_re.match(bullet): 1694 return 1695 1696 _, prev = self._search_bullet(line, indent, -1) 1697 if prev and is_numbered_bullet_re.match(prev): 1698 newbullet = increase_list_bullet(prev) 1699 else: 1700 newbullet = bullet 1701 1702 self._renumber_list(line, indent, newbullet) 1703 1704 def renumber_list_after_indent(self, line, old_indent): 1705 '''Like L{renumber_list()}, but more complex rules because indent 1706 change has different heuristics. 1707 1708 It implements the following rules: 1709 1710 - If the bullet type is a checkbox, never change it (else information is 1711 lost on the checkbox state) 1712 - Check for bullet style of the item above on the same level, else 1713 the item below on the same level 1714 - If the bullet became part of a numbered list, renumber that list 1715 either from the item above, or copying starting number from below 1716 - If the bullet became part of a bullet or checkbox list, change it to 1717 match the list 1718 - If there are no other bullets on the same level and the bullet was 1719 a numbered bullet, switch bullet style (number vs letter) and reset 1720 the count 1721 - Else keep the bullet as it was 1722 1723 Also, if the bullet was a numbered bullet, also renumber the 1724 list level where it came from. 1725 ''' 1726 indent = self.get_indent(line) 1727 bullet = self.get_bullet(line) 1728 if bullet is None or bullet in CHECKBOXES: 1729 return 1730 1731 _, prev = self._search_bullet(line, indent, -1) 1732 if prev: 1733 newbullet = increase_list_bullet(prev) or prev 1734 else: 1735 _, newbullet = self._search_bullet(line, indent, +1) 1736 if not newbullet: 1737 if not is_numbered_bullet_re.match(bullet): 1738 return 1739 elif bullet.rstrip('.') in 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz': 1740 newbullet = '1.' # switch e.g. "a." -> "1." 1741 else: 1742 newbullet = 'a.' # switch "1." -> "a." 1743 1744 if is_numbered_bullet_re.match(newbullet): 1745 self._renumber_list(line, indent, newbullet) 1746 else: 1747 if newbullet in CHECKBOXES: 1748 newbullet = UNCHECKED_BOX 1749 self._replace_bullet(line, newbullet) 1750 1751 if is_numbered_bullet_re.match(bullet): 1752 # Also update old list level 1753 newline, newbullet = self._search_bullet(line+1, old_indent, -1) 1754 if newbullet and is_numbered_bullet_re.match(newbullet): 1755 self._renumber_list(newline, old_indent, newbullet) 1756 else: 1757 # If no item above on old level, was top or middle on old level, 1758 # so reset count 1759 newline, newbullet = self._search_bullet(line, old_indent, +1) 1760 if newbullet and is_numbered_bullet_re.match(newbullet): 1761 if newbullet.rstrip('.') in 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz': 1762 self._renumber_list(newline, old_indent, 'a.') 1763 else: 1764 self._renumber_list(newline, old_indent, '1.') 1765 1766 def _search_bullet(self, line, indent, step): 1767 # Return bullet for previous/next bullet item at same level 1768 while True: 1769 line += step 1770 try: 1771 mybullet = self.get_bullet(line) 1772 myindent = self.get_indent(line) 1773 except ValueError: 1774 return None, None 1775 1776 if not mybullet or myindent < indent: 1777 return None, None 1778 elif myindent == indent: 1779 return line, mybullet 1780 # else mybullet and myindent > indent 1781 1782 def _renumber_list(self, line, indent, newbullet): 1783 # Actually renumber for a given line downward 1784 assert is_numbered_bullet_re.match(newbullet) 1785 1786 while True: 1787 try: 1788 mybullet = self.get_bullet(line) 1789 myindent = self.get_indent(line) 1790 except ValueError: 1791 break 1792 1793 if not mybullet or myindent < indent: 1794 break 1795 elif myindent == indent: 1796 if not is_numbered_bullet_re.match(mybullet): 1797 break # Do not replace other bullet types 1798 elif mybullet != newbullet: 1799 self._replace_bullet(line, newbullet) 1800 newbullet = increase_list_bullet(newbullet) 1801 else: 1802 pass # mybullet and myindent > indent 1803 1804 line += 1 1805 1806 #endregion 1807 1808 #region Text Styles 1809 1810 def set_textstyles(self, names): 1811 '''Sets the current text format style. 1812 1813 @param names: the name of the format style 1814 1815 This style will be applied to text inserted at the cursor. 1816 Use C{set_textstyles(None)} to reset to normal text. 1817 ''' 1818 if self._deleted_editmode_mark is not None: 1819 self.delete_mark(self._deleted_editmode_mark) 1820 self._deleted_editmode_mark = None 1821 1822 self._editmode_tags = list(filter(_is_not_style_tag, self._editmode_tags)) # remove all text styles first 1823 1824 if names: 1825 for name in names: 1826 tag = self.get_tag_table().lookup('style-' + name) 1827 if _is_heading_tag(tag): 1828 self._editmode_tags = \ 1829 list(filter(_is_not_indent_tag, self._editmode_tags)) 1830 self._editmode_tags.append(tag) 1831 1832 if not self._insert_tree_in_progress: 1833 self.emit('textstyle-changed', names) 1834 1835 def get_textstyles(self): 1836 '''Get the name of the formatting style that will be applied 1837 to newly inserted text 1838 1839 This style may change as soon as the cursor position changes, 1840 so only relevant for current cursor position. 1841 ''' 1842 tags = list(filter(_is_style_tag, self._editmode_tags)) 1843 if tags: 1844 # X not anymore assert len(tags) == 1, 'BUG: can not have multiple text styles' 1845 return [tag.get_property('name')[6:] for tag in tags] # len('style-') == 6 1846 else: 1847 return [] 1848 1849 #endregion 1850 1851 def update_editmode(self): 1852 '''Updates the text style and indenting applied to newly indented 1853 text based on the current cursor position 1854 1855 This method is triggered automatically when the cursor is moved, 1856 but there are some cases where you may need to call it manually 1857 to force a consistent state. 1858 ''' 1859 bounds = self.get_selection_bounds() 1860 if bounds: 1861 # For selection we set editmode based on left hand side and looking forward 1862 # so counting tags that apply to start of selection 1863 tags = list(filter(_is_zim_tag, bounds[0].get_tags())) 1864 else: 1865 # Otherwise base editmode on cursor position (looking backward) 1866 iter = self.get_insert_iter() 1867 tags = self.iter_get_zim_tags(iter) 1868 1869 tags = list(tags) 1870 if not tags == self._editmode_tags: 1871 #print('> %r' % [(t.zim_type, t.get_property('name')) for t in tags]) 1872 self._editmode_tags = tags 1873 self.emit('textstyle-changed', [tag.get_property('name')[6:] for tag in tags if tag.zim_type == 'style']) 1874 1875 def iter_get_zim_tags(self, iter): 1876 '''Replacement for C{Gtk.TextIter.get_tags()} which returns 1877 zim specific tags 1878 1879 In contrast to C{Gtk.TextIter.get_tags()} this method assumes 1880 "left gravity" for TextTags. This means that it returns TextTags 1881 ending to the left of the iter position but not TextTags starting 1882 to the right. 1883 1884 For TextTags that should be applied per line (like 'indent', 'h', 1885 'pre') some additional logic is used to keep them consistent. 1886 So at the start of the line, we do copy TextTags starting to 1887 the right and not inadvertently copy formatting from the 1888 previous line which ends on the left. 1889 1890 This method is for example used by L{update_editmode()} to 1891 determine which TextTags should be applied to newly inserted 1892 text at at a specific location. 1893 1894 @param iter: a C{Gtk.TextIter} 1895 @returns: a list of C{Gtk.TextTag}s (sorted by priority) 1896 ''' 1897 # Current logic works without additional indent set in 1898 # do_end_of_line due to the fact that the "\n" also caries 1899 # formatting. So putting a new \n at the end of e.g. an indented 1900 # line will result in two indent formatted \n characters. 1901 # The start of the new line is in between and has continuous 1902 # indent formatting. 1903 start_tags = list(filter(_is_zim_tag, iter.get_toggled_tags(True))) 1904 tags = list(filter(_is_zim_tag, iter.get_tags())) 1905 for tag in start_tags: 1906 if tag in tags: 1907 tags.remove(tag) 1908 end_tags = list(filter(_is_zim_tag, iter.get_toggled_tags(False))) 1909 # So now we have 3 separate sets with tags ending here, 1910 # starting here and being continuous here. Result will be 1911 # continuous tags and ending tags but logic for line based 1912 # tags can mix in tags starting here and filter out 1913 # tags ending here. 1914 1915 if iter.starts_line(): 1916 tags += list(filter(_is_line_based_tag, start_tags)) 1917 tags += list(filter(_is_not_line_based_tag, end_tags)) 1918 elif iter.ends_line(): 1919 # Force only use tags from the left in order to prevent tag 1920 # from next line "spilling over" (should not happen, since 1921 # \n after end of line is still formatted with same line 1922 # based tag as rest of line, but handled anyway to be 1923 # robust to edge cases) 1924 tags += end_tags 1925 else: 1926 # Take any tag from left or right, with left taking precendence 1927 # 1928 # HACK: We assume line based tags are mutually exclusive 1929 # if this assumption breaks down need to check by tag type 1930 tags += end_tags 1931 if not list(filter(_is_line_based_tag, tags)): 1932 tags += list(filter(_is_line_based_tag, start_tags)) 1933 1934 tags.sort(key=lambda tag: tag.get_priority()) 1935 return tags 1936 1937 def toggle_textstyle(self, name): 1938 '''Toggle the current textstyle 1939 1940 If there is a selection toggle the text style of the selection, 1941 otherwise toggle the text style for newly inserted text. 1942 1943 This method is mainly to change the behavior for 1944 interactive editing. E.g. it is called indirectly when the 1945 user clicks one of the formatting buttons in the EditBar. 1946 1947 For selections we remove the format if the whole range has the 1948 format already. If some part of the range does not have the 1949 format we apply the format to the whole tange. This makes the 1950 behavior of the format buttons consistent if a single tag 1951 applies to any range. 1952 1953 @param name: the format style name 1954 ''' 1955 if not self.get_has_selection(): 1956 styles = self.get_textstyles() 1957 if 'pre' in styles and name != 'pre': 1958 pass # do not allow styles within verbatim block 1959 elif name in styles: 1960 styles.remove(name) 1961 self.set_textstyles(styles) 1962 else: 1963 self.set_textstyles(styles + [name]) 1964 else: 1965 with self.user_action: 1966 start, end = self.get_selection_bounds() 1967 if name == 'code' and start.starts_line() \ 1968 and end.get_line() != start.get_line(): 1969 name = 'pre' 1970 tag = self.get_tag_table().lookup('style-' + name) 1971 if not self.whole_range_has_tag(tag, start, end): 1972 start, end = self._fix_pre_selection(start, end) 1973 1974 tag = self.get_tag_table().lookup('style-' + name) 1975 had_tag = self.whole_range_has_tag(tag, start, end) 1976 pre_tag = self.get_tag_table().lookup('style-pre') 1977 1978 if tag.zim_tag == "h": 1979 self.smart_remove_tags(_is_heading_tag, start, end) 1980 for line in range(start.get_line(), end.get_line()+1): 1981 self._remove_indent(line) 1982 elif tag.zim_tag in ('pre', 'code'): 1983 self.smart_remove_tags(_is_non_nesting_tag, start, end) 1984 if tag.zim_tag == 'pre': 1985 self.smart_remove_tags(_is_link_tag, start, end) 1986 self.smart_remove_tags(_is_style_tag, start, end) 1987 elif self.range_has_tag(pre_tag, start, end): 1988 return # do not allow formatting withing verbatim block 1989 1990 if had_tag: 1991 self.remove_tag(tag, start, end) 1992 else: 1993 self.apply_tag(tag, start, end) 1994 self.set_modified(True) 1995 1996 self.update_editmode() 1997 1998 def _fix_pre_selection(self, start, end): 1999 # This method converts indent back into TAB before a region is 2000 # formatted as "pre" 2001 start_mark = self.create_mark(None, start, True) 2002 end_mark = self.create_mark(None, end, True) 2003 2004 lines = range(*sorted([start.get_line(), end.get_line()+1])) 2005 min_indent = min(self.get_indent(line) for line in lines) 2006 2007 for line in lines: 2008 indent = self.get_indent(line) 2009 if indent > min_indent: 2010 self.set_indent(line, min_indent) 2011 n_tabs = indent - min_indent 2012 iter = self.get_iter_at_line(line) 2013 self.insert(iter, "\t"*n_tabs) 2014 2015 start = self.get_iter_at_mark(start_mark) 2016 end = self.get_iter_at_mark(end_mark) 2017 self.delete_mark(start_mark) 2018 self.delete_mark(end_mark) 2019 return start, end 2020 2021 def whole_range_has_tag(self, tag, start, end): 2022 '''Check if a certain TextTag is applied to the whole range or 2023 not 2024 2025 @param tag: a C{Gtk.TextTag} 2026 @param start: a C{Gtk.TextIter} 2027 @param end: a C{Gtk.TextIter} 2028 ''' 2029 if tag in start.get_tags() \ 2030 and tag in self.iter_get_zim_tags(end): 2031 iter = start.copy() 2032 if iter.forward_to_tag_toggle(tag): 2033 return iter.compare(end) >= 0 2034 else: 2035 return True 2036 else: 2037 return False 2038 2039 def range_has_tag(self, tag, start, end): 2040 '''Check if a certain TextTag appears anywhere in a range 2041 2042 @param tag: a C{Gtk.TextTag} 2043 @param start: a C{Gtk.TextIter} 2044 @param end: a C{Gtk.TextIter} 2045 ''' 2046 # test right gravity for start iter, but left gravity for end iter 2047 if tag in start.get_tags() \ 2048 or tag in self.iter_get_zim_tags(end): 2049 return True 2050 else: 2051 iter = start.copy() 2052 if iter.forward_to_tag_toggle(tag): 2053 return iter.compare(end) < 0 2054 else: 2055 return False 2056 2057 def range_has_tags(self, func, start, end): 2058 '''Like L{range_has_tag()} but uses a function to check for 2059 multiple tags. The function gets called for each TextTag in the 2060 range and the method returns as soon as the function returns 2061 C{True} for any tag. There are a number of lambda functions 2062 defined in the module to test categories of TextTags. 2063 2064 @param func: a function that is called as: C{func(tag)} for each 2065 TextTag in the range 2066 @param start: a C{Gtk.TextIter} 2067 @param end: a C{Gtk.TextIter} 2068 ''' 2069 # test right gravity for start iter, but left gravity for end iter 2070 if any(filter(func, start.get_tags())) \ 2071 or any(filter(func, self.iter_get_zim_tags(end))): 2072 return True 2073 else: 2074 iter = start.copy() 2075 iter.forward_to_tag_toggle(None) 2076 while iter.compare(end) == -1: 2077 if any(filter(func, iter.get_tags())): 2078 return True 2079 2080 if not iter.forward_to_tag_toggle(None): 2081 return False 2082 2083 return False 2084 2085 def remove_textstyle_tags(self, start, end): 2086 '''Removes all format style TexTags from a range 2087 2088 @param start: a C{Gtk.TextIter} 2089 @param end: a C{Gtk.TextIter} 2090 ''' 2091 # Also remove links until we support links nested in tags 2092 self.smart_remove_tags(_is_style_tag, start, end) 2093 self.smart_remove_tags(_is_link_tag, start, end) 2094 self.smart_remove_tags(_is_tag_tag, start, end) 2095 self.update_editmode() 2096 2097 def smart_remove_tags(self, func, start, end): 2098 '''This method removes tags over a range based on a function 2099 2100 So L{range_has_tags()} for a details on such a test function. 2101 2102 Please use this method instead of C{remove_tag()} when you 2103 are not sure if specific tags are present in the first place. 2104 Calling C{remove_tag()} will emit signals which make the 2105 L{UndoStackManager} assume the tag was there. If this was not 2106 the case the undo stack gets messed up. 2107 ''' 2108 with self.user_action: 2109 iter = start.copy() 2110 while iter.compare(end) == -1: 2111 for tag in filter(func, iter.get_tags()): 2112 bound = iter.copy() 2113 bound.forward_to_tag_toggle(tag) 2114 if not bound.compare(end) == -1: 2115 bound = end.copy() 2116 self.remove_tag(tag, iter, bound) 2117 self.set_modified(True) 2118 2119 if not iter.forward_to_tag_toggle(None): 2120 break 2121 2122 def get_indent_at_cursor(self): 2123 '''Get the indent level at the cursor 2124 2125 @returns: a number for the indenting level 2126 ''' 2127 iter = self.get_iter_at_mark(self.get_insert()) 2128 return self.get_indent(iter.get_line()) 2129 2130 def get_indent(self, line): 2131 '''Get the indent level for a specific line 2132 2133 @param line: the line number 2134 @returns: a number for the indenting level 2135 ''' 2136 iter = self.get_iter_at_line(line) 2137 tags = list(filter(_is_indent_tag, iter.get_tags())) 2138 if tags: 2139 if len(tags) > 1: 2140 logger.warn('BUG: overlapping indent tags') 2141 return int(tags[0].zim_attrib['indent']) 2142 else: 2143 return 0 2144 2145 def _get_indent_tag(self, level, bullet=None, dir='LTR'): 2146 if dir is None: 2147 dir = 'LTR' # Assume western default direction - FIXME need system default 2148 name = 'indent-%s-%i' % (dir, level) 2149 if bullet: 2150 name += '-' + bullet 2151 tag = self.get_tag_table().lookup(name) 2152 if tag is None: 2153 if bullet: 2154 if bullet == BULLET: 2155 stylename = 'bullet-list' 2156 elif bullet == CHECKED_BOX: 2157 stylename = 'checked-checkbox' 2158 elif bullet == UNCHECKED_BOX: 2159 stylename = 'unchecked-checkbox' 2160 elif bullet == XCHECKED_BOX: 2161 stylename = 'xchecked-checkbox' 2162 elif bullet == MIGRATED_BOX: 2163 stylename = 'migrated-checkbox' 2164 elif bullet == TRANSMIGRATED_BOX: 2165 stylename = 'transmigrated-checkbox' 2166 elif is_numbered_bullet_re.match(bullet): 2167 stylename = 'numbered-list' 2168 else: 2169 raise AssertionError('BUG: Unknown bullet type') 2170 margin = 12 + self.pixels_indent * level # offset from left side for all lines 2171 indent = -12 # offset for first line (bullet) 2172 if dir == 'LTR': 2173 tag = self.create_tag(name, 2174 left_margin=margin, indent=indent, 2175 **self.tag_styles[stylename]) 2176 else: # RTL 2177 tag = self.create_tag(name, 2178 right_margin=margin, indent=indent, 2179 **self.tag_styles[stylename]) 2180 else: 2181 margin = 12 + self.pixels_indent * level 2182 # Note: I would think the + 12 is not needed here, but 2183 # the effect in the view is different than expected, 2184 # putting text all the way to the left against the 2185 # window border 2186 if dir == 'LTR': 2187 tag = self.create_tag(name, 2188 left_margin=margin, 2189 **self.tag_styles['indent']) 2190 else: # RTL 2191 tag = self.create_tag(name, 2192 right_margin=margin, 2193 **self.tag_styles['indent']) 2194 2195 tag.zim_type = 'indent' 2196 tag.zim_tag = 'indent' 2197 tag.zim_attrib = {'indent': level, '_bullet': (bullet is not None)} 2198 2199 # Set the prioriy below any _static_style_tags 2200 tag.set_priority(0) 2201 2202 return tag 2203 2204 def _find_base_dir(self, line): 2205 # Look for basedir of current line, else previous line 2206 # till start of paragraph 2207 # FIXME: anyway to actually find out what the TextView will render ?? 2208 while line >= 0: 2209 start, end = self.get_line_bounds(line) 2210 text = start.get_slice(start) 2211 if not text or text.isspace(): 2212 break 2213 2214 dir = Pango.find_base_dir(text, len(text)) 2215 if dir == Pango.DIRECTION_LTR: 2216 return 'LTR' 2217 elif dir == Pango.DIRECTION_RTL: 2218 return 'RTL' 2219 else: 2220 line -= 1 2221 else: 2222 return 'LTR' # default 2223 2224 def set_indent(self, line, level, interactive=False): 2225 '''Set the indenting for a specific line. 2226 2227 May also trigger renumbering for numbered lists. 2228 2229 @param line: the line number 2230 @param level: the indenting level as a number, C{0} for no 2231 indenting, C{1} for the equivalent of 1 tab, etc. 2232 @param interactive: hint if indenting is result of user 2233 interaction, or automatic action 2234 2235 If interactive, the line will be forced to end with a newline. 2236 Reason is that if the last line of the buffer is empty and 2237 does not end with a newline, the indenting will not be visible, 2238 giving the impression that it failed. 2239 2240 @returns: C{True} for success (e.g. indenting a heading is not 2241 allowed, if you try it will fail and return C{False} here) 2242 ''' 2243 level = level or 0 2244 2245 if interactive: 2246 # Without content effect of indenting is not visible 2247 # end-of-line gives content to empty line, but last line 2248 # may not have end-of-line. 2249 start, end = self.get_line_bounds(line) 2250 bufferend = self.get_end_iter() 2251 if start.equal(end) or end.equal(bufferend): 2252 with self.tmp_cursor(): 2253 self.insert(end, '\n') 2254 start, end = self.get_line_bounds(line) 2255 2256 bullet = self.get_bullet(line) 2257 ok = self._set_indent(line, level, bullet) 2258 2259 if ok: 2260 self.set_modified(True) 2261 return ok 2262 2263 def update_indent_tag(self, line, bullet): 2264 '''Update the indent TextTag for a given line 2265 2266 The TextTags used for indenting differ between normal indented 2267 paragraphs and indented items in a bullet list. The reason for 2268 this is that the line wrap behavior of list items should be 2269 slightly different to align wrapped text with the bullet. 2270 2271 This method does not change the indent level for a specific line, 2272 but it makes sure the correct TextTag is applied. Typically 2273 called e.g. after inserting or deleting a bullet. 2274 2275 @param line: the line number 2276 @param bullet: the bullet type for this line, or C{None} 2277 ''' 2278 level = self.get_indent(line) 2279 self._set_indent(line, level, bullet) 2280 2281 def _set_indent(self, line, level, bullet, dir=None): 2282 # Common code between set_indent() and update_indent_tag() 2283 self._remove_indent(line) 2284 2285 start, end = self.get_line_bounds(line) 2286 if list(filter(_is_heading_tag, start.get_tags())): 2287 return level == 0 # False if you try to indent a header 2288 2289 if level > 0 or bullet is not None: 2290 # For bullets there is a 0-level tag, otherwise 0 means None 2291 if dir is None: 2292 dir = self._find_base_dir(line) 2293 tag = self._get_indent_tag(level, bullet, dir=dir) 2294 self.apply_tag(tag, start, end) 2295 2296 self.update_editmode() # also updates indent tag 2297 return True 2298 2299 def _remove_indent(self, line): 2300 start, end = self.get_line_bounds(line) 2301 for tag in filter(_is_indent_tag, start.get_tags()): 2302 self.remove_tag(tag, start, end) 2303 2304 def indent(self, line, interactive=False): 2305 '''Increase the indent for a given line 2306 2307 Can be used as function for L{foreach_line_in_selection()}. 2308 2309 @param line: the line number 2310 @param interactive: hint if indenting is result of user 2311 interaction, or automatic action 2312 2313 @returns: C{True} if successful 2314 ''' 2315 level = self.get_indent(line) 2316 return self.set_indent(line, level + 1, interactive) 2317 2318 def unindent(self, line, interactive=False): 2319 '''Decrease the indent level for a given line 2320 2321 Can be used as function for L{foreach_line_in_selection()}. 2322 2323 @param line: the line number 2324 @param interactive: hint if indenting is result of user 2325 interaction, or automatic action 2326 2327 @returns: C{True} if successful 2328 ''' 2329 level = self.get_indent(line) 2330 return self.set_indent(line, level - 1, interactive) 2331 2332 def foreach_line_in_selection(self, func, *args, **kwarg): 2333 '''Convenience function to call a function for each line that 2334 is currently selected 2335 2336 @param func: function which will be called as:: 2337 2338 func(line, *args, **kwargs) 2339 2340 where C{line} is the line number 2341 @param args: additional argument for C{func} 2342 @param kwarg: additional keyword argument for C{func} 2343 2344 @returns: C{False} if there is no selection, C{True} otherwise 2345 ''' 2346 bounds = self.get_selection_bounds() 2347 if bounds: 2348 start, end = bounds 2349 if end.starts_line(): 2350 # exclude last line if selection ends at newline 2351 # because line is not visually part of selection 2352 end.backward_char() 2353 for line in range(start.get_line(), end.get_line() + 1): 2354 func(line, *args, **kwarg) 2355 return True 2356 else: 2357 return False 2358 2359 def do_mark_set(self, iter, mark): 2360 Gtk.TextBuffer.do_mark_set(self, iter, mark) 2361 if mark.get_name() in ('insert', 'selection_bound'): 2362 self.update_editmode() 2363 2364 def do_insert_text(self, iter, string, length): 2365 '''Signal handler for insert-text signal''' 2366 #print("INSERT %r %d" % (string, length)) 2367 2368 if self._deleted_editmode_mark is not None: 2369 # Use mark if we are the same postion, clear it anyway 2370 markiter = self.get_iter_at_mark(self._deleted_editmode_mark) 2371 if iter.equal(markiter): 2372 self._editmode_tags = self._deleted_editmode_mark.editmode_tags 2373 self.delete_mark(self._deleted_editmode_mark) 2374 self._deleted_editmode_mark = None 2375 2376 def end_or_protect_tags(string, length): 2377 tags = list(filter(_is_tag_tag, self._editmode_tags)) 2378 if tags: 2379 if iter.ends_tag(tags[0]): 2380 # End tags if end-of-word char is typed at end of a tag 2381 # without this you can not insert text behind a tag e.g. at the end of a line 2382 self._editmode_tags = list(filter(_is_not_tag_tag, self._editmode_tags)) 2383 else: 2384 # Forbid breaking a tag 2385 return '', 0 2386 # TODO this should go into the TextView, not here 2387 # Now it goes OK only because we only check single char inserts, but would break 2388 # for multi char inserts from the view - fixing that here breaks insert parsetree 2389 return string, length 2390 2391 # Check if we are at a bullet or checkbox line 2392 # if so insert behind the bullet when you type at start of line 2393 # FIXME FIXME FIXME - break undo - instead disallow this home position ? 2394 if not self._insert_tree_in_progress and iter.starts_line() \ 2395 and not string.endswith('\n'): 2396 bullet = self._get_bullet_at_iter(iter) 2397 if bullet: 2398 self._iter_forward_past_bullet(iter, bullet) 2399 self.place_cursor(iter) 2400 2401 # Check current formatting 2402 if string == '\n': # CHARS_END_OF_LINE 2403 # Break tags that are not allowed to span over multiple lines 2404 self._editmode_tags = [tag for tag in self._editmode_tags if _is_pre_tag(tag) or _is_not_style_tag(tag)] 2405 self._editmode_tags = list(filter(_is_not_link_tag, self._editmode_tags)) 2406 self.emit('textstyle-changed', None) 2407 # TODO make this more robust for multiline inserts 2408 2409 string, length = end_or_protect_tags(string, length) 2410 2411 elif not self._insert_tree_in_progress and string in CHARS_END_OF_WORD: 2412 # Break links if end-of-word char is typed at end of a link 2413 # without this you can not insert text behind a link e.g. at the end of a line 2414 links = list(filter(_is_link_tag, self._editmode_tags)) 2415 if links and iter.ends_tag(links[0]): 2416 self._editmode_tags = list(filter(_is_not_link_tag, self._editmode_tags)) 2417 # TODO this should go into the TextView, not here 2418 # Now it goes OK only because we only check single char inserts, but would break 2419 # for multi char inserts from the view - fixing that here breaks insert parsetree 2420 2421 string, length = end_or_protect_tags(string, length) 2422 2423 # Call parent for the actual insert 2424 Gtk.TextBuffer.do_insert_text(self, iter, string, length) 2425 2426 # And finally apply current text style 2427 # Note: looks like parent call modified the position of the TextIter object 2428 # since it is still valid and now matched the end of the inserted string 2429 length = len(string) 2430 # default function argument gives byte length :S 2431 start = iter.copy() 2432 start.backward_chars(length) 2433 self.remove_all_tags(start, iter) 2434 for tag in self._editmode_tags: 2435 self.apply_tag(tag, start, iter) 2436 2437 def insert_child_anchor(self, iter, anchor): 2438 # Make sure we always apply the correct tags when inserting an object 2439 if iter.equal(self.get_iter_at_mark(self.get_insert())): 2440 Gtk.TextBuffer.insert_child_anchor(self, iter, anchor) 2441 else: 2442 with self.tmp_cursor(iter): 2443 Gtk.TextBuffer.insert_child_anchor(self, iter, anchor) 2444 2445 def do_insert_child_anchor(self, iter, anchor): 2446 # Like do_insert_pixbuf() 2447 Gtk.TextBuffer.do_insert_child_anchor(self, iter, anchor) 2448 2449 start = iter.copy() 2450 start.backward_char() 2451 self.remove_all_tags(start, iter) 2452 for tag in filter(_is_indent_tag, self._editmode_tags): 2453 self.apply_tag(tag, start, iter) 2454 2455 def insert_pixbuf(self, iter, pixbuf): 2456 # Make sure we always apply the correct tags when inserting a pixbuf 2457 if iter.equal(self.get_iter_at_mark(self.get_insert())): 2458 Gtk.TextBuffer.insert_pixbuf(self, iter, pixbuf) 2459 else: 2460 with self.tmp_cursor(iter): 2461 Gtk.TextBuffer.insert_pixbuf(self, iter, pixbuf) 2462 2463 def do_insert_pixbuf(self, iter, pixbuf): 2464 # Like do_insert_text() but for pixbuf 2465 # however only apply indenting tags, ignore other 2466 Gtk.TextBuffer.do_insert_pixbuf(self, iter, pixbuf) 2467 2468 start = iter.copy() 2469 start.backward_char() 2470 self.remove_all_tags(start, iter) 2471 if hasattr(pixbuf, 'zim_type') and pixbuf.zim_type == 'anchor': 2472 for tag in self._editmode_tags: 2473 self.apply_tag(tag, start, iter) 2474 else: 2475 for tag in filter(_is_indent_tag, self._editmode_tags): 2476 self.apply_tag(tag, start, iter) 2477 2478 def do_pre_delete_range(self, start, end): 2479 # (Interactive) deleting a formatted word with <del>, or <backspace> 2480 # should drop the formatting, however selecting a formatted word and 2481 # than typing to replace it, should keep formatting 2482 # Therefore we set a mark to remember the formatting and clear it 2483 # at the end of a user action, or with the next insert at a different 2484 # location 2485 if self._deleted_editmode_mark: 2486 self.delete_mark(self._deleted_editmode_mark) 2487 self._deleted_editmode_mark = self.create_mark(None, end, left_gravity=True) 2488 self._deleted_editmode_mark.editmode_tags = self.iter_get_zim_tags(end) 2489 2490 # Also need to know whether range spanned multiple lines or not 2491 self._deleted_line_end = start.get_line() != end.get_line() 2492 2493 def do_post_delete_range(self, start, end): 2494 # Post handler to hook _do_lines_merged and do some logic 2495 # when deleting bullets 2496 # Note that 'start' and 'end' refer to the same postion here ... 2497 2498 was_list = ( 2499 not start.ends_line() 2500 and any(t for t in start.get_tags() if _is_indent_tag(t) and t.zim_attrib.get('_bullet')) 2501 ) 2502 2503 # Do merging of tags regardless of whether we deleted a line end or not 2504 # worst case some clean up of run-aways tags is done 2505 if ( 2506 ( 2507 not start.starts_line() 2508 and list(filter(_is_line_based_tag, start.get_toggled_tags(True))) 2509 ) or ( 2510 not start.ends_line() 2511 and list(filter(_is_line_based_tag, start.get_toggled_tags(False))) 2512 ) 2513 ): 2514 self._do_lines_merged(start) 2515 2516 # For cleaning up bullets do check more, else we can delete sequences 2517 # that look like a bullet but aren't - see issue #1328 2518 bullet = self._get_bullet_at_iter(start) # Does not check start of line ! 2519 if self._deleted_line_end and bullet is not None: 2520 if start.starts_line(): 2521 self._check_renumber.append(start.get_line()) 2522 elif was_list: 2523 # Clean up the redundant bullet 2524 offset = start.get_offset() 2525 bound = start.copy() 2526 self._iter_forward_past_bullet(bound, bullet) 2527 self.delete(start, bound) 2528 new = self.get_iter_at_offset(offset) 2529 2530 # NOTE: these assignments should not be needed, but without them 2531 # there is a crash here on some systems - see issue #766 2532 start.assign(new) 2533 end.assign(new) 2534 else: 2535 pass 2536 elif start.starts_line(): 2537 indent_tags = list(filter(_is_indent_tag, start.get_tags())) 2538 if indent_tags and indent_tags[0].zim_attrib['_bullet']: 2539 # had a bullet, but no longer (implies we are start of 2540 # line - case where we are not start of line is 2541 # handled by _do_lines_merged by extending the indent tag) 2542 self.update_indent_tag(start.get_line(), None) 2543 2544 self.update_editmode() 2545 2546 def _do_lines_merged(self, iter): 2547 # Enforce tags like 'h', 'pre' and 'indent' to be consistent over the line 2548 # Merge links that have same href target 2549 if iter.starts_line() or iter.ends_line(): 2550 return # TODO Why is this ??? 2551 2552 end = iter.copy() 2553 end.forward_to_line_end() 2554 2555 self.smart_remove_tags(_is_line_based_tag, iter, end) 2556 2557 for tag in self.iter_get_zim_tags(iter): 2558 if _is_line_based_tag(tag): 2559 if tag.zim_tag == 'pre': 2560 self.smart_remove_tags(_is_zim_tag, iter, end) 2561 self.apply_tag(tag, iter, end) 2562 elif _is_link_tag(tag): 2563 for rh_tag in filter(_is_link_tag, iter.get_tags()): 2564 if rh_tag is not tag and rh_tag.zim_attrib['href'] == tag.zim_attrib['href']: 2565 bound = iter.copy() 2566 bound.forward_to_tag_toggle(rh_tag) 2567 self.remove_tag(rh_tag, iter, bound) 2568 self.apply_tag(tag, iter, bound) 2569 2570 self.update_editmode() 2571 2572 def get_bullet(self, line): 2573 '''Get the bullet type on a specific line, if any 2574 2575 @param line: the line number 2576 @returns: the bullet type, if any, or C{None}. 2577 The bullet type can be any of:: 2578 BULLET 2579 UNCHECKED_BOX 2580 CHECKED_BOX 2581 XCHECKED_BOX 2582 MIGRATED_BOX 2583 TRANSMIGRATED_BOX 2584 or a numbered list bullet (test with L{is_numbered_bullet_re}) 2585 ''' 2586 iter = self.get_iter_at_line(line) 2587 return self._get_bullet_at_iter(iter) 2588 2589 def get_bullet_at_iter(self, iter): 2590 '''Return the bullet type in a specific location 2591 2592 Like L{get_bullet()} 2593 2594 @param iter: a C{Gtk.TextIter} 2595 @returns: a bullet type, or C{None} 2596 ''' 2597 if not iter.starts_line(): 2598 return None 2599 else: 2600 return self._get_bullet_at_iter(iter) 2601 2602 def _get_bullet_at_iter(self, iter): 2603 pixbuf = iter.get_pixbuf() 2604 if pixbuf: 2605 if getattr(pixbuf, 'zim_type', None) == 'icon': 2606 2607 return bullets.get(pixbuf.zim_attrib['stock']) 2608 else: 2609 return None 2610 else: 2611 bound = iter.copy() 2612 if not self.iter_forward_word_end(bound): 2613 return None # empty line or whitespace at start of line 2614 2615 text = iter.get_slice(bound) 2616 if text.startswith('\u2022'): 2617 return BULLET 2618 elif is_numbered_bullet_re.match(text): 2619 return text 2620 else: 2621 return None 2622 2623 def iter_forward_past_bullet(self, iter): 2624 '''Move an TextIter past a bullet 2625 2626 This method is useful because we typically want to insert new 2627 text on a line with a bullet after the bullet. This method can 2628 help to find that position. 2629 2630 @param iter: a C{Gtk.TextIter}. The position of this iter will 2631 be modified by this method. 2632 ''' 2633 bullet = self.get_bullet_at_iter(iter) 2634 if bullet: 2635 self._iter_forward_past_bullet(iter, bullet) 2636 return True 2637 else: 2638 return False 2639 2640 def _iter_forward_past_bullet(self, iter, bullet, raw=False): 2641 if bullet in BULLETS: 2642 # Each of these just means one char 2643 iter.forward_char() 2644 else: 2645 assert is_numbered_bullet_re.match(bullet) 2646 self.iter_forward_word_end(iter) 2647 2648 if not raw: 2649 # Skip whitespace as well 2650 bound = iter.copy() 2651 bound.forward_char() 2652 while iter.get_text(bound) == ' ': 2653 if iter.forward_char(): 2654 bound.forward_char() 2655 else: 2656 break 2657 2658 def get_parsetree(self, bounds=None, raw=False): 2659 '''Get a L{ParseTree} representing the buffer contents 2660 2661 @param bounds: a 2-tuple with two C{Gtk.TextIter} specifying a 2662 range in the buffer (e.g. current selection). If C{None} the 2663 whole buffer is returned. 2664 2665 @param raw: if C{True} you get a tree that is B{not} nicely 2666 cleaned up. This raw tree should result in the exact same 2667 contents in the buffer when reloaded. However such a 'raw' 2668 tree may cause problems when passed to one of the format 2669 modules. So it is intended only for internal use between the 2670 buffer and e.g. the L{UndoStackManager}. 2671 2672 Raw parsetrees have an attribute to flag them as a raw tree, so 2673 on insert we can make sure they are inserted in the same way. 2674 2675 When C{raw} is C{False} reloading the same tree may have subtle 2676 differences. 2677 2678 @returns: a L{ParseTree} object 2679 ''' 2680 if self.showing_template and not raw: 2681 return None 2682 2683 if bounds is None: 2684 start, end = self.get_bounds() 2685 attrib = {} 2686 else: 2687 start, end = bounds 2688 attrib = {'partial': True} 2689 2690 if raw: 2691 builder = ElementTreeModule.TreeBuilder() 2692 attrib['raw'] = True 2693 builder.start('zim-tree', attrib) 2694 else: 2695 builder = OldParseTreeBuilder() 2696 builder.start('zim-tree', attrib) 2697 2698 open_tags = [] 2699 def set_tags(iter, tags): 2700 # This function changes the parse tree based on the TextTags in 2701 # effect for the next section of text. 2702 # It does so be keeping the stack of open tags and compare it 2703 # with the new set of tags in order to decide which of the 2704 # tags can be closed and which new ones need to be opened. 2705 2706 tags.sort(key=lambda tag: tag.get_priority()) 2707 if any(_is_tag_tag(t) for t in tags): 2708 # Although not highest prio, no other tag can nest below a tag-tag 2709 while not _is_tag_tag(tags[-1]): 2710 tags.pop() 2711 2712 if any(_is_inline_nesting_tag(t) for t in tags): 2713 tags = self._sort_nesting_style_tags(iter, end, tags, [t[0] for t in open_tags]) 2714 2715 # For tags that can only appear once, if somehow an overlap 2716 # occured, choose the one with the highest prio 2717 for i in range(len(tags)-2, -1, -1): 2718 if tags[i].zim_type in ('link', 'tag', 'indent') \ 2719 and tags[i+1].zim_type == tags[i].zim_type: 2720 tags.pop(i) 2721 elif tags[i+1].zim_tag == 'h' \ 2722 and tags[i].zim_tag in ('h', 'indent'): 2723 tags.pop(i) 2724 elif tags[i+1].zim_tag == 'pre' \ 2725 and tags[i].zim_type == 'style': 2726 tags.pop(i) 2727 2728 i = 0 2729 while i < len(tags) and i < len(open_tags) \ 2730 and tags[i] == open_tags[i][0]: 2731 i += 1 2732 2733 # so i is the breakpoint where new stack is different 2734 while len(open_tags) > i: 2735 builder.end(open_tags[-1][1]) 2736 open_tags.pop() 2737 2738 # Convert some tags on the fly 2739 if tags: 2740 continue_attrib = {} 2741 for tag in tags[i:]: 2742 t, attrib = tag.zim_tag, tag.zim_attrib 2743 if t == 'indent': 2744 attrib = attrib.copy() # break ref with tree 2745 del attrib['_bullet'] 2746 bullet = self._get_bullet_at_iter(iter) 2747 if bullet: 2748 t = 'li' 2749 attrib['bullet'] = bullet 2750 self._iter_forward_past_bullet(iter, bullet, raw=raw) 2751 elif not raw and not iter.starts_line(): 2752 # Indent not visible if it does not start at begin of line 2753 t = '_ignore_' 2754 elif len([t for t in tags[i:] if t.zim_tag == 'pre']): 2755 # Indent of 'pre' blocks handled in subsequent iteration 2756 continue_attrib.update(attrib) 2757 continue 2758 else: 2759 t = 'div' 2760 elif t == 'pre' and not raw and not iter.starts_line(): 2761 # Without indenting 'pre' looks the same as 'code' 2762 # Prevent turning into a separate paragraph here 2763 t = 'code' 2764 elif t == 'pre': 2765 if attrib: 2766 attrib.update(continue_attrib) 2767 else: 2768 attrib = continue_attrib 2769 continue_attrib = {} 2770 elif t == 'link': 2771 attrib = self.get_link_data(iter, raw=raw) 2772 elif t == 'tag': 2773 attrib = self.get_tag_data(iter) 2774 if not attrib['name']: 2775 t = '_ignore_' 2776 builder.start(t, attrib or {}) 2777 open_tags.append((tag, t)) 2778 if t == 'li': 2779 break 2780 # HACK - ignore any other tags because we moved 2781 # the cursor - needs also a break_tags before 2782 # which is special cased below 2783 # TODO: cleaner solution for this issue - 2784 # maybe easier when tags for list and indent 2785 # are separated ? 2786 2787 def break_tags(type): 2788 # Forces breaking the stack of open tags on the level of 'tag' 2789 # The next set_tags() will re-open any tags that are still open 2790 i = 0 2791 for i in range(len(open_tags)): 2792 if open_tags[i][1] == type: 2793 break 2794 2795 # so i is the breakpoint 2796 while len(open_tags) > i: 2797 builder.end(open_tags[-1][1]) 2798 open_tags.pop() 2799 2800 # And now the actual loop going through the buffer 2801 iter = start.copy() 2802 set_tags(iter, list(filter(_is_zim_tag, iter.get_tags()))) 2803 while iter.compare(end) == -1: 2804 pixbuf = iter.get_pixbuf() 2805 anchor = iter.get_child_anchor() 2806 if pixbuf: 2807 if pixbuf.zim_type == 'icon': 2808 # Reset all tags - and let set_tags parse the bullet 2809 if open_tags: 2810 break_tags(open_tags[0][1]) 2811 set_tags(iter, list(filter(_is_indent_tag, iter.get_tags()))) 2812 elif pixbuf.zim_type == 'anchor': 2813 pass # allow as object nested in e.g. header tag 2814 else: 2815 # reset all tags except indenting 2816 set_tags(iter, list(filter(_is_indent_tag, iter.get_tags()))) 2817 2818 pixbuf = iter.get_pixbuf() # iter may have moved 2819 if pixbuf is None: 2820 continue 2821 2822 if pixbuf.zim_type == 'icon': 2823 logger.warn('BUG: Checkbox outside of indent ?') 2824 elif pixbuf.zim_type == 'image': 2825 attrib = pixbuf.zim_attrib.copy() 2826 builder.start('img', attrib or {}) 2827 builder.end('img') 2828 elif pixbuf.zim_type == 'anchor': 2829 attrib = pixbuf.zim_attrib.copy() 2830 builder.start('anchor', attrib) 2831 builder.data(attrib['name']) # HACK for OldParseTreeBuilder cleanup 2832 builder.end('anchor') 2833 else: 2834 assert False, 'BUG: unknown pixbuf type' 2835 2836 iter.forward_char() 2837 2838 # embedded widget 2839 elif anchor: 2840 set_tags(iter, list(filter(_is_indent_tag, iter.get_tags()))) 2841 anchor = iter.get_child_anchor() # iter may have moved 2842 if isinstance(anchor, InsertedObjectAnchor): 2843 anchor.dump(builder) 2844 iter.forward_char() 2845 else: 2846 continue 2847 else: 2848 # Set tags 2849 copy = iter.copy() 2850 2851 bullet = self.get_bullet_at_iter(iter) # implies check for start of line 2852 if bullet: 2853 break_tags('indent') 2854 # This is part of the HACK for bullets in 2855 # set_tags() 2856 2857 set_tags(iter, list(filter(_is_zim_tag, iter.get_tags()))) 2858 if not iter.equal(copy): # iter moved 2859 continue 2860 2861 # Find biggest slice without tags being toggled 2862 bound = iter.copy() 2863 toggled = [] 2864 while not toggled: 2865 if not bound.is_end() and bound.forward_to_tag_toggle(None): 2866 # For some reason the not is_end check is needed 2867 # to prevent an odd corner case infinite loop 2868 toggled = list(filter(_is_zim_tag, 2869 bound.get_toggled_tags(False) 2870 + bound.get_toggled_tags(True))) 2871 else: 2872 bound = end.copy() # just to be sure.. 2873 break 2874 2875 # But limit slice to first pixbuf or any embeddded widget 2876 2877 text = iter.get_slice(bound) 2878 if text.startswith(PIXBUF_CHR): 2879 text = text[1:] # special case - we see this char, but get_pixbuf already returned None, so skip it 2880 2881 if PIXBUF_CHR in text: 2882 i = text.index(PIXBUF_CHR) 2883 bound = iter.copy() 2884 bound.forward_chars(i) 2885 text = text[:i] 2886 2887 # And limit to end 2888 if bound.compare(end) == 1: 2889 bound = end.copy() 2890 text = iter.get_slice(end) 2891 2892 break_at = None 2893 MULTI_LINE_BLOCK = [t for t in BLOCK_LEVEL if t != HEADING] 2894 if bound.get_line() != iter.get_line(): 2895 if any(t[1] == LISTITEM for t in open_tags): 2896 # And limit bullets to a single line 2897 break_at = LISTITEM 2898 elif not raw and any(t[1] not in MULTI_LINE_BLOCK for t in open_tags): 2899 # Prevent formatting tags to run multiple lines 2900 for t in open_tags: 2901 if t[1] not in MULTI_LINE_BLOCK: 2902 break_at = t[1] 2903 break 2904 2905 if break_at: 2906 orig = bound 2907 bound = iter.copy() 2908 bound.forward_line() 2909 assert bound.compare(orig) < 1 2910 text = iter.get_slice(bound).rstrip('\n') 2911 builder.data(text) 2912 break_tags(break_at) 2913 builder.data('\n') # add to tail 2914 else: 2915 # Else just insert text we got 2916 builder.data(text) 2917 2918 iter = bound 2919 2920 # close any open tags 2921 set_tags(end, []) 2922 2923 builder.end('zim-tree') 2924 tree = ParseTree(builder.close()) 2925 tree.encode_urls() 2926 2927 if not raw and tree.hascontent: 2928 # Reparsing the parsetree in order to find raw wiki codes 2929 # and get rid of oddities in our generated parsetree. 2930 #print(">>> Parsetree original:\n", tree.tostring()) 2931 from zim.formats import get_format 2932 format = get_format("wiki") # FIXME should the format used here depend on the store ? 2933 dumper = format.Dumper() 2934 parser = format.Parser() 2935 text = dumper.dump(tree) 2936 #print(">>> Wiki text:\n", text) 2937 tree = parser.parse(text, partial=tree.ispartial) 2938 #print(">>> Parsetree recreated:\n", tree.tostring()) 2939 2940 return tree 2941 2942 def _sort_nesting_style_tags(self, iter, end, tags, open_tags): 2943 new_block, new_nesting, new_leaf = self._split_nesting_style_tags(tags) 2944 open_block, open_nesting, open_leaf = self._split_nesting_style_tags(open_tags) 2945 sorted_new_nesting = [] 2946 2947 # First prioritize open tags - these are sorted already 2948 if new_block == open_block: 2949 for tag in open_nesting: 2950 if tag in new_nesting: 2951 i = new_nesting.index(tag) 2952 sorted_new_nesting.append(new_nesting.pop(i)) 2953 else: 2954 break 2955 2956 # Then sort by length untill closing all tags that open at the same time 2957 def tag_close_pos(tag): 2958 my_iter = iter.copy() 2959 my_iter.forward_to_tag_toggle(tag) 2960 if my_iter.compare(end) > 0: 2961 return end.get_offset() 2962 else: 2963 return my_iter.get_offset() 2964 2965 new_nesting.sort(key=tag_close_pos, reverse=True) 2966 sorted_new_nesting += new_nesting 2967 2968 return new_block + sorted_new_nesting + new_leaf 2969 2970 def _split_nesting_style_tags(self, tags): 2971 block, nesting = [], [] 2972 while tags and not _is_inline_nesting_tag(tags[0]): 2973 block.append(tags.pop(0)) 2974 while tags and _is_inline_nesting_tag(tags[0]): 2975 nesting.append(tags.pop(0)) 2976 return block, nesting, tags 2977 2978 def select_line(self, line=None): 2979 '''Selects a line 2980 @param line: line number; if C{None} current line will be selected 2981 @returns: C{True} when successful 2982 ''' 2983 # Differs from get_line_bounds because we exclude the trailing 2984 # line break while get_line_bounds selects these 2985 if line is None: 2986 iter = self.get_iter_at_mark(self.get_insert()) 2987 line = iter.get_line() 2988 return self.select_lines(line, line) 2989 2990 def select_lines(self, first, last): 2991 '''Select multiple lines 2992 @param first: line number first line 2993 @param last: line number last line 2994 @returns: C{True} when successful 2995 ''' 2996 start = self.get_iter_at_line(first) 2997 end = self.get_iter_at_line(last) 2998 if end.ends_line(): 2999 if end.equal(start): 3000 return False 3001 else: 3002 pass 3003 else: 3004 end.forward_to_line_end() 3005 self.select_range(start, end) 3006 return True 3007 3008 def select_word(self): 3009 '''Selects the current word, if any 3010 3011 @returns: C{True} when succcessful 3012 ''' 3013 insert = self.get_iter_at_mark(self.get_insert()) 3014 if not insert.inside_word(): 3015 return False 3016 3017 bound = insert.copy() 3018 if not insert.starts_word(): 3019 insert.backward_word_start() 3020 if not bound.ends_word(): 3021 bound.forward_word_end() 3022 3023 self.select_range(insert, bound) 3024 return True 3025 3026 def strip_selection(self): 3027 '''Shrinks the selection to exclude any whitespace on start and end. 3028 If only white space was selected this function will not change the selection. 3029 @returns: C{True} when this function changed the selection. 3030 ''' 3031 bounds = self.get_selection_bounds() 3032 if not bounds: 3033 return False 3034 3035 text = bounds[0].get_text(bounds[1]) 3036 if not text or text.isspace(): 3037 return False 3038 3039 start, end = bounds[0].copy(), bounds[1].copy() 3040 iter = start.copy() 3041 iter.forward_char() 3042 text = start.get_text(iter) 3043 while text and text.isspace(): 3044 start.forward_char() 3045 iter.forward_char() 3046 text = start.get_text(iter) 3047 3048 iter = end.copy() 3049 iter.backward_char() 3050 text = iter.get_text(end) 3051 while text and text.isspace(): 3052 end.backward_char() 3053 iter.backward_char() 3054 text = iter.get_text(end) 3055 3056 if (start.equal(bounds[0]) and end.equal(bounds[1])): 3057 return False 3058 else: 3059 self.select_range(start, end) 3060 return True 3061 3062 def select_link(self): 3063 '''Selects the current link, if any 3064 @returns: link attributes when succcessful, C{None} otherwise 3065 ''' 3066 insert = self.get_iter_at_mark(self.get_insert()) 3067 tag = self.get_link_tag(insert) 3068 if tag is None: 3069 return None 3070 start, end = self.get_tag_bounds(insert, tag) 3071 self.select_range(start, end) 3072 return self.get_link_data(start) 3073 3074 def get_has_link_selection(self): 3075 '''Check whether a link is selected or not 3076 @returns: link attributes when succcessful, C{None} otherwise 3077 ''' 3078 bounds = self.get_selection_bounds() 3079 if not bounds: 3080 return None 3081 3082 insert = self.get_iter_at_mark(self.get_insert()) 3083 tag = self.get_link_tag(insert) 3084 if tag is None: 3085 return None 3086 start, end = self.get_tag_bounds(insert, tag) 3087 if start.equal(bounds[0]) and end.equal(bounds[1]): 3088 return self.get_link_data(start) 3089 else: 3090 return None 3091 3092 def remove_link(self, start, end): 3093 '''Removes any links between in a range 3094 3095 @param start: a C{Gtk.TextIter} 3096 @param end: a C{Gtk.TextIter} 3097 ''' 3098 self.smart_remove_tags(_is_link_tag, start, end) 3099 self.update_editmode() 3100 3101 def find_implicit_anchor(self, name): 3102 """Search the current page for a heading who's derived (implicit) anchor name is 3103 matching the provided parameter. 3104 @param name: the name of the anchor 3105 @returns: a C{Gtk.TextIter} pointing to the start of the heading or C{None}. 3106 """ 3107 iter = self.get_start_iter() 3108 while True: 3109 tags = list(filter(_is_heading_tag, iter.get_tags())) 3110 if tags: 3111 tag = tags[0] 3112 end = iter.copy() 3113 end.forward_to_tag_toggle(tag) 3114 text = iter.get_text(end) 3115 if heading_to_anchor(text) == name: 3116 return iter 3117 if not iter.forward_line(): 3118 break 3119 return None 3120 3121 def find_anchor(self, name): 3122 """Searches the current page for an anchor with the requested name. 3123 3124 Explicit anchors are being searched with precedence over implicit 3125 anchors derived from heading elements. 3126 3127 @param name: the name of the anchor to look for 3128 @returns: a C{Gtk.TextIter} pointing to the start of the heading or C{None}. 3129 """ 3130 # look for explicit anchors tags including image or object tags 3131 start, end = self.get_bounds() 3132 for iter, myname in self.iter_anchors_for_range(start, end): 3133 if myname == name: 3134 return iter 3135 3136 # look for an implicit heading anchor 3137 return self.find_implicit_anchor(name) 3138 3139 def toggle_checkbox(self, line, checkbox_type=None, recursive=False): 3140 '''Toggles the state of the checkbox at a specific line, if any 3141 3142 @param line: the line number 3143 @param checkbox_type: the checkbox type that we want to toggle: 3144 one of C{CHECKED_BOX}, C{XCHECKED_BOX}, C{MIGRATED_BOX}, 3145 C{TRANSMIGRATED_BOX}. 3146 If C{checkbox_type} is given, it toggles between this type and 3147 unchecked. Otherwise it rotates through unchecked, checked 3148 and xchecked. 3149 As a special case when the C{checkbox_type} ir C{UNCHECKED_BOX} 3150 the box is always unchecked. 3151 @param recursive: When C{True} any child items in the list will 3152 also be upadted accordingly (see L{TextBufferList.set_bullet()} 3153 3154 @returns: C{True} for success, C{False} if no checkbox was found. 3155 ''' 3156 # For mouse click no checkbox type is given, so we cycle 3157 # For <F12> and <Shift><F12> checkbox_type is given so we toggle 3158 # between the two 3159 bullet = self.get_bullet(line) 3160 if bullet in CHECKBOXES: 3161 if checkbox_type: 3162 if bullet == checkbox_type: 3163 newbullet = UNCHECKED_BOX 3164 else: 3165 newbullet = checkbox_type 3166 else: 3167 i = list(CHECKBOXES).index(bullet) # use list() to be python 2.5 compatible 3168 next = (i + 1) % len(CHECKBOXES) 3169 newbullet = CHECKBOXES[next] 3170 else: 3171 return False 3172 3173 if recursive: 3174 row, clist = TextBufferList.new_from_line(self, line) 3175 clist.set_bullet(row, newbullet) 3176 else: 3177 self.set_bullet(line, newbullet) 3178 3179 return True 3180 3181 def toggle_checkbox_for_cursor_or_selection(self, checkbox_type=None, recursive=False): 3182 '''Like L{toggle_checkbox()} but applies to current line or 3183 current selection. Intended for interactive use. 3184 3185 @param checkbox_type: the checkbox type that we want to toggle 3186 @param recursive: When C{True} any child items in the list will 3187 also be upadted accordingly (see L{TextBufferList.set_bullet()} 3188 ''' 3189 if self.get_has_selection(): 3190 self.foreach_line_in_selection(self.toggle_checkbox, checkbox_type, recursive) 3191 else: 3192 line = self.get_insert_iter().get_line() 3193 return self.toggle_checkbox(line, checkbox_type, recursive) 3194 3195 def iter_backward_word_start(self, iter): 3196 '''Like C{Gtk.TextIter.backward_word_start()} but less intelligent. 3197 This method does not take into account the language or 3198 punctuation and just skips to either the last whitespace or 3199 the beginning of line. 3200 3201 @param iter: a C{Gtk.TextIter}, the position of this iter will 3202 be modified 3203 @returns: C{True} when successful 3204 ''' 3205 if iter.starts_line(): 3206 return False 3207 3208 orig = iter.copy() 3209 while True: 3210 if iter.starts_line(): 3211 break 3212 else: 3213 bound = iter.copy() 3214 bound.backward_char() 3215 char = bound.get_slice(iter) 3216 if char == PIXBUF_CHR or char.isspace(): 3217 break # whitespace or pixbuf before start iter 3218 else: 3219 iter.backward_char() 3220 3221 return iter.compare(orig) != 0 3222 3223 def iter_forward_word_end(self, iter): 3224 '''Like C{Gtk.TextIter.forward_word_end()} but less intelligent. 3225 This method does not take into account the language or 3226 punctuation and just skips to either the next whitespace or the 3227 end of the line. 3228 3229 @param iter: a C{Gtk.TextIter}, the position of this iter will 3230 be modified 3231 @returns: C{True} when successful 3232 ''' 3233 if iter.ends_line(): 3234 return False 3235 3236 orig = iter.copy() 3237 while True: 3238 if iter.ends_line(): 3239 break 3240 else: 3241 bound = iter.copy() 3242 bound.forward_char() 3243 char = bound.get_slice(iter) 3244 if char == PIXBUF_CHR or char.isspace(): 3245 break # whitespace or pixbuf after iter 3246 else: 3247 iter.forward_char() 3248 3249 return iter.compare(orig) != 0 3250 3251 def get_iter_at_line(self, line): 3252 '''Like C{Gtk.TextBuffer.get_iter_at_line()} but with additional 3253 safety check 3254 @param line: an integer line number counting from 0 3255 @returns: a Gtk.TextIter 3256 @raises ValueError: when line is not within the buffer 3257 ''' 3258 # Gtk TextBuffer returns iter of last line for lines past the 3259 # end of the buffer 3260 if line < 0: 3261 raise ValueError('Negative line number: %i' % line) 3262 else: 3263 iter = Gtk.TextBuffer.get_iter_at_line(self, line) 3264 if iter.get_line() != line: 3265 raise ValueError('Line number beyond the end of the buffer: %i' % line) 3266 return iter 3267 3268 def get_line_bounds(self, line): 3269 '''Get the TextIters at start and end of line 3270 3271 @param line: the line number 3272 @returns: a 2-tuple of C{Gtk.TextIter} for start and end of the 3273 line 3274 ''' 3275 start = self.get_iter_at_line(line) 3276 end = start.copy() 3277 end.forward_line() 3278 return start, end 3279 3280 def get_line_is_empty(self, line): 3281 '''Check for empty lines 3282 3283 @param line: the line number 3284 @returns: C{True} if the line only contains whitespace 3285 ''' 3286 start, end = self.get_line_bounds(line) 3287 return start.equal(end) or start.get_slice(end).isspace() 3288 3289 def get_has_selection(self): 3290 '''Check if there is a selection 3291 3292 Method available in C{Gtk.TextBuffer} for gtk version >= 2.10 3293 reproduced here for backward compatibility. 3294 3295 @returns: C{True} when there is a selection 3296 ''' 3297 return bool(self.get_selection_bounds()) 3298 3299 def iter_in_selection(self, iter): 3300 '''Check if a specific TextIter is within the selection 3301 3302 @param iter: a C{Gtk.TextIter} 3303 @returns: C{True} if there is a selection and C{iter} is within 3304 the range of the selection 3305 ''' 3306 bounds = self.get_selection_bounds() 3307 return bounds \ 3308 and bounds[0].compare(iter) <= 0 \ 3309 and bounds[1].compare(iter) >= 0 3310 # not using iter.in_range to be inclusive of bounds 3311 3312 def unset_selection(self): 3313 '''Remove any selection in the buffer''' 3314 iter = self.get_iter_at_mark(self.get_insert()) 3315 self.select_range(iter, iter) 3316 3317 def copy_clipboard(self, clipboard, format='plain'): 3318 '''Copy current selection to a clipboard 3319 3320 @param clipboard: a L{Clipboard} object 3321 @param format: a format name 3322 ''' 3323 bounds = self.get_selection_bounds() 3324 if bounds: 3325 tree = self.get_parsetree(bounds) 3326 #~ print(">>>> SET", tree.tostring()) 3327 clipboard.set_parsetree(self.notebook, self.page, tree, format) 3328 3329 def cut_clipboard(self, clipboard, default_editable): 3330 '''Cut current selection to a clipboard 3331 3332 First copies the selection to the clipboard and then deletes 3333 the selection in the buffer. 3334 3335 @param clipboard: a L{Clipboard} object 3336 @param default_editable: default state of the L{TextView} 3337 ''' 3338 if self.get_has_selection(): 3339 self.copy_clipboard(clipboard) 3340 self.delete_selection(True, default_editable) 3341 3342 def paste_clipboard(self, clipboard, iter, default_editable, text_format=None): 3343 '''Paste data from a clipboard into the buffer 3344 3345 @param clipboard: a L{Clipboard} object 3346 @param iter: a C{Gtk.TextIter} for the insert location 3347 @param default_editable: default state of the L{TextView} 3348 ''' 3349 if not default_editable: 3350 return 3351 3352 if iter is None: 3353 iter = self.get_iter_at_mark(self.get_insert()) 3354 tags = list(filter(_is_pre_or_code_tag, self._editmode_tags)) 3355 if tags: 3356 text_format = 'verbatim-' + tags[0].zim_tag 3357 elif self.get_has_selection(): 3358 # unset selection if explicit iter is given 3359 bound = self.get_selection_bound() 3360 insert = self.get_insert() 3361 self.move_mark(bound, self.get_iter_at_mark(insert)) 3362 3363 mark = self.get_mark('zim-paste-position') 3364 if mark: 3365 self.move_mark(mark, iter) 3366 else: 3367 self.create_mark('zim-paste-position', iter, left_gravity=False) 3368 3369 #~ clipboard.debug_dump_contents() 3370 if text_format is None: 3371 tags = list(filter(_is_pre_or_code_tag, self.iter_get_zim_tags(iter))) 3372 if tags: 3373 text_format = 'verbatim-' + tags[0].zim_tag 3374 else: 3375 text_format = 'wiki' # TODO: should depend on page format 3376 parsetree = clipboard.get_parsetree(self.notebook, self.page, text_format) 3377 if not parsetree: 3378 return 3379 3380 #~ print('!! PASTE', parsetree.tostring()) 3381 with self.user_action: 3382 if self.get_has_selection(): 3383 start, end = self.get_selection_bounds() 3384 self.delete(start, end) 3385 3386 mark = self.get_mark('zim-paste-position') 3387 if not mark: 3388 return # prevent crash - see lp:807830 3389 3390 iter = self.get_iter_at_mark(mark) 3391 self.delete_mark(mark) 3392 3393 self.place_cursor(iter) 3394 self.insert_parsetree_at_cursor(parsetree, interactive=True) 3395 3396 3397class TextBufferList(list): 3398 '''This class represents a bullet or checkbox list in a L{TextBuffer}. 3399 It is used to perform recursive actions on the list. 3400 3401 While the L{TextBuffer} just treats list items as lines that start 3402 with a bullet, the TextBufferList maps to a number of lines that 3403 together form a list. It uses "row ids" to refer to specific 3404 items within this range. 3405 3406 TextBufferList objects will become invalid after any modification 3407 to the buffer that changes the line count within the list. Using 3408 them after such modification will result in errors. 3409 ''' 3410 3411 # This class is a list of tuples, each tuple is a pair of 3412 # (linenumber, indentlevel, bullettype) 3413 3414 LINE_COL = 0 3415 INDENT_COL = 1 3416 BULLET_COL = 2 3417 3418 @classmethod 3419 def new_from_line(self, textbuffer, line): 3420 '''Constructor for a new TextBufferList mapping the list at a 3421 specific line in the buffer 3422 3423 @param textbuffer: a L{TextBuffer} object 3424 @param line: a line number 3425 3426 This line should be part of a list, the TextBufferList object 3427 that is returned maps the full list, so it possibly extends 3428 above and below C{line}. 3429 3430 @returns: a 2-tuple of a row id and a the new TextBufferList 3431 object, or C{(None, None)} if C{line} is not part of a list. 3432 The row id points to C{line} in the list. 3433 ''' 3434 if textbuffer.get_bullet(line) is None: 3435 return None, None 3436 3437 # find start of list 3438 start = line 3439 for myline in range(start, -1, -1): 3440 if textbuffer.get_bullet(myline) is None: 3441 break # TODO skip lines with whitespace 3442 else: 3443 start = myline 3444 3445 # find end of list 3446 end = line 3447 lastline = textbuffer.get_end_iter().get_line() 3448 for myline in range(end, lastline + 1, 1): 3449 if textbuffer.get_bullet(myline) is None: 3450 break # TODO skip lines with whitespace 3451 else: 3452 end = myline 3453 3454 list = TextBufferList(textbuffer, start, end) 3455 row = list.get_row_at_line(line) 3456 #~ print('!! LIST %i..%i ROW %i' % (start, end, row)) 3457 #~ print('>>', list) 3458 return row, list 3459 3460 def __init__(self, textbuffer, firstline, lastline): 3461 '''Constructor 3462 3463 @param textbuffer: a L{TextBuffer} object 3464 @param firstline: the line number for the first line of the list 3465 @param lastline: the line number for the last line of the list 3466 ''' 3467 self.buffer = textbuffer 3468 self.firstline = firstline 3469 self.lastline = lastline 3470 for line in range(firstline, lastline + 1): 3471 bullet = self.buffer.get_bullet(line) 3472 indent = self.buffer.get_indent(line) 3473 if bullet: 3474 self.append((line, indent, bullet)) 3475 3476 def get_row_at_line(self, line): 3477 '''Get the row in the list for a specific line 3478 3479 @param line: the line number for a line in the L{TextBuffer} 3480 @returns: the row id for a row in the list or C{None} when 3481 the line was outside of the list 3482 ''' 3483 for i in range(len(self)): 3484 if self[i][self.LINE_COL] == line: 3485 return i 3486 else: 3487 return None 3488 3489 def can_indent(self, row): 3490 '''Check whether a specific item in the list can be indented 3491 3492 List items can only be indented if they are on top of the list 3493 or when there is some node above them to serve as new parent node. 3494 This avoids indenting two levels below the parent. 3495 3496 So e.g. in the case of:: 3497 3498 * item a 3499 * item b 3500 3501 then "item b" can indent and become a child of "item a". 3502 However after indenting once:: 3503 3504 * item a 3505 * item b 3506 3507 now "item b" can not be indented further because it is already 3508 one level below "item a". 3509 3510 @param row: the row id 3511 @returns: C{True} when indenting is possible 3512 ''' 3513 if row == 0: 3514 return True 3515 else: 3516 parents = self._parents(row) 3517 if row - 1 in parents: 3518 return False # we are first child 3519 else: 3520 return True 3521 3522 def can_unindent(self, row): 3523 '''Check if a specific item in the list has indenting which 3524 can be reduced 3525 3526 @param row: the row id 3527 @returns: C{True} when the item has indenting 3528 ''' 3529 return self[row][self.INDENT_COL] > 0 3530 3531 def indent(self, row): 3532 '''Indent a list item and all it's children 3533 3534 For example, when indenting "item b" in this list:: 3535 3536 * item a 3537 * item b 3538 * item C 3539 3540 it will result in:: 3541 3542 * item a 3543 * item b 3544 * item C 3545 3546 @param row: the row id 3547 @returns: C{True} if successfulll 3548 ''' 3549 if not self.can_indent(row): 3550 return False 3551 with self.buffer.user_action: 3552 self._indent(row, 1) 3553 return True 3554 3555 def unindent(self, row): 3556 '''Un-indent a list item and it's children 3557 3558 @param row: the row id 3559 @returns: C{True} if successfulll 3560 ''' 3561 if not self.can_unindent(row): 3562 return False 3563 with self.buffer.user_action: 3564 self._indent(row, -1) 3565 return True 3566 3567 def _indent(self, row, step): 3568 line, level, bullet = self[row] 3569 self._indent_row(row, step) 3570 3571 if row == 0: 3572 # Indent the whole list 3573 for i in range(1, len(self)): 3574 if self[i][self.INDENT_COL] >= level: 3575 # double check implicit assumption that first item is at lowest level 3576 self._indent_row(i, step) 3577 else: 3578 break 3579 else: 3580 # Indent children 3581 for i in range(row + 1, len(self)): 3582 if self[i][self.INDENT_COL] > level: 3583 self._indent_row(i, step) 3584 else: 3585 break 3586 3587 # Renumber - *after* children have been updated as well 3588 # Do not restrict to number bullets - we might be moving 3589 # a normal bullet into a numbered sub list 3590 # TODO - pull logic of renumber_list_after_indent here and use just renumber_list 3591 self.buffer.renumber_list_after_indent(line, level) 3592 3593 def _indent_row(self, row, step): 3594 #~ print("(UN)INDENT", row, step) 3595 line, level, bullet = self[row] 3596 newlevel = level + step 3597 if self.buffer.set_indent(line, newlevel): 3598 self.buffer.update_editmode() # also updates indent tag 3599 self[row] = (line, newlevel, bullet) 3600 3601 def set_bullet(self, row, bullet): 3602 '''Set the bullet type for a specific item and update parents 3603 and children accordingly 3604 3605 Used to (un-)check the checkboxes and synchronize child 3606 nodes and parent nodes. When a box is checked, any open child 3607 nodes are checked. Also when this is the last checkbox on the 3608 given level to be checked, the parent box can be checked as 3609 well. When a box is un-checked, also the parent checkbox is 3610 un-checked. Both updating of children and parents is recursive. 3611 3612 @param row: the row id 3613 @param bullet: the bullet type, which can be one of:: 3614 BULLET 3615 CHECKED_BOX 3616 UNCHECKED_BOX 3617 XCHECKED_BOX 3618 MIGRATED_BOX 3619 TRANSMIGRATED_BOX 3620 ''' 3621 assert bullet in BULLETS 3622 with self.buffer.user_action: 3623 self._change_bullet_type(row, bullet) 3624 if bullet == BULLET: 3625 pass 3626 elif bullet == UNCHECKED_BOX: 3627 self._checkbox_unchecked(row) 3628 else: # CHECKED_BOX, XCHECKED_BOX, MIGRATED_BOX, TRANSMIGRATED_BOX 3629 self._checkbox_checked(row, bullet) 3630 3631 def _checkbox_unchecked(self, row): 3632 # When a row is unchecked, it's children are untouched but 3633 # all parents will be unchecked as well 3634 for parent in self._parents(row): 3635 if self[parent][self.BULLET_COL] not in CHECKBOXES: 3636 continue # ignore non-checkbox bullet 3637 3638 self._change_bullet_type(parent, UNCHECKED_BOX) 3639 3640 def _checkbox_checked(self, row, state): 3641 # If a row is checked, all un-checked children are updated as 3642 # well. For parent nodes we first check consistency of all 3643 # children before we check them. 3644 3645 # First synchronize down 3646 level = self[row][self.INDENT_COL] 3647 for i in range(row + 1, len(self)): 3648 if self[i][self.INDENT_COL] > level: 3649 if self[i][self.BULLET_COL] == UNCHECKED_BOX: 3650 self._change_bullet_type(i, state) 3651 else: 3652 # ignore non-checkbox bullet 3653 # ignore xchecked items etc. 3654 pass 3655 else: 3656 break 3657 3658 # Then go up, checking direct children for each parent 3659 # if children are inconsistent, do not change the parent 3660 # and break off updating parents. Do overwrite parents that 3661 # are already checked with a different type. 3662 for parent in self._parents(row): 3663 if self[parent][self.BULLET_COL] not in CHECKBOXES: 3664 continue # ignore non-checkbox bullet 3665 3666 consistent = True 3667 level = self[parent][self.INDENT_COL] 3668 for i in range(parent + 1, len(self)): 3669 if self[i][self.INDENT_COL] <= level: 3670 break 3671 elif self[i][self.INDENT_COL] == level + 1 \ 3672 and self[i][self.BULLET_COL] in CHECKBOXES \ 3673 and self[i][self.BULLET_COL] != state: 3674 consistent = False 3675 break 3676 3677 if consistent: 3678 self._change_bullet_type(parent, state) 3679 else: 3680 break 3681 3682 def _change_bullet_type(self, row, bullet): 3683 line, indent, _ = self[row] 3684 self.buffer.set_bullet(line, bullet) 3685 self[row] = (line, indent, bullet) 3686 3687 def _parents(self, row): 3688 # Collect row ids of parent nodes 3689 parents = [] 3690 level = self[row][self.INDENT_COL] 3691 for i in range(row, -1, -1): 3692 if self[i][self.INDENT_COL] < level: 3693 parents.append(i) 3694 level = self[i][self.INDENT_COL] 3695 return parents 3696 3697 3698FIND_CASE_SENSITIVE = 1 #: Constant to find case sensitive 3699FIND_WHOLE_WORD = 2 #: Constant to find whole words only 3700FIND_REGEX = 4 #: Constant to find based on regexes 3701 3702class TextFinder(object): 3703 '''This class handles finding text in the L{TextBuffer} 3704 3705 Typically you should get an instance of this class from the 3706 L{TextBuffer.finder} attribute. 3707 ''' 3708 3709 def __init__(self, textbuffer): 3710 '''constructor 3711 3712 @param textbuffer: a L{TextBuffer} object 3713 ''' 3714 self.buffer = textbuffer 3715 self._signals = () 3716 self.regex = None 3717 self.string = None 3718 self.flags = 0 3719 self.highlight = False 3720 3721 self.highlight_tag = self.buffer.create_tag( 3722 None, **self.buffer.tag_styles['find-highlight']) 3723 self.match_tag = self.buffer.create_tag( 3724 None, **self.buffer.tag_styles['find-match']) 3725 3726 def get_state(self): 3727 '''Get the query and any options. Used to copy the current state 3728 of find, can be restored later using L{set_state()}. 3729 3730 @returns: a 3-tuple of the search string, the option flags, and 3731 the highlight state 3732 ''' 3733 return self.string, self.flags, self.highlight 3734 3735 def set_state(self, string, flags, highlight): 3736 '''Set the query and any options. Can be used to restore the 3737 state of a find action without triggering a find immediatly. 3738 3739 @param string: the text (or regex) to find 3740 @param flags: a combination of C{FIND_CASE_SENSITIVE}, 3741 C{FIND_WHOLE_WORD} & C{FIND_REGEX} 3742 @param highlight: highlight state C{True} or C{False} 3743 ''' 3744 if not string is None: 3745 self._parse_query(string, flags) 3746 self.set_highlight(highlight) 3747 3748 def find(self, string, flags=0): 3749 '''Find and select the next occurrence of a given string 3750 3751 @param string: the text (or regex) to find 3752 @param flags: options, a combination of: 3753 - C{FIND_CASE_SENSITIVE}: check case of matches 3754 - C{FIND_WHOLE_WORD}: only match whole words 3755 - C{FIND_REGEX}: input is a regular expression 3756 @returns: C{True} if a match was found 3757 ''' 3758 self._parse_query(string, flags) 3759 #~ print('!! FIND "%s" (%s, %s)' % (self.regex.pattern, string, flags)) 3760 3761 if self.highlight: 3762 self._update_highlight() 3763 3764 iter = self.buffer.get_insert_iter() 3765 return self._find_next(iter) 3766 3767 def _parse_query(self, string, flags): 3768 assert isinstance(string, str) 3769 self.string = string 3770 self.flags = flags 3771 3772 if not flags & FIND_REGEX: 3773 string = re.escape(string) 3774 3775 if flags & FIND_WHOLE_WORD: 3776 string = '\\b' + string + '\\b' 3777 3778 if flags & FIND_CASE_SENSITIVE: 3779 self.regex = re.compile(string, re.U) 3780 else: 3781 self.regex = re.compile(string, re.U | re.I) 3782 3783 def find_next(self): 3784 '''Skip to the next match and select it 3785 3786 @returns: C{True} if a match was found 3787 ''' 3788 iter = self.buffer.get_insert_iter() 3789 iter.forward_char() # Skip current position 3790 return self._find_next(iter) 3791 3792 def _find_next(self, iter): 3793 # Common functionality between find() and find_next() 3794 # Looking for a match starting at iter 3795 if self.regex is None: 3796 self.unset_match() 3797 return False 3798 3799 line = iter.get_line() 3800 lastline = self.buffer.get_end_iter().get_line() 3801 for start, end, _ in self._check_range(line, lastline, 1): 3802 if start.compare(iter) == -1: 3803 continue 3804 else: 3805 self.set_match(start, end) 3806 return True 3807 3808 for start, end, _ in self._check_range(0, line, 1): 3809 self.set_match(start, end) 3810 return True 3811 3812 self.unset_match() 3813 return False 3814 3815 def find_previous(self): 3816 '''Go back to the previous match and select it 3817 3818 @returns: C{True} if a match was found 3819 ''' 3820 if self.regex is None: 3821 self.unset_match() 3822 return False 3823 3824 iter = self.buffer.get_insert_iter() 3825 line = iter.get_line() 3826 lastline = self.buffer.get_end_iter().get_line() 3827 for start, end, _ in self._check_range(line, 0, -1): 3828 if start.compare(iter) != -1: 3829 continue 3830 else: 3831 self.set_match(start, end) 3832 return True 3833 for start, end, _ in self._check_range(lastline, line, -1): 3834 self.set_match(start, end) 3835 return True 3836 3837 self.unset_match() 3838 return False 3839 3840 def set_match(self, start, end): 3841 self._remove_tag() 3842 3843 self.buffer.apply_tag(self.match_tag, start, end) 3844 self.buffer.select_range(start, end) 3845 3846 self._signals = tuple( 3847 self.buffer.connect(s, self._remove_tag) 3848 for s in ('mark-set', 'changed')) 3849 3850 def unset_match(self): 3851 self._remove_tag() 3852 self.buffer.unset_selection() 3853 3854 def _remove_tag(self, *a): 3855 if len(a) > 2 and isinstance(a[2], Gtk.TextMark) \ 3856 and a[2] is not self.buffer.get_insert(): 3857 # mark-set signal, but not for cursor 3858 return 3859 3860 for id in self._signals: 3861 self.buffer.disconnect(id) 3862 self._signals = () 3863 self.buffer.remove_tag(self.match_tag, *self.buffer.get_bounds()) 3864 3865 def select_match(self): 3866 # Select last match 3867 bounds = self.match_bounds 3868 if not None in bounds: 3869 self.buffer.select_range(*bounds) 3870 3871 def set_highlight(self, highlight): 3872 '''Toggle highlighting of matches in the L{TextBuffer} 3873 3874 @param highlight: C{True} to enable highlighting, C{False} to 3875 disable 3876 ''' 3877 self.highlight = highlight 3878 self._update_highlight() 3879 # TODO we could connect to buffer signals to update highlighting 3880 # when the buffer is modified. 3881 3882 def _update_highlight(self): 3883 # Clear highlighting 3884 tag = self.highlight_tag 3885 start, end = self.buffer.get_bounds() 3886 self.buffer.remove_tag(tag, start, end) 3887 3888 # Set highlighting 3889 if self.highlight: 3890 lastline = end.get_line() 3891 for start, end, _ in self._check_range(0, lastline, 1): 3892 self.buffer.apply_tag(tag, start, end) 3893 3894 def _check_range(self, firstline, lastline, step): 3895 # Generator for matches in a line. Arguments are start and 3896 # end line numbers and a step size (1 or -1). If the step is 3897 # negative results are yielded in reversed order. Yields pair 3898 # of TextIter's for begin and end of the match as well as the 3899 # match object. 3900 assert self.regex 3901 for line in range(firstline, lastline + step, step): 3902 start = self.buffer.get_iter_at_line(line) 3903 if start.ends_line(): 3904 continue 3905 3906 end = start.copy() 3907 end.forward_to_line_end() 3908 text = start.get_slice(end) 3909 matches = self.regex.finditer(text) 3910 if step == -1: 3911 matches = list(matches) 3912 matches.reverse() 3913 for match in matches: 3914 startiter = self.buffer.get_iter_at_line_offset( 3915 line, match.start()) 3916 enditer = self.buffer.get_iter_at_line_offset( 3917 line, match.end()) 3918 yield startiter, enditer, match 3919 3920 def replace(self, string): 3921 '''Replace current match 3922 3923 @param string: the replacement string 3924 3925 In case of a regex find and replace the string will be expanded 3926 with terms from the regex. 3927 3928 @returns: C{True} is successful 3929 ''' 3930 iter = self.buffer.get_insert_iter() 3931 if not self._find_next(iter): 3932 return False 3933 3934 iter = self.buffer.get_insert_iter() 3935 line = iter.get_line() 3936 for start, end, match in self._check_range(line, line, 1): 3937 if start.equal(iter): 3938 if self.flags & FIND_REGEX: 3939 string = match.expand(string) 3940 3941 offset = start.get_offset() 3942 3943 with self.buffer.user_action: 3944 self.buffer.select_range(start, end) # ensure editmode logic is used 3945 self.buffer.delete(start, end) 3946 self.buffer.insert_at_cursor(string) 3947 3948 start = self.buffer.get_iter_at_offset(offset) 3949 end = self.buffer.get_iter_at_offset(offset + len(string)) 3950 self.buffer.select_range(start, end) 3951 3952 return True 3953 else: 3954 return False 3955 3956 self._update_highlight() 3957 3958 def replace_all(self, string): 3959 '''Replace all matched 3960 3961 Like L{replace()} but replaces all matches in the buffer 3962 3963 @param string: the replacement string 3964 @returns: C{True} is successful 3965 ''' 3966 # Avoid looping when replace value matches query 3967 3968 matches = [] 3969 orig = string 3970 lastline = self.buffer.get_end_iter().get_line() 3971 for start, end, match in self._check_range(0, lastline, 1): 3972 if self.flags & FIND_REGEX: 3973 string = match.expand(orig) 3974 matches.append((start.get_offset(), end.get_offset(), string)) 3975 3976 matches.reverse() # work our way back top keep offsets valid 3977 3978 with self.buffer.user_action: 3979 for startoff, endoff, string in matches: 3980 start = self.buffer.get_iter_at_offset(startoff) 3981 end = self.buffer.get_iter_at_offset(endoff) 3982 if start.get_child_anchor() is not None: 3983 self._replace_in_widget(start, self.regex, string, True) 3984 else: 3985 self.buffer.delete(start, end) 3986 start = self.buffer.get_iter_at_offset(startoff) 3987 self.buffer.insert(start, string) 3988 3989 self._update_highlight() 3990 3991 3992CURSOR_TEXT = Gdk.Cursor.new_from_name(Gdk.Display.get_default(), 'text') 3993CURSOR_LINK = Gdk.Cursor.new_from_name(Gdk.Display.get_default(), 'pointer') 3994CURSOR_WIDGET = Gdk.Cursor.new_from_name(Gdk.Display.get_default(), 'default') 3995 3996 3997class TextView(Gtk.TextView): 3998 '''Widget to display a L{TextBuffer} with page content. Implements 3999 zim specific behavior like additional key bindings, on-mouse-over 4000 signals for links, and the custom popup menu. 4001 4002 @ivar preferences: dict with preferences 4003 4004 @signal: C{link-clicked (link)}: Emitted when the user clicks a link 4005 @signal: C{link-enter (link)}: Emitted when the mouse pointer enters a link 4006 @signal: C{link-leave (link)}: Emitted when the mouse pointer leaves a link 4007 @signal: C{end-of-word (start, end, word, char, editmode)}: 4008 Emitted when the user typed a character like space that ends a word 4009 4010 - C{start}: a C{Gtk.TextIter} for the start of the word 4011 - C{end}: a C{Gtk.TextIter} for the end of the word 4012 - C{word}: the word as string 4013 - C{char}: the character that caused the signal (a space, tab, etc.) 4014 - C{editmode}: a list of constants for the formatting being in effect, 4015 e.g. C{VERBATIM} 4016 4017 Plugins that want to add auto-formatting logic can connect to this 4018 signal. If the handler matches the word it should stop the signal 4019 with C{stop_emission()} to prevent other hooks from formatting the 4020 same word. 4021 4022 @signal: C{end-of-line (end)}: Emitted when the user typed a newline 4023 ''' 4024 4025 # define signals we want to use - (closure type, return type and arg types) 4026 __gsignals__ = { 4027 # New signals 4028 'link-clicked': (GObject.SignalFlags.RUN_LAST, None, (object,)), 4029 'link-enter': (GObject.SignalFlags.RUN_LAST, None, (object,)), 4030 'link-leave': (GObject.SignalFlags.RUN_LAST, None, (object,)), 4031 'end-of-word': (GObject.SignalFlags.RUN_LAST, None, (object, object, object, object, object)), 4032 'end-of-line': (GObject.SignalFlags.RUN_LAST, None, (object,)), 4033 } 4034 4035 def __init__(self, preferences): 4036 '''Constructor 4037 4038 @param preferences: dict with preferences 4039 4040 @todo: make sure code sets proper defaults for preferences 4041 & document preferences used 4042 ''' 4043 GObject.GObject.__init__(self) 4044 self.set_buffer(TextBuffer(None, None)) 4045 self.set_name('zim-pageview') 4046 self.set_size_request(24, 24) 4047 self._cursor = CURSOR_TEXT 4048 self._cursor_link = None 4049 self._object_widgets = weakref.WeakSet() 4050 self.set_left_margin(10) 4051 self.set_right_margin(5) 4052 self.set_wrap_mode(Gtk.WrapMode.WORD) 4053 self.preferences = preferences 4054 4055 self._object_wrap_width = -1 4056 self.connect_after('size-allocate', self.__class__.on_size_allocate) 4057 self.connect_after('motion-notify-event', self.__class__.on_motion_notify_event) 4058 4059 # Tooltips for images 4060 self.props.has_tooltip = True 4061 self.connect("query-tooltip", self.on_query_tooltip) 4062 4063 def set_buffer(self, buffer): 4064 # Clear old widgets 4065 for child in self.get_children(): 4066 if isinstance(child, InsertedObjectWidget): 4067 self._object_widgets.remove(child) 4068 self.remove(child) 4069 4070 # Set new buffer 4071 Gtk.TextView.set_buffer(self, buffer) 4072 4073 # Connect new widgets 4074 for anchor in buffer.list_objectanchors(): 4075 self.on_insert_object(buffer, anchor) 4076 4077 buffer.connect('insert-objectanchor', self.on_insert_object) 4078 4079 def on_insert_object(self, buffer, anchor): 4080 # Connect widget for this view to object 4081 widget = anchor.create_widget() 4082 assert isinstance(widget, InsertedObjectWidget) 4083 4084 def on_release_cursor(widget, position, anchor): 4085 myiter = buffer.get_iter_at_child_anchor(anchor) 4086 if position == POSITION_END: 4087 myiter.forward_char() 4088 buffer.place_cursor(myiter) 4089 self.grab_focus() 4090 4091 widget.connect('release-cursor', on_release_cursor, anchor) 4092 4093 def widget_connect(signal): 4094 widget.connect(signal, lambda o, *a: self.emit(signal, *a)) 4095 4096 for signal in ('link-clicked', 'link-enter', 'link-leave'): 4097 widget_connect(signal) 4098 4099 widget.set_textview_wrap_width(self._object_wrap_width) 4100 # TODO - compute indenting 4101 4102 self.add_child_at_anchor(widget, anchor) 4103 self._object_widgets.add(widget) 4104 widget.show_all() 4105 4106 def on_size_allocate(self, *a): 4107 # Update size request for widgets 4108 wrap_width = self._get_object_wrap_width() 4109 if wrap_width != self._object_wrap_width: 4110 for widget in self._object_widgets: 4111 widget.set_textview_wrap_width(wrap_width) 4112 # TODO - compute indenting 4113 self._object_wrap_width = wrap_width 4114 4115 def _get_object_wrap_width(self): 4116 text_window = self.get_window(Gtk.TextWindowType.TEXT) 4117 if text_window: 4118 width = text_window.get_geometry()[2] 4119 hmargin = self.get_left_margin() + self.get_right_margin() + 5 4120 # the +5 is arbitrary, but without it we show a scrollbar anyway .. 4121 return width - hmargin 4122 else: 4123 return -1 4124 4125 def do_copy_clipboard(self, format=None): 4126 # Overriden to force usage of our Textbuffer.copy_clipboard 4127 # over Gtk.TextBuffer.copy_clipboard 4128 format = format or self.preferences['copy_format'] 4129 format = zim.formats.canonical_name(format) 4130 self.get_buffer().copy_clipboard(Clipboard, format) 4131 4132 def do_cut_clipboard(self): 4133 # Overriden to force usage of our Textbuffer.cut_clipboard 4134 # over Gtk.TextBuffer.cut_clipboard 4135 self.get_buffer().cut_clipboard(Clipboard, self.get_editable()) 4136 self.scroll_mark_onscreen(self.get_buffer().get_insert()) 4137 4138 def do_paste_clipboard(self, format=None): 4139 # Overriden to force usage of our Textbuffer.paste_clipboard 4140 # over Gtk.TextBuffer.paste_clipboard 4141 self.get_buffer().paste_clipboard(Clipboard, None, self.get_editable(), text_format=format) 4142 self.scroll_mark_onscreen(self.get_buffer().get_insert()) 4143 4144 #~ def do_drag_motion(self, context, *a): 4145 #~ # Method that echos drag data types - only enable for debugging 4146 #~ print context.targets 4147 4148 def on_motion_notify_event(self, event): 4149 # Update the cursor type when the mouse moves 4150 x, y = event.get_coords() 4151 x, y = int(x), int(y) # avoid some strange DeprecationWarning 4152 coords = self.window_to_buffer_coords(Gtk.TextWindowType.WIDGET, x, y) 4153 self.update_cursor(coords) 4154 4155 def do_visibility_notify_event(self, event): 4156 # Update the cursor type when the window visibility changed 4157 self.update_cursor() 4158 return False # continue emit 4159 4160 def do_move_cursor(self, step_size, count, extend_selection): 4161 # Overloaded signal handler for cursor movements which will 4162 # move cursor into any object that accept a cursor focus 4163 4164 if step_size in (Gtk.MovementStep.LOGICAL_POSITIONS, Gtk.MovementStep.VISUAL_POSITIONS) \ 4165 and count in (1, -1) and not extend_selection: 4166 # logic below only supports 1 char forward or 1 char backward movements 4167 4168 buffer = self.get_buffer() 4169 iter = buffer.get_iter_at_mark(buffer.get_insert()) 4170 if count == -1: 4171 iter.backward_char() 4172 position = POSITION_END # enter end of object 4173 else: 4174 position = POSITION_BEGIN 4175 4176 anchor = iter.get_child_anchor() 4177 if iter.get_child_anchor(): 4178 widgets = anchor.get_widgets() 4179 assert len(widgets) == 1, 'TODO: support multiple views of same buffer' 4180 widget = widgets[0] 4181 if widget.has_cursor(): 4182 widget.grab_cursor(position) 4183 return None 4184 4185 return Gtk.TextView.do_move_cursor(self, step_size, count, extend_selection) 4186 4187 def do_button_press_event(self, event): 4188 # Handle middle click for pasting and right click for context menu 4189 # Needed to override these because implementation details of 4190 # gtktextview.c do not use proper signals for these actions. 4191 # 4192 # Note that clicking links is in button-release to avoid 4193 # conflict with making selections 4194 buffer = self.get_buffer() 4195 4196 if event.type == Gdk.EventType.BUTTON_PRESS: 4197 iter, coords = self._get_pointer_location() 4198 if event.button == 2 and iter and not buffer.get_has_selection(): 4199 buffer.paste_clipboard(SelectionClipboard, iter, self.get_editable()) 4200 return False 4201 elif Gdk.Event.triggers_context_menu(event): 4202 self._set_popup_menu_mark(iter) # allow iter to be None 4203 4204 return Gtk.TextView.do_button_press_event(self, event) 4205 4206 def do_button_release_event(self, event): 4207 # Handle clicking a link or checkbox 4208 cont = Gtk.TextView.do_button_release_event(self, event) 4209 if not self.get_buffer().get_has_selection(): 4210 if self.get_editable(): 4211 if event.button == 1: 4212 if self.preferences['cycle_checkbox_type']: 4213 # Cycle through all states - more useful for 4214 # single click input devices 4215 self.click_link() or self.click_checkbox() or self.click_anchor() 4216 else: 4217 self.click_link() or self.click_checkbox(CHECKED_BOX) or self.click_anchor() 4218 elif event.button == 1: 4219 # no changing checkboxes for read-only content 4220 self.click_link() 4221 4222 return cont # continue emit ? 4223 4224 def do_popup_menu(self): 4225 # Handler that gets called when user activates the popup-menu 4226 # by a keybinding (Shift-F10 or "menu" key). 4227 # Due to implementation details in gtktextview.c this method is 4228 # not called when a popup is triggered by a mouse click. 4229 buffer = self.get_buffer() 4230 iter = buffer.get_iter_at_mark(buffer.get_insert()) 4231 self._set_popup_menu_mark(iter) 4232 return Gtk.TextView.do_popup_menu(self) 4233 4234 def get_popup(self): 4235 '''Get the popup menu - intended for testing''' 4236 buffer = self.get_buffer() 4237 iter = buffer.get_iter_at_mark(buffer.get_insert()) 4238 self._set_popup_menu_mark(iter) 4239 menu = Gtk.Menu() 4240 self.emit('populate-popup', menu) 4241 return menu 4242 4243 def _set_popup_menu_mark(self, iter): 4244 # If iter is None, remove the mark 4245 buffer = self.get_buffer() 4246 mark = buffer.get_mark('zim-popup-menu') 4247 if iter: 4248 if mark: 4249 buffer.move_mark(mark, iter) 4250 else: 4251 mark = buffer.create_mark('zim-popup-menu', iter, True) 4252 elif mark: 4253 buffer.delete_mark(mark) 4254 else: 4255 pass 4256 4257 def _get_popup_menu_mark(self): 4258 buffer = self.get_buffer() 4259 mark = buffer.get_mark('zim-popup-menu') 4260 return buffer.get_iter_at_mark(mark) if mark else None 4261 4262 def do_key_press_event(self, event): 4263 keyval = strip_boolean_result(event.get_keyval()) 4264 #print 'KEY %s (%r)' % (Gdk.keyval_name(keyval), keyval) 4265 event_state = event.get_state() 4266 #print 'STATE %s' % event_state 4267 4268 run_post, handled = self._do_key_press_event(keyval, event_state) 4269 if not handled: 4270 handled = Gtk.TextView.do_key_press_event(self, event) 4271 4272 if run_post and handled: 4273 self._post_key_press_event(keyval) 4274 4275 return handled 4276 4277 def test_key_press_event(self, keyval, event_state=0): 4278 run_post, handled = self._do_key_press_event(keyval, event_state) 4279 4280 if not handled: 4281 if keyval in KEYVALS_BACKSPACE: 4282 self.emit('backspace') 4283 else: 4284 if keyval in KEYVALS_ENTER: 4285 char = '\n' 4286 elif keyval in KEYVALS_TAB: 4287 char = '\t' 4288 else: 4289 char = chr(Gdk.keyval_to_unicode(keyval)) 4290 4291 self.emit('insert-at-cursor', char) 4292 handled = True 4293 4294 if run_post and handled: 4295 self._post_key_press_event(keyval) 4296 4297 return handled 4298 4299 def _do_key_press_event(self, keyval, event_state): 4300 buffer = self.get_buffer() 4301 if not self.get_editable(): 4302 # Dispatch read-only mode 4303 return False, self._do_key_press_event_readonly(keyval, event_state) 4304 elif buffer.get_has_selection(): 4305 # Dispatch selection mode 4306 return False, self._do_key_press_event_selection(keyval, event_state) 4307 else: 4308 return True, self._do_key_press_event_default(keyval, event_state) 4309 4310 def _do_key_press_event_default(self, keyval, event_state): 4311 buffer = self.get_buffer() 4312 if (keyval in KEYVALS_HOME 4313 and not event_state & Gdk.ModifierType.CONTROL_MASK): 4314 # Smart Home key - can be combined with shift state 4315 insert = buffer.get_iter_at_mark(buffer.get_insert()) 4316 home, ourhome = self.get_visual_home_positions(insert) 4317 if insert.equal(ourhome): 4318 iter = home 4319 else: 4320 iter = ourhome 4321 if event_state & Gdk.ModifierType.SHIFT_MASK: 4322 buffer.move_mark_by_name('insert', iter) 4323 else: 4324 buffer.place_cursor(iter) 4325 return True 4326 elif keyval in KEYVALS_TAB and not (event_state & KEYSTATES): 4327 # Tab at start of line indents 4328 iter = buffer.get_insert_iter() 4329 home, ourhome = self.get_visual_home_positions(iter) 4330 if home.starts_line() and iter.compare(ourhome) < 1 \ 4331 and not list(filter(_is_pre_tag, iter.get_tags())): 4332 row, mylist = TextBufferList.new_from_line(buffer, iter.get_line()) 4333 if mylist and self.preferences['recursive_indentlist']: 4334 mylist.indent(row) 4335 else: 4336 buffer.indent(iter.get_line(), interactive=True) 4337 return True 4338 elif (keyval in KEYVALS_LEFT_TAB 4339 and not (event_state & KEYSTATES & ~Gdk.ModifierType.SHIFT_MASK) 4340 ) or (keyval in KEYVALS_BACKSPACE 4341 and self.preferences['unindent_on_backspace'] 4342 and not (event_state & KEYSTATES) 4343 ): 4344 # Backspace or Ctrl-Tab unindents line 4345 # note that Shift-Tab give Left_Tab + Shift mask, so allow shift 4346 default = True if keyval in KEYVALS_LEFT_TAB else False 4347 # Prevent <Shift><Tab> to insert a Tab if unindent fails 4348 iter = buffer.get_iter_at_mark(buffer.get_insert()) 4349 home, ourhome = self.get_visual_home_positions(iter) 4350 if home.starts_line() and iter.compare(ourhome) < 1 \ 4351 and not list(filter(_is_pre_tag, iter.get_tags())): 4352 bullet = buffer.get_bullet_at_iter(home) 4353 indent = buffer.get_indent(home.get_line()) 4354 if keyval in KEYVALS_BACKSPACE \ 4355 and bullet and indent == 0 and not iter.equal(home): 4356 # Delete bullet at start of line (if iter not before bullet) 4357 buffer.delete(home, ourhome) 4358 return True 4359 elif indent == 0 or indent is None: 4360 # Nothing to unindent 4361 return default 4362 elif bullet: 4363 # Unindent list maybe recursive 4364 row, mylist = TextBufferList.new_from_line(buffer, iter.get_line()) 4365 if mylist and self.preferences['recursive_indentlist']: 4366 return bool(mylist.unindent(row)) or default 4367 else: 4368 return bool(buffer.unindent(iter.get_line(), interactive=True)) or default 4369 else: 4370 # Unindent normal text 4371 return bool(buffer.unindent(iter.get_line(), interactive=True)) or default 4372 4373 elif keyval in KEYVALS_ENTER: 4374 # Enter can trigger links 4375 iter = buffer.get_iter_at_mark(buffer.get_insert()) 4376 tag = buffer.get_link_tag(iter) 4377 if tag and not iter.begins_tag(tag): 4378 # get_link_tag() is left gravitating, we additionally 4379 # exclude the position in front of the link. 4380 # As a result you can not "Enter" a 1 character link, 4381 # this is by design. 4382 if (self.preferences['follow_on_enter'] 4383 or event_state & Gdk.ModifierType.MOD1_MASK): # MOD1 == Alt 4384 self.click_link_at_iter(iter) 4385 # else do not insert newline, just ignore 4386 return True 4387 4388 def _post_key_press_event(self, keyval): 4389 # Trigger end-of-line and/or end-of-word signals if char was 4390 # really inserted by parent class. 4391 # 4392 # We do it this way because in some cases e.g. a space is not 4393 # inserted but is used to select an option in an input mode e.g. 4394 # to select between various Chinese characters. See lp:460438 4395 4396 if not (keyval in KEYVALS_END_OF_WORD or keyval in KEYVALS_ENTER): 4397 return 4398 4399 buffer = self.get_buffer() 4400 insert = buffer.get_iter_at_mark(buffer.get_insert()) 4401 mark = buffer.create_mark(None, insert, left_gravity=False) 4402 iter = insert.copy() 4403 iter.backward_char() 4404 4405 if keyval in KEYVALS_ENTER: 4406 char = '\n' 4407 elif keyval in KEYVALS_TAB: 4408 char = '\t' 4409 else: 4410 char = chr(Gdk.keyval_to_unicode(keyval)) 4411 4412 if iter.get_text(insert) != char: 4413 return 4414 4415 with buffer.user_action: 4416 buffer.emit('undo-save-cursor', insert) 4417 start = iter.copy() 4418 if buffer.iter_backward_word_start(start): 4419 word = start.get_text(iter) 4420 editmode = [t.zim_tag for t in buffer.iter_get_zim_tags(iter)] 4421 self.emit('end-of-word', start, iter, word, char, editmode) 4422 4423 if keyval in KEYVALS_ENTER: 4424 # iter may be invalid by now because of end-of-word 4425 iter = buffer.get_iter_at_mark(mark) 4426 iter.backward_char() 4427 self.emit('end-of-line', iter) 4428 4429 buffer.place_cursor(buffer.get_iter_at_mark(mark)) 4430 self.scroll_mark_onscreen(mark) 4431 buffer.delete_mark(mark) 4432 4433 def _do_key_press_event_readonly(self, keyval, event_state): 4434 # Key bindings in read-only mode: 4435 # Space scrolls one page 4436 # Shift-Space scrolls one page up 4437 if keyval in KEYVALS_SPACE: 4438 if event_state & Gdk.ModifierType.SHIFT_MASK: 4439 i = -1 4440 else: 4441 i = 1 4442 self.emit('move-cursor', Gtk.MovementStep.PAGES, i, False) 4443 return True 4444 else: 4445 return False 4446 4447 def _do_key_press_event_selection(self, keyval, event_state): 4448 # Key bindings when there is an active selections: 4449 # Tab indents whole selection 4450 # Shift-Tab and optionally Backspace unindent whole selection 4451 # * Turns whole selection in bullet list, or toggle back 4452 # > Quotes whole selection with '>' 4453 handled = True 4454 buffer = self.get_buffer() 4455 4456 def delete_char(line): 4457 # Deletes the character at the iterator position 4458 iter = buffer.get_iter_at_line(line) 4459 next = iter.copy() 4460 if next.forward_char(): 4461 buffer.delete(iter, next) 4462 4463 def decrement_indent(start, end): 4464 # Check if inside verbatim block AND entire selection without tag toggle 4465 if selection_in_pre_block(start, end): 4466 # Handle indent in pre differently 4467 missing_tabs = [] 4468 check_tab = lambda l: (buffer.get_iter_at_line(l).get_char() == '\t') or missing_tabs.append(1) 4469 buffer.foreach_line_in_selection(check_tab) 4470 if len(missing_tabs) == 0: 4471 return buffer.foreach_line_in_selection(delete_char) 4472 else: 4473 return False 4474 elif multi_line_indent(start, end): 4475 level = [] 4476 buffer.foreach_line_in_selection( 4477 lambda l: level.append(buffer.get_indent(l))) 4478 if level and min(level) > 0: 4479 # All lines have some indent 4480 return buffer.foreach_line_in_selection(buffer.unindent) 4481 else: 4482 return False 4483 else: 4484 return False 4485 4486 def selection_in_pre_block(start, end): 4487 # Checks if there are any tag changes within the selection 4488 if list(filter(_is_pre_tag, start.get_tags())): 4489 toggle = start.copy() 4490 toggle.forward_to_tag_toggle(None) 4491 return toggle.compare(end) < 0 4492 else: 4493 return False 4494 4495 def multi_line_indent(start, end): 4496 # Check if: 4497 # a) one line selected from start till end or 4498 # b) multiple lines selected and selection starts at line start 4499 home, ourhome = self.get_visual_home_positions(start) 4500 if not (home.starts_line() and start.compare(ourhome) < 1): 4501 return False 4502 else: 4503 return end.ends_line() \ 4504 or end.get_line() > start.get_line() 4505 4506 start, end = buffer.get_selection_bounds() 4507 with buffer.user_action: 4508 if keyval in KEYVALS_TAB: 4509 if selection_in_pre_block(start, end): 4510 # Handle indent in pre differently 4511 prepend_tab = lambda l: buffer.insert(buffer.get_iter_at_line(l), '\t') 4512 buffer.foreach_line_in_selection(prepend_tab) 4513 elif multi_line_indent(start, end): 4514 buffer.foreach_line_in_selection(buffer.indent) 4515 else: 4516 handled = False 4517 elif keyval in KEYVALS_LEFT_TAB: 4518 decrement_indent(start, end) 4519 # do not set handled = False when decrement failed - 4520 # LEFT_TAB should not do anything else 4521 elif keyval in KEYVALS_BACKSPACE \ 4522 and self.preferences['unindent_on_backspace']: 4523 handled = decrement_indent(start, end) 4524 elif keyval in KEYVALS_ASTERISK + (KEYVAL_POUND,): 4525 def toggle_bullet(line, newbullet): 4526 bullet = buffer.get_bullet(line) 4527 if not bullet and not buffer.get_line_is_empty(line): 4528 buffer.set_bullet(line, newbullet) 4529 elif bullet == newbullet: # FIXME broken for numbered list 4530 buffer.set_bullet(line, None) 4531 if keyval == KEYVAL_POUND: 4532 buffer.foreach_line_in_selection(toggle_bullet, NUMBER_BULLET) 4533 else: 4534 buffer.foreach_line_in_selection(toggle_bullet, BULLET) 4535 elif keyval in KEYVALS_GT \ 4536 and multi_line_indent(start, end): 4537 def email_quote(line): 4538 iter = buffer.get_iter_at_line(line) 4539 bound = iter.copy() 4540 bound.forward_char() 4541 if iter.get_text(bound) == '>': 4542 buffer.insert(iter, '>') 4543 else: 4544 buffer.insert(iter, '> ') 4545 buffer.foreach_line_in_selection(email_quote) 4546 else: 4547 handled = False 4548 4549 return handled 4550 4551 def _get_pointer_location(self): 4552 '''Get an iter and coordinates for the mouse pointer 4553 4554 @returns: a 2-tuple of a C{Gtk.TextIter} and a C{(x, y)} 4555 tupple with coordinates for the mouse pointer. 4556 ''' 4557 x, y = self.get_pointer() 4558 x, y = self.window_to_buffer_coords(Gtk.TextWindowType.WIDGET, x, y) 4559 iter = strip_boolean_result(self.get_iter_at_location(x, y)) 4560 return iter, (x, y) 4561 4562 def _get_pixbuf_at_pointer(self, iter, coords): 4563 '''Returns the pixbuf that is under the mouse or C{None}. The 4564 parameters should be the TextIter and the (x, y) coordinates 4565 from L{_get_pointer_location()}. This method handles the special 4566 case where the pointer it on an iter next to the image but the 4567 mouse is visible above the image. 4568 ''' 4569 pixbuf = iter.get_pixbuf() 4570 if not pixbuf: 4571 # right side of pixbuf will map to next iter 4572 iter = iter.copy() 4573 iter.backward_char() 4574 pixbuf = iter.get_pixbuf() 4575 4576 if pixbuf and hasattr(pixbuf, 'zim_type'): 4577 # If we have a pixbuf double check the cursor is really 4578 # over the image and not actually on the next cursor position 4579 area = self.get_iter_location(iter) 4580 if (coords[0] >= area.x and coords[0] <= area.x + area.width 4581 and coords[1] >= area.y and coords[1] <= area.y + area.height): 4582 return pixbuf 4583 else: 4584 return None 4585 else: 4586 return None 4587 4588 def update_cursor(self, coords=None): 4589 '''Update the mouse cursor type 4590 4591 E.g. set a "hand" cursor when hovering over a link. 4592 4593 @param coords: a tuple with C{(x, y)} position in buffer coords. 4594 Only give this argument if coords are known from an event, 4595 otherwise the current cursor position is used. 4596 4597 @emits: link-enter 4598 @emits: link-leave 4599 ''' 4600 if coords is None: 4601 iter, coords = self._get_pointer_location() 4602 else: 4603 iter = strip_boolean_result(self.get_iter_at_location(*coords)) 4604 4605 if iter is None: 4606 self._set_cursor(CURSOR_TEXT) 4607 else: 4608 pixbuf = self._get_pixbuf_at_pointer(iter, coords) 4609 if pixbuf: 4610 if pixbuf.zim_type == 'icon' and pixbuf.zim_attrib['stock'] in bullets: 4611 self._set_cursor(CURSOR_WIDGET) 4612 elif pixbuf.zim_type == 'anchor': 4613 self._set_cursor(CURSOR_WIDGET) 4614 elif 'href' in pixbuf.zim_attrib: 4615 self._set_cursor(CURSOR_LINK, link={'href': pixbuf.zim_attrib['href']}) 4616 else: 4617 self._set_cursor(CURSOR_TEXT) 4618 else: 4619 link = self.get_buffer().get_link_data(iter) 4620 if link: 4621 self._set_cursor(CURSOR_LINK, link=link) 4622 else: 4623 self._set_cursor(CURSOR_TEXT) 4624 4625 def _set_cursor(self, cursor, link=None): 4626 if cursor != self._cursor: 4627 window = self.get_window(Gtk.TextWindowType.TEXT) 4628 window.set_cursor(cursor) 4629 4630 # Check if we need to emit any events for hovering 4631 if self._cursor == CURSOR_LINK: # was over link before 4632 if cursor == CURSOR_LINK: # still over link 4633 if link != self._cursor_link: 4634 # but other link 4635 self.emit('link-leave', self._cursor_link) 4636 self.emit('link-enter', link) 4637 else: 4638 self.emit('link-leave', self._cursor_link) 4639 elif cursor == CURSOR_LINK: # was not over link, but is now 4640 self.emit('link-enter', link) 4641 4642 self._cursor = cursor 4643 self._cursor_link = link 4644 4645 def click_link(self): 4646 '''Activate the link under the mouse pointer, if any 4647 4648 @emits: link-clicked 4649 @returns: C{True} when there was indeed a link 4650 ''' 4651 iter, coords = self._get_pointer_location() 4652 if iter is None: 4653 return False 4654 4655 pixbuf = self._get_pixbuf_at_pointer(iter, coords) 4656 if pixbuf and pixbuf.zim_attrib.get('href'): 4657 self.emit('link-clicked', {'href': pixbuf.zim_attrib['href']}) 4658 return True 4659 elif iter: 4660 return self.click_link_at_iter(iter) 4661 4662 def click_link_at_iter(self, iter): 4663 '''Activate the link at C{iter}, if any 4664 4665 Like L{click_link()} but activates a link at a specific text 4666 iter location 4667 4668 @emits: link-clicked 4669 @param iter: a C{Gtk.TextIter} 4670 @returns: C{True} when there was indeed a link 4671 ''' 4672 link = self.get_buffer().get_link_data(iter) 4673 if link: 4674 self.emit('link-clicked', link) 4675 return True 4676 else: 4677 return False 4678 4679 def click_checkbox(self, checkbox_type=None): 4680 '''Toggle the checkbox under the mouse pointer, if any 4681 4682 @param checkbox_type: the checkbox type to toggle between, see 4683 L{TextBuffer.toggle_checkbox()} for details. 4684 @returns: C{True} for success, C{False} if no checkbox was found. 4685 ''' 4686 iter, coords = self._get_pointer_location() 4687 if iter and iter.get_line_offset() < 2: 4688 # Only position 0 or 1 can map to a checkbox 4689 buffer = self.get_buffer() 4690 recurs = self.preferences['recursive_checklist'] 4691 return buffer.toggle_checkbox(iter.get_line(), checkbox_type, recurs) 4692 else: 4693 return False 4694 4695 def click_anchor(self): 4696 '''Show popover for anchor under the cursor''' 4697 iter, coords = self._get_pointer_location() 4698 if not iter: 4699 return False 4700 4701 pixbuf = self._get_pixbuf_at_pointer(iter, coords) 4702 if not (pixbuf and hasattr(pixbuf, 'zim_type') and pixbuf.zim_type == 'anchor'): 4703 return False 4704 4705 # Show popover with achor name and option to copy link 4706 popover = Gtk.Popover() 4707 popover.set_relative_to(self) 4708 rect = Gdk.Rectangle() 4709 rect.x, rect.y = self.get_pointer() 4710 rect.width, rect.height = 1, 1 4711 popover.set_pointing_to(rect) 4712 4713 name = pixbuf.zim_attrib['name'] 4714 def _copy_link_to_anchor(o): 4715 buffer = self.get_buffer() 4716 notebook, page = buffer.notebook, buffer.page 4717 Clipboard.set_pagelink(notebook, page, name) 4718 SelectionClipboard.set_pagelink(notebook, page, name) 4719 popover.popdown() 4720 4721 hbox = Gtk.Box(Gtk.Orientation.HORIZONTAL, 12) 4722 hbox.set_border_width(3) 4723 label = Gtk.Label() 4724 label.set_markup('#%s' %name) 4725 hbox.add(label) 4726 button = Gtk.Button.new_from_icon_name('edit-copy-symbolic', Gtk.IconSize.BUTTON) 4727 button.set_tooltip_text(_("Copy link to clipboard")) # T: tooltip for button in anchor popover 4728 button.connect('clicked', _copy_link_to_anchor) 4729 hbox.add(button) 4730 popover.add(hbox) 4731 popover.show_all() 4732 popover.popup() 4733 4734 return True 4735 4736 def get_visual_home_positions(self, iter): 4737 '''Get the TextIters for the visuale start of the line 4738 4739 @param iter: a C{Gtk.TextIter} 4740 @returns: a 2-tuple with two C{Gtk.TextIter} 4741 4742 The first iter is the start of the visual line - which can be 4743 the start of the line as the buffer sees it (which is also called 4744 the paragraph start in the view) or the iter at the place where 4745 the line is wrapped. The second iter is the start of the line 4746 after skipping any bullets and whitespace. For a wrapped line 4747 the second iter will be the same as the first. 4748 ''' 4749 home = iter.copy() 4750 if not self.starts_display_line(home): 4751 self.backward_display_line_start(home) 4752 4753 if home.starts_line(): 4754 ourhome = home.copy() 4755 self.get_buffer().iter_forward_past_bullet(ourhome) 4756 bound = ourhome.copy() 4757 bound.forward_char() 4758 while ourhome.get_text(bound) in (' ', '\t'): 4759 if ourhome.forward_char(): 4760 bound.forward_char() 4761 else: 4762 break 4763 return home, ourhome 4764 else: 4765 # only start visual line, not start of real line 4766 return home, home.copy() 4767 4768 def do_end_of_word(self, start, end, word, char, editmode): 4769 # Default handler with built-in auto-formatting options 4770 buffer = self.get_buffer() 4771 handled = False 4772 #print('WORD >>%s<< CHAR >>%s<<' % (word, char)) 4773 4774 def apply_anchor(match): 4775 #print("ANCHOR >>%s<<" % word) 4776 if buffer.range_has_tags(_is_non_nesting_tag, start, end): 4777 return False 4778 name = match[2:] 4779 buffer.delete(start, end) 4780 buffer.insert_anchor(start, name) 4781 return True 4782 4783 def apply_tag(match): 4784 #print("TAG >>%s<<" % word) 4785 start = end.copy() 4786 if not start.backward_chars(len(match)): 4787 return False 4788 elif buffer.range_has_tags(_is_non_nesting_tag, start, end): 4789 return False 4790 else: 4791 tag = buffer._create_tag_tag(match) 4792 buffer.apply_tag(tag, start, end) 4793 return True 4794 4795 def apply_link(match, offset_end=0): 4796 #print("LINK >>%s<<" % word) 4797 myend = end.copy() 4798 myend.backward_chars(offset_end) 4799 start = myend.copy() 4800 if not start.backward_chars(len(match)): 4801 return False 4802 elif buffer.range_has_tags(_is_non_nesting_tag, start, myend) \ 4803 or buffer.range_has_tags(_is_link_tag, start, myend): 4804 return False # No link inside a link 4805 else: 4806 tag = buffer._create_link_tag(match, match) 4807 buffer.apply_tag(tag, start, myend) 4808 return True 4809 4810 def allow_bullet(iter, is_replacement_numbered_bullet): 4811 if iter.starts_line(): 4812 return True 4813 elif iter.get_line_offset() < 10: 4814 home = buffer.get_iter_at_line(iter.get_line()) 4815 return buffer.iter_forward_past_bullet(home) \ 4816 and start.equal(iter) \ 4817 and not is_replacement_numbered_bullet # don't replace existing bullets with numbered bullets 4818 else: 4819 return False 4820 word_is_numbered_bullet = is_numbered_bullet_re.match(word) 4821 if (char == ' ' or char == '\t') \ 4822 and allow_bullet(start, word_is_numbered_bullet) \ 4823 and (word in autoformat_bullets or word_is_numbered_bullet): 4824 if buffer.range_has_tags(_is_heading_tag, start, end): 4825 handled = False # No bullets in headings 4826 else: 4827 # format bullet and checkboxes 4828 line = start.get_line() 4829 end.forward_char() # also overwrite the space triggering the action 4830 buffer.delete(start, end) 4831 bullet = autoformat_bullets.get(word) or word 4832 buffer.set_bullet(line, bullet) # takes care of replacing bullets as well 4833 handled = True 4834 elif tag_re.match(word): 4835 handled = apply_tag(tag_re[0]) 4836 elif anchor_re.match(word): 4837 handled = apply_anchor(anchor_re[0]) 4838 elif url_re.search(word): 4839 if char == ')': 4840 handled = False # to early to call 4841 else: 4842 m = url_re.search(word) 4843 url = match_url(m.group(0)) 4844 tail = word[m.start()+len(url):] 4845 handled = apply_link(url, offset_end=len(tail)) 4846 elif link_to_anchor_re.match(word): 4847 handled = apply_link(link_to_anchor_re[0]) 4848 elif link_to_page_re.match(word): 4849 # Do not link "10:20h", "10:20PM" etc. so check two letters before first ":" 4850 w = word.strip(':').split(':') 4851 if w and twoletter_re.search(w[0]): 4852 handled = apply_link(link_to_page_re[0]) 4853 else: 4854 handled = False 4855 elif interwiki_re.match(word): 4856 handled = apply_link(interwiki_re[0]) 4857 elif self.preferences['autolink_files'] and file_re.match(word): 4858 handled = apply_link(file_re[0]) 4859 elif self.preferences['autolink_camelcase'] and camelcase(word): 4860 handled = apply_link(word) 4861 elif self.preferences['auto_reformat']: 4862 linestart = buffer.get_iter_at_line(end.get_line()) 4863 partial_line = linestart.get_slice(end) 4864 for style, style_re in markup_re: 4865 m = style_re.search(partial_line) 4866 if m: 4867 matchstart = linestart.copy() 4868 matchstart.forward_chars(m.start()) 4869 matchend = linestart.copy() 4870 matchend.forward_chars(m.end()) 4871 if buffer.range_has_tags(_is_non_nesting_tag, matchstart, matchend) \ 4872 or buffer.range_has_tags(_is_link_tag_without_href, matchstart, matchend): 4873 break 4874 else: 4875 with buffer.tmp_cursor(matchstart): 4876 buffer.delete(matchstart, matchend) 4877 buffer.insert_with_tags_by_name(matchstart, m.group(1), style) 4878 handled = True 4879 break 4880 4881 if handled: 4882 self.stop_emission('end-of-word') 4883 4884 def do_end_of_line(self, end): 4885 # Default handler, takes care of cutting of formatting on the 4886 # line end, set indenting and bullet items on the new line etc. 4887 4888 if end.starts_line(): 4889 return # empty line 4890 4891 buffer = self.get_buffer() 4892 start = buffer.get_iter_at_line(end.get_line()) 4893 if any(_is_pre_or_code_tag(t) for t in start.get_tags()): 4894 logger.debug('pre-formatted code') 4895 return # pre-formatted 4896 4897 line = start.get_text(end) 4898 #~ print('LINE >>%s<<' % line) 4899 4900 if heading_re.match(line): 4901 level = len(heading_re[1]) - 1 4902 heading = heading_re[2] 4903 mark = buffer.create_mark(None, end) 4904 buffer.delete(start, end) 4905 buffer.insert_with_tags_by_name( 4906 buffer.get_iter_at_mark(mark), heading, 'style-h' + str(level)) 4907 buffer.delete_mark(mark) 4908 elif is_line(line): 4909 with buffer.user_action: 4910 offset = start.get_offset() 4911 buffer.delete(start, end) 4912 iter = buffer.get_iter_at_offset(offset) 4913 buffer.insert_objectanchor(iter, LineSeparatorAnchor()) 4914 elif buffer.get_bullet_at_iter(start) is not None: 4915 # we are part of bullet list 4916 # FIXME should logic be handled by TextBufferList ? 4917 ourhome = start.copy() 4918 buffer.iter_forward_past_bullet(ourhome) 4919 newlinestart = end.copy() 4920 newlinestart.forward_line() 4921 if ourhome.equal(end) and newlinestart.ends_line(): 4922 # line with bullet but no text - break list if no text on next line 4923 line, newline = start.get_line(), newlinestart.get_line() 4924 buffer.delete(start, end) 4925 buffer.set_indent(line, None) 4926 buffer.set_indent(newline, None) 4927 else: 4928 # determine indent 4929 start_sublist = False 4930 newline = newlinestart.get_line() 4931 indent = buffer.get_indent(start.get_line()) 4932 nextlinestart = newlinestart.copy() 4933 if nextlinestart.forward_line() \ 4934 and buffer.get_bullet_at_iter(nextlinestart): 4935 nextindent = buffer.get_indent(nextlinestart.get_line()) 4936 if nextindent >= indent: 4937 # we are at the head of a sublist 4938 indent = nextindent 4939 start_sublist = True 4940 4941 # add bullet on new line 4942 bulletiter = nextlinestart if start_sublist else start # Either look back or look forward 4943 bullet = buffer.get_bullet_at_iter(bulletiter) 4944 if bullet in (CHECKED_BOX, XCHECKED_BOX, MIGRATED_BOX, TRANSMIGRATED_BOX): 4945 bullet = UNCHECKED_BOX 4946 elif is_numbered_bullet_re.match(bullet): 4947 if not start_sublist: 4948 bullet = increase_list_bullet(bullet) 4949 # else copy number 4950 else: 4951 pass # Keep same bullet 4952 4953 buffer.set_bullet(newline, bullet, indent=indent) 4954 # Set indent in one-go because setting before fails for 4955 # end of buffer while setting after messes up renumbering 4956 # of lists 4957 4958 buffer.update_editmode() # also updates indent tag 4959 4960 def on_query_tooltip(self, widget, x, y, keyboard_tip, tooltip): 4961 # Handle tooltip query event 4962 x,y = self.window_to_buffer_coords(Gtk.TextWindowType.WIDGET, x, y) 4963 iter = strip_boolean_result(self.get_iter_at_location(x, y)) 4964 if iter is not None: 4965 pixbuf = self._get_pixbuf_at_pointer(iter, (x, y)) 4966 if pixbuf and hasattr(pixbuf, 'zim_type') and pixbuf.zim_type == 'image': 4967 data = pixbuf.zim_attrib.copy() 4968 text = data['src'] + '\n\n' 4969 if 'href' in data: 4970 text += '<b>%s:</b> %s\n' % (_('Link'), data['href']) # T: tooltip label for image with href 4971 if 'id' in data: 4972 text += '<b>%s:</b> %s\n' % (_('Id'), data['id']) # T: tooltip label for image with anchor id 4973 tooltip.set_markup(text.strip()) 4974 return True 4975 elif pixbuf and hasattr(pixbuf, 'zim_type') and pixbuf.zim_type == 'anchor': 4976 text = '#' + pixbuf.zim_attrib['name'] 4977 tooltip.set_markup(text) 4978 return True 4979 4980 return False 4981 4982 4983class UndoActionGroup(list): 4984 '''Group of actions that should un-done or re-done in a single step 4985 4986 Inherits from C{list}, so can be treates as a list of actions. 4987 See L{UndoStackManager} for more details on undo actions. 4988 4989 @ivar can_merge: C{True} when this group can be merged with another 4990 group 4991 @ivar cursor: the position to restore the cursor afre un-/re-doning 4992 ''' 4993 4994 __slots__ = ('can_merge', 'cursor') 4995 4996 def __init__(self): 4997 self.can_merge = False 4998 self.cursor = None 4999 5000 def reversed(self): 5001 '''Returns a new UndoActionGroup with the reverse actions of 5002 this group. 5003 ''' 5004 group = UndoActionGroup() 5005 group.cursor = self.cursor 5006 for action in self: 5007 # constants are defined such that negating them reverses the action 5008 action = (-action[0],) + action[1:] 5009 group.insert(0, action) 5010 return group 5011 5012 5013class UndoStackManager: 5014 '''Undo stack implementation for L{TextBuffer}. It records any 5015 changes to the buffer and allows undoing and redoing edits. 5016 5017 The stack undostack will be folded when you undo a few steps and 5018 then start editing again. This means that even the 'undo' action 5019 is recorded in the undo stakc and can always be undone itself; 5020 so no data is discarded. 5021 5022 Say you start with a certain buffer state "A", then make two edits 5023 ("B" and "C") and then undo the last one, so you get back in state 5024 "B":: 5025 5026 State A --> State B --> State C 5027 <-- 5028 undo 5029 5030 when you now make a new edit ("D"), state "C" is not discarded, instead 5031 it is "folded" as follows:: 5032 5033 State A --> State B --> State C --> State B --> State D 5034 5035 so you can still go back to state "C" using Undo. 5036 5037 Undo actions 5038 ============ 5039 5040 Each action is recorded as a 4-tuple of: 5041 - C{action_type}: one of C{ACTION_INSERT}, C{ACTION_DELETE}, 5042 C{ACTION_APPLY_TAG}, C{ACTION_REMOVE_TAG} 5043 - C{start_iter}: a C{Gtk.TextIter} 5044 - C{end_iter}: a C{Gtk.TextIter} 5045 - C{data}: either a (raw) L{ParseTree} or a C{Gtk.TextTag} 5046 5047 These actions are low level operations, so they are 5048 5049 Actions are collected as L{UndoActionGroup}s. When the user selects 5050 Undo or Redo we actually undo or redo a whole UndoActionGroup as a 5051 single step. E.g. inserting a link will consist of inserting the 5052 text and than applying the TextTag with the link data. These are 5053 technically two separate modifications of the TextBuffer, however 5054 when selecting Undo both are undone at once because they are 5055 combined in a single group. 5056 5057 Typically when recording modifications the action groups are 5058 delimited by the begin-user-action and end-user-action signals of 5059 the L{TextBuffer}. (This is why we use the L{TextBuffer.user_action} 5060 attribute context manager in the TextBuffer code.) 5061 5062 Also we try to group single-character inserts and deletes into words. 5063 This makes the stack more compact and makes the undo action more 5064 meaningful. 5065 ''' 5066 5067 # Each interactive action (e.g. every single key stroke) is wrapped 5068 # in a set of begin-user-action and end-user-action signals. We use 5069 # these signals to group actions. This implies that any sequence on 5070 # non-interactive actions will also end up in a single group. An 5071 # interactively created group consisting of a single character insert 5072 # or single character delete is a candidate for merging. 5073 5074 MAX_UNDO = 100 #: Constant for the max number of undo steps to be remembered 5075 5076 # Constants for action types - negating an action gives it opposite. 5077 ACTION_INSERT = 1 #: action type for inserting text 5078 ACTION_DELETE = -1 #: action type for deleting text 5079 ACTION_APPLY_TAG = 2 #: action type for applying a C{Gtk.TextTag} 5080 ACTION_REMOVE_TAG = -2 #: action type for removing a C{Gtk.TextTag} 5081 5082 def __init__(self, textbuffer): 5083 '''Constructor 5084 5085 @param textbuffer: a C{Gtk.TextBuffer} 5086 ''' 5087 self.buffer = textbuffer 5088 self.stack = [] # stack of actions & action groups 5089 self.group = UndoActionGroup() # current group of actions 5090 self.interactive = False # interactive edit or not 5091 self.insert_pending = False # whether we need to call flush insert or not 5092 self.undo_count = 0 # number of undo steps that were done 5093 self.block_count = 0 # number of times block() was called 5094 self._insert_tree_start = None 5095 5096 self.recording_handlers = [] # handlers to be blocked when not recording 5097 for signal, handler in ( 5098 ('undo-save-cursor', self.do_save_cursor), 5099 ('insert-text', self.do_insert_text), 5100 ('insert-pixbuf', self.do_insert_pixbuf), 5101 ('insert-child-anchor', self.do_insert_pixbuf), 5102 ('delete-range', self.do_delete_range), 5103 ('begin-user-action', self.do_begin_user_action), 5104 ('end-user-action', self.do_end_user_action), 5105 ): 5106 self.recording_handlers.append( 5107 self.buffer.connect(signal, handler)) 5108 5109 for signal, handler in ( 5110 ('end-user-action', self.do_end_user_action), 5111 ): 5112 self.recording_handlers.append( 5113 self.buffer.connect_after(signal, handler)) 5114 5115 for signal, action in ( 5116 ('apply-tag', self.ACTION_APPLY_TAG), 5117 ('remove-tag', self.ACTION_REMOVE_TAG), 5118 ): 5119 self.recording_handlers.append( 5120 self.buffer.connect(signal, self.do_change_tag, action)) 5121 5122 for signal, handler in ( 5123 ('begin-insert-tree', self.do_begin_insert_tree), 5124 ('end-insert-tree', self.do_end_insert_tree), 5125 ): 5126 self.buffer.connect_after(signal, handler) 5127 5128 #~ self.buffer.connect_object('edit-textstyle-changed', 5129 #~ self.__class__._flush_if_typing, self) 5130 #~ self.buffer.connect_object('set-mark', 5131 #~ self.__class__._flush_if_typing, self) 5132 5133 def block(self): 5134 '''Stop listening to events from the L{TextBuffer} until 5135 the next call to L{unblock()}. Any change in between will not 5136 be undo-able (and mess up the undo stack) unless it is recorded 5137 explicitly. 5138 5139 The number of calls C{block()} and C{unblock()} is counted, so 5140 they can be called recursively. 5141 ''' 5142 if self.block_count == 0: 5143 for id in self.recording_handlers: 5144 self.buffer.handler_block(id) 5145 self.block_count += 1 5146 5147 def unblock(self): 5148 '''Start listening to events from the L{TextBuffer} again''' 5149 if self.block_count > 1: 5150 self.block_count -= 1 5151 else: 5152 for id in self.recording_handlers: 5153 self.buffer.handler_unblock(id) 5154 self.block_count = 0 5155 5156 def do_save_cursor(self, buffer, iter): 5157 # Store the cursor position 5158 self.group.cursor = iter.get_offset() 5159 5160 def do_begin_user_action(self, buffer): 5161 # Start a group of actions that will be undone as a single action 5162 if self.undo_count > 0: 5163 self.flush_redo_stack() 5164 5165 if self.group: 5166 self.stack.append(self.group) 5167 self.group = UndoActionGroup() 5168 while len(self.stack) > self.MAX_UNDO: 5169 self.stack.pop(0) 5170 5171 self.interactive = True 5172 5173 def do_end_user_action(self, buffer): 5174 # End a group of actions that will be undone as a single action 5175 if self.group: 5176 self.stack.append(self.group) 5177 self.group = UndoActionGroup() 5178 while len(self.stack) > self.MAX_UNDO: 5179 self.stack.pop(0) 5180 5181 self.interactive = False 5182 5183 def do_begin_insert_tree(self, buffer, interactive): 5184 if self.block_count == 0: 5185 if self.undo_count > 0: 5186 self.flush_redo_stack() 5187 elif self.insert_pending: 5188 self.flush_insert() 5189 # Do not start new group here - insert tree can be part of bigger change 5190 5191 self._insert_tree_start = buffer.get_insert_iter().get_offset() 5192 self.block() 5193 5194 def do_end_insert_tree(self, buffer): 5195 self.unblock() 5196 if self.block_count == 0: 5197 start = self._insert_tree_start 5198 start_iter = buffer.get_iter_at_offset(start) 5199 end_iter = buffer.get_insert_iter() 5200 end = end_iter.get_offset() 5201 tree = self.buffer.get_parsetree((start_iter, end_iter), raw=True) 5202 self.group.append((self.ACTION_INSERT, start, end, tree)) 5203 5204 def do_insert_text(self, buffer, iter, text, length): 5205 # Handle insert text event 5206 # Do not use length argument, it gives length in bytes, not characters 5207 length = len(text) 5208 if self.undo_count > 0: 5209 self.flush_redo_stack() 5210 5211 start = iter.get_offset() 5212 end = start + length 5213 #~ print('INSERT at %i: "%s" (%i)' % (start, text, length)) 5214 5215 if length == 1 and not text.isspace() \ 5216 and self.interactive and not self.group: 5217 # we can merge 5218 if self.stack and self.stack[-1].can_merge: 5219 previous = self.stack[-1][-1] 5220 if previous[0] == self.ACTION_INSERT \ 5221 and previous[2] == start \ 5222 and previous[3] is None: 5223 # so can previous group - let's merge 5224 self.group = self.stack.pop() 5225 self.group[-1] = (self.ACTION_INSERT, previous[1], end, None) 5226 return 5227 # we didn't merge - set flag for next 5228 self.group.can_merge = True 5229 5230 self.group.append((self.ACTION_INSERT, start, end, None)) 5231 self.insert_pending = True 5232 5233 def do_insert_pixbuf(self, buffer, iter, pixbuf): 5234 # Handle insert pixbuf event 5235 if self.undo_count > 0: 5236 self.flush_redo_stack() 5237 elif self.insert_pending: 5238 self.flush_insert() 5239 5240 start = iter.get_offset() 5241 end = start + 1 5242 #~ print('INSERT PIXBUF at %i' % start) 5243 self.group.append((self.ACTION_INSERT, start, end, None)) 5244 self.group.can_merge = False 5245 self.insert_pending = True 5246 5247 def flush_insert(self): 5248 '''Flush all pending actions and store them on the stack 5249 5250 The reason for this method is that because of the possibility of 5251 merging actions we do not immediatly request the parse tree for 5252 each single character insert. Instead we first group inserts 5253 based on cursor positions and then request the parse tree for 5254 the group at once. This method proceses all such delayed 5255 requests. 5256 ''' 5257 def _flush_group(group): 5258 for i in reversed(list(range(len(group)))): 5259 action, start, end, tree = group[i] 5260 if action == self.ACTION_INSERT and tree is None: 5261 bounds = (self.buffer.get_iter_at_offset(start), 5262 self.buffer.get_iter_at_offset(end)) 5263 tree = self.buffer.get_parsetree(bounds, raw=True) 5264 #~ print('FLUSH %i to %i\n\t%s' % (start, end, tree.tostring())) 5265 group[i] = (self.ACTION_INSERT, start, end, tree) 5266 else: 5267 return False 5268 return True 5269 5270 if _flush_group(self.group): 5271 for i in reversed(list(range(len(self.stack)))): 5272 if not _flush_group(self.stack[i]): 5273 break 5274 5275 self.insert_pending = False 5276 5277 def do_delete_range(self, buffer, start, end): 5278 # Handle deleting text 5279 if self.undo_count > 0: 5280 self.flush_redo_stack() 5281 elif self.insert_pending: 5282 self.flush_insert() 5283 5284 bounds = (start, end) 5285 tree = self.buffer.get_parsetree(bounds, raw=True) 5286 start, end = start.get_offset(), end.get_offset() 5287 #~ print('DELETE RANGE from %i to %i\n\t%s' % (start, end, tree.tostring())) 5288 self.group.append((self.ACTION_DELETE, start, end, tree)) 5289 self.group.can_merge = False 5290 5291 def do_change_tag(self, buffer, tag, start, end, action): 5292 assert action in (self.ACTION_APPLY_TAG, self.ACTION_REMOVE_TAG) 5293 if not hasattr(tag, 'zim_type'): 5294 return 5295 5296 start, end = start.get_offset(), end.get_offset() 5297 if self.group \ 5298 and self.group[-1][0] == self.ACTION_INSERT \ 5299 and self.group[-1][1] <= start \ 5300 and self.group[-1][2] >= end \ 5301 and self.group[-1][3] is None: 5302 pass # for text that is not yet flushed tags will be in the tree 5303 else: 5304 if self.undo_count > 0: 5305 self.flush_redo_stack() 5306 elif self.insert_pending: 5307 self.flush_insert() 5308 5309 #~ print('TAG CHANGED', start, end, tag) 5310 self.group.append((action, start, end, tag)) 5311 self.group.can_merge = False 5312 5313 def undo(self): 5314 '''Undo one user action''' 5315 if self.group: 5316 self.stack.append(self.group) 5317 self.group = UndoActionGroup() 5318 if self.insert_pending: 5319 self.flush_insert() 5320 5321 #~ import pprint 5322 #~ pprint.pprint( self.stack ) 5323 5324 l = len(self.stack) 5325 if self.undo_count == l: 5326 return False 5327 else: 5328 self.undo_count += 1 5329 i = l - self.undo_count 5330 self._replay(self.stack[i].reversed()) 5331 return True 5332 5333 def flush_redo_stack(self): 5334 '''Fold the "redo" part of the stack, called before new actions 5335 are appended after some step was undone. 5336 ''' 5337 i = len(self.stack) - self.undo_count 5338 fold = UndoActionGroup() 5339 for group in reversed(self.stack[i:]): 5340 fold.extend(group.reversed()) 5341 self.stack.append(fold) 5342 self.undo_count = 0 5343 5344 def redo(self): 5345 '''Redo one user action''' 5346 if self.undo_count == 0: 5347 return False 5348 else: 5349 assert not self.group, 'BUG: undo count should have been zero' 5350 i = len(self.stack) - self.undo_count 5351 self._replay(self.stack[i]) 5352 self.undo_count -= 1 5353 return True 5354 5355 def _replay(self, actiongroup): 5356 self.block() 5357 5358 #~ print('='*80) 5359 for action, start, end, data in actiongroup: 5360 iter = self.buffer.get_iter_at_offset(start) 5361 bound = self.buffer.get_iter_at_offset(end) 5362 5363 if action == self.ACTION_INSERT: 5364 #~ print('INSERTING', data.tostring()) 5365 self.buffer.place_cursor(iter) 5366 self.buffer.insert_parsetree_at_cursor(data) 5367 elif action == self.ACTION_DELETE: 5368 #~ print('DELETING', data.tostring()) 5369 self.buffer.place_cursor(iter) 5370 tree = self.buffer.get_parsetree((iter, bound), raw=True) 5371 #~ print('REAL', tree.tostring()) 5372 with self.buffer.user_action: 5373 self.buffer.delete(iter, bound) 5374 self.buffer._check_renumber = [] 5375 # Flush renumber check - HACK to avoid messing up the stack 5376 if tree.tostring() != data.tostring(): 5377 logger.warn('Mismatch in undo stack\n%s\n%s\n', tree.tostring(), data.tostring()) 5378 elif action == self.ACTION_APPLY_TAG: 5379 #~ print('APPLYING', data) 5380 self.buffer.apply_tag(data, iter, bound) 5381 self.buffer.place_cursor(bound) 5382 elif action == self.ACTION_REMOVE_TAG: 5383 #~ print('REMOVING', data) 5384 self.buffer.remove_tag(data, iter, bound) 5385 self.buffer.place_cursor(bound) 5386 else: 5387 assert False, 'BUG: unknown action type' 5388 5389 if not actiongroup.cursor is None: 5390 iter = self.buffer.get_iter_at_offset(actiongroup.cursor) 5391 self.buffer.place_cursor(iter) 5392 5393 self.unblock() 5394 5395 5396class SavePageHandler(object): 5397 '''Object for handling page saving. 5398 5399 This class implements auto-saving on a timer and tries writing in 5400 a background thread to ot block the user interface. 5401 ''' 5402 5403 def __init__(self, pageview, notebook, get_page_cb, timeout=15, use_thread=True): 5404 self.pageview = pageview 5405 self.notebook = notebook 5406 self.get_page_cb = get_page_cb 5407 self.timeout = timeout 5408 self.use_thread = use_thread 5409 self._autosave_timer = None 5410 self._error_event = None 5411 5412 def wait_for_store_page_async(self): 5413 # FIXME: duplicate of notebook method 5414 self.notebook.wait_for_store_page_async() 5415 5416 def queue_autosave(self, timeout=15): 5417 '''Queue a single autosave action after a given timeout. 5418 Will not do anything once an autosave is already queued. 5419 Autosave will keep running until page is no longer modified and 5420 then stop. 5421 @param timeout: timeout in seconds 5422 ''' 5423 if not self._autosave_timer: 5424 self._autosave_timer = GObject.timeout_add( 5425 self.timeout * 1000, # s -> ms 5426 self.do_try_save_page 5427 ) 5428 5429 def cancel_autosave(self): 5430 '''Cancel a pending autosave''' 5431 if self._autosave_timer: 5432 GObject.source_remove(self._autosave_timer) 5433 self._autosave_timer = None 5434 5435 def _assert_can_save_page(self, page): 5436 if self.pageview.readonly: 5437 raise AssertionError('BUG: can not save page when UI is read-only') 5438 elif page.readonly: 5439 raise AssertionError('BUG: can not save read-only page') 5440 5441 def save_page_now(self, dialog_timeout=False): 5442 '''Save the page in the foregound 5443 5444 Can result in a L{SavePageErrorDialog} when there is an error 5445 while saving a page. If that dialog is cancelled by the user, 5446 the page may not be saved after all. 5447 5448 @param dialog_timeout: passed on to L{SavePageErrorDialog} 5449 ''' 5450 self.cancel_autosave() 5451 5452 self._error_event = None 5453 5454 with NotebookState(self.notebook): 5455 page = self.get_page_cb() 5456 if page: 5457 try: 5458 self._assert_can_save_page(page) 5459 logger.debug('Saving page: %s', page) 5460 buffer = page.get_textbuffer() 5461 if buffer: 5462 buffer.showing_template = False # allow save_page to save template content 5463 #~ assert False, "TEST" 5464 self.notebook.store_page(page) 5465 5466 except Exception as error: 5467 logger.exception('Failed to save page: %s', page.name) 5468 SavePageErrorDialog(self.pageview, error, page, dialog_timeout).run() 5469 5470 def try_save_page(self): 5471 '''Try to save the page 5472 5473 * Will not do anything if page is not modified or when an 5474 autosave is already in progress. 5475 * If last autosave resulted in an error, will run in the 5476 foreground, else it tries to write the page in a background 5477 thread 5478 ''' 5479 self.cancel_autosave() 5480 self.do_try_save_page() 5481 5482 def do_try_save_page(self, *a): 5483 page = self.get_page_cb() 5484 if not (page and page.modified): 5485 self._autosave_timer = None 5486 return False # stop timer 5487 5488 if ongoing_operation(self.notebook): 5489 logger.debug('Operation in progress, skipping auto-save') # Could be auto-save 5490 return True # Check back later if on timer 5491 5492 5493 if not self.use_thread: 5494 self.save_page_now(dialog_timeout=True) 5495 elif self._error_event and self._error_event.is_set(): 5496 # Error in previous auto-save, save in foreground to allow error dialog 5497 logger.debug('Last auto-save resulted in error, re-try in foreground') 5498 self.save_page_now(dialog_timeout=True) 5499 else: 5500 # Save in background async 5501 # Retrieve tree here and pass on to thread to prevent 5502 # changing the buffer while extracting it 5503 parsetree = page.get_parsetree() 5504 op = self.notebook.store_page_async(page, parsetree) 5505 self._error_event = op.error_event 5506 5507 if page.modified: 5508 return True # if True, timer will keep going 5509 else: 5510 self._autosave_timer = None 5511 return False # stop timer 5512 5513 5514class SavePageErrorDialog(ErrorDialog): 5515 '''Error dialog used when we hit an error while trying to save a page. 5516 Allow to save a copy or to discard changes. Includes a timer which 5517 delays the action buttons becoming sensitive. Reason for this timer is 5518 that the dialog may popup from auto-save while the user is typing, and 5519 we want to prevent an accidental action. 5520 ''' 5521 5522 def __init__(self, pageview, error, page, timeout=False): 5523 msg = _('Could not save page: %s') % page.name 5524 # T: Heading of error dialog 5525 desc = str(error).strip() \ 5526 + '\n\n' \ 5527 + _('''\ 5528To continue you can save a copy of this page or discard 5529any changes. If you save a copy changes will be also 5530discarded, but you can restore the copy later.''') 5531 # T: text in error dialog when saving page failed 5532 ErrorDialog.__init__(self, pageview, (msg, desc), buttons=Gtk.ButtonsType.NONE) 5533 5534 self.timeout = timeout 5535 5536 self.pageview = pageview 5537 self.page = page 5538 self.error = error 5539 5540 self.timer_label = Gtk.Label() 5541 self.timer_label.set_alignment(0.9, 0.5) 5542 self.timer_label.set_sensitive(False) 5543 self.timer_label.show() 5544 self.vbox.add(self.timer_label) 5545 5546 cancel_button = Gtk.Button.new_with_mnemonic(_('_Cancel')) # T: Button label 5547 self.add_action_widget(cancel_button, Gtk.ResponseType.CANCEL) 5548 5549 self._done = False 5550 5551 discard_button = Gtk.Button.new_with_mnemonic(_('_Discard Changes')) 5552 # T: Button in error dialog 5553 discard_button.connect('clicked', lambda o: self.discard()) 5554 self.add_action_widget(discard_button, Gtk.ResponseType.OK) 5555 5556 save_button = Gtk.Button.new_with_mnemonic(_('_Save Copy')) 5557 # T: Button in error dialog 5558 save_button.connect('clicked', lambda o: self.save_copy()) 5559 self.add_action_widget(save_button, Gtk.ResponseType.OK) 5560 5561 for button in (cancel_button, discard_button, save_button): 5562 button.set_sensitive(False) 5563 button.show() 5564 5565 def discard(self): 5566 self.page.reload_textbuffer() 5567 self._done = True 5568 5569 def save_copy(self): 5570 from zim.gui.uiactions import SaveCopyDialog 5571 if SaveCopyDialog(self, self.pageview.notebook, self.page).run(): 5572 self.discard() 5573 5574 def do_response_ok(self): 5575 return self._done 5576 5577 def run(self): 5578 if self.timeout: 5579 self.timer = 5 5580 self.timer_label.set_text('%i sec.' % self.timer) 5581 def timer(self): 5582 self.timer -= 1 5583 if self.timer > 0: 5584 self.timer_label.set_text('%i sec.' % self.timer) 5585 return True # keep timer going 5586 else: 5587 for button in self.action_area.get_children(): 5588 button.set_sensitive(True) 5589 self.timer_label.set_text('') 5590 return False # remove timer 5591 5592 # older gobject version doesn't know about seconds 5593 id = GObject.timeout_add(1000, timer, self) 5594 ErrorDialog.run(self) 5595 GObject.source_remove(id) 5596 else: 5597 for button in self.action_area.get_children(): 5598 button.set_sensitive(True) 5599 ErrorDialog.run(self) 5600 5601 5602from zim.plugins import ExtensionBase, extendable 5603from zim.config import ConfigDict 5604from zim.gui.actionextension import ActionExtensionBase 5605from zim.gui.widgets import LEFT_PANE, RIGHT_PANE, BOTTOM_PANE, PANE_POSITIONS 5606 5607 5608class PageViewExtensionBase(ActionExtensionBase): 5609 '''Base class for extensions that want to interact with the "page view", 5610 which is the primary editor view of the application. 5611 5612 This extension class will collect actions defined with the C{@action}, 5613 C{@toggle_action} or C{@radio_action} decorators and add them to the window. 5614 5615 This extension class also supports showing side panes that are visible as 5616 part of the "decoration" of the editor view. 5617 5618 @ivar pageview: the L{PageView} object 5619 @ivar navigation: a L{NavigationModel} model 5620 @ivar uistate: a L{ConfigDict} to store the extensions ui state or 5621 5622 The "uistate" is the per notebook state of the interface, it is 5623 intended for stuff like the last folder opened by the user or the 5624 size of a dialog after resizing. It is stored in the X{state.conf} 5625 file in the notebook cache folder. It differs from the preferences, 5626 which are stored globally and dictate the behavior of the application. 5627 (To access the preference use C{plugin.preferences}.) 5628 ''' 5629 5630 def __init__(self, plugin, pageview): 5631 ExtensionBase.__init__(self, plugin, pageview) 5632 self.pageview = pageview 5633 self._window = self.pageview.get_toplevel() 5634 assert hasattr(self._window, 'add_tab'), 'expect mainwindow, got %s' % self._window 5635 5636 self.navigation = self._window.navigation 5637 self.uistate = pageview.notebook.state[self.plugin.config_key] 5638 5639 self._sidepane_widgets = {} 5640 self._add_actions(self._window.uimanager) 5641 5642 actiongroup = self.pageview.get_action_group('pageview') 5643 for name, action in get_actions(self): 5644 gaction = action.get_gaction() 5645 actiongroup.add_action(gaction) 5646 5647 def add_sidepane_widget(self, widget, preferences_key): 5648 key = widget.__class__.__name__ 5649 position = self.plugin.preferences[preferences_key] 5650 self._window.add_tab(key, widget, position) 5651 5652 def on_preferences_changed(preferences): 5653 position = self.plugin.preferences[preferences_key] 5654 self._window.remove(widget) 5655 self._window.add_tab(key, widget, position) 5656 5657 sid = self.connectto(self.plugin.preferences, 'changed', on_preferences_changed) 5658 self._sidepane_widgets[widget] = sid 5659 widget.show_all() 5660 5661 def remove_sidepane_widget(self, widget): 5662 try: 5663 self._window.remove(widget) 5664 except ValueError: 5665 pass 5666 5667 try: 5668 sid = self._sidepane_widgets.pop(widget) 5669 self.plugin.preferences.disconnect(sid) 5670 except KeyError: 5671 pass 5672 5673 def teardown(self): 5674 for widget in list(self._sidepane_widgets): 5675 self.remove_sidepane_widget(widget) 5676 widget.disconnect_all() 5677 5678 actiongroup = self.pageview.get_action_group('pageview') 5679 for name, action in get_actions(self): 5680 actiongroup.remove_action(action.name) 5681 5682 5683class PageViewExtension(PageViewExtensionBase): 5684 '''Base class for extensions of the L{PageView}, 5685 see L{PageViewExtensionBase} for API documentation. 5686 ''' 5687 pass 5688 5689 5690class InsertedObjectPageviewManager(object): 5691 '''"Glue" object to manage "insert object" actions for the L{PageView} 5692 Creates an action object for each object type and inserts UI elements 5693 for the action in the pageview. 5694 ''' 5695 5696 _class_actions = set() 5697 5698 def __init__(self, pageview): 5699 self.pageview = pageview 5700 self._actions = set() 5701 self.on_changed(None) 5702 PluginManager.insertedobjects.connect('changed', self.on_changed) 5703 5704 @staticmethod 5705 def _action_name(key): 5706 return 'insert_' + re.sub('\W', '_', key) 5707 5708 def on_changed(self, o): 5709 insertedobjects = PluginManager.insertedobjects 5710 keys = set(insertedobjects.keys()) 5711 5712 actiongroup = self.pageview.get_action_group('pageview') 5713 for key in self._actions - keys: 5714 action = getattr(self, self._action_name(key)) 5715 actiongroup.remove_action(action.name) 5716 self._actions.remove(key) 5717 5718 self._update_class_actions() # Modifies class 5719 5720 for key in keys - self._actions: 5721 action = getattr(self, self._action_name(key)) 5722 gaction = action.get_gaction() 5723 actiongroup.add_action(gaction) 5724 self._actions.add(key) 5725 5726 assert self._actions == keys 5727 5728 @classmethod 5729 def _update_class_actions(cls): 5730 # Triggered by instance, could be run multiple times for same change 5731 # but redundant runs should do nothing because of no change compared 5732 # to "_class_actions" 5733 insertedobjects = PluginManager.insertedobjects 5734 keys = set(insertedobjects.keys()) 5735 for key in cls._class_actions - keys: 5736 name = cls._action_name(key) 5737 if hasattr(cls, name): 5738 delattr(cls, name) 5739 cls._class_actions.remove(key) 5740 5741 for key in keys - cls._class_actions: 5742 name = cls._action_name(key) 5743 obj = insertedobjects[key] 5744 func = functools.partial(cls._action_handler, key) 5745 action = ActionClassMethod( 5746 name, func, obj.label, 5747 verb_icon=obj.verb_icon, 5748 menuhints='insert', 5749 ) 5750 setattr(cls, name, action) 5751 cls._class_actions.add(key) 5752 5753 assert cls._class_actions == keys 5754 5755 def _action_handler(key, self): # reverse arg spec due to partial 5756 try: 5757 otype = PluginManager.insertedobjects[key] 5758 notebook, page = self.pageview.notebook, self.pageview.page 5759 try: 5760 model = otype.new_model_interactive(self.pageview, notebook, page) 5761 except ValueError: 5762 return # dialog cancelled 5763 self.pageview.insert_object_model(otype, model) 5764 except: 5765 zim.errors.exception_handler( 5766 'Exception during action: insert_%s' % key) 5767 5768 5769def _install_format_actions(klass): 5770 for name, label, accelerator in ( 5771 ('apply_format_h1', _('Heading _1'), '<Primary>1'), # T: Menu item 5772 ('apply_format_h2', _('Heading _2'), '<Primary>2'), # T: Menu item 5773 ('apply_format_h3', _('Heading _3'), '<Primary>3'), # T: Menu item 5774 ('apply_format_h4', _('Heading _4'), '<Primary>4'), # T: Menu item 5775 ('apply_format_h5', _('Heading _5'), '<Primary>5'), # T: Menu item 5776 ('apply_format_strong', _('_Strong'), '<Primary>B'), # T: Menu item 5777 ('apply_format_emphasis', _('_Emphasis'), '<Primary>I'), # T: Menu item 5778 ('apply_format_mark', _('_Mark'), '<Primary>U'), # T: Menu item 5779 ('apply_format_strike', _('_Strike'), '<Primary>K'), # T: Menu item 5780 ('apply_format_sub', _('_Subscript'), '<Primary><Shift>b'), # T: Menu item 5781 ('apply_format_sup', _('_Superscript'), '<Primary><Shift>p'), # T: Menu item 5782 ('apply_format_code', _('_Verbatim'), '<Primary>T'), # T: Menu item 5783 ): 5784 func = functools.partial(klass.do_toggle_format_action, action=name) 5785 setattr(klass, name, 5786 ActionClassMethod(name, func, label, accelerator=accelerator, menuhints='edit') 5787 ) 5788 5789 klass._format_toggle_actions = [] 5790 for name, label, icon in ( 5791 ('toggle_format_strong', _('_Strong'), 'format-text-bold-symbolic'), # T: menu item for formatting 5792 ('toggle_format_emphasis', _('_Emphasis'), 'format-text-italic-symbolic'), # T: menu item for formatting 5793 ('toggle_format_mark', _('_Mark'), 'format-text-underline-symbolic'), # T: menu item for formatting 5794 ('toggle_format_strike', _('_Strike'), 'format-text-strikethrough-symbolic'), # T: menu item for formatting 5795 ('toggle_format_code', _('_Verbatim'), 'format-text-code-symbolic'), # T: menu item for formatting 5796 ('toggle_format_sup', _('Su_perscript'), 'format-text-superscript-symbolic'), # T: menu item for formatting 5797 ('toggle_format_sub', _('Su_bscript'), 'format-text-subscript-symbolic'), # T: menu item for formatting 5798 ): 5799 func = functools.partial(klass.do_toggle_format_action_alt, action=name) 5800 setattr(klass, name, 5801 ToggleActionClassMethod(name, func, label, icon=icon, menuhints='edit') 5802 ) 5803 klass._format_toggle_actions.append(name) 5804 5805 return klass 5806 5807 5808from zim.signals import GSignalEmitterMixin 5809 5810@_install_format_actions 5811@extendable(PageViewExtension, register_after_init=False) 5812class PageView(GSignalEmitterMixin, Gtk.VBox): 5813 '''Widget to display a single page, consists of a L{TextView} and 5814 a L{FindBar}. Also adds menu items and in general integrates 5815 the TextView with the rest of the application. 5816 5817 @ivar text_style: a L{ConfigSectionsDict} with style properties. Although this 5818 is a class attribute loading the data from the config file is 5819 delayed till the first object is constructed 5820 5821 @ivar page: L{Page} object for the current page displayed in the widget 5822 @ivar readonly: C{True} when the widget is read-only, see 5823 L{set_readonly()} for details 5824 @ivar view: the L{TextView} child object 5825 @ivar find_bar: the L{FindBar} child widget 5826 @ivar preferences: a L{ConfigDict} with preferences 5827 5828 @signal: C{modified-changed ()}: emitted when the page is edited 5829 @signal: C{textstyle-changed (style)}: 5830 Emitted when textstyle at the cursor changes, gets the list of text styles or None. 5831 @signal: C{activate-link (link, hints)}: emitted when a link is opened, 5832 stops emission after the first handler returns C{True} 5833 5834 @todo: document preferences supported by PageView 5835 @todo: document extra keybindings implemented in this widget 5836 @todo: document style properties supported by this widget 5837 ''' 5838 5839 # define signals we want to use - (closure type, return type and arg types) 5840 __gsignals__ = { 5841 'modified-changed': (GObject.SignalFlags.RUN_LAST, None, ()), 5842 'textstyle-changed': (GObject.SignalFlags.RUN_LAST, None, (object,)), 5843 'page-changed': (GObject.SignalFlags.RUN_LAST, None, (object,)), 5844 'link-caret-enter': (GObject.SignalFlags.RUN_LAST, None, (object,)), 5845 'link-caret-leave': (GObject.SignalFlags.RUN_LAST, None, (object,)), 5846 'readonly-changed': (GObject.SignalFlags.RUN_LAST, None, (bool,)), 5847 } 5848 5849 __signals__ = { 5850 'activate-link': (GObject.SignalFlags.RUN_LAST, bool, (object, object)) 5851 } 5852 5853 def __init__(self, notebook, navigation): 5854 '''Constructor 5855 @param notebook: the L{Notebook} object 5856 @param navigation: L{NavigationModel} object 5857 ''' 5858 GObject.GObject.__init__(self) 5859 GSignalEmitterMixin.__init__(self) 5860 5861 self._buffer_signals = () 5862 self.notebook = notebook 5863 self.page = None 5864 self.navigation = navigation 5865 self.readonly = True 5866 self._readonly_set = False 5867 self._readonly_set_error = False 5868 self.ui_is_initialized = False 5869 self._caret_link = None 5870 self._undo_history_queue = [] # we never lookup in this list, only keep refs - notebook does the caching 5871 5872 self.preferences = ConfigManager.preferences['PageView'] 5873 self.preferences.define( 5874 show_edit_bar=Boolean(True), 5875 follow_on_enter=Boolean(True), 5876 read_only_cursor=Boolean(False), 5877 autolink_camelcase=Boolean(True), 5878 autolink_files=Boolean(True), 5879 autoselect=Boolean(True), 5880 unindent_on_backspace=Boolean(True), 5881 cycle_checkbox_type=Boolean(True), 5882 recursive_indentlist=Boolean(True), 5883 recursive_checklist=Boolean(False), 5884 auto_reformat=Boolean(False), 5885 copy_format=Choice('Text', COPY_FORMATS), 5886 file_templates_folder=String('~/Templates'), 5887 ) 5888 5889 self.textview = TextView(preferences=self.preferences) 5890 self.swindow = ScrolledWindow(self.textview) 5891 self._hack_hbox = Gtk.HBox() 5892 self._hack_hbox.add(self.swindow) 5893 self._hack_label = Gtk.Label() # any widget would do I guess 5894 self._hack_hbox.pack_end(self._hack_label, False, True, 1) 5895 5896 self.overlay = Gtk.Overlay() 5897 self.overlay.add(self._hack_hbox) 5898 self._overlay_label = Gtk.Label() 5899 self._overlay_label.set_halign(Gtk.Align.START) 5900 self._overlay_label.set_margin_start(12) 5901 self._overlay_label.set_valign(Gtk.Align.END) 5902 self._overlay_label.set_margin_bottom(5) 5903 widget_set_css(self._overlay_label, 'overlay-label', 5904 'background: rgba(0, 0, 0, 0.8); ' 5905 'padding: 3px 5px; border-radius: 3px; ' 5906 'color: #fff; ' 5907 ) # Tried to make it look like tooltip - based on Adwaita css 5908 self._overlay_label.set_no_show_all(True) 5909 self.overlay.add_overlay(self._overlay_label) 5910 self.overlay.set_overlay_pass_through(self._overlay_label, True) 5911 self.add(self.overlay) 5912 5913 self.textview.connect_object('link-clicked', PageView.activate_link, self) 5914 self.textview.connect_object('populate-popup', PageView.do_populate_popup, self) 5915 self.textview.connect('link-enter', self.on_link_enter) 5916 self.textview.connect('link-leave', self.on_link_leave) 5917 self.connect('link-caret-enter', self.on_link_enter) 5918 self.connect('link-caret-leave', self.on_link_leave) 5919 5920 ## Create search box 5921 self.find_bar = FindBar(textview=self.textview) 5922 self.pack_end(self.find_bar, False, True, 0) 5923 self.find_bar.hide() 5924 5925 ## setup GUI actions 5926 group = get_gtk_actiongroup(self) 5927 group.add_actions(MENU_ACTIONS, self) 5928 5929 # setup hooks for new file submenu 5930 action = self.actiongroup.get_action('insert_new_file_menu') 5931 action.zim_readonly = False 5932 action.connect('activate', self._update_new_file_submenu) 5933 5934 # ... 5935 self.edit_bar = EditBar(self) 5936 self._edit_bar_visible = True 5937 self.pack_start(self.edit_bar, False, True, 0) 5938 #self.reorder_child(self.edit_bar, 0) 5939 5940 self.edit_bar.show_all() 5941 self.edit_bar.set_no_show_all(True) 5942 5943 def _show_edit_bar_on_hide_find(*a): 5944 if self._edit_bar_visible and not self.readonly: 5945 self.edit_bar.show() 5946 5947 self.find_bar.connect('show', lambda o: self.edit_bar.hide()) 5948 self.find_bar.connect_after('hide', _show_edit_bar_on_hide_find) 5949 5950 # ... 5951 self.preferences.connect('changed', self.on_preferences_changed) 5952 self.on_preferences_changed() 5953 5954 self.text_style = ConfigManager.get_config_dict('style.conf') 5955 self.text_style.connect('changed', lambda o: self.on_text_style_changed()) 5956 self.on_text_style_changed() 5957 5958 def assert_not_modified(page, *a): 5959 if page == self.page \ 5960 and self.textview.get_buffer().get_modified(): 5961 raise AssertionError('BUG: page changed while buffer changed as well') 5962 # not using assert here because it could be optimized away 5963 5964 for s in ('store-page', 'delete-page', 'move-page'): 5965 self.notebook.connect(s, assert_not_modified) 5966 5967 # Setup saving 5968 if_preferences = ConfigManager.preferences['GtkInterface'] 5969 if_preferences.setdefault('autosave_timeout', 15) 5970 if_preferences.setdefault('autosave_use_thread', True) 5971 logger.debug('Autosave interval: %r - use threads: %r', 5972 if_preferences['autosave_timeout'], 5973 if_preferences['autosave_use_thread'] 5974 ) 5975 self._save_page_handler = SavePageHandler( 5976 self, notebook, 5977 lambda: self.page, 5978 timeout=if_preferences['autosave_timeout'], 5979 use_thread=if_preferences['autosave_use_thread'] 5980 ) 5981 5982 def on_focus_out_event(*a): 5983 self._save_page_handler.try_save_page() 5984 return False # don't block the event 5985 self.textview.connect('focus-out-event', on_focus_out_event) 5986 5987 PluginManager.insertedobjects.connect( 5988 'changed', 5989 self.on_insertedobjecttypemap_changed 5990 ) 5991 5992 initialize_actiongroup(self, 'pageview') 5993 self._insertedobject_manager = InsertedObjectPageviewManager(self) 5994 self.__zim_extension_objects__.append(self._insertedobject_manager) # HACK to make actions discoverable 5995 5996 def grab_focus(self): 5997 self.textview.grab_focus() 5998 5999 def on_preferences_changed(self, *a): 6000 self.textview.set_cursor_visible( 6001 self.preferences['read_only_cursor'] or not self.readonly) 6002 self._set_edit_bar_visible(self.preferences['show_edit_bar']) 6003 6004 def on_text_style_changed(self, *a): 6005 '''(Re-)intializes properties for TextView, TextBuffer and 6006 TextTags based on the properties in the style config. 6007 ''' 6008 6009 # TODO: reload buffer on style changed to make change visible 6010 # now it is only visible on next page load 6011 6012 self.text_style['TextView'].define( 6013 bullet_icon_size=ConfigDefinitionConstant( 6014 'GTK_ICON_SIZE_MENU', 6015 Gtk.IconSize, 6016 'GTK_ICON_SIZE' 6017 ) 6018 ) 6019 6020 self.text_style['TextView'].setdefault('indent', TextBuffer.pixels_indent) 6021 self.text_style['TextView'].setdefault('tabs', None, int) 6022 # Don't set a default for 'tabs' as not to break pages that 6023 # were created before this setting was introduced. 6024 self.text_style['TextView'].setdefault('linespacing', 3) 6025 self.text_style['TextView'].setdefault('wrapped-lines-linespacing', 0) 6026 self.text_style['TextView'].setdefault('font', None, str) 6027 self.text_style['TextView'].setdefault('justify', None, str) 6028 #~ print self.text_style['TextView'] 6029 6030 # Set properties for TextVIew 6031 if self.text_style['TextView']['tabs']: 6032 tabarray = Pango.TabArray(1, True) # Initial size, position in pixels 6033 tabarray.set_tab(0, Pango.TabAlign.LEFT, self.text_style['TextView']['tabs']) 6034 # We just set the size for one tab, apparently this gets 6035 # copied automaticlly when a new tab is created by the textbuffer 6036 self.textview.set_tabs(tabarray) 6037 6038 if self.text_style['TextView']['linespacing']: 6039 self.textview.set_pixels_below_lines(self.text_style['TextView']['linespacing']) 6040 6041 if self.text_style['TextView']['wrapped-lines-linespacing']: 6042 self.textview.set_pixels_inside_wrap(self.text_style['TextView']['wrapped-lines-linespacing']) 6043 6044 if self.text_style['TextView']['font']: 6045 font = Pango.FontDescription(self.text_style['TextView']['font']) 6046 self.textview.modify_font(font) 6047 else: 6048 self.textview.modify_font(None) 6049 6050 if self.text_style['TextView']['justify']: 6051 try: 6052 const = self.text_style['TextView']['justify'] 6053 assert hasattr(gtk, const), 'No such constant: Gtk.%s' % const 6054 self.textview.set_justification(getattr(gtk, const)) 6055 except: 6056 logger.exception('Exception while setting justification:') 6057 6058 # Set properties for TextBuffer 6059 TextBuffer.pixels_indent = self.text_style['TextView']['indent'] 6060 TextBuffer.bullet_icon_size = self.text_style['TextView']['bullet_icon_size'] 6061 6062 # Load TextTags 6063 testbuffer = Gtk.TextBuffer() 6064 for key in [k for k in list(self.text_style.keys()) if k.startswith('Tag ')]: 6065 section = self.text_style[key] 6066 defs = [(k, TextBuffer.tag_attributes[k]) 6067 for k in section._input if k in TextBuffer.tag_attributes] 6068 section.define(defs) 6069 tag = key[4:] 6070 6071 try: 6072 if not tag in TextBuffer.tag_styles: 6073 raise AssertionError('No such tag: %s' % tag) 6074 6075 attrib = dict(i for i in list(section.items()) if i[1] is not None) 6076 if 'linespacing' in attrib: 6077 attrib['pixels-below-lines'] = attrib.pop('linespacing') 6078 6079 #~ print('TAG', tag, attrib) 6080 testtag = testbuffer.create_tag('style-' + tag, **attrib) 6081 if not testtag: 6082 raise AssertionError('Could not create tag: %s' % tag) 6083 except: 6084 logger.exception('Exception while parsing tag: %s:', tag) 6085 else: 6086 TextBuffer.tag_styles[tag].update(attrib) 6087 6088 def _connect_focus_event(self): 6089 # Connect to parent window here in a HACK to ensure 6090 # we do not hijack keybindings like ^C and ^V while we are not 6091 # focus (e.g. paste in find bar) Put it here to ensure 6092 # mainwindow is initialized. 6093 def set_actiongroup_sensitive(window, widget): 6094 #~ print('!! FOCUS SET:', widget) 6095 sensitive = widget is self.textview 6096 6097 # Enable keybindings and buttons for find functionality if find bar is in focus 6098 force_sensitive = () 6099 if widget and widget.get_parent() is self.find_bar: 6100 force_sensitive = ("show_find", "find_next", "find_previous", 6101 "show_find_alt1", "find_next_alt1", "find_previous_alt1") 6102 6103 self._set_menuitems_sensitive(sensitive, force_sensitive) 6104 6105 window = self.get_toplevel() 6106 if window and window != self: 6107 window.connect('set-focus', set_actiongroup_sensitive) 6108 6109 def on_link_enter(self, view, link): 6110 href = normalize_file_uris(link['href']) 6111 if link_type(href) == 'page': 6112 href = HRef.new_from_wiki_link(href) 6113 path = self.notebook.pages.resolve_link(self.page, href) 6114 name = path.name + '#' + href.anchor if href.anchor else path.name 6115 self._overlay_label.set_text('Go to "%s"' % name)# T: tooltip text for links to pages 6116 else: 6117 self._overlay_label.set_text('Open "%s"' % href) # T: tooltip text for links to files/URLs etc. 6118 6119 self._overlay_label.show() 6120 6121 def on_link_leave(self, view, link): 6122 self._overlay_label.hide() 6123 6124 def set_edit_bar_visible(self, visible): 6125 self.preferences['show_edit_bar'] = visible 6126 # Bit of a hack, but prevents preferences to overwrite setting from Toolbar plugin 6127 # triggers _set_edit_bar_visible() via changed signal on preferences 6128 6129 def _set_edit_bar_visible(self, visible): 6130 self._edit_bar_visible = visible 6131 if not visible: 6132 self.edit_bar.hide() 6133 elif self.find_bar.get_property('visible') or self.readonly: 6134 self.edit_bar.hide() 6135 else: 6136 self.edit_bar.show() 6137 6138 def set_page(self, page, cursor=None): 6139 '''Set the current page to be displayed in the pageview 6140 6141 When the page does not yet exist a template is loaded for a 6142 new page which is obtained from 6143 L{Notebook.get_template()<zim.notebook.Notebook.get_template>}. 6144 6145 Exceptions while loading the page are handled gracefully with 6146 an error dialog and will result in the widget to be read-only 6147 and insensitive until the next page is loaded. 6148 6149 @param page: a L{Page} object 6150 @keyword cursor: optional cursor position (integer) 6151 6152 When the cursor is set to C{-1} the cursor will be placed at 6153 the end of the buffer. 6154 6155 If cursor is C{None} the cursor is set at the start of the page 6156 for existing pages or to the end of the template when the page 6157 does not yet exist. 6158 ''' 6159 if self.page is None: 6160 # first run - bootstrap HACK 6161 self._connect_focus_event() 6162 6163 # Teardown connection with current page buffer 6164 prev_buffer = self.textview.get_buffer() 6165 finderstate = prev_buffer.finder.get_state() 6166 for id in self._buffer_signals: 6167 prev_buffer.disconnect(id) 6168 self._buffer_signals = () 6169 6170 # now create the new buffer 6171 self._readonly_set_error = False 6172 try: 6173 self.page = page 6174 buffer = page.get_textbuffer(self._create_textbuffer) 6175 self._buffer_signals = ( 6176 buffer.connect('end-insert-tree', self._hack_on_inserted_tree), 6177 ) 6178 # TODO: also connect after insert widget ? 6179 6180 self.textview.set_buffer(buffer) 6181 self._hack_on_inserted_tree() 6182 6183 if cursor is None: 6184 cursor = -1 if buffer.showing_template else 0 6185 6186 except Exception as error: 6187 # Maybe corrupted parse tree - prevent page to be edited or saved back 6188 self._readonly_set_error = True 6189 self._update_readonly() 6190 self.set_sensitive(False) 6191 ErrorDialog(self, error).run() 6192 else: 6193 6194 # Finish hooking up the new page 6195 self.set_cursor_pos(cursor) 6196 6197 self._buffer_signals += ( 6198 buffer.connect('textstyle-changed', lambda o, *a: self.emit('textstyle-changed', *a)), 6199 buffer.connect('modified-changed', lambda o: self.on_modified_changed(o)), 6200 buffer.connect_after('mark-set', self.do_mark_set), 6201 ) 6202 6203 buffer.finder.set_state(*finderstate) # maintain state 6204 6205 self.set_sensitive(True) 6206 self._update_readonly() 6207 6208 self.emit('page-changed', self.page) 6209 6210 def _create_textbuffer(self, parsetree=None): 6211 # Callback for page.get_textbuffer 6212 buffer = TextBuffer(self.notebook, self.page, parsetree=parsetree) 6213 6214 readonly = self._readonly_set or self.notebook.readonly or self.page.readonly 6215 # Do not use "self.readonly" here, may not yet be intialized 6216 if parsetree is None and not readonly: 6217 # HACK: using None value instead of "hascontent" to distinguish 6218 # between a page without source and an existing empty page 6219 parsetree = self.notebook.get_template(self.page) 6220 buffer.set_parsetree(parsetree, showing_template=True) 6221 buffer.set_modified(False) 6222 # By setting this instead of providing to the TextBuffer constructor 6223 # this template can be undone 6224 6225 return buffer 6226 6227 def on_modified_changed(self, buffer): 6228 if buffer.get_modified(): 6229 if self.readonly: 6230 logger.warn('Buffer edited while textview read-only - potential bug') 6231 else: 6232 if not (self._undo_history_queue and self._undo_history_queue[-1] is self.page): 6233 if self.page in self._undo_history_queue: 6234 self._undo_history_queue.remove(self.page) 6235 elif len(self._undo_history_queue) > MAX_PAGES_UNDO_STACK: 6236 self._undo_history_queue.pop(0) 6237 self._undo_history_queue.append(self.page) 6238 6239 buffer.showing_template = False 6240 self.emit('modified-changed') 6241 self._save_page_handler.queue_autosave() 6242 6243 def save_changes(self, write_if_not_modified=False): 6244 '''Save contents of the widget back to the page object and 6245 synchronize it with the notebook. 6246 6247 @param write_if_not_modified: If C{True} page will be written 6248 even if it is not changed. (This allows e.g. to force saving template 6249 content to disk without editing.) 6250 ''' 6251 if write_if_not_modified or self.page.modified: 6252 self._save_page_handler.save_page_now() 6253 self._save_page_handler.wait_for_store_page_async() 6254 6255 def _hack_on_inserted_tree(self, *a): 6256 if self.textview._object_widgets: 6257 # Force resize of the scroll window, forcing a redraw to fix 6258 # glitch in allocation of embedded obejcts, see isse #642 6259 # Will add another timeout to rendering the page, increasing the 6260 # priority breaks the hack though. Which shows the glitch is 6261 # probably also happening in a drawing or resizing idle event 6262 # 6263 # Additional hook is needed for scrolling because re-rendering the 6264 # objects changes the textview size and thus looses the scrolled 6265 # position. Here idle didn't work so used a time-out with the 6266 # potential risk that in some cases the timeout is to fast or to slow. 6267 6268 self._hack_label.show_all() 6269 def scroll(): 6270 self.scroll_cursor_on_screen() 6271 return False 6272 6273 def hide_hack(): 6274 self._hack_label.hide() 6275 GLib.timeout_add(100, scroll) 6276 return False 6277 6278 GLib.idle_add(hide_hack) 6279 else: 6280 self._hack_label.hide() 6281 6282 def on_insertedobjecttypemap_changed(self, *a): 6283 self.save_changes() 6284 self.page.reload_textbuffer() # HACK - should not need to reload whole page just to load objects 6285 6286 def set_readonly(self, readonly): 6287 '''Set the widget read-only or not 6288 6289 Sets the read-only state but also update menu items etc. to 6290 reflect the new state. 6291 6292 @param readonly: C{True} or C{False} to set the read-only state 6293 6294 Effective read-only state seen in the C{self.readonly} attribute 6295 is in fact C{True} (so read-only) when either the widget itself 6296 OR the current page is read-only. So setting read-only to 6297 C{False} here may not immediately change C{self.readonly} if 6298 a read-only page is loaded. 6299 ''' 6300 self._readonly_set = readonly 6301 self._update_readonly() 6302 self.emit('readonly-changed', readonly) 6303 6304 def _update_readonly(self): 6305 self.readonly = self._readonly_set \ 6306 or self._readonly_set_error \ 6307 or self.page is None \ 6308 or self.notebook.readonly \ 6309 or self.page.readonly 6310 self.textview.set_editable(not self.readonly) 6311 self.textview.set_cursor_visible( 6312 self.preferences['read_only_cursor'] or not self.readonly) 6313 self._set_menuitems_sensitive(True) # XXX not sure why this is here 6314 6315 if not self._edit_bar_visible: 6316 pass 6317 elif self.find_bar.get_property('visible') or self.readonly: 6318 self.edit_bar.hide() 6319 else: 6320 self.edit_bar.show() 6321 6322 def _set_menuitems_sensitive(self, sensitive, force_sensitive=()): 6323 '''Batch update global menu sensitivity while respecting 6324 sensitivities set due to cursor position, readonly state etc. 6325 ''' 6326 6327 if sensitive: 6328 # partly overrule logic in window.toggle_editable() 6329 for action in self.actiongroup.list_actions(): 6330 action.set_sensitive( 6331 action.zim_readonly or not self.readonly) 6332 6333 # update state for menu items for checkboxes and links 6334 buffer = self.textview.get_buffer() 6335 iter = buffer.get_insert_iter() 6336 mark = buffer.get_insert() 6337 self.do_mark_set(buffer, iter, mark) 6338 else: 6339 for action in self.actiongroup.list_actions(): 6340 if action.get_name() not in force_sensitive: 6341 action.set_sensitive(False) 6342 else: 6343 action.set_sensitive(True) 6344 6345 def set_cursor_pos(self, pos): 6346 '''Set the cursor position in the buffer and scroll the TextView 6347 to show it 6348 6349 @param pos: the cursor position as an integer offset from the 6350 start of the buffer 6351 6352 As a special case when the cursor position is C{-1} the cursor 6353 is set at the end of the buffer. 6354 ''' 6355 buffer = self.textview.get_buffer() 6356 if pos < 0: 6357 start, end = buffer.get_bounds() 6358 iter = end 6359 else: 6360 iter = buffer.get_iter_at_offset(pos) 6361 6362 buffer.place_cursor(iter) 6363 self.scroll_cursor_on_screen() 6364 6365 def get_cursor_pos(self): 6366 '''Get the cursor position in the buffer 6367 6368 @returns: the cursor position as an integer offset from the 6369 start of the buffer 6370 ''' 6371 buffer = self.textview.get_buffer() 6372 iter = buffer.get_iter_at_mark(buffer.get_insert()) 6373 return iter.get_offset() 6374 6375 def scroll_cursor_on_screen(self): 6376 buffer = self.textview.get_buffer() 6377 self.textview.scroll_to_mark(buffer.get_insert(), SCROLL_TO_MARK_MARGIN, False, 0, 0) 6378 6379 def set_scroll_pos(self, pos): 6380 pass # FIXME set scroll position 6381 6382 def get_scroll_pos(self): 6383 pass # FIXME get scroll position 6384 6385 def get_selection(self, format=None): 6386 '''Convenience method to get the text of the current selection. 6387 6388 @param format: format to use for the formatting of the returned 6389 text (e.g. 'wiki' or 'html'). If the format is C{None} only the 6390 text will be returned without any formatting. 6391 6392 @returns: text selection or C{None} 6393 ''' 6394 buffer = self.textview.get_buffer() 6395 bounds = buffer.get_selection_bounds() 6396 if bounds: 6397 if format: 6398 tree = buffer.get_parsetree(bounds) 6399 dumper = get_format(format).Dumper() 6400 lines = dumper.dump(tree) 6401 return ''.join(lines) 6402 else: 6403 return bounds[0].get_text(bounds[1]) 6404 else: 6405 return None 6406 6407 def get_word(self, format=None): 6408 '''Convenience method to get the word that is under the cursor 6409 6410 @param format: format to use for the formatting of the returned 6411 text (e.g. 'wiki' or 'html'). If the format is C{None} only the 6412 text will be returned without any formatting. 6413 6414 @returns: current word or C{None} 6415 ''' 6416 buffer = self.textview.get_buffer() 6417 buffer.select_word() 6418 return self.get_selection(format) 6419 6420 def replace_selection(self, text, autoselect=None): 6421 assert autoselect in (None, 'word') 6422 buffer = self.textview.get_buffer() 6423 if not buffer.get_has_selection(): 6424 if autoselect == 'word': 6425 buffer.select_word() 6426 else: 6427 raise AssertionError 6428 6429 bounds = buffer.get_selection_bounds() 6430 if bounds: 6431 start, end = bounds 6432 with buffer.user_action: 6433 buffer.delete(start, end) 6434 buffer.insert_at_cursor(''.join(text)) 6435 else: 6436 buffer.insert_at_cursor(''.join(text)) 6437 6438 def do_mark_set(self, buffer, iter, mark): 6439 ''' 6440 @emits link-caret-enter 6441 @emits link-caret-leave 6442 ''' 6443 6444 # Update menu items relative to cursor position 6445 if self.readonly or mark.get_name() != 'insert': 6446 return 6447 6448 # Set sensitivity of various menu options 6449 line = iter.get_line() 6450 bullet = buffer.get_bullet(line) 6451 if bullet and bullet in CHECKBOXES: 6452 self.actiongroup.get_action('uncheck_checkbox').set_sensitive(True) 6453 self.actiongroup.get_action('toggle_checkbox').set_sensitive(True) 6454 self.actiongroup.get_action('xtoggle_checkbox').set_sensitive(True) 6455 self.actiongroup.get_action('migrate_checkbox').set_sensitive(True) 6456 self.actiongroup.get_action('transmigrate_checkbox').set_sensitive(True) 6457 else: 6458 self.actiongroup.get_action('uncheck_checkbox').set_sensitive(False) 6459 self.actiongroup.get_action('toggle_checkbox').set_sensitive(False) 6460 self.actiongroup.get_action('xtoggle_checkbox').set_sensitive(False) 6461 self.actiongroup.get_action('migrate_checkbox').set_sensitive(False) 6462 self.actiongroup.get_action('transmigrate_checkbox').set_sensitive(False) 6463 6464 if buffer.get_link_tag(iter): 6465 self.actiongroup.get_action('remove_link').set_sensitive(True) 6466 self.actiongroup.get_action('edit_object').set_sensitive(True) 6467 elif buffer.get_image_data(iter): 6468 self.actiongroup.get_action('remove_link').set_sensitive(False) 6469 self.actiongroup.get_action('edit_object').set_sensitive(True) 6470 else: 6471 self.actiongroup.get_action('edit_object').set_sensitive(False) 6472 self.actiongroup.get_action('remove_link').set_sensitive(False) 6473 6474 # Emit signal if passing through a link 6475 link = buffer.get_link_data(iter) 6476 if link: 6477 if not self._caret_link: # we enter link for the first time 6478 self.emit("link-caret-enter", link) 6479 elif self._caret_link != link: # we changed the link 6480 self.emit("link-caret-leave", self._caret_link) 6481 self.emit("link-caret-enter", link) 6482 elif self._caret_link: # we left the link 6483 self.emit("link-caret-leave", self._caret_link) 6484 self._caret_link = link 6485 6486 def do_textstyle_changed(self, styles): 6487 if not styles: # styles can be None or a list 6488 styles = [] 6489 6490 for name in self._format_toggle_actions: 6491 getattr(self, name).set_active(name[14:] in styles) # len("toggle_format_") = 14 6492 6493 def activate_link(self, link, new_window=False): 6494 if not isinstance(link, str): 6495 link = link['href'] 6496 6497 href = normalize_file_uris(link) 6498 # can translate file:// -> smb:// so do before link_type() 6499 # FIXME implement function in notebook to resolve any link 6500 # type and take care of this stuff ? 6501 logger.debug('Activate link: %s', link) 6502 6503 if link_type(link) == 'interwiki': 6504 target = interwiki_link(link) 6505 if target is not None: 6506 link = target 6507 else: 6508 name = link.split('?')[0] 6509 error = Error(_('No such wiki defined: %s') % name) 6510 # T: error when unknown interwiki link is clicked 6511 return ErrorDialog(self, error).run() 6512 6513 hints = {'new_window': new_window} 6514 self.emit_return_first('activate-link', link, hints) 6515 6516 def do_activate_link(self, link, hints): 6517 try: 6518 self._do_activate_link(link, hints) 6519 except: 6520 zim.errors.exception_handler( 6521 'Exception during activate-link(%r)' % ((link, hints),)) 6522 6523 def _do_activate_link(self, link, hints): 6524 type = link_type(link) 6525 6526 if type == 'page': 6527 href = HRef.new_from_wiki_link(link) 6528 path = self.notebook.pages.resolve_link(self.page, href) 6529 self.navigation.open_page(path, anchor=href.anchor, new_window=hints.get('new_window', False)) 6530 elif type == 'file': 6531 path = self.notebook.resolve_file(link, self.page) 6532 open_file(self, path) 6533 elif type == 'notebook': 6534 from zim.main import ZIM_APPLICATION 6535 6536 if link.startswith('zim+'): 6537 uri, pagename = link[4:], None 6538 if '?' in uri: 6539 uri, pagename = uri.split('?', 1) 6540 6541 ZIM_APPLICATION.run('--gui', uri, pagename) 6542 6543 else: 6544 ZIM_APPLICATION.run('--gui', FilePath(link).uri) 6545 6546 else: 6547 if type == 'mailto' and not link.startswith('mailto:'): 6548 link = 'mailto:' + link # Enforce proper URI form 6549 open_url(self, link) 6550 6551 return True # handled 6552 6553 def navigate_to_anchor(self, name, select_line=False): 6554 """Navigate to an anchor on the current page. 6555 @param name: The name of the anchor to navigate to 6556 @param select_line: Select the whole line after 6557 """ 6558 logger.debug("navigating to anchor '%s'", name) 6559 textview = self.textview 6560 buffer = textview.get_buffer() 6561 iter = buffer.find_anchor(name) 6562 if not iter: 6563 ErrorDialog(self, _('Id "%s" not found on the current page') % name).run() # T: error when anchor location in page not found 6564 return 6565 buffer.place_cursor(iter) 6566 if select_line: 6567 buffer.select_line() 6568 textview.scroll_to_mark(buffer.get_insert(), SCROLL_TO_MARK_MARGIN, False, 0, 0) 6569 6570 def do_populate_popup(self, menu): 6571 buffer = self.textview.get_buffer() 6572 if not buffer.get_has_selection(): 6573 iter = self.textview._get_popup_menu_mark() 6574 if iter is None: 6575 self._default_do_populate_popup(menu) 6576 else: 6577 if iter.get_line_offset() == 1: 6578 iter.backward_char() # if clicked on right half of image, iter is after the image 6579 bullet = buffer.get_bullet_at_iter(iter) 6580 if bullet and bullet in CHECKBOXES: 6581 self._checkbox_do_populate_popup(menu, buffer, iter) 6582 else: 6583 self._default_do_populate_popup(menu) 6584 else: 6585 self._default_do_populate_popup(menu) 6586 menu.show_all() 6587 6588 def _default_do_populate_popup(self, menu): 6589 # Add custom tool 6590 # FIXME need way to (deep)copy widgets in the menu 6591 #~ toolmenu = uimanager.get_widget('/text_popup') 6592 #~ tools = [tool for tool in toolmenu.get_children() 6593 #~ if not isinstance(tool, Gtk.SeparatorMenuItem)] 6594 #~ print('>>> TOOLS', tools) 6595 #~ if tools: 6596 #~ menu.prepend(Gtk.SeparatorMenuItem()) 6597 #~ for tool in tools: 6598 #~ tool.reparent(menu) 6599 6600 buffer = self.textview.get_buffer() 6601 6602 ### Copy As option ### 6603 default = self.preferences['copy_format'].lower() 6604 copy_as_menu = Gtk.Menu() 6605 for label in COPY_FORMATS: 6606 if label.lower() == default: 6607 continue # Covered by default Copy action 6608 6609 format = zim.formats.canonical_name(label) 6610 item = Gtk.MenuItem.new_with_mnemonic(label) 6611 if buffer.get_has_selection(): 6612 item.connect('activate', 6613 lambda o, f: self.textview.do_copy_clipboard(format=f), 6614 format) 6615 else: 6616 item.set_sensitive(False) 6617 copy_as_menu.append(item) 6618 6619 item = Gtk.MenuItem.new_with_mnemonic(_('Copy _As...')) # T: menu item for context menu of editor 6620 item.set_submenu(copy_as_menu) 6621 menu.insert(item, 2) # position after Copy in the standard menu - may not be robust... 6622 # FIXME get code from test to seek stock item 6623 6624 ### Paste As 6625 item = Gtk.MenuItem.new_with_mnemonic(_('Paste As _Verbatim')) # T: menu item for context menu of editor 6626 item.set_sensitive(Clipboard.clipboard.wait_is_text_available()) 6627 item.connect('activate', lambda o: self.textview.do_paste_clipboard(format='verbatim')) 6628 item.show_all() 6629 menu.insert(item, 4) # position after Paste in the standard menu - may not be robust... 6630 # FIXME get code from test to seek stock item 6631 6632 ### Move text to new page ### 6633 item = Gtk.MenuItem.new_with_mnemonic(_('Move Selected Text...')) 6634 # T: Context menu item for pageview to move selected text to new/other page 6635 menu.insert(item, 7) # position after Copy in the standard menu - may not be robust... 6636 # FIXME get code from test to seek stock item 6637 6638 if buffer.get_has_selection(): 6639 item.connect('activate', 6640 lambda o: MoveTextDialog(self, self.notebook, self.page, buffer, self.navigation).run()) 6641 else: 6642 item.set_sensitive(False) 6643 ### 6644 6645 iter = self.textview._get_popup_menu_mark() 6646 # This iter can be either cursor position or pointer 6647 # position, depending on how the menu was called 6648 if iter is None: 6649 return 6650 6651 def _copy_link_to_anchor(o, anchor, text): 6652 path = self.page 6653 Clipboard.set_pagelink(self.notebook, path, anchor, text) 6654 SelectionClipboard.set_pagelink(self.notebook, path, anchor, text) 6655 6656 # copy link to anchor or heading 6657 item = Gtk.MenuItem.new_with_mnemonic(_('Copy _link to this location')) # T: menu item to copy link to achor location in page 6658 anchor = buffer.get_anchor_for_location(iter) 6659 if anchor: 6660 heading_text = buffer._get_heading_text(iter) # can be None if not a heading 6661 item.connect('activate', _copy_link_to_anchor, anchor, heading_text) 6662 else: 6663 item.set_sensitive(False) 6664 menu.insert(item, 3) 6665 6666 # link 6667 link = buffer.get_link_data(iter) 6668 if link: 6669 type = link_type(link['href']) 6670 if type == 'file': 6671 file = link['href'] 6672 else: 6673 file = None 6674 else: 6675 image = buffer.get_image_data(iter) 6676 if image is None: 6677 # Maybe we clicked right side of an image 6678 iter.backward_char() 6679 image = buffer.get_image_data(iter) 6680 6681 if image: 6682 type = 'image' 6683 file = image['src'] 6684 else: 6685 return # No link or image 6686 6687 if file: 6688 file = self.notebook.resolve_file(file, self.page) 6689 6690 menu.prepend(Gtk.SeparatorMenuItem()) 6691 6692 # remove link 6693 if link: 6694 item = Gtk.MenuItem.new_with_mnemonic(_('_Remove Link')) 6695 item.connect('activate', lambda o: self.remove_link(iter=iter)) 6696 item.set_sensitive(not self.readonly) 6697 menu.prepend(item) 6698 6699 # edit 6700 if type == 'image': 6701 item = Gtk.MenuItem.new_with_mnemonic(_('_Edit Properties')) # T: menu item in context menu for image 6702 else: 6703 item = Gtk.MenuItem.new_with_mnemonic(_('_Edit Link')) # T: menu item in context menu 6704 item.connect('activate', lambda o: self.edit_object(iter=iter)) 6705 item.set_sensitive(not self.readonly) 6706 menu.prepend(item) 6707 6708 # copy 6709 def set_pagelink(o, path, anchor): 6710 Clipboard.set_pagelink(self.notebook, path, anchor) 6711 SelectionClipboard.set_pagelink(self.notebook, path, anchor) 6712 6713 def set_interwikilink(o, data): 6714 href, url = data 6715 Clipboard.set_interwikilink(href, url) 6716 SelectionClipboard.set_interwikilink(href, url) 6717 6718 def set_uri(o, uri): 6719 Clipboard.set_uri(uri) 6720 SelectionClipboard.set_uri(uri) 6721 6722 if type == 'page': 6723 item = Gtk.MenuItem.new_with_mnemonic(_('Copy _Link')) # T: context menu item 6724 href = HRef.new_from_wiki_link(link['href']) 6725 path = self.notebook.pages.resolve_link(self.page, href) 6726 item.connect('activate', set_pagelink, path, href.anchor) 6727 elif type == 'interwiki': 6728 item = Gtk.MenuItem.new_with_mnemonic(_('Copy _Link')) # T: context menu item 6729 url = interwiki_link(link['href']) 6730 item.connect('activate', set_interwikilink, (link['href'], url)) 6731 elif type == 'mailto': 6732 item = Gtk.MenuItem.new_with_mnemonic(_('Copy Email Address')) # T: context menu item 6733 item.connect('activate', set_uri, file or link['href']) 6734 else: 6735 item = Gtk.MenuItem.new_with_mnemonic(_('Copy _Link')) # T: context menu item 6736 item.connect('activate', set_uri, file or link['href']) 6737 menu.prepend(item) 6738 6739 menu.prepend(Gtk.SeparatorMenuItem()) 6740 6741 # open with & open folder 6742 if type in ('file', 'image') and file: 6743 item = Gtk.MenuItem.new_with_mnemonic(_('Open Folder')) 6744 # T: menu item to open containing folder of files 6745 menu.prepend(item) 6746 dir = file.dir 6747 if dir.exists(): 6748 item.connect('activate', lambda o: open_file(self, dir)) 6749 else: 6750 item.set_sensitive(False) 6751 6752 item = Gtk.MenuItem.new_with_mnemonic(_('Open With...')) 6753 # T: menu item for sub menu with applications 6754 menu.prepend(item) 6755 if file.exists(): 6756 submenu = OpenWithMenu(self, file) 6757 item.set_submenu(submenu) 6758 else: 6759 item.set_sensitive(False) 6760 elif type not in ('page', 'notebook', 'interwiki', 'file', 'image'): # urls etc. 6761 # FIXME: for interwiki inspect final link and base 6762 # open with menu based on that url type 6763 item = Gtk.MenuItem.new_with_mnemonic(_('Open With...')) 6764 menu.prepend(item) 6765 submenu = OpenWithMenu(self, link['href']) 6766 if submenu.get_children(): 6767 item.set_submenu(submenu) 6768 else: 6769 item.set_sensitive(False) 6770 6771 # open in new window 6772 if type == 'page': 6773 item = Gtk.MenuItem.new_with_mnemonic(_('Open in New _Window')) 6774 # T: menu item to open a link 6775 item.connect( 6776 'activate', lambda o: self.activate_link(link, new_window=True)) 6777 menu.prepend(item) 6778 6779 # open 6780 if type == 'image': 6781 link = {'href': file.uri} 6782 6783 item = Gtk.MenuItem.new_with_mnemonic(_('_Open')) 6784 # T: menu item to open a link or file 6785 if file and not file.exists(): 6786 item.set_sensitive(False) 6787 else: 6788 item.connect_object( 6789 'activate', PageView.activate_link, self, link) 6790 menu.prepend(item) 6791 6792 def _checkbox_do_populate_popup(self, menu, buffer, iter): 6793 line = iter.get_line() 6794 6795 menu.prepend(Gtk.SeparatorMenuItem()) 6796 6797 for bullet, label in ( 6798 (TRANSMIGRATED_BOX, _('Check Checkbox \'<\'')), # T: popup menu menuitem 6799 (MIGRATED_BOX, _('Check Checkbox \'>\'')), # T: popup menu menuitem 6800 (XCHECKED_BOX, _('Check Checkbox \'X\'')), # T: popup menu menuitem 6801 (CHECKED_BOX, _('Check Checkbox \'V\'')), # T: popup menu menuitem 6802 (UNCHECKED_BOX, _('Un-check Checkbox')), # T: popup menu menuitem 6803 ): 6804 item = Gtk.ImageMenuItem(bullet_types[bullet]) 6805 item.set_label(label) 6806 item.connect('activate', callback(buffer.set_bullet, line, bullet)) 6807 menu.prepend(item) 6808 6809 menu.show_all() 6810 6811 @action(_('_Save'), '<Primary>S', menuhints='edit') # T: Menu item 6812 def save_page(self): 6813 '''Menu action to save the current page. 6814 6815 Can result in a L{SavePageErrorDialog} when there is an error 6816 while saving a page. If that dialog is cancelled by the user, 6817 the page may not be saved after all. 6818 ''' 6819 self.save_changes(write_if_not_modified=True) 6820 6821 @action(_('_Reload'), '<Primary>R') # T: Menu item 6822 def reload_page(self): 6823 '''Menu action to reload the current page. Will first try 6824 to save any unsaved changes, then reload the page from disk. 6825 ''' 6826 cursor = self.get_cursor_pos() 6827 self.save_changes() 6828 self.page.reload_textbuffer() 6829 self.set_cursor_pos(cursor) 6830 6831 @action(_('_Undo'), '<Primary>Z', menuhints='edit') # T: Menu item 6832 def undo(self): 6833 '''Menu action to undo a single step''' 6834 buffer = self.textview.get_buffer() 6835 buffer.undostack.undo() 6836 self.scroll_cursor_on_screen() 6837 6838 @action(_('_Redo'), '<Primary><shift>Z', alt_accelerator='<Primary>Y', menuhints='edit') # T: Menu item 6839 def redo(self): 6840 '''Menu action to redo a single step''' 6841 buffer = self.textview.get_buffer() 6842 buffer.undostack.redo() 6843 self.scroll_cursor_on_screen() 6844 6845 @action(_('Cu_t'), '<Primary>X', menuhints='edit') # T: Menu item 6846 def cut(self): 6847 '''Menu action for cut to clipboard''' 6848 self.textview.emit('cut-clipboard') 6849 6850 @action(_('_Copy'), '<Primary>C', menuhints='edit') # T: Menu item 6851 def copy(self): 6852 '''Menu action for copy to clipboard''' 6853 self.textview.emit('copy-clipboard') 6854 6855 @action(_('_Paste'), '<Primary>V', menuhints='edit') # T: Menu item 6856 def paste(self): 6857 '''Menu action for paste from clipboard''' 6858 self.textview.emit('paste-clipboard') 6859 6860 @action(_('_Delete'), menuhints='edit') # T: Menu item 6861 def delete(self): 6862 '''Menu action for delete''' 6863 self.textview.emit('delete-from-cursor', Gtk.DeleteType.CHARS, 1) 6864 6865 @action(_('Un-check Checkbox'), verb_icon=STOCK_UNCHECKED_BOX, menuhints='edit') # T: Menu item 6866 def uncheck_checkbox(self): 6867 buffer = self.textview.get_buffer() 6868 recurs = self.preferences['recursive_checklist'] 6869 buffer.toggle_checkbox_for_cursor_or_selection(UNCHECKED_BOX, recurs) 6870 6871 @action(_('Toggle Checkbox \'V\''), 'F12', verb_icon=STOCK_CHECKED_BOX, menuhints='edit') # T: Menu item 6872 def toggle_checkbox(self): 6873 '''Menu action to toggle checkbox at the cursor or in current 6874 selected text 6875 ''' 6876 buffer = self.textview.get_buffer() 6877 recurs = self.preferences['recursive_checklist'] 6878 buffer.toggle_checkbox_for_cursor_or_selection(CHECKED_BOX, recurs) 6879 6880 @action(_('Toggle Checkbox \'X\''), '<shift>F12', verb_icon=STOCK_XCHECKED_BOX, menuhints='edit') # T: Menu item 6881 def xtoggle_checkbox(self): 6882 '''Menu action to toggle checkbox at the cursor or in current 6883 selected text 6884 ''' 6885 buffer = self.textview.get_buffer() 6886 recurs = self.preferences['recursive_checklist'] 6887 buffer.toggle_checkbox_for_cursor_or_selection(XCHECKED_BOX, recurs) 6888 6889 @action(_('Toggle Checkbox \'>\''), verb_icon=STOCK_MIGRATED_BOX, menuhints='edit') # T: Menu item 6890 def migrate_checkbox(self): 6891 '''Menu action to toggle checkbox at the cursor or in current 6892 selected text 6893 ''' 6894 buffer = self.textview.get_buffer() 6895 recurs = self.preferences['recursive_checklist'] 6896 buffer.toggle_checkbox_for_cursor_or_selection(MIGRATED_BOX, recurs) 6897 6898 @action(_('Toggle Checkbox \'<\''), verb_icon=STOCK_TRANSMIGRATED_BOX, menuhints='edit') # T: Menu item 6899 def transmigrate_checkbox(self): 6900 '''Menu action to toggle checkbox at the cursor or in current 6901 selected text 6902 ''' 6903 buffer = self.textview.get_buffer() 6904 recurs = self.preferences['recursive_checklist'] 6905 buffer.toggle_checkbox_for_cursor_or_selection(TRANSMIGRATED_BOX, recurs) 6906 6907 @action(_('_Edit Link or Object...'), '<Primary>E', menuhints='edit') # T: Menu item 6908 def edit_object(self, iter=None): 6909 '''Menu action to trigger proper edit dialog for the current 6910 object at the cursor 6911 6912 Can show e.g. L{InsertLinkDialog} for a link, C{EditImageDialog} 6913 for the a image, or a plugin dialog for e.g. an equation. 6914 6915 @param iter: C{TextIter} for an alternative cursor position 6916 ''' 6917 buffer = self.textview.get_buffer() 6918 if iter: 6919 buffer.place_cursor(iter) 6920 6921 iter = buffer.get_iter_at_mark(buffer.get_insert()) 6922 if buffer.get_link_tag(iter): 6923 return InsertLinkDialog(self, self).run() 6924 6925 image = buffer.get_image_data(iter) 6926 anchor = buffer.get_objectanchor(iter) 6927 if not (image or (anchor and isinstance(anchor, PluginInsertedObjectAnchor))): 6928 iter.backward_char() # maybe we clicked right side of an image 6929 image = buffer.get_image_data(iter) 6930 anchor = buffer.get_objectanchor(iter) 6931 6932 if image: 6933 EditImageDialog(self, buffer, self.notebook, self.page).run() 6934 elif anchor and isinstance(anchor, PluginInsertedObjectAnchor): 6935 widget = anchor.get_widgets()[0] 6936 try: 6937 widget.edit_object() 6938 except NotImplementedError: 6939 return False 6940 else: 6941 return True 6942 else: 6943 return False 6944 6945 @action(_('_Remove Link'), menuhints='edit') # T: Menu item 6946 def remove_link(self, iter=None): 6947 '''Menu action to remove link object at the current cursor position 6948 6949 @param iter: C{TextIter} for an alternative cursor position 6950 ''' 6951 buffer = self.textview.get_buffer() 6952 6953 if not buffer.get_has_selection() \ 6954 or (iter and not buffer.iter_in_selection(iter)): 6955 if iter: 6956 buffer.place_cursor(iter) 6957 buffer.select_link() 6958 6959 bounds = buffer.get_selection_bounds() 6960 if bounds: 6961 buffer.remove_link(*bounds) 6962 6963 @action(_('Copy Line'), accelerator='<Primary><Shift>C', menuhints='edit') # T: menu item to copy current line to clipboard 6964 def copy_current_line(self): 6965 '''Menu action to copy the current line to the clipboard''' 6966 buffer = self.textview.get_buffer() 6967 mark = buffer.create_mark(None, buffer.get_insert_iter()) 6968 buffer.select_line() 6969 6970 if buffer.get_has_selection(): 6971 bounds = buffer.get_selection_bounds() 6972 tree = buffer.get_parsetree(bounds) 6973 Clipboard.set_parsetree(self.notebook, self.page, tree) 6974 buffer.unset_selection() 6975 buffer.place_cursor(buffer.get_iter_at_mark(mark)) 6976 6977 buffer.delete_mark(mark) 6978 6979 @action(_('Date and Time...'), accelerator='<Primary>D', menuhints='insert') # T: Menu item 6980 def insert_date(self): 6981 '''Menu action to insert a date, shows the L{InsertDateDialog}''' 6982 InsertDateDialog(self, self.textview.get_buffer(), self.notebook, self.page).run() 6983 6984 def insert_object(self, attrib, data): 6985 buffer = self.textview.get_buffer() 6986 with buffer.user_action: 6987 buffer.insert_object_at_cursor(attrib, data) 6988 6989 def insert_object_model(self, otype, model): 6990 buffer = self.textview.get_buffer() 6991 with buffer.user_action: 6992 buffer.insert_object_model_at_cursor(otype, model) 6993 6994 @action(_('Horizontal _Line'), menuhints='insert') # T: Menu item for Insert menu 6995 def insert_line(self): 6996 '''Menu action to insert a line at the cursor position''' 6997 buffer = self.textview.get_buffer() 6998 with buffer.user_action: 6999 buffer.insert_objectanchor_at_cursor(LineSeparatorAnchor()) 7000 # Add newline after line separator widget. 7001 buffer.insert_at_cursor('\n') 7002 7003 @action(_('_Image...'), menuhints='insert') # T: Menu item 7004 def show_insert_image(self, file=None): 7005 '''Menu action to insert an image, shows the L{InsertImageDialog} 7006 @param file: optional file to suggest in the dialog 7007 ''' 7008 InsertImageDialog(self, self.textview.get_buffer(), self.notebook, self.page, file).run() 7009 7010 @action(_('_Attachment...'), verb_icon='zim-attachment', menuhints='insert') # T: Menu item 7011 def attach_file(self, file=None): 7012 '''Menu action to show the L{AttachFileDialog} 7013 @param file: optional file to suggest in the dialog 7014 ''' 7015 AttachFileDialog(self, self.textview.get_buffer(), self.notebook, self.page, file).run() 7016 7017 def insert_image(self, file): 7018 '''Insert a image 7019 @param file: the image file to insert. If C{file} does not exist or 7020 isn't an image, a "broken image" icon will be shown 7021 ''' 7022 file = adapt_from_newfs(file) 7023 assert isinstance(file, File) 7024 src = self.notebook.relative_filepath(file, self.page) or file.uri 7025 self.textview.get_buffer().insert_image_at_cursor(file, src) 7026 7027 @action(_('Bulle_t List'), menuhints='insert') # T: Menu item 7028 def insert_bullet_list(self): 7029 '''Menu action insert a bullet item at the cursor''' 7030 self._start_bullet(BULLET) 7031 7032 @action(_('_Numbered List'), menuhints='insert') # T: Menu item 7033 def insert_numbered_list(self): 7034 '''Menu action insert a numbered list item at the cursor''' 7035 self._start_bullet(NUMBER_BULLET) 7036 7037 @action(_('Checkbo_x List'), menuhints='insert') # T: Menu item 7038 def insert_checkbox_list(self): 7039 '''Menu action insert an open checkbox at the cursor''' 7040 self._start_bullet(UNCHECKED_BOX) 7041 7042 def _start_bullet(self, bullet_type): 7043 buffer = self.textview.get_buffer() 7044 line = buffer.get_insert_iter().get_line() 7045 7046 with buffer.user_action: 7047 iter = buffer.get_iter_at_line(line) 7048 buffer.insert(iter, '\n') 7049 buffer.set_bullet(line, bullet_type) 7050 iter = buffer.get_iter_at_line(line) 7051 iter.forward_to_line_end() 7052 buffer.place_cursor(iter) 7053 7054 @action(_('Bulle_t List'), menuhints='edit') # T: Menu item, 7055 def apply_format_bullet_list(self): 7056 '''Menu action to format selection as bullet list''' 7057 self._apply_bullet(BULLET) 7058 7059 @action(_('_Numbered List'), menuhints='edit') # T: Menu item, 7060 def apply_format_numbered_list(self): 7061 '''Menu action to format selection as numbered list''' 7062 self._apply_bullet(NUMBER_BULLET) 7063 7064 @action(_('Checkbo_x List'), menuhints='edit') # T: Menu item, 7065 def apply_format_checkbox_list(self): 7066 '''Menu action to format selection as checkbox list''' 7067 self._apply_bullet(UNCHECKED_BOX) 7068 7069 @action(_('_Remove List'), menuhints='edit') # T: Menu item, 7070 def clear_list_format(self): 7071 '''Menu action to remove list formatting''' 7072 self._apply_bullet(None) 7073 7074 def _apply_bullet(self, bullet_type): 7075 buffer = self.textview.get_buffer() 7076 bounds = buffer.get_selection_bounds() 7077 if bounds: 7078 # set for selected lines & restore selection 7079 start_mark = buffer.create_mark(None, bounds[0], left_gravity=True) 7080 end_mark = buffer.create_mark(None, bounds[1], left_gravity=False) 7081 try: 7082 buffer.foreach_line_in_selection(buffer.set_bullet, bullet_type) 7083 except: 7084 raise 7085 else: 7086 start = buffer.get_iter_at_mark(start_mark) 7087 end = buffer.get_iter_at_mark(end_mark) 7088 buffer.select_range(start, end) 7089 finally: 7090 buffer.delete_mark(start_mark) 7091 buffer.delete_mark(end_mark) 7092 else: 7093 # set for current line 7094 line = buffer.get_insert_iter().get_line() 7095 buffer.set_bullet(line, bullet_type) 7096 7097 @action(_('Text From _File...'), menuhints='insert') # T: Menu item 7098 def insert_text_from_file(self): 7099 '''Menu action to show a L{InsertTextFromFileDialog}''' 7100 InsertTextFromFileDialog(self, self.textview.get_buffer(), self.notebook, self.page).run() 7101 7102 def insert_links(self, links): 7103 '''Non-interactive method to insert one or more links 7104 7105 Inserts the links separated by newlines. Intended e.g. for 7106 drag-and-drop or copy-paste actions of e.g. files from a 7107 file browser. 7108 7109 @param links: list of links, either as string, L{Path} objects, 7110 or L{File} objects 7111 ''' 7112 links = list(map(adapt_from_newfs, links)) 7113 for i in range(len(links)): 7114 if isinstance(links[i], Path): 7115 links[i] = links[i].name 7116 continue 7117 elif isinstance(links[i], File): 7118 file = links[i] 7119 else: 7120 type = link_type(links[i]) 7121 if type == 'file': 7122 file = File(links[i]) 7123 else: 7124 continue # not a file 7125 links[i] = self.notebook.relative_filepath(file, self.page) or file.uri 7126 7127 if len(links) == 1: 7128 sep = ' ' 7129 else: 7130 sep = '\n' 7131 7132 buffer = self.textview.get_buffer() 7133 with buffer.user_action: 7134 if buffer.get_has_selection(): 7135 start, end = buffer.get_selection_bounds() 7136 buffer.delete(start, end) 7137 for link in links: 7138 buffer.insert_link_at_cursor(link, link) 7139 buffer.insert_at_cursor(sep) 7140 7141 @action(_('_Link...'), '<Primary>L', verb_icon='zim-link', menuhints='insert') # T: Menu item 7142 def insert_link(self): 7143 '''Menu item to show the L{InsertLinkDialog}''' 7144 InsertLinkDialog(self, self).run() 7145 7146 def _update_new_file_submenu(self, action): 7147 dir = self.preferences['file_templates_folder'] 7148 if isinstance(dir, str): 7149 dir = Dir(dir) 7150 7151 items = [] 7152 if dir.exists(): 7153 def handler(menuitem, file): 7154 self.insert_new_file(file) 7155 7156 for name in dir.list(): # FIXME could use list objects here 7157 file = dir.file(name) 7158 if file.exists(): # it is a file 7159 name = file.basename 7160 if '.' in name: 7161 name, x = name.rsplit('.', 1) 7162 name = name.replace('_', ' ') 7163 item = Gtk.MenuItem.new_with_mnemonic(name) 7164 # TODO mimetype icon would be nice to have 7165 item.connect('activate', handler, file) 7166 item.zim_new_file_action = True 7167 items.append(item) 7168 7169 if not items: 7170 item = Gtk.MenuItem.new_with_mnemonic(_('No templates installed')) 7171 # T: message when no file templates are found in ~/Templates 7172 item.set_sensitive(False) 7173 item.zim_new_file_action = True 7174 items.append(item) 7175 7176 7177 for widget in action.get_proxies(): 7178 if hasattr(widget, 'get_submenu'): 7179 menu = widget.get_submenu() 7180 if not menu: 7181 continue 7182 7183 # clear old items 7184 for item in menu.get_children(): 7185 if hasattr(item, 'zim_new_file_action'): 7186 menu.remove(item) 7187 7188 # add new ones 7189 populate_popup_add_separator(menu, prepend=True) 7190 for item in reversed(items): 7191 menu.prepend(item) 7192 7193 # and finish up 7194 menu.show_all() 7195 7196 def insert_new_file(self, template, basename=None): 7197 dir = self.notebook.get_attachments_dir(self.page) 7198 7199 if not basename: 7200 basename = NewFileDialog(self, template.basename).run() 7201 if basename is None: 7202 return # cancelled 7203 7204 file = dir.new_file(basename) 7205 template.copyto(file) 7206 7207 # Same logic as in AttachFileDialog 7208 # TODO - incorporate in the insert_links function ? 7209 if file.isimage(): 7210 ok = self.insert_image(file) 7211 if not ok: # image type not supported? 7212 logger.info('Could not insert image: %s', file) 7213 self.insert_links([file]) 7214 else: 7215 self.insert_links([file]) 7216 7217 #~ open_file(self, file) # FIXME should this be optional ? 7218 7219 @action(_('File _Templates...')) # T: Menu item in "Insert > New File Attachment" submenu 7220 def open_file_templates_folder(self): 7221 '''Menu action to open the templates folder''' 7222 dir = self.preferences['file_templates_folder'] 7223 if isinstance(dir, str): 7224 dir = Dir(dir) 7225 7226 if dir.exists(): 7227 open_file(self, dir) 7228 else: 7229 path = dir.user_path or dir.path 7230 question = ( 7231 _('Create folder?'), 7232 # T: Heading in a question dialog for creating a folder 7233 _('The folder\n%s\ndoes not yet exist.\nDo you want to create it now?') 7234 % path 7235 ) 7236 # T: Text in a question dialog for creating a folder, %s is the folder path 7237 create = QuestionDialog(self, question).run() 7238 if create: 7239 dir.touch() 7240 open_file(self, dir) 7241 7242 @action(_('_Clear Formatting'), accelerator='<Primary>9', menuhints='edit', verb_icon='edit-clear-all-symbolic') # T: Menu item 7243 def clear_formatting(self): 7244 '''Menu item to remove formatting from current (auto-)selection''' 7245 buffer = self.textview.get_buffer() 7246 mark = buffer.create_mark(None, buffer.get_insert_iter()) 7247 selected = self.autoselect() 7248 7249 if buffer.get_has_selection(): 7250 start, end = buffer.get_selection_bounds() 7251 buffer.remove_textstyle_tags(start, end) 7252 if selected: 7253 # If we keep the selection we can not continue typing 7254 # so remove the selection and restore the cursor. 7255 buffer.unset_selection() 7256 buffer.place_cursor(buffer.get_iter_at_mark(mark)) 7257 else: 7258 buffer.set_textstyles(None) 7259 7260 buffer.delete_mark(mark) 7261 7262 @action(_('_Remove Heading'), menuhints='edit') # T: Menu item 7263 def clear_heading_format(self): 7264 '''Menu item to remove heading''' 7265 buffer = self.textview.get_buffer() 7266 mark = buffer.create_mark(None, buffer.get_insert_iter()) 7267 selected = self.autoselect(selectline=True) 7268 if buffer.get_has_selection(): 7269 start, end = buffer.get_selection_bounds() 7270 buffer.smart_remove_tags(_is_heading_tag, start, end) 7271 if selected: 7272 buffer.unset_selection() 7273 buffer.place_cursor(buffer.get_iter_at_mark(mark)) 7274 else: 7275 buffer.set_textstyles(None) 7276 7277 buffer.delete_mark(mark) 7278 7279 def do_toggle_format_action_alt(self, active, action): 7280 self.do_toggle_format_action(action) 7281 7282 def do_toggle_format_action(self, action): 7283 '''Handler that catches all actions to apply and/or toggle formats''' 7284 if isinstance(action, str): 7285 name = action 7286 else: 7287 name = action.get_name() 7288 logger.debug('Action: %s (toggle_format action)', name) 7289 if name.startswith('apply_format_'): 7290 style = name[13:] 7291 elif name.startswith('toggle_format_'): 7292 style = name[14:] 7293 else: 7294 assert False, "BUG: don't known this action" 7295 self.toggle_format(style) 7296 7297 def toggle_format(self, format): 7298 '''Toggle the format for the current (auto-)selection or new 7299 insertions at the current cursor position 7300 7301 When the cursor is at the begin or in the middle of a word and there is 7302 not selection, the word is selected automatically to toggle the format. 7303 For headings and other line based formats auto-selects the whole line. 7304 7305 This is the handler for all the format actions. 7306 7307 @param format: the format style name (e.g. "h1", "strong" etc.) 7308 ''' 7309 buffer = self.textview.get_buffer() 7310 selected = False 7311 mark = buffer.create_mark(None, buffer.get_insert_iter()) 7312 7313 if format in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'): 7314 selected = self.autoselect(selectline=True) 7315 else: 7316 # Check formatting is consistent left and right 7317 iter = buffer.get_insert_iter() 7318 format_left = format in [t.zim_tag for t in buffer.iter_get_zim_tags(iter)] 7319 format_right = format in [t.zim_tag for t in filter(_is_zim_tag, iter.get_tags())] 7320 if format_left is format_right: 7321 selected = self.autoselect(selectline=False) 7322 7323 buffer.toggle_textstyle(format) 7324 7325 if selected: 7326 # If we keep the selection we can not continue typing 7327 # so remove the selection and restore the cursor. 7328 buffer.unset_selection() 7329 buffer.place_cursor(buffer.get_iter_at_mark(mark)) 7330 buffer.delete_mark(mark) 7331 7332 def autoselect(self, selectline=False): 7333 '''Auto select either a word or a line. 7334 7335 Does not do anything if a selection is present already or when 7336 the preference for auto-select is set to False. 7337 7338 @param selectline: if C{True} auto-select a whole line, 7339 only auto-select a single word otherwise 7340 @returns: C{True} when this function changed the selection. 7341 ''' 7342 if not self.preferences['autoselect']: 7343 return False 7344 7345 buffer = self.textview.get_buffer() 7346 if buffer.get_has_selection(): 7347 if selectline: 7348 start, end = buffer.get_selection_bounds() 7349 return buffer.select_lines(start.get_line(), end.get_line()) 7350 else: 7351 return buffer.strip_selection() 7352 elif selectline: 7353 return buffer.select_line() 7354 else: 7355 return buffer.select_word() 7356 7357 def find(self, string, flags=0): 7358 '''Find some string in the text, scroll there and select it 7359 7360 @param string: the text to find 7361 @param flags: options for find behavior, see L{TextFinder.find()} 7362 ''' 7363 self.hide_find() # remove previous highlighting etc. 7364 buffer = self.textview.get_buffer() 7365 buffer.finder.find(string, flags) 7366 self.textview.scroll_to_mark(buffer.get_insert(), SCROLL_TO_MARK_MARGIN, False, 0, 0) 7367 7368 @action(_('_Find...'), '<Primary>F', alt_accelerator='<Primary>F3') # T: Menu item 7369 def show_find(self, string=None, flags=0, highlight=False): 7370 '''Show the L{FindBar} widget 7371 7372 @param string: the text to find 7373 @param flags: options for find behavior, see L{TextFinder.find()} 7374 @param highlight: if C{True} highlight the results 7375 ''' 7376 self.find_bar.show() 7377 if string: 7378 self.find_bar.find(string, flags, highlight) 7379 self.textview.grab_focus() 7380 else: 7381 self.find_bar.set_from_buffer() 7382 self.find_bar.grab_focus() 7383 7384 def hide_find(self): 7385 '''Hide the L{FindBar} widget''' 7386 self.find_bar.hide() 7387 self.textview.grab_focus() 7388 7389 @action(_('Find Ne_xt'), accelerator='<Primary>G', alt_accelerator='F3') # T: Menu item 7390 def find_next(self): 7391 '''Menu action to skip to next match''' 7392 self.find_bar.show() 7393 self.find_bar.find_next() 7394 7395 @action(_('Find Pre_vious'), accelerator='<Primary><shift>G', alt_accelerator='<shift>F3') # T: Menu item 7396 def find_previous(self): 7397 '''Menu action to go back to previous match''' 7398 self.find_bar.show() 7399 self.find_bar.find_previous() 7400 7401 @action(_('_Replace...'), '<Primary>H', menuhints='edit') # T: Menu item 7402 def show_find_and_replace(self): 7403 '''Menu action to show the L{FindAndReplaceDialog}''' 7404 dialog = FindAndReplaceDialog.unique(self, self, self.textview) 7405 dialog.set_from_buffer() 7406 dialog.present() 7407 7408 @action(_('Word Count...')) # T: Menu item 7409 def show_word_count(self): 7410 '''Menu action to show the L{WordCountDialog}''' 7411 WordCountDialog(self).run() 7412 7413 @action(_('_Zoom In'), '<Primary>plus', alt_accelerator='<Primary>equal') # T: Menu item 7414 def zoom_in(self): 7415 '''Menu action to increase the font size''' 7416 self._zoom_increase_decrease_font_size(+1) 7417 7418 @action(_('Zoom _Out'), '<Primary>minus') # T: Menu item 7419 def zoom_out(self): 7420 '''Menu action to decrease the font size''' 7421 self._zoom_increase_decrease_font_size(-1) 7422 7423 def _zoom_increase_decrease_font_size(self, plus_or_minus): 7424 style = self.text_style 7425 if self.text_style['TextView']['font']: 7426 font = Pango.FontDescription(self.text_style['TextView']['font']) 7427 else: 7428 logger.debug('Switching to custom font implicitly because of zoom action') 7429 style = self.textview.get_style_context() 7430 font = style.get_property(Gtk.STYLE_PROPERTY_FONT, Gtk.StateFlags.NORMAL) 7431 7432 font_size = font.get_size() 7433 if font_size <= 1 * 1024 and plus_or_minus < 0: 7434 return 7435 else: 7436 font_size_new = font_size + plus_or_minus * 1024 7437 font.set_size(font_size_new) 7438 try: 7439 self.text_style['TextView']['font'] = font.to_string() 7440 except UnicodeDecodeError: 7441 logger.exception('FIXME') 7442 self.textview.modify_font(font) 7443 7444 self.text_style.write() 7445 7446 @action(_('_Normal Size'), '<Primary>0') # T: Menu item to reset zoom 7447 def zoom_reset(self): 7448 '''Menu action to reset the font size''' 7449 if not self.text_style['TextView']['font']: 7450 return 7451 7452 widget = TextView({}) # Get new widget 7453 style = widget.get_style_context() 7454 default_font = style.get_property(Gtk.STYLE_PROPERTY_FONT, Gtk.StateFlags.NORMAL) 7455 7456 font = Pango.FontDescription(self.text_style['TextView']['font']) 7457 font.set_size(default_font.get_size()) 7458 7459 if font.equal(default_font): 7460 self.text_style['TextView']['font'] = None 7461 self.textview.modify_font(None) 7462 else: 7463 self.text_style['TextView']['font'] = font.to_string() 7464 self.textview.modify_font(font) 7465 7466 self.text_style.write() 7467 7468 7469class InsertedObjectAnchor(Gtk.TextChildAnchor): 7470 7471 def create_widget(self): 7472 raise NotImplementedError 7473 7474 def dump(self, builder): 7475 raise NotImplementedError 7476 7477 7478class LineSeparatorAnchor(InsertedObjectAnchor): 7479 7480 def create_widget(self): 7481 return LineSeparator() 7482 7483 def dump(self, builder): 7484 builder.start(LINE, {}) 7485 builder.data('-'*20) # FIXME: get rid of text here 7486 builder.end(LINE) 7487 7488 7489class TableAnchor(InsertedObjectAnchor): 7490 # HACK - table support is native in formats, but widget is still in plugin 7491 # so we need to "glue" the table tokens to the plugin widget 7492 7493 def __init__(self, objecttype, objectmodel): 7494 GObject.GObject.__init__(self) 7495 self.objecttype = objecttype 7496 self.objectmodel = objectmodel 7497 7498 def create_widget(self): 7499 return self.objecttype.create_widget(self.objectmodel) 7500 7501 def dump(self, builder): 7502 self.objecttype.dump(builder, self.objectmodel) 7503 7504 7505class PluginInsertedObjectAnchor(InsertedObjectAnchor): 7506 7507 def __init__(self, objecttype, objectmodel): 7508 GObject.GObject.__init__(self) 7509 self.objecttype = objecttype 7510 self.objectmodel = objectmodel 7511 7512 def create_widget(self): 7513 return self.objecttype.create_widget(self.objectmodel) 7514 7515 def dump(self, builder): 7516 attrib, data = self.objecttype.data_from_model(self.objectmodel) 7517 builder.start(OBJECT, dict(attrib)) # dict() because ElementTree doesn't like ConfigDict 7518 if data is not None: 7519 builder.data(data) 7520 builder.end(OBJECT) 7521 7522 7523class InsertDateDialog(Dialog): 7524 '''Dialog to insert a date-time in the page''' 7525 7526 FORMAT_COL = 0 # format string 7527 DATE_COL = 1 # strfime rendering of the format 7528 7529 def __init__(self, parent, buffer, notebook, page): 7530 Dialog.__init__( 7531 self, 7532 parent, 7533 _('Insert Date and Time'), # T: Dialog title 7534 button=_('_Insert'), # T: Button label 7535 use_default_button=True 7536 ) 7537 self.buffer = buffer 7538 self.notebook = notebook 7539 self.page = page 7540 self.date = datetime.now() 7541 7542 self.uistate.setdefault('lastusedformat', '') 7543 self.uistate.setdefault('linkdate', False) 7544 7545 ## Add Calendar widget 7546 from zim.plugins.journal import Calendar # FIXME put this in zim.gui.widgets 7547 7548 label = Gtk.Label() 7549 label.set_markup('<b>' + _("Date") + '</b>') # T: label in "insert date" dialog 7550 label.set_alignment(0.0, 0.5) 7551 self.vbox.pack_start(label, False, False, 0) 7552 7553 self.calendar = Calendar() 7554 self.calendar.set_display_options( 7555 Gtk.CalendarDisplayOptions.SHOW_HEADING | 7556 Gtk.CalendarDisplayOptions.SHOW_DAY_NAMES | 7557 Gtk.CalendarDisplayOptions.SHOW_WEEK_NUMBERS) 7558 self.calendar.connect('day-selected', lambda c: self.set_date(c.get_date())) 7559 self.vbox.pack_start(self.calendar, False, True, 0) 7560 7561 ## Add format list box 7562 label = Gtk.Label() 7563 label.set_markup('<b>' + _("Format") + '</b>') # T: label in "insert date" dialog 7564 label.set_alignment(0.0, 0.5) 7565 self.vbox.pack_start(label, False, False, 0) 7566 7567 model = Gtk.ListStore(str, str) # FORMAT_COL, DATE_COL 7568 self.view = BrowserTreeView(model) 7569 self.vbox.pack_start(ScrolledWindow(self.view), True, True, 0) 7570 7571 cell_renderer = Gtk.CellRendererText() 7572 column = Gtk.TreeViewColumn('_date_', cell_renderer, text=1) 7573 self.view.append_column(column) 7574 self.view.set_headers_visible(False) 7575 self.view.connect('row-activated', 7576 lambda *a: self.response(Gtk.ResponseType.OK)) 7577 7578 ## Add Link checkbox and Edit button 7579 self.linkbutton = Gtk.CheckButton.new_with_mnemonic(_('_Link to date')) 7580 # T: check box in InsertDate dialog 7581 self.linkbutton.set_active(self.uistate['linkdate']) 7582 self.vbox.pack_start(self.linkbutton, False, True, 0) 7583 7584 button = Gtk.Button.new_with_mnemonic(_('_Edit')) # T: Button label 7585 button.connect('clicked', self.on_edit) 7586 self.action_area.add(button) 7587 self.action_area.reorder_child(button, 1) 7588 7589 ## Setup data 7590 self.load_file() 7591 self.set_date(self.date) 7592 7593 def load_file(self): 7594 lastused = None 7595 model = self.view.get_model() 7596 model.clear() 7597 file = ConfigManager.get_config_file('dates.list') 7598 for line in file.readlines(): 7599 line = line.strip() 7600 if not line or line.startswith('#'): 7601 continue 7602 try: 7603 format = line 7604 iter = model.append((format, format)) 7605 if format == self.uistate['lastusedformat']: 7606 lastused = iter 7607 except: 7608 logger.exception('Could not parse date: %s', line) 7609 7610 if len(model) == 0: 7611 # file not found ? 7612 model.append(("%c", "%c")) 7613 7614 if not lastused is None: 7615 path = model.get_path(lastused) 7616 self.view.get_selection().select_path(path) 7617 7618 def set_date(self, date): 7619 self.date = date 7620 7621 def update_date(model, path, iter): 7622 format = model[iter][self.FORMAT_COL] 7623 try: 7624 string = datetime.strftime(format, date) 7625 except ValueError: 7626 string = 'INVALID: ' + format 7627 model[iter][self.DATE_COL] = string 7628 7629 model = self.view.get_model() 7630 model.foreach(update_date) 7631 7632 link = date.strftime('%Y-%m-%d') # YYYY-MM-DD 7633 self.link = self.notebook.suggest_link(self.page, link) 7634 self.linkbutton.set_sensitive(not self.link is None) 7635 7636 #def run(self): 7637 #self.view.grab_focus() 7638 #Dialog.run(self) 7639 7640 def save_uistate(self): 7641 model, iter = self.view.get_selection().get_selected() 7642 if iter: 7643 format = model[iter][self.FORMAT_COL] 7644 self.uistate['lastusedformat'] = format 7645 self.uistate['linkdate'] = self.linkbutton.get_active() 7646 7647 def on_edit(self, button): 7648 file = ConfigManager.get_config_file('dates.list') # XXX 7649 if edit_config_file(self, file): 7650 self.load_file() 7651 7652 def do_response_ok(self): 7653 model, iter = self.view.get_selection().get_selected() 7654 if iter: 7655 text = model[iter][self.DATE_COL] 7656 else: 7657 text = model[0][self.DATE_COL] 7658 7659 if self.link and self.linkbutton.get_active(): 7660 self.buffer.insert_link_at_cursor(text, self.link.name) 7661 else: 7662 self.buffer.insert_at_cursor(text) 7663 7664 return True 7665 7666 7667class InsertImageDialog(FileDialog): 7668 '''Dialog to insert an image in the page''' 7669 7670 def __init__(self, parent, buffer, notebook, path, file=None): 7671 FileDialog.__init__( 7672 self, parent, _('Insert Image'), Gtk.FileChooserAction.OPEN) 7673 # T: Dialog title 7674 7675 self.buffer = buffer 7676 self.notebook = notebook 7677 self.path = path 7678 7679 self.uistate.setdefault('attach_inserted_images', False) 7680 self.uistate.setdefault('last_image_folder', None, check=str) 7681 7682 self.add_shortcut(notebook, path) 7683 self.add_filter_images() 7684 7685 checkbox = Gtk.CheckButton.new_with_mnemonic(_('Attach image first')) 7686 # T: checkbox in the "Insert Image" dialog 7687 checkbox.set_active(self.uistate['attach_inserted_images']) 7688 self.filechooser.set_extra_widget(checkbox) 7689 7690 if file: 7691 self.set_file(file) 7692 else: 7693 self.load_last_folder() 7694 7695 def do_response_ok(self): 7696 file = self.get_file() 7697 if file is None: 7698 return False 7699 7700 if not image_file_get_dimensions(file.path): 7701 ErrorDialog(self, _('File type not supported: %s') % file.get_mimetype()).run() 7702 # T: Error message when trying to insert a not supported file as image 7703 return False 7704 7705 self.save_last_folder() 7706 7707 # Similar code in AttachFileDialog 7708 checkbox = self.filechooser.get_extra_widget() 7709 self.uistate['attach_inserted_images'] = checkbox.get_active() 7710 if self.uistate['attach_inserted_images']: 7711 folder = self.notebook.get_attachments_dir(self.path) 7712 if not file.ischild(folder): 7713 file = attach_file(self, self.notebook, self.path, file) 7714 if file is None: 7715 return False # Cancelled overwrite dialog 7716 7717 src = self.notebook.relative_filepath(file, self.path) or file.uri 7718 self.buffer.insert_image_at_cursor(file, src) 7719 return True 7720 7721 7722class AttachFileDialog(FileDialog): 7723 7724 def __init__(self, parent, buffer, notebook, path, file=None): 7725 assert path, 'Need a page here' 7726 FileDialog.__init__(self, parent, _('Attach File'), multiple=True) # T: Dialog title 7727 self.buffer = buffer 7728 self.notebook = notebook 7729 self.path = path 7730 7731 dir = notebook.get_attachments_dir(path) 7732 if dir is None: 7733 ErrorDialog(self, _('Page "%s" does not have a folder for attachments') % self.path) 7734 # T: Error dialog - %s is the full page name 7735 raise Exception('Page "%s" does not have a folder for attachments' % self.path) 7736 7737 self.add_shortcut(notebook, path) 7738 if file: 7739 self.set_file(file) 7740 else: 7741 self.load_last_folder() 7742 7743 def do_response_ok(self): 7744 files = self.get_files() 7745 if not files: 7746 return False 7747 7748 self.save_last_folder() 7749 7750 inserted = False 7751 last = len(files) - 1 7752 for i, file in enumerate(files): 7753 file = attach_file(self, self.notebook, self.path, file) 7754 if file is not None: 7755 inserted = True 7756 text = self.notebook.relative_filepath(file, path=self.path) 7757 self.buffer.insert_link_at_cursor(text, href=text) 7758 if i != last: 7759 self.buffer.insert_at_cursor(' ') 7760 7761 return inserted # If nothing is inserted, return False and do not close dialog 7762 7763 7764def attach_file(widget, notebook, path, file, force_overwrite=False): 7765 folder = notebook.get_attachments_dir(path) 7766 if folder is None: 7767 raise Error('%s does not have an attachments dir' % path) 7768 7769 dest = folder.file(file.basename) 7770 if dest.exists() and not force_overwrite: 7771 dialog = PromptExistingFileDialog(widget, dest) 7772 dest = dialog.run() 7773 if dest is None: 7774 return None # dialog was cancelled 7775 elif dest.exists(): 7776 dest.remove() 7777 7778 file.copyto(dest) 7779 return dest 7780 7781 7782class PromptExistingFileDialog(Dialog): 7783 '''Dialog that is used e.g. when a file should be attached to zim, 7784 but a file with the same name already exists in the attachment 7785 directory. This Dialog allows to suggest a new name or overwrite 7786 the existing one. 7787 7788 For this dialog C{run()} will return either the original file 7789 (for overwrite), a new file, or None when the dialog was canceled. 7790 ''' 7791 7792 def __init__(self, widget, file): 7793 Dialog.__init__(self, widget, _('File Exists'), buttons=None) # T: Dialog title 7794 self.add_help_text( _('''\ 7795A file with the name <b>"%s"</b> already exists. 7796You can use another name or overwrite the existing file.''' % file.basename), 7797 ) # T: Dialog text in 'new filename' dialog 7798 self.folder = file.parent() 7799 self.old_file = file 7800 7801 suggested_filename = self.folder.new_file(file.basename).basename 7802 self.add_form(( 7803 ('name', 'string', _('Filename')), # T: Input label 7804 ), { 7805 'name': suggested_filename 7806 } 7807 ) 7808 self.form.widgets['name'].set_check_func(self._check_valid) 7809 7810 # all buttons are defined in this class, to get the ordering right 7811 # [show folder] [overwrite] [cancel] [ok] 7812 button = Gtk.Button.new_with_mnemonic(_('_Browse')) # T: Button label 7813 button.connect('clicked', self.do_show_folder) 7814 self.action_area.add(button) 7815 self.action_area.set_child_secondary(button, True) 7816 7817 button = Gtk.Button.new_with_mnemonic(_('Overwrite')) # T: Button label 7818 button.connect('clicked', self.do_response_overwrite) 7819 self.add_action_widget(button, Gtk.ResponseType.NONE) 7820 7821 self.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) # T: Button label 7822 self.add_button(_('_OK'), Gtk.ResponseType.OK) # T: Button label 7823 self._no_ok_action = False 7824 7825 self.form.widgets['name'].connect('focus-in-event', self._on_focus) 7826 7827 def _on_focus(self, widget, event): 7828 # filename length without suffix 7829 length = len(os.path.splitext(widget.get_text())[0]) 7830 widget.select_region(0, length) 7831 7832 def _check_valid(self, filename): 7833 # Only valid when same dir and does not yet exist 7834 file = self.folder.file(filename) 7835 return file.ischild(self.folder) and not file.exists() 7836 7837 def do_show_folder(self, *a): 7838 open_folder(self, self.folder) 7839 7840 def do_response_overwrite(self, *a): 7841 logger.info('Overwriting %s', self.old_file.path) 7842 self.result = self.old_file 7843 7844 def do_response_ok(self): 7845 if not self.form.widgets['name'].get_input_valid(): 7846 return False 7847 7848 newfile = self.folder.file(self.form['name']) 7849 logger.info('Selected %s', newfile.path) 7850 assert newfile.ischild(self.folder) # just to be real sure 7851 assert not newfile.exists() # just to be real sure 7852 self.result = newfile 7853 return True 7854 7855 7856class EditImageDialog(Dialog): 7857 '''Dialog to edit properties of an embedded image''' 7858 7859 def __init__(self, parent, buffer, notebook, path): 7860 Dialog.__init__(self, parent, _('Edit Image')) # T: Dialog title 7861 self.buffer = buffer 7862 self.notebook = notebook 7863 self.path = path 7864 7865 iter = buffer.get_iter_at_mark(buffer.get_insert()) 7866 image_data = self.buffer.get_image_data(iter) 7867 if image_data is None: 7868 iter.backward_char() 7869 image_data = self.buffer.get_image_data(iter) 7870 assert image_data, 'No image found' 7871 self._image_data = image_data.copy() 7872 self._iter = iter.get_offset() 7873 7874 src = image_data['src'] 7875 if '?' in src: 7876 i = src.find('?') 7877 src = src[:i] 7878 href = image_data.get('href', '') 7879 anchor = image_data.get('id', '') 7880 self.add_form([ 7881 ('file', 'image', _('Location')), # T: Input in 'edit image' dialog 7882 ('href', 'link', _('Link to'), path), # T: Input in 'edit image' dialog 7883 ('width', 'int', _('Width'), (0, 1)), # T: Input in 'edit image' dialog 7884 ('height', 'int', _('Height'), (0, 1)), # T: Input in 'edit image' dialog 7885 ('anchor', 'string', _('Id')) 7886 ], 7887 {'file': src, 'href': href, 'anchor': anchor} 7888 # range for width and height are set in set_ranges() 7889 ) 7890 self.form.widgets['file'].set_use_relative_paths(notebook, path) 7891 self.form.widgets['file'].allow_empty = False 7892 self.form.widgets['file'].show_empty_invalid = True 7893 self.form.widgets['file'].update_input_valid() 7894 7895 reset_button = Gtk.Button.new_with_mnemonic(_('_Reset Size')) 7896 # T: Button in 'edit image' dialog 7897 hbox = Gtk.HBox() 7898 hbox.pack_end(reset_button, False, True, 0) 7899 self.vbox.add(hbox) 7900 7901 reset_button.connect_object('clicked', 7902 self.__class__.reset_dimensions, self) 7903 self.form.widgets['file'].connect_object('changed', 7904 self.__class__.do_file_changed, self) 7905 self.form.widgets['width'].connect_object('value-changed', 7906 self.__class__.do_width_changed, self) 7907 self.form.widgets['height'].connect_object('value-changed', 7908 self.__class__.do_height_changed, self) 7909 7910 # Init ranges based on original 7911 self.reset_dimensions() 7912 7913 # Set current scale if any 7914 if 'width' in image_data: 7915 self.form.widgets['width'].set_value(int(image_data['width'])) 7916 elif 'height' in image_data: 7917 self.form.widgets['height'].set_value(int(image_data['height'])) 7918 7919 def reset_dimensions(self): 7920 self._image_data.pop('width', None) 7921 self._image_data.pop('height', None) 7922 width = self.form.widgets['width'] 7923 height = self.form.widgets['height'] 7924 file = self.form['file'] 7925 try: 7926 if file is None: 7927 raise AssertionError 7928 w, h = image_file_get_dimensions(file.path) 7929 if w <= 0 or h <= 0: 7930 raise AssertionError 7931 except: 7932 logger.warn('Could not get size for image: %s', file.path) 7933 width.set_sensitive(False) 7934 height.set_sensitive(False) 7935 else: 7936 width.set_sensitive(True) 7937 height.set_sensitive(True) 7938 self._block = True 7939 width.set_range(0, 4 * w) 7940 width.set_value(w) 7941 height.set_range(0, 4 * w) 7942 height.set_value(h) 7943 self._block = False 7944 self._ratio = float(w) / h 7945 7946 def do_file_changed(self): 7947 # Prevent images becoming one pixel wide 7948 file = self.form['file'] 7949 if file is None: 7950 return 7951 try: 7952 if self._image_data['width'] == 1: 7953 self.reset_dimensions() 7954 except KeyError: 7955 # width hasn't been set 7956 pass 7957 7958 def do_width_changed(self): 7959 if hasattr(self, '_block') and self._block: 7960 return 7961 self._image_data.pop('height', None) 7962 self._image_data['width'] = int(self.form['width']) 7963 h = int(float(self._image_data['width']) / self._ratio) 7964 self._block = True 7965 self.form['height'] = h 7966 self._block = False 7967 7968 def do_height_changed(self): 7969 if hasattr(self, '_block') and self._block: 7970 return 7971 self._image_data.pop('width', None) 7972 self._image_data['height'] = int(self.form['height']) 7973 w = int(self._ratio * float(self._image_data['height'])) 7974 self._block = True 7975 self.form['width'] = w 7976 self._block = False 7977 7978 def do_response_ok(self): 7979 file = self.form['file'] 7980 if file is None: 7981 return False 7982 7983 attrib = self._image_data 7984 attrib['src'] = self.notebook.relative_filepath(file, self.path) or file.uri 7985 7986 href = self.form['href'] 7987 if href: 7988 type = link_type(href) 7989 if type == 'file': 7990 # Try making the path relative 7991 linkfile = self.form.widgets['href'].get_file() 7992 href = self.notebook.relative_filepath(linkfile, self.path) or linkfile.uri 7993 attrib['href'] = href 7994 else: 7995 attrib.pop('href', None) 7996 7997 id = self.form['anchor'] 7998 if id: 7999 attrib['id'] = id 8000 else: 8001 attrib.pop('id', None) 8002 8003 iter = self.buffer.get_iter_at_offset(self._iter) 8004 bound = iter.copy() 8005 bound.forward_char() 8006 with self.buffer.user_action: 8007 self.buffer.delete(iter, bound) 8008 self.buffer.insert_image_at_cursor(file, **attrib) 8009 return True 8010 8011 8012class InsertTextFromFileDialog(FileDialog): 8013 '''Dialog to insert text from an external file into the page''' 8014 8015 def __init__(self, parent, buffer, notebook, page): 8016 FileDialog.__init__( 8017 self, parent, _('Insert Text From File'), Gtk.FileChooserAction.OPEN) 8018 # T: Dialog title 8019 self.load_last_folder() 8020 self.add_shortcut(notebook, page) 8021 self.buffer = buffer 8022 8023 def do_response_ok(self): 8024 file = self.get_file() 8025 if file is None: 8026 return False 8027 parser = get_format('plain').Parser() 8028 tree = parser.parse(file.readlines()) 8029 self.buffer.insert_parsetree_at_cursor(tree) 8030 self.save_last_folder() 8031 return True 8032 8033 8034class InsertLinkDialog(Dialog): 8035 '''Dialog to insert a new link in the page or edit properties of 8036 an existing link 8037 ''' 8038 8039 def __init__(self, parent, pageview): 8040 self.pageview = pageview 8041 href, text = self._get_link_from_buffer() 8042 8043 if href: 8044 title = _('Edit Link') # T: Dialog title 8045 else: 8046 title = _('Insert Link') # T: Dialog title 8047 8048 Dialog.__init__(self, parent, title, button=_('_Link')) # T: Dialog button 8049 8050 self.uistate.setdefault('short_links', pageview.notebook.config['Notebook']['short_links']) 8051 self.add_form( 8052 [ 8053 ('href', 'link', _('Link to'), pageview.page), # T: Input in 'insert link' dialog 8054 ('text', 'string', _('Text')), # T: Input in 'insert link' dialog 8055 ('short_links', 'bool', _('Prefer short link names for pages')), # T: Input in 'insert link' dialog 8056 ], { 8057 'href': href, 8058 'text': text, 8059 'short_links': self.uistate['short_links'], 8060 }, 8061 notebook=pageview.notebook 8062 ) 8063 8064 # Hook text entry to copy text from link when apropriate 8065 self.form.widgets['href'].connect('changed', self.on_href_changed) 8066 self.form.widgets['text'].connect('changed', self.on_text_changed) 8067 self.form.widgets['short_links'].connect('toggled', self.on_short_link_pref_changed) 8068 self._text_for_link = self._link_to_text(href) 8069 self._copy_text = self._text_for_link == text and not self._selected_text 8070 8071 def _get_link_from_buffer(self): 8072 # Get link and text from the text buffer 8073 href, text = '', '' 8074 8075 buffer = self.pageview.textview.get_buffer() 8076 if buffer.get_has_selection(): 8077 buffer.strip_selection() 8078 link = buffer.get_has_link_selection() 8079 else: 8080 link = buffer.select_link() 8081 if not link: 8082 self.pageview.autoselect() 8083 8084 if buffer.get_has_selection(): 8085 start, end = buffer.get_selection_bounds() 8086 text = start.get_text(end) 8087 self._selection_bounds = (start.get_offset(), end.get_offset()) 8088 # Interaction in the dialog causes buffer to loose selection 8089 # maybe due to clipboard focus !?? 8090 # Anyway, need to remember bounds ourselves. 8091 if link: 8092 href = link['href'] 8093 self._selected_text = False 8094 else: 8095 href = text 8096 self._selected_text = True 8097 else: 8098 self._selection_bounds = None 8099 self._selected_text = False 8100 8101 return href, text 8102 8103 def on_href_changed(self, o): 8104 # Check if we can also update text 8105 self._text_for_link = self._link_to_text(self.form['href']) 8106 if self._copy_text: 8107 self.form['text'] = self._text_for_link 8108 self._copy_text = True # just to be sure 8109 8110 def on_text_changed(self, o): 8111 # Check if we should stop updating text 8112 self._copy_text = self.form['text'] == self._text_for_link 8113 8114 def on_short_link_pref_changed(self, o): 8115 self.on_href_changed(None) 8116 8117 def _link_to_text(self, link): 8118 if not link: 8119 return '' 8120 if self.form['short_links'] and link_type(link) == 'page': 8121 # Similar to 'short_links' notebook property but using uistate instead 8122 parts = HRef.new_from_wiki_link(link).parts() 8123 if len(parts) > 0: 8124 return parts[-1] 8125 return link 8126 8127 def do_response_ok(self): 8128 self.uistate['short_links'] = self.form['short_links'] 8129 8130 href = self.form['href'] 8131 if not href: 8132 self.form.widgets['href'].set_input_valid(False) 8133 return False 8134 8135 type = link_type(href) 8136 if type == 'file': 8137 # Try making the path relative 8138 try: 8139 file = self.form.widgets['href'].get_file() 8140 page = self.pageview.page 8141 notebook = self.pageview.notebook 8142 href = notebook.relative_filepath(file, page) or file.uri 8143 except: 8144 pass # E.g. non-local file://host/path URI causing exception 8145 8146 text = self.form['text'] or href 8147 8148 buffer = self.pageview.textview.get_buffer() 8149 with buffer.user_action: 8150 if self._selection_bounds: 8151 start, end = list(map( 8152 buffer.get_iter_at_offset, self._selection_bounds)) 8153 buffer.delete(start, end) 8154 buffer.insert_link_at_cursor(text, href) 8155 8156 return True 8157 8158 8159class FindWidget(object): 8160 '''Base class for L{FindBar} and L{FindAndReplaceDialog}''' 8161 8162 def __init__(self, textview): 8163 self.textview = textview 8164 8165 self.find_entry = InputEntry(allow_whitespace=True) 8166 self.find_entry.connect_object( 8167 'changed', self.__class__.on_find_entry_changed, self) 8168 self.find_entry.connect_object( 8169 'activate', self.__class__.on_find_entry_activate, self) 8170 8171 self.next_button = Gtk.Button.new_with_mnemonic(_('_Next')) 8172 # T: button in find bar and find & replace dialog 8173 self.next_button.connect_object( 8174 'clicked', self.__class__.find_next, self) 8175 self.next_button.set_sensitive(False) 8176 8177 self.previous_button = Gtk.Button.new_with_mnemonic(_('_Previous')) 8178 # T: button in find bar and find & replace dialog 8179 self.previous_button.connect_object( 8180 'clicked', self.__class__.find_previous, self) 8181 self.previous_button.set_sensitive(False) 8182 8183 self.case_option_checkbox = Gtk.CheckButton.new_with_mnemonic(_('Match _case')) 8184 # T: checkbox option in find bar and find & replace dialog 8185 self.case_option_checkbox.connect_object( 8186 'toggled', self.__class__.on_find_entry_changed, self) 8187 8188 self.word_option_checkbox = Gtk.CheckButton.new_with_mnemonic(_('Whole _word')) 8189 # T: checkbox option in find bar and find & replace dialog 8190 self.word_option_checkbox.connect_object( 8191 'toggled', self.__class__.on_find_entry_changed, self) 8192 8193 self.regex_option_checkbox = Gtk.CheckButton.new_with_mnemonic(_('_Regular expression')) 8194 # T: checkbox option in find bar and find & replace dialog 8195 self.regex_option_checkbox.connect_object( 8196 'toggled', self.__class__.on_find_entry_changed, self) 8197 8198 self.highlight_checkbox = Gtk.CheckButton.new_with_mnemonic(_('_Highlight')) 8199 # T: checkbox option in find bar and find & replace dialog 8200 self.highlight_checkbox.connect_object( 8201 'toggled', self.__class__.on_highlight_toggled, self) 8202 8203 @property 8204 def _flags(self): 8205 flags = 0 8206 if self.case_option_checkbox.get_active(): 8207 flags = flags | FIND_CASE_SENSITIVE 8208 if self.word_option_checkbox.get_active(): 8209 flags = flags | FIND_WHOLE_WORD 8210 if self.regex_option_checkbox.get_active(): 8211 flags = flags | FIND_REGEX 8212 return flags 8213 8214 def set_from_buffer(self): 8215 '''Copies settings from last find in the buffer. Uses the 8216 selected text for find if there is a selection. 8217 ''' 8218 buffer = self.textview.get_buffer() 8219 string, flags, highlight = buffer.finder.get_state() 8220 bounds = buffer.get_selection_bounds() 8221 if bounds: 8222 start, end = bounds 8223 string = start.get_slice(end) 8224 if flags & FIND_REGEX: 8225 string = re.escape(string) 8226 self.find(string, flags, highlight) 8227 8228 def on_find_entry_changed(self): 8229 string = self.find_entry.get_text() 8230 buffer = self.textview.get_buffer() 8231 ok = buffer.finder.find(string, flags=self._flags) 8232 8233 if not string: 8234 self.find_entry.set_input_valid(True) 8235 else: 8236 self.find_entry.set_input_valid(ok) 8237 8238 for button in (self.next_button, self.previous_button): 8239 button.set_sensitive(ok) 8240 8241 if ok: 8242 self.textview.scroll_to_mark(buffer.get_insert(), SCROLL_TO_MARK_MARGIN, False, 0, 0) 8243 8244 def on_find_entry_activate(self): 8245 self.on_find_entry_changed() 8246 8247 def on_highlight_toggled(self): 8248 highlight = self.highlight_checkbox.get_active() 8249 buffer = self.textview.get_buffer() 8250 buffer.finder.set_highlight(highlight) 8251 8252 def find(self, string, flags=0, highlight=False): 8253 if string: 8254 self.find_entry.set_text(string) 8255 self.case_option_checkbox.set_active(flags & FIND_CASE_SENSITIVE) 8256 self.word_option_checkbox.set_active(flags & FIND_WHOLE_WORD) 8257 self.regex_option_checkbox.set_active(flags & FIND_REGEX) 8258 self.highlight_checkbox.set_active(highlight) 8259 8260 # Force update 8261 self.on_find_entry_changed() 8262 self.on_highlight_toggled() 8263 8264 def find_next(self): 8265 buffer = self.textview.get_buffer() 8266 buffer.finder.find_next() 8267 self.textview.scroll_to_mark(buffer.get_insert(), SCROLL_TO_MARK_MARGIN, False, 0, 0) 8268 8269 def find_previous(self): 8270 buffer = self.textview.get_buffer() 8271 buffer.finder.find_previous() 8272 self.textview.scroll_to_mark(buffer.get_insert(), SCROLL_TO_MARK_MARGIN, False, 0, 0) 8273 8274 8275class FindBar(FindWidget, Gtk.ActionBar): 8276 '''Bar to be shown below the TextView for find functions''' 8277 8278 # TODO use smaller buttons ? 8279 8280 def __init__(self, textview): 8281 GObject.GObject.__init__(self) 8282 FindWidget.__init__(self, textview) 8283 8284 self.pack_start(Gtk.Label(_('Find') + ': ')) 8285 # T: label for input in find bar on bottom of page 8286 self.pack_start(self.find_entry) 8287 self.pack_start(self.previous_button) 8288 self.pack_start(self.next_button) 8289 self.pack_start(self.case_option_checkbox) 8290 self.pack_start(self.highlight_checkbox) 8291 # TODO allow box to shrink further by putting buttons in menu 8292 8293 close_button = IconButton(Gtk.STOCK_CLOSE, relief=False, size=Gtk.IconSize.MENU) 8294 close_button.connect_object('clicked', self.__class__.hide, self) 8295 self.pack_end(close_button) 8296 8297 def grab_focus(self): 8298 self.find_entry.grab_focus() 8299 8300 def show(self): 8301 self.on_highlight_toggled() 8302 self.set_no_show_all(False) 8303 self.show_all() 8304 8305 def hide(self): 8306 Gtk.ActionBar.hide(self) 8307 self.set_no_show_all(True) 8308 buffer = self.textview.get_buffer() 8309 buffer.finder.set_highlight(False) 8310 self.textview.grab_focus() 8311 8312 def on_find_entry_activate(self): 8313 self.on_find_entry_changed() 8314 self.find_next() 8315 8316 def do_key_press_event(self, event): 8317 keyval = strip_boolean_result(event.get_keyval()) 8318 if keyval == KEYVAL_ESC: 8319 self.hide() 8320 return True 8321 else: 8322 return Gtk.HBox.do_key_press_event(self, event) 8323 8324 8325class FindAndReplaceDialog(FindWidget, Dialog): 8326 '''Dialog for find and replace''' 8327 8328 def __init__(self, parent, textview): 8329 Dialog.__init__(self, parent, 8330 _('Find and Replace'), buttons=Gtk.ButtonsType.CLOSE) # T: Dialog title 8331 FindWidget.__init__(self, textview) 8332 8333 hbox = Gtk.HBox(spacing=12) 8334 hbox.set_border_width(12) 8335 self.vbox.add(hbox) 8336 8337 vbox = Gtk.VBox(spacing=5) 8338 hbox.pack_start(vbox, True, True, 0) 8339 8340 label = Gtk.Label(label=_('Find what') + ': ') 8341 # T: input label in find & replace dialog 8342 label.set_alignment(0.0, 0.5) 8343 vbox.add(label) 8344 vbox.add(self.find_entry) 8345 vbox.add(self.case_option_checkbox) 8346 vbox.add(self.word_option_checkbox) 8347 vbox.add(self.regex_option_checkbox) 8348 vbox.add(self.highlight_checkbox) 8349 8350 label = Gtk.Label(label=_('Replace with') + ': ') 8351 # T: input label in find & replace dialog 8352 label.set_alignment(0.0, 0.5) 8353 vbox.add(label) 8354 self.replace_entry = InputEntry(allow_whitespace=True) 8355 vbox.add(self.replace_entry) 8356 8357 self.bbox = Gtk.ButtonBox(orientation=Gtk.Orientation.VERTICAL) 8358 self.bbox.set_layout(Gtk.ButtonBoxStyle.START) 8359 self.bbox.set_spacing(5) 8360 hbox.pack_start(self.bbox, False, False, 0) 8361 self.bbox.add(self.next_button) 8362 self.bbox.add(self.previous_button) 8363 8364 replace_button = Gtk.Button.new_with_mnemonic(_('_Replace')) 8365 # T: Button in search & replace dialog 8366 replace_button.connect_object('clicked', self.__class__.replace, self) 8367 self.bbox.add(replace_button) 8368 8369 all_button = Gtk.Button.new_with_mnemonic(_('Replace _All')) 8370 # T: Button in search & replace dialog 8371 all_button.connect_object('clicked', self.__class__.replace_all, self) 8372 self.bbox.add(all_button) 8373 8374 def set_input(self, **inputs): 8375 # Hide implementation for test cases 8376 for key, value in list(inputs.items()): 8377 if key == 'query': 8378 self.find_entry.set_text(value) 8379 elif key == 'replacement': 8380 self.replace_entry.set_text(value) 8381 else: 8382 raise ValueError 8383 8384 def replace(self): 8385 string = self.replace_entry.get_text() 8386 buffer = self.textview.get_buffer() 8387 buffer.finder.replace(string) 8388 buffer.finder.find_next() 8389 8390 def replace_all(self): 8391 string = self.replace_entry.get_text() 8392 buffer = self.textview.get_buffer() 8393 buffer.finder.replace_all(string) 8394 8395 def do_response(self, id): 8396 Dialog.do_response(self, id) 8397 buffer = self.textview.get_buffer() 8398 buffer.finder.set_highlight(False) 8399 8400 8401class WordCountDialog(Dialog): 8402 '''Dialog showing line, word, and character counts''' 8403 8404 def __init__(self, pageview): 8405 Dialog.__init__(self, pageview, 8406 _('Word Count'), buttons=Gtk.ButtonsType.CLOSE) # T: Dialog title 8407 self.set_resizable(False) 8408 8409 def count(buffer, bounds): 8410 start, end = bounds 8411 lines = end.get_line() - start.get_line() + 1 8412 chars = end.get_offset() - start.get_offset() 8413 8414 strings = start.get_text(end).strip().split() 8415 non_space_chars = sum(len(s) for s in strings) 8416 8417 words = 0 8418 iter = start.copy() 8419 while iter.compare(end) < 0: 8420 if iter.forward_word_end(): 8421 words += 1 8422 elif iter.compare(end) == 0: 8423 # When end is end of buffer forward_end_word returns False 8424 words += 1 8425 break 8426 else: 8427 break 8428 8429 return lines, words, chars, non_space_chars 8430 8431 buffer = pageview.textview.get_buffer() 8432 buffercount = count(buffer, buffer.get_bounds()) 8433 insert = buffer.get_iter_at_mark(buffer.get_insert()) 8434 start = buffer.get_iter_at_line(insert.get_line()) 8435 end = start.copy() 8436 end.forward_line() 8437 paracount = count(buffer, (start, end)) 8438 if buffer.get_has_selection(): 8439 selectioncount = count(buffer, buffer.get_selection_bounds()) 8440 else: 8441 selectioncount = (0, 0, 0, 0) 8442 8443 table = Gtk.Table(3, 4) 8444 table.set_row_spacings(5) 8445 table.set_col_spacings(12) 8446 self.vbox.add(table) 8447 8448 plabel = Gtk.Label(label=_('Page')) # T: label in word count dialog 8449 alabel = Gtk.Label(label=_('Paragraph')) # T: label in word count dialog 8450 slabel = Gtk.Label(label=_('Selection')) # T: label in word count dialog 8451 wlabel = Gtk.Label(label='<b>' + _('Words') + '</b>:') # T: label in word count dialog 8452 llabel = Gtk.Label(label='<b>' + _('Lines') + '</b>:') # T: label in word count dialog 8453 clabel = Gtk.Label(label='<b>' + _('Characters') + '</b>:') # T: label in word count dialog 8454 dlabel = Gtk.Label(label='<b>' + _('Characters excluding spaces') + '</b>:') # T: label in word count dialog 8455 8456 for label in (wlabel, llabel, clabel, dlabel): 8457 label.set_use_markup(True) 8458 label.set_alignment(0.0, 0.5) 8459 8460 # Heading 8461 table.attach(plabel, 1, 2, 0, 1) 8462 table.attach(alabel, 2, 3, 0, 1) 8463 table.attach(slabel, 3, 4, 0, 1) 8464 8465 # Lines 8466 table.attach(llabel, 0, 1, 1, 2) 8467 table.attach(Gtk.Label(label=str(buffercount[0])), 1, 2, 1, 2) 8468 table.attach(Gtk.Label(label=str(paracount[0])), 2, 3, 1, 2) 8469 table.attach(Gtk.Label(label=str(selectioncount[0])), 3, 4, 1, 2) 8470 8471 # Words 8472 table.attach(wlabel, 0, 1, 2, 3) 8473 table.attach(Gtk.Label(label=str(buffercount[1])), 1, 2, 2, 3) 8474 table.attach(Gtk.Label(label=str(paracount[1])), 2, 3, 2, 3) 8475 table.attach(Gtk.Label(label=str(selectioncount[1])), 3, 4, 2, 3) 8476 8477 # Characters 8478 table.attach(clabel, 0, 1, 3, 4) 8479 table.attach(Gtk.Label(label=str(buffercount[2])), 1, 2, 3, 4) 8480 table.attach(Gtk.Label(label=str(paracount[2])), 2, 3, 3, 4) 8481 table.attach(Gtk.Label(label=str(selectioncount[2])), 3, 4, 3, 4) 8482 8483 # Characters excluding spaces 8484 table.attach(dlabel, 0, 1, 4, 5) 8485 table.attach(Gtk.Label(label=str(buffercount[3])), 1, 2, 4, 5) 8486 table.attach(Gtk.Label(label=str(paracount[3])), 2, 3, 4, 5) 8487 table.attach(Gtk.Label(label=str(selectioncount[3])), 3, 4, 4, 5) 8488 8489 8490from zim.notebook import update_parsetree_and_copy_images 8491 8492class MoveTextDialog(Dialog): 8493 '''This dialog allows moving a selected text to a new page 8494 The idea is to allow "refactoring" of pages more easily. 8495 ''' 8496 8497 def __init__(self, pageview, notebook, page, buffer, navigation): 8498 assert buffer.get_has_selection(), 'No Selection present' 8499 Dialog.__init__( 8500 self, 8501 pageview, 8502 _('Move Text to Other Page'), # T: Dialog title 8503 button=_('_Move') # T: Button label 8504 ) 8505 self.pageview = pageview 8506 self.notebook = notebook 8507 self.page = page 8508 self.buffer = buffer 8509 self.navigation = navigation 8510 8511 self.uistate.setdefault('link', True) 8512 self.uistate.setdefault('open_page', False) 8513 self.add_form([ 8514 ('page', 'page', _('Move text to'), page), # T: Input in 'move text' dialog 8515 ('link', 'bool', _('Leave link to new page')), # T: Input in 'move text' dialog 8516 ('open_page', 'bool', _('Open new page')), # T: Input in 'move text' dialog 8517 8518 ], self.uistate) 8519 8520 def do_response_ok(self): 8521 newpage = self.form['page'] 8522 if not newpage: 8523 return False 8524 8525 try: 8526 newpage = self.notebook.get_page(newpage) 8527 except PageNotFoundError: 8528 return False 8529 8530 # Copy text 8531 bounds = self.buffer.get_selection_bounds() 8532 if not bounds: 8533 ErrorDialog(self, _('No text selected')).run() # T: error message in "move selected text" action 8534 return False 8535 8536 if not newpage.exists(): 8537 template = self.notebook.get_template(newpage) 8538 newpage.set_parsetree(template) 8539 8540 parsetree = self.buffer.get_parsetree(bounds) 8541 update_parsetree_and_copy_images(parsetree, self.notebook, self.page, newpage) 8542 8543 newpage.append_parsetree(parsetree) 8544 self.notebook.store_page(newpage) 8545 8546 # Delete text (after copy was successfulll..) 8547 self.buffer.delete(*bounds) 8548 8549 # Insert Link 8550 self.uistate['link'] = self.form['link'] 8551 if self.form['link']: 8552 href = self.form.widgets['page'].get_text() # TODO add method to Path "get_link" which gives rel path formatted correctly 8553 self.buffer.insert_link_at_cursor(href, href) 8554 8555 # Show page 8556 self.uistate['open_page'] = self.form['open_page'] 8557 if self.form['open_page']: 8558 self.navigation.open_page(newpage) 8559 8560 return True 8561 8562 8563class NewFileDialog(Dialog): 8564 8565 def __init__(self, parent, basename): 8566 Dialog.__init__(self, parent, _('New File')) # T: Dialog title 8567 self.add_form(( 8568 ('basename', 'string', _('Name')), # T: input for new file name 8569 ), { 8570 'basename': basename 8571 }) 8572 8573 def show_all(self): 8574 Dialog.show_all(self) 8575 8576 # Select only first part of name 8577 # TODO - make this a widget type in widgets.py 8578 text = self.form.widgets['basename'].get_text() 8579 if '.' in text: 8580 name, ext = text.split('.', 1) 8581 self.form.widgets['basename'].select_region(0, len(name)) 8582 8583 def do_response_ok(self): 8584 self.result = self.form['basename'] 8585 return True 8586