apostrophe/apostrophe/text_view.py

305 lines
11 KiB
Python

import gi
from apostrophe.helpers import user_action
from apostrophe.inline_preview import InlinePreview
from apostrophe.text_view_drag_drop_handler import DragDropHandler, TARGET_URI, TARGET_TEXT
from apostrophe.text_view_format_inserter import FormatInserter
from apostrophe.text_view_markup_handler import MarkupHandler
from apostrophe.text_view_scroller import TextViewScroller
from apostrophe.text_view_undo_redo_handler import UndoRedoHandler
gi.require_version('Gtk', '3.0')
gi.require_version('Gspell', '1')
from gi.repository import Gtk, Gdk, GObject, GLib, Gspell
import logging
LOGGER = logging.getLogger('apostrophe')
class TextView(Gtk.TextView):
"""ApostropheTextView encapsulates all the features around the editor.
It combines the following:
- Undo / redo (via TextBufferUndoRedoHandler)
- Format shortcuts (via TextBufferShortcutInserter)
- Markup (via TextBufferMarkupHandler)
- Preview popover (via TextBufferMarkupHandler)
- Drag and drop (via TextViewDragDropHandler)
- Scrolling (via TextViewScroller)
- The various modes supported by Apostrophe (eg. Focus Mode, Hemingway Mode)
"""
__gsignals__ = {
'insert-italic': (GObject.SignalFlags.ACTION, None, ()),
'insert-bold': (GObject.SignalFlags.ACTION, None, ()),
'insert-hrule': (GObject.SignalFlags.ACTION, None, ()),
'insert-listitem': (GObject.SignalFlags.ACTION, None, ()),
'insert-header': (GObject.SignalFlags.ACTION, None, ()),
'insert-strikethrough': (GObject.SignalFlags.ACTION, None, ()),
'undo': (GObject.SignalFlags.ACTION, None, ()),
'redo': (GObject.SignalFlags.ACTION, None, ()),
'scroll-scale-changed': (GObject.SIGNAL_RUN_LAST, None, (float,)),
}
font_sizes = [18, 17, 16, 15, 14] # Must match CSS selectors in gtk/base.css
def __init__(self, line_chars):
super().__init__()
# Appearance
self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
self.set_pixels_above_lines(4)
self.set_pixels_below_lines(4)
self.set_pixels_inside_wrap(8)
self.get_style_context().add_class('apostrophe-editor')
self.set_margin_left(8)
self.set_margin_right(8)
# Text sizing
self.props.halign = Gtk.Align.FILL
self.line_chars = line_chars
self.font_size = 16
self.get_style_context().add_class('size16')
# General behavior
self.connect('size-allocate', self.on_size_allocate)
self.get_buffer().connect('changed', self.on_text_changed)
self.get_buffer().connect('paste-done', self.on_paste_done)
# Spell checking
self.spellcheck = True
self.gspell_view = Gspell.TextView.get_from_gtk_text_view(self)
self.gspell_view.basic_setup()
# Undo / redo
self.undo_redo = UndoRedoHandler()
self.get_buffer().connect('begin-user-action', self.undo_redo.on_begin_user_action)
self.get_buffer().connect('end-user-action', self.undo_redo.on_end_user_action)
self.get_buffer().connect('insert-text', self.undo_redo.on_insert_text)
self.get_buffer().connect('delete-range', self.undo_redo.on_delete_range)
self.connect('undo', self.undo_redo.undo)
self.connect('redo', self.undo_redo.redo)
# Format shortcuts
self.shortcut = FormatInserter()
self.connect('insert-italic', self.shortcut.insert_italic)
self.connect('insert-bold', self.shortcut.insert_bold)
self.connect('insert-strikethrough', self.shortcut.insert_strikethrough)
self.connect('insert-hrule', self.shortcut.insert_horizontal_rule)
self.connect('insert-listitem', self.shortcut.insert_list_item)
self.connect('insert-header', self.shortcut.insert_header)
# Markup
self.markup = MarkupHandler(self)
self.connect('style-updated', self.markup.on_style_updated)
self.connect('destroy', self.markup.stop)
# Preview popover
self.preview_popover = InlinePreview(self)
# Drag and drop
self.drag_drop = DragDropHandler(self, TARGET_URI, TARGET_TEXT)
# Scrolling
self.scroller = None
self.connect('parent-set', self.on_parent_set)
self.get_buffer().connect('mark-set', self.on_mark_set)
# Focus mode
self.focus_mode = False
self.connect('button-release-event', self.on_button_release_event)
# Hemingway mode
self.hemingway_mode = False
self.connect('key-press-event', self._on_key_press_event)
# While resizing the TextView, there is unwanted scroll upwards if a top margin is present.
# When a size allocation is detected, this variable will hold the scroll to re-set until the
# UI is idle again.
# TODO: Find a better way to handle unwanted scroll.
self.frozen_scroll_scale = None
def get_text(self):
text_buffer = self.get_buffer()
start_iter = text_buffer.get_start_iter()
end_iter = text_buffer.get_end_iter()
return text_buffer.get_text(start_iter, end_iter, False)
def set_text(self, text):
"""Set text and clear undo history"""
text_buffer = self.get_buffer()
with user_action(text_buffer):
text_buffer.set_text(text)
self.undo_redo.clear()
def can_scroll(self):
return self.scroller.can_scroll()
def get_scroll_scale(self):
return self.scroller.get_scroll_scale() if self.scroller else 0
def set_scroll_scale(self, scale):
if self.scroller:
self.scroller.set_scroll_scale(scale)
def on_size_allocate(self, *_):
self.update_horizontal_margin()
self.markup.update_margins_indents()
self.queue_draw()
# TODO: Find a better way to handle unwanted scroll on resize.
self.frozen_scroll_scale = self.get_scroll_scale()
GLib.idle_add(self.unfreeze_scroll_scale)
def on_text_changed(self, *_):
self.markup.apply()
def on_paste_done(self, *_):
self.smooth_scroll_to()
def on_parent_set(self, *_):
parent = self.get_parent()
if parent:
parent.set_size_request(self.get_min_width(), 500)
self.scroller = TextViewScroller(self, parent)
parent.get_vadjustment().connect("changed", self.on_vadjustment_changed)
parent.get_vadjustment().connect("value-changed", self.on_vadjustment_changed)
else:
self.scroller = None
def on_mark_set(self, _text_buffer, _location, mark, _data=None):
if mark.get_name() == 'selection_bound':
self.markup.apply()
if not self.get_buffer().get_has_selection():
self.smooth_scroll_to(mark)
elif mark.get_name() == 'gtk_drag_target':
self.smooth_scroll_to(mark)
return True
def on_button_release_event(self, _widget, _event):
if self.focus_mode:
self.markup.apply()
return False
def on_vadjustment_changed(self, *_):
if self.frozen_scroll_scale is not None:
self.set_scroll_scale(self.frozen_scroll_scale)
elif self.can_scroll():
self.emit("scroll-scale-changed", self.get_scroll_scale())
def unfreeze_scroll_scale(self):
self.frozen_scroll_scale = None
self.queue_draw()
def set_focus_mode(self, focus_mode, hb_height):
"""Toggle focus mode.
When in focus mode, the cursor sits in the middle of the text view,
and the surrounding text is greyed out."""
self.focus_mode = focus_mode
self.update_vertical_margin(hb_size=hb_height)
self.markup.apply()
self.smooth_scroll_to()
self.set_spellcheck(self.spellcheck)
def set_spellcheck(self, spellcheck):
self.spellcheck = spellcheck
self.gspell_view.set_inline_spell_checking(self.spellcheck and not self.focus_mode)
def update_horizontal_margin(self):
width = self.get_allocation().width
# Ensure the appropriate font size is being used
for font_size in self.font_sizes:
if width >= self.get_min_width(font_size) or font_size == self.font_sizes[-1]:
if font_size != self.font_size:
self.font_size = font_size
for fs in self.font_sizes:
self.get_style_context().remove_class("size{}".format(fs))
self.get_style_context().add_class("size{}".format(font_size))
break
# Apply margin with the remaining space to allow for markup
line_width = (self.line_chars + 1) * int(self.get_char_width(self.font_size)) - 1
horizontal_margin = (width - line_width) / 2
self.props.left_margin = horizontal_margin
self.props.right_margin = horizontal_margin
def update_vertical_margin(self, top_margin=0, hb_size=0):
if self.focus_mode:
height = self.get_allocation().height + top_margin + hb_size
self.props.top_margin = height / 2 + top_margin
self.props.bottom_margin = height / 2 - top_margin
else:
self.props.top_margin = 80 + top_margin
self.props.bottom_margin = 64
def set_hemingway_mode(self, hemingway_mode):
"""Toggle hemingway mode.
When in hemingway mode, the backspace and delete keys are ignored."""
self.hemingway_mode = hemingway_mode
def clear(self):
"""Clear text and undo history"""
self.set_text('')
def smooth_scroll_to(self, mark=None):
"""Scrolls if needed to ensure mark is visible.
If mark is unspecified, the cursor is used."""
if self.scroller is None:
return
if mark is None:
mark = self.get_buffer().get_insert()
GLib.idle_add(self.scroller.smooth_scroll_to_mark, mark, self.focus_mode)
def get_min_width(self, font_size=None):
"""Returns the minimum width of this text view."""
if font_size is None:
font_size = self.font_sizes[-1]
return (self.line_chars + self.get_pad_chars(font_size) + 1) \
* self.get_char_width(font_size) - 1
def get_pad_chars(self, font_size):
"""Returns the amount of character padding for font_size.
Markup can use up to 7 in normal conditions."""
return 8 * (1 + font_size - self.font_sizes[-1])
@staticmethod
def get_char_width(font_size):
"""Returns the font width for a given size. Note: specific to Fira Mono!"""
return font_size * 1 / 1.6
def _on_key_press_event(self, _widget, event):
if self.hemingway_mode:
return event.keyval == Gdk.KEY_BackSpace or event.keyval == Gdk.KEY_Delete
if event.state & Gdk.ModifierType.SHIFT_MASK == Gdk.ModifierType.SHIFT_MASK \
and event.keyval == Gdk.KEY_ISO_Left_Tab: # Capure Shift-Tab
self._on_shift_tab()
return True
def _on_shift_tab(self):
"""Delete last character if it is a tab"""
text_buffer = self.get_buffer()
pen_iter = text_buffer.get_end_iter()
pen_iter.backward_char()
end_iter = text_buffer.get_end_iter()
if pen_iter.get_char() == "\t":
with user_action(text_buffer):
text_buffer.delete(pen_iter, end_iter)