Exemplo n.º 1
0
class KalpanaObject(FailSafeBase):
    """
    An interface that all main objects should implement.

    Subclassing this class means the object will be able to register commands,
    settings, autocompletion patterns, and print to the terminal.
    """
    error_signal = mk_signal1(str)
    log_signal = mk_signal1(str)
    confirm_signal = cast(Signal3[str, Any, str],
                          pyqtSignal(str, QVariant, str))
    kalpana_commands: list[Command] = []
    kalpana_autocompletion_patterns: list[AutocompletionPattern] = []

    def init(self, settings: Settings) -> None:
        self._settings = settings

    def error(self, text: str) -> None:
        """Show an error in the terminal."""
        self.error_signal.emit(text)

    def log(self, text: str) -> None:
        """Show a regular message in the terminal."""
        self.log_signal.emit(text)

    def confirm(self,
                text: str,
                callback: Callable[..., Any],
                arg: str = '') -> None:
        self.confirm_signal.emit(text, callback, arg)

    def file_opened(self, filepath: str, is_new: bool) -> None:
        """
        This is called whenever a file is opened.

        is_new - The file does not exist yet.
        """
        pass

    def file_saved(self, filepath: str, new_name: bool) -> None:
        """
        This is called whenever a file is saved.

        new_name - The file was saved with a new name. (aka Save As)
        """
        pass

    @property
    def settings(self) -> Settings:
        return self._settings
Exemplo n.º 2
0
class Settings(QtConfig):
    """Loads and takes care of settings and stylesheets."""

    css_changed = mk_signal1(str)

    def __init__(self, config_dir: Optional[Path]) -> None:
        super().__init__(
            (config_dir or
             (Path.home() / '.config' / 'kalpana2')) / 'settings.cfg',
            default_config=read_data_file('default_settings.cfg'))
        self._file_settings_path = self.config_dir / 'file_settings.json'
        self.css = ''
        self._active_file: Optional[Path] = None
        self._editable_options: dict[str, QtEditableOption[Any]] = {}
        self.command_history = CommandHistory(self.config_path.parent)
        config = self.load_config()
        # Standard settings
        main_section = ['settings']
        self.autohide_scrollbar = QtOption(main_section,
                                           ['autohide-scrollbar'],
                                           Option.as_bool, config, self)
        self.bold_marker = QtOption(main_section, ['bold-marker'],
                                    Option.as_string, config, self)
        self.chapter_keyword = QtOption(main_section, ['chapter-keyword'],
                                        Option.as_string, config, self)
        self.horizontal_ruler_marker = QtOption(main_section,
                                                ['horizontal-ruler-marker'],
                                                Option.as_string, config, self)
        self.italic_marker = QtOption(main_section, ['italic-marker'],
                                      Option.as_string, config, self)
        self.max_textarea_width = QtEditableOption(main_section,
                                                   'max-textarea-width',
                                                   Option.as_int, config, self)
        self.show_line_numbers = QtEditableOption(main_section,
                                                  'show-line-numbers',
                                                  Option.as_bool, config, self)
        self.spellcheck_active = QtEditableOption(main_section,
                                                  'spellcheck-active',
                                                  Option.as_bool, config, self)
        self.spellcheck_language = QtEditableOption(main_section,
                                                    'spellcheck-language',
                                                    Option.as_string, config,
                                                    self)
        self.vim_mode = QtEditableOption(main_section, 'vim-mode',
                                         Option.as_bool, config, self)
        # Export formats
        self.export_formats = QtSubSectionOption(
            ['export-formats'],
            ExportFormat._from_option,
            config,
            self,
            default_to_empty=True,
        )
        self.export_settings = QtSectionDictOption(
            ['export-settings'],
            ExportSettings._from_option,
            config,
            self,
            default_to_empty=True,
        )

        def parse_start_pos(opt: Option) -> tuple[int, int]:
            cursor_pos, sb_pos = opt.as_string().split()
            return (int(cursor_pos), int(sb_pos))

        self.start_at_pos = QtEditableOption(main_section, 'start-at-pos',
                                             parse_start_pos, config, self)
        self.underline_marker = QtOption(main_section, ['underline-marker'],
                                         Option.as_string, config, self)
        # Keybindings
        self.key_bindings = QtSubOptionList(['hotkeys'], Hotkey._from_option,
                                            config, self)

    def file_opened(self, filepath: str, is_new: bool) -> None:
        if self._active_file is not None:
            self.save_settings()
        if filepath:
            self._active_file = Path(filepath).resolve()
            file_data = self._load_file_settings()
            for key, value in file_data.get(str(self._active_file),
                                            {}).items():
                if key in self._editable_options:
                    self._editable_options[key].change(value)
        else:
            for opt in self._editable_options.values():
                opt._overload_value = None
                opt.changed.emit(opt.value)
            self._active_file = None

    def file_saved(self, filepath: str, new_name: bool) -> None:
        if new_name:
            self._active_file = Path(filepath).resolve()
            self.reload()

    def _load_file_settings(self) -> dict[str, Any]:
        try:
            out: dict[str,
                      Any] = json.loads(self._file_settings_path.read_text())
            return out
        except FileNotFoundError:
            return {}

    def reload_stylesheet(self) -> None:
        # TODO: put the try_it in the calling function?
        # with self.try_it("Couldn't reload stylesheet"):
        default_css = read_data_file('qt.css')
        try:
            user_css = (self.config_dir / 'qt.css').read_text()
        except OSError:
            # No file present which is perfectly fine
            user_css = ''
        self.css = default_css + '\n' + user_css
        self.css_changed.emit(self.css)

    def save_settings(self) -> None:
        self.command_history.save()
        if self._active_file is not None:
            file_data = self._load_file_settings()
            data = {}
            for key, opt in self._editable_options.items():
                if opt._overload_value is not None:
                    data[key] = opt.value
            file_data[str(self._active_file)] = data
            self._file_settings_path.write_text(json.dumps(file_data))
Exemplo n.º 3
0
class FileHandler(QtCore.QObject, KalpanaObject):
    """Takes care of saving and opening files."""
    # file_opened(filepath, is new file)
    file_opened_signal = mk_signal2(str, bool)
    # file_saved(filepath, new save name)
    file_saved_signal = mk_signal2(str, bool)
    set_text = mk_signal1(str)

    def __init__(self, get_text: Callable[[], str],
                 is_modified: Callable[[], bool], settings: Settings) -> None:
        super().__init__()
        KalpanaObject.init(self, settings)
        self.is_modified = is_modified
        self.get_text = get_text
        self.filepath: Optional[str] = None
        self.kalpana_commands = [
            make_command(
                'new-file',
                self.new_file,
                help_text='Create a new file.',
                short_name='n',
                category='file',
                arg_help={
                    '':
                    'Create a new unnamed file.',
                    ' path/to/file':
                    ('Create a new file with the specified path and name. '
                     'It will not be created on disk until you save it, '
                     "but you can't use a file that already exists."),
                },
            ),
            make_command(
                'new-file-in-new-window',
                self.new_file_in_new_window,
                help_text='Create a new file in a new window',
                short_name='N',
                category='file',
                arg_help={
                    '':
                    'Create a new unnamed file.',
                    ' path/to/file':
                    ('Create a new file with the specified path and name. '
                     'It will not be created on disk until you save it, '
                     "but you can't use a file that already exists."),
                },
            ),
            make_command(
                'open-file',
                self.open_file,
                help_text='Open a file',
                args=ArgumentRules.REQUIRED,
                short_name='o',
                category='file',
                arg_help={' path/to/file': 'Open the specified file.'},
            ),
            make_command(
                'open-file-in-new-window',
                self.open_file_in_new_window,
                help_text='Open a file in a new window',
                args=ArgumentRules.REQUIRED,
                short_name='O',
                category='file',
                arg_help={
                    ' path/to/file': 'Open the specified file in a new window.'
                },
            ),
            make_command(
                'save-file',
                self.save_file,
                help_text='Save the file',
                short_name='s',
                category='file',
                arg_help={
                    '': "Save the file. (Can't be a new and unnamed file.)",
                    ' path/to/file': 'Save the file to the specified path.',
                },
            ),
        ]
        self.kalpana_autocompletion_patterns = [
            AutocompletionPattern('new-file',
                                  autocomplete_file_path,
                                  prefix=r'n\s*'),
            AutocompletionPattern(
                'new-file-in-new-window',
                autocomplete_file_path,
                prefix=r'N\s*',
            ),
            AutocompletionPattern(
                'open-file',
                autocomplete_file_path,
                prefix=r'o\s*',
            ),
            AutocompletionPattern(
                'open-file-in-new-window',
                autocomplete_file_path,
                prefix=r'O\s*',
            ),
            AutocompletionPattern(
                'save-file',
                autocomplete_file_path,
                prefix=r's\s*',
            ),
        ]

    def load_file_at_startup(self, filepath: str) -> None:
        """
        Initialize the filepath and textarea when the application starts.

        This is a convenience method so you can create a new file with a
        file name from the operating system's terminal or the
        open_file_in_new_window command easily.
        """
        if os.path.exists(filepath):
            self.open_file(filepath)
        else:
            self.new_file(filepath)

    def force_new_file(self, filepath: str) -> None:
        self.new_file(filepath, force=True)

    @command_callback
    def new_file(self, filepath: Optional[str], force: bool = False) -> None:
        """
        Clear the textarea and filepath unless there are unsaved changes.

        If filepath is not an empty string, that string is made the active
        filepath, which means you can then use save_file without a filepath
        to save.

        Note that nothing is written to the disk when new_file is run. An
        invalid filepath will only be detected when trying to save.
        """
        if self.is_modified() and not force:
            self.confirm('There are unsaved changes. Discard them?',
                         self.force_new_file, filepath or '')
        elif filepath and os.path.exists(filepath):
            self.error('File already exists, open it instead')
        else:
            self.set_text.emit('')
            if filepath:
                self.filepath = filepath
                self.file_opened_signal.emit(filepath, True)
                self.log(f'New file: {filepath}')
            else:
                self.filepath = None
                self.file_opened_signal.emit('', True)
                self.log('New file')

    @command_callback
    def new_file_in_new_window(self, filepath: Optional[str]) -> None:
        """Open a new file in a new instance of Kalpana."""
        if filepath and os.path.exists(filepath):
            self.error('File already exists, open it instead')
        else:
            subprocess.Popen([sys.executable, sys.argv[0]] +
                             ([filepath] if filepath else []))

    def force_open_file(self, filepath: str) -> None:
        self.open_file(filepath, force=True)

    @command_callback
    def open_file(self, filepath: str, force: bool = False) -> None:
        """
        Open a file, unless there are unsaved changes.

        This will only open files encoded in utf-8 or latin1.
        """
        if self.is_modified() and not force:
            self.confirm('There are unsaved changes. Discard them?',
                         self.force_open_file, filepath)
        elif not os.path.isfile(filepath):
            self.error('The path is not a file')
        else:
            encodings = ['utf-8', 'latin1']
            for e in encodings:
                try:
                    with open(filepath, encoding=e) as f:
                        text = f.read()
                except UnicodeDecodeError:
                    continue
                else:
                    self.set_text.emit(text)
                    self.filepath = filepath
                    self.log(f'File opened: {filepath}')
                    self.file_opened_signal.emit(filepath, False)
                    return
            else:
                self.error(f'Unable to open the file: {filepath}')

    @command_callback
    def open_file_in_new_window(self, filepath: str) -> None:
        """Open an existing file in a new instance of Kalpana."""
        if not filepath:
            self.error('No file specified')
        else:
            subprocess.Popen([sys.executable, sys.argv[0], filepath])

    def force_save_file(self, filepath: str) -> None:
        self.save_file(filepath, force=True)

    @command_callback
    def save_file(self, filepath: Optional[str], force: bool = False) -> None:
        """
        Save the file to the disk.

        If filepath is specified, this works as "save as" works in most
        programs, otherwise it saves over the existing filepath without
        prompting.

        Note that this always saves in utf-8, no matter the original encoding.
        """
        if not filepath and not self.filepath:
            self.error('No active file')
        elif filepath and filepath != self.filepath \
                and os.path.exists(filepath) and not force:
            self.confirm('File already exists. Overwrite?',
                         self.force_save_file, filepath)
        else:
            if self.filepath is None or filepath:
                file_to_save = filepath
            else:
                file_to_save = self.filepath
            # When we get here, either filepath or self.filepath has
            # a valid value (see the first part of this if statement)
            assert file_to_save is not None
            try:
                with open(file_to_save, 'w', encoding='utf-8') as f:
                    f.write(self.get_text())
            except IOError:
                self.error(f'Unable to save the file: {file_to_save}')
            else:
                self.log(f'File saved: {file_to_save}')
                self.file_saved_signal.emit(file_to_save,
                                            file_to_save != self.filepath)
                self.filepath = file_to_save
Exemplo n.º 4
0
class Spellchecker(QtCore.QObject, KalpanaObject):

    rehighlight = mk_signal0()
    rehighlight_word = mk_signal1(str)

    def __init__(self, word_under_cursor: Callable[[], Optional[str]],
                 settings: Settings) -> None:
        super().__init__()
        KalpanaObject.init(self, settings)
        self.word_cache: dict[str, bool] = {}
        self.word_under_cursor = word_under_cursor
        self.kalpana_commands = [
            make_command(
                'toggle-spellcheck',
                self.toggle_spellcheck,
                help_text='Toggle the spellcheck.',
                args=ArgumentRules.NONE,
                short_name='&',
                category='spellcheck',
            ),
            make_command(
                'set-spellcheck-language',
                self.set_language,
                help_text='Set the spellcheck language',
                short_name='l',
                category='spellcheck',
                arg_help={' en-US': 'Set the language to English.'},
            ),
            make_command(
                'suggest-spelling',
                self.suggest,
                help_text='Suggest spelling corrections for a word.',
                short_name='@',
                category='spellcheck',
                arg_help={
                    '':
                    'Suggest spelling corrections for the word under the cursor.',
                    'foo': 'Suggest spelling corrections for the word "foo".',
                },
            ),
            make_command(
                'add-word',
                self.add_word,
                help_text='Add word to the spellcheck word list.',
                short_name='+',
                category='spellcheck',
                arg_help={
                    '': 'Add the word under the cursor to the dictionary.',
                    'foo': 'Add the word "foo" to the dictionary.',
                },
            ),
        ]
        self.kalpana_autocompletion_patterns = [
            AutocompletionPattern('set-spellcheck-language',
                                  get_spellcheck_languages,
                                  prefix=r'l\s*',
                                  illegal_chars=' ')
        ]
        self.language = self.settings.spellcheck_language.value
        self.pwl_path = self.settings.config_dir / 'spellcheck-pwl'
        self.pwl_path.mkdir(exist_ok=True, parents=True)
        pwl = self.pwl_path / (self.language + '.pwl')
        self.language_dict = enchant.DictWithPWL(self.language, pwl=str(pwl))
        self.spellcheck_active = False

        def update_setting_spellcheck_active(active: bool) -> None:
            self.spellcheck_active = active

        self.settings.spellcheck_active.changed.connect(
            update_setting_spellcheck_active)
        self.settings.spellcheck_language.changed.connect(
            self._change_language)

    @command_callback
    @_get_word_if_missing
    def add_word(self, word: str) -> None:
        """
        Add a word to the spellcheck dictionary.

        This automatically saves the word to the wordlist file as well.
        """
        self.language_dict.add_to_pwl(word)
        self.word_cache[word] = True
        self.rehighlight_word.emit(word)
        self.log(f'Added "{word}" to dictionary')

    @command_callback
    @_get_word_if_missing
    def suggest(self, word: str) -> None:
        """Print spelling suggestions for a certain word."""
        suggestions = ', '.join(self.language_dict.suggest(word)[:5])
        self.log(f'{word}: {suggestions}')

    def check_word(self, word: str) -> bool:
        """A callback for the highlighter to check a word's spelling."""
        if word in self.word_cache:
            return self.word_cache[word]
        result = self.language_dict.check(word)
        self.word_cache[word] = result
        return result

    @command_callback
    def set_language(self, language: str) -> None:
        """Set the language. (callback for the terminal command)"""
        self._change_language(language)
        self.settings.spellcheck_language.change(self.language)

    def _change_language(self, language: str) -> None:
        if not language:
            self.error('No language specified')
            return
        try:
            pwl = self.pwl_path / (language + '.pwl')
            self.language_dict = enchant.DictWithPWL(language, pwl=str(pwl))
        except enchant.errors.DictNotFoundError:
            self.error(f'Invalid language: {language}')
        else:
            self.language = language
            self.rehighlight.emit()

    @command_callback
    def toggle_spellcheck(self) -> None:
        self.spellcheck_active = not self.spellcheck_active
        if self.spellcheck_active:
            self.log('Spellcheck activated')
        else:
            self.log('Spellcheck deactivated')
        self.settings.spellcheck_active.change(self.spellcheck_active)
Exemplo n.º 5
0
class ChapterIndex(QtCore.QObject, KalpanaObject):
    center_on_line = mk_signal1(int)

    def __init__(self, get_document: Callable[[], QtGui.QTextDocument],
                 get_cursor: Callable[[], QtGui.QTextCursor],
                 settings: Settings) -> None:
        super().__init__()
        KalpanaObject.init(self, settings)
        self.kalpana_commands = [
            make_command(
                'word-count-chapter',
                self.count_chapter_words,
                help_text='Print the word count of a chapter',
                short_name='c',
                arg_help={
                    '':
                    'Print the word count of the chapter your cursor is in.',
                    '7': 'Print the word count of chapter 7.'
                },
            ),
            make_command(
                'go-to-chapter',
                self.go_to_chapter,
                help_text='Jump to a specified chapter.',
                short_name='.',
                category='movement',
                arg_help={
                    '0': 'Jump to the start of the file.',
                    '1': 'Jump to the first chapter.',
                    'n': 'Jump to the nth chapter (has to be a number).',
                    '-1': 'Jump to last chapter.',
                    '-n': 'Jump to nth to last chapter '
                    '(has to be a number).',
                },
            ),
            make_command('go-to-next-chapter',
                         self.go_to_next_chapter,
                         help_text='Jump to the next chapter.',
                         args=ArgumentRules.NONE,
                         short_name='>',
                         category='movement'),
            make_command(
                'go-to-prev-chapter',
                self.go_to_prev_chapter,
                help_text='Jump to the previous chapter.',
                args=ArgumentRules.NONE,
                short_name='<',
                category='movement',
            ),
            make_command(
                'export-chapter',
                self.export_chapter,
                help_text='Export a chapter',
                args=ArgumentRules.REQUIRED,
                short_name='e',
                arg_help={'3 fmt': 'Export chapter 3 with the format "fmt".'},
            ),
        ]
        self._get_document = get_document
        self._get_cursor = get_cursor
        self.chapters: list[Chapter] = []
        self.chapter_keyword = self.settings.chapter_keyword.value
        self._block_count = -1
        # TODO: maybe actually use TextBlockState here?
        self._block_states: dict[int, int] = {}
        self._init_done = False

        def update_setting_chapter_keyword(new_keyword: str) -> None:
            self.chapter_keyword = new_keyword

        self.settings.chapter_keyword.changed.connect(
            update_setting_chapter_keyword)

    def init_done(self) -> None:
        self._init_done = True

    @command_callback
    def count_chapter_words(self, arg: str) -> None:
        if not self.chapters:
            self.error('No chapters detected!')
        elif not arg:
            self.full_line_index_update()
            current_line = self._get_cursor().blockNumber()
            current_chapter = self.which_chapter(current_line)
            words = self.chapters[current_chapter].word_count
            self.log(f'Words in chapter {current_chapter}: {words}')
        elif not arg.isdecimal():
            self.error('Argument has to be a number!')
        elif int(arg) >= len(self.chapters):
            self.error('Invalid chapter!')
        else:
            # yes this is an ugly hack
            self.full_line_index_update()
            words = self.chapters[int(arg)].word_count
            self.log(f'Words in chapter {arg}: {words}')

    def _go_to_chapter(self, chapter: int) -> None:
        total_chapters = len(self.chapters)
        if chapter not in range(-total_chapters, total_chapters):
            self.error('Invalid chapter!')
        else:
            if chapter < 0:
                chapter += total_chapters
            line = self.get_chapter_line(chapter)
            self.center_on_line.emit(line)

    @command_callback
    def go_to_chapter(self, arg: str) -> None:
        """
        Go to the chapter specified in arg.

        arg - The argument string entered in the terminal. Negative values
            means going from the end, where -1 is the last chapter
            and -2 is the second to last.
        """
        if not self.chapters:
            self.error('No chapters detected!')
        elif not re.match(r'-?\d+$', arg):
            self.error('Argument has to be a number!')
        else:
            self._go_to_chapter(int(arg))

    @command_callback
    def go_to_next_chapter(self) -> None:
        self.go_to_chapter_incremental(1)

    @command_callback
    def go_to_prev_chapter(self) -> None:
        self.go_to_chapter_incremental(-1)

    def go_to_chapter_incremental(self, diff: int) -> None:
        """
        Move to a chapter a number of chapters from the current.

        diff - How many chapters to move, negative to move backwards.
        """
        current_line = self._get_cursor().blockNumber()
        current_chapter = self.which_chapter(current_line)
        target_chapter = max(
            0, min(len(self.chapters) - 1, current_chapter + diff))
        if current_chapter != target_chapter:
            line = self.get_chapter_line(target_chapter)
            current_chapter_line = self.get_chapter_line(current_chapter)
            # Go to the top of the current chapter if going up and not there
            if diff < 0 and current_line != current_chapter_line:
                if diff == -1:
                    line = current_chapter_line
                else:
                    line = self.get_chapter_line(target_chapter + 1)
            self.center_on_line.emit(line)

    @command_callback
    def export_chapter(self, arg: str) -> None:
        # TODO: unify the whole chapter arg thingy
        export_formats: dict[
            str, list[ExportFormat]] = self.settings.export_formats.value
        chapter: Optional[Chapter] = None
        if len(self.chapters) > 1:
            if (args := re.fullmatch(r'(?P<ch>\d+)\s*(?P<fmt>\S+)',
                                     arg)) is None:
                self.error('Specify both chapter and format!')
                return
            if (ch_num := int(args['ch'])) >= len(self.chapters):
                self.error('Invalid chapter!')
                return
            fmt = args['fmt']
            chapter = self.chapters[ch_num]
            start = self.get_chapter_line(ch_num) + chapter.metadata_line_count
            end = start + chapter.line_count - chapter.metadata_line_count
Exemplo n.º 6
0
class VimMode(QtCore.QObject):
    align_cursor_to_edge = mk_signal1(bool)
    center_cursor = mk_signal0()
    change_chapter = mk_signal1(int)
    go_to_chapter = mk_signal1(int)
    search_next = mk_signal1(bool)
    show_terminal = mk_signal1(str)

    def __init__(self, doc: QtGui.QTextDocument, get_height: Callable[[], int],
                 get_cursor: Callable[[], QTextCursor],
                 set_cursor: Callable[[QTextCursor], None],
                 get_visible_blocks: Callable[[], Iterable[tuple[
                     QtCore.QRectF, QtGui.QTextBlock]]],
                 activate_insert_mode: Callable[[], None]) -> None:
        super().__init__()
        self.ops: list[str] = []
        self.counts: list[str] = ['']
        self.partial_key = ''
        self.document = doc
        self.get_height = get_height
        self.get_cursor = get_cursor
        self.set_cursor = set_cursor
        self.get_visible_blocks = get_visible_blocks
        self.activate_insert_mode = activate_insert_mode

    def clear(self) -> None:
        self.ops = []
        self.counts = ['']
        self.partial_key = ''

    @property
    def count(self) -> int:
        return reduce(mul, (int(c or '1') for c in self.counts))

    # COMMANDS

    @command({':', KEYS[Qt.Key_Escape]}, 'Switch to the terminal')
    def _show_terminal(self) -> None:
        self.show_terminal.emit('')

    @command({'/'}, 'Start a search string')
    def _start_search(self) -> None:
        self.show_terminal.emit('/')

    @command({'n'}, 'Search next')
    def _search_next(self) -> None:
        self.search_next.emit(False)

    @command({'N'}, 'Search next (reverse)')
    def _search_next_reverse(self) -> None:
        self.search_next.emit(True)

    @command({'zz'},
             'Scroll to put the current line in the middle of the screen')
    def _center_cursor(self) -> None:
        self.center_cursor.emit()

    @command({'zt'}, 'Scroll to put the current line at the top of the screen')
    def _scroll_cursor_top(self) -> None:
        self.align_cursor_to_edge.emit(True)

    @command({'zb'},
             'Scroll to put the current line at the bottom of the screen')
    def _scroll_cursor_bottom(self) -> None:
        self.align_cursor_to_edge.emit(False)

    @command({'<c-b>'}, 'Scroll up one screen height')
    def _scroll_screen_up(self) -> None:
        tc = self.get_cursor()
        tc.setPosition(next(iter(self.get_visible_blocks()))[1].position())
        self.set_cursor(tc)
        self.align_cursor_to_edge.emit(False)

    @command({'<c-f>'}, 'Scroll down one screen height')
    def _scroll_screen_down(self) -> None:
        tc = self.get_cursor()
        for _, block in self.get_visible_blocks():
            pass
        tc.setPosition(block.position())
        self.set_cursor(tc)
        self.align_cursor_to_edge.emit(True)

    @command({'D'}, 'Delete to the end of the block')
    def _delete_to_eob(self) -> None:
        tc = self.get_cursor()
        if not tc.atBlockEnd():
            tc.movePosition(QTC.EndOfBlock, QTC.KeepAnchor)
            tc.deleteChar()

    @command({'C'}, 'Delete to the end of the block and switch to insert mode')
    def _change_to_eob(self) -> None:
        self._delete_to_eob()
        self.activate_insert_mode()

    def _append_insert_generic(
            self,
            motion: QTC.MoveOperation,
            insert_block: bool = False,
            motion2: QTC.MoveOperation = QTC.NoMove) -> None:
        tc = self.get_cursor()
        tc.movePosition(motion)
        if insert_block:
            tc.insertBlock()
        tc.movePosition(motion2)
        self.set_cursor(tc)
        self.activate_insert_mode()

    @command({'A'}, 'Switch to insert mode at the end of the block')
    def _append_at_eob(self) -> None:
        self._append_insert_generic(QTC.EndOfBlock)

    @command({'a'}, 'Switch to insert mode after the current character')
    def _append(self) -> None:
        self._append_insert_generic(QTC.NextCharacter)

    @command({'I'}, 'Switch to insert mode at the start of the block')
    def _insert_at_sob(self) -> None:
        self._append_insert_generic(QTC.StartOfBlock)

    @command({'i'}, 'Switch to insert mode')
    def _insert(self) -> None:
        self.activate_insert_mode()

    @command(
        {'O'},
        'Add a new block after the current and switch to insert mode there')
    def _append_block(self) -> None:
        self._append_insert_generic(QTC.StartOfBlock, True, QTC.PreviousBlock)

    @command(
        {'o'},
        'Insert a new block before the current and switch to insert mode there'
    )
    def _insert_block(self) -> None:
        self._append_insert_generic(QTC.EndOfBlock, True)

    def _paste_generic(self, motion1: QTC.MoveOperation,
                       motion2: QTC.MoveOperation) -> None:
        clipboard = QtGui.QGuiApplication.clipboard()
        tc = self.get_cursor()
        text = clipboard.text()
        # \u2029 is paragraph separator
        if '\n' in text or '\u2029' in text:
            tc.movePosition(motion1)
        else:
            tc.movePosition(motion2)
        tc.insertText(text)
        self.set_cursor(tc)

    @count_command({'p'},
                   'Paste <count> times after the current character/block')
    def _paste(self) -> None:
        self._paste_generic(QTC.NextBlock, QTC.Right)

    @count_command({'P'},
                   'Paste <count> times before the current character/block')
    def _paste_before(self) -> None:
        self._paste_generic(QTC.StartOfBlock, QTC.NoMove)

    # COUNT COMMANDS

    @count_command({'u', '<c-z>'}, 'Undo <count> actions')
    def _undo(self, count: int) -> None:
        for _ in range(count):
            if not self.document.isUndoAvailable():
                break
            self.document.undo()

    @count_command({'<c-r>', '<c-y>'}, 'Redo <count> actions')
    def _redo(self, count: int) -> None:
        for _ in range(count):
            if not self.document.isRedoAvailable():
                break
            self.document.redo()

    @count_command({'gc'}, 'Go to chapter <count>')
    def _go_to_chapter(self, count: int) -> None:
        self.go_to_chapter.emit(count)

    @count_command({'gC'}, 'Go to chapter <count>, counting from the end')
    def _go_to_chapter_reverse(self, count: int) -> None:
        self.go_to_chapter.emit(-count)

    @count_command({'<c-tab>'}, 'Go <count> chapters forward')
    def _change_chapter(self, count: int) -> None:
        self.change_chapter.emit(count)

    @count_command({'<cs-tab>'}, 'Go <count> chapters backward')
    def _change_chapter_reverse(self, count: int) -> None:
        self.change_chapter.emit(-count)

    @count_command({'x'}, 'Delete <count> characters')
    def _delete(self, count: int) -> None:
        tc = self.get_cursor()
        tc.beginEditBlock()
        tc.movePosition(QTC.NextCharacter, QTC.KeepAnchor, n=count)
        tc.removeSelectedText()
        tc.endEditBlock()

    @count_command({'s'},
                   'Delete <count> characters and switch to insert mode')
    def _delete_and_insert(self, count: int) -> None:
        self._delete(count)
        self.activate_insert_mode()

    @count_command({'~'}, 'Swap the case of <count> characters')
    def _swap_case(self, count: int) -> None:
        tc = self.get_cursor()
        tc.beginEditBlock()
        tc.movePosition(QTC.NextCharacter, QTC.KeepAnchor, n=count)
        text = tc.selectedText()
        tc.removeSelectedText()
        tc.insertText(text.swapcase())
        tc.endEditBlock()

    @count_command({'J'}, 'Join the next <count> blocks with this')
    def _join_lines(self, count: int) -> None:
        tc = self.get_cursor()
        tc.beginEditBlock()
        for _ in range(count):
            next_block = tc.block().next()
            if not next_block.isValid():
                break
            add_space = bool(next_block.text().strip())
            tc.movePosition(QTC.EndOfBlock)
            tc.deleteChar()
            if add_space:
                tc.insertText(' ')
        tc.endEditBlock()

    # MOTIONS

    @motion({'0'}, 'Go to the start of the block')
    def _go_to_sob(self, tc: QTC, move_mode: QTC.MoveMode) -> None:
        tc.movePosition(QTC.StartOfBlock, move_mode)

    @motion({'^'}, 'Go to the start of the line')
    def _go_to_sol(self, tc: QTC, move_mode: QTC.MoveMode) -> None:
        tc.movePosition(QTC.StartOfLine, move_mode)

    @motion({'$'}, 'Go to the end of the block')
    def _go_to_eob(self, tc: QTC, move_mode: QTC.MoveMode) -> None:
        tc.movePosition(QTC.EndOfBlock, move_mode)

    @motion({'G'}, 'Go to the end of the document')
    def _go_to_end(self, tc: QTC, move_mode: QTC.MoveMode) -> None:
        tc.movePosition(QTC.End, move_mode)

    @motion({'H'}, 'Go to the top of the screen')
    def _go_to_screen_top(self, tc: QTC, move_mode: QTC.MoveMode) -> None:
        tc.setPosition(
            next(iter(self.get_visible_blocks()))[1].position(), move_mode)

    @motion({'M'}, 'Go to the middle of the screen')
    def _go_to_screen_mid(self, tc: QTC, move_mode: QTC.MoveMode) -> None:
        height = self.get_height()
        for rect, block in self.get_visible_blocks():
            if rect.top() < height / 2 and rect.bottom() > height / 2:
                tc.setPosition(block.position(), move_mode)
                break

    @motion({'L'}, 'Go to the bottom of the screen')
    def _go_to_screen_bottom(self, tc: QTC, move_mode: QTC.MoveMode) -> None:
        tc.setPosition(
            list(self.get_visible_blocks())[-1][1].position(), move_mode)

    # COUNT MOTIONS

    @count_motion({'b'}, 'Go to the start of <count> words left')
    def _word_backwards(self, count: int, tc: QTC,
                        move_mode: QTC.MoveMode) -> None:
        tc.movePosition(QTC.PreviousWord, move_mode, count)

    @count_motion({'w'}, 'Go to the start of <count> words right')
    def _word_forwards(self, count: int, tc: QTC,
                       move_mode: QTC.MoveMode) -> None:
        tc.movePosition(QTC.NextWord, move_mode, count)

    @count_motion({'e'}, 'Go to the end of <count> words right')
    def _word_end_forwards(self, count: int, tc: QTC,
                           move_mode: QTC.MoveMode) -> None:
        if count == 1:
            pos = self.get_cursor().position()
            tc.movePosition(QTC.EndOfWord, move_mode)
            if pos == tc.position():
                tc.movePosition(QTC.NextWord, move_mode)
                tc.movePosition(QTC.EndOfWord, move_mode)
        else:
            tc.movePosition(QTC.NextWord, move_mode, count)
            tc.movePosition(QTC.EndOfWord, move_mode)

    @count_motion({'gg'}, 'Go to block <count>')
    def _go_to_block(self, count: int, tc: QTC,
                     move_mode: QTC.MoveMode) -> None:
        count = min(count - 1, self.document.blockCount() - 1)
        tc.setPosition(
            self.document.findBlockByNumber(count).position(), move_mode)

    @count_motion({KEYS[Qt.Key_Backspace], KEYS[Qt.Key_Left]},
                  'Go <count> characters left')
    def _go_left(self, count: int, tc: QTC, move_mode: QTC.MoveMode) -> None:
        tc.movePosition(QTC.Left, move_mode, count)

    @count_motion({KEYS[Qt.Key_Space], KEYS[Qt.Key_Right]},
                  'Go <count> characters right')
    def _go_right(self, count: int, tc: QTC, move_mode: QTC.MoveMode) -> None:
        tc.movePosition(QTC.Right, move_mode, count)

    @count_motion({'h', KEYS[Qt.Key_Up]}, 'Go <count> lines up')
    def _go_up(self, count: int, tc: QTC, move_mode: QTC.MoveMode) -> None:
        tc.movePosition(QTC.Up, move_mode, count)

    @count_motion({'k', KEYS[Qt.Key_Down]}, 'Go <count> lines down')
    def _go_down(self, count: int, tc: QTC, move_mode: QTC.MoveMode) -> None:
        tc.movePosition(QTC.Down, move_mode, count)

    @count_motion({'('}, 'Go <count> sentences left')
    def _prev_sentence(self, count: int, tc: QTC,
                       move_mode: QTC.MoveMode) -> None:
        for _ in range(count):
            if tc.atStart():
                return
            if tc.atBlockStart():
                tc.movePosition(QTC.Left, move_mode)
                if tc.atBlockStart():
                    continue
            pos = tc.positionInBlock() - 1
            text = tc.block().text()
            while 0 <= pos < len(text) and is_sentence_sep(text[pos]):
                pos -= 1
            start_pos = max(text.rfind('.', 0, pos), text.rfind('?', 0, pos),
                            text.rfind('!', 0, pos))
            if start_pos != -1:
                start_pos += 1
                while start_pos < len(text) and is_sentence_sep(
                        text[start_pos]):
                    start_pos += 1
                tc.setPosition(tc.block().position() + start_pos, move_mode)
            elif pos == 0:
                tc.movePosition(QTC.PreviousBlock, move_mode)
            else:
                tc.movePosition(QTC.StartOfBlock, move_mode)

    @count_motion({')'}, 'Go <count> sentences right')
    def _next_sentence(self, count: int, tc: QTC,
                       move_mode: QTC.MoveMode) -> None:
        for _ in range(count):
            pos = tc.positionInBlock()
            text = tc.block().text()
            end_poses = [
                text.find('.', pos),
                text.find('?', pos),
                text.find('!', pos)
            ]
            end_pos = -1
            if max(end_poses) != -1:
                end_pos = min(p for p in end_poses if p != -1) + 1
                while end_pos < len(text) and is_sentence_sep(text[end_pos]):
                    end_pos += 1
                if end_pos == len(text):
                    tc.movePosition(QTC.NextBlock, move_mode)
                else:
                    tc.setPosition(tc.block().position() + end_pos, move_mode)
            else:
                block = tc.block().next()
                while block.isValid():
                    if block.text().strip():
                        tc.setPosition(block.position(), move_mode)
                        break
                    block = block.next()

    def _prev_next_block(self, count: int, tc: QTC, move_mode: QTC.MoveMode,
                         forward: bool) -> None:
        block = tc.block().next() if forward else tc.block().previous()
        pos = -1
        while block.isValid():
            if block.text().strip():
                pos = block.position()
                count -= 1
                if count == 0:
                    tc.setPosition(pos, move_mode)
                    break
            block = block.next() if forward else block.previous()

    @count_motion({'{'}, 'Go <count> blocks up')
    def _prev_block(self, count: int, tc: QTC,
                    move_mode: QTC.MoveMode) -> None:
        self._prev_next_block(count, tc, move_mode, False)

    @count_motion({'}'}, 'Go <count> blocks down')
    def _next_block(self, count: int, tc: QTC,
                    move_mode: QTC.MoveMode) -> None:
        self._prev_next_block(count, tc, move_mode, True)

    # TO-CHAR MOTIONS

    def _to_char_generic(
            self, forward: bool, greedy: bool, count: int, key: str,
            move_mode: QTextCursor.MoveMode) -> Optional[QTextCursor]:
        if len(key) != 1 and key not in {KEYS[Qt.Key_Space]}:
            return None
        if key == KEYS[Qt.Key_Space]:
            key = ' '
        tc = self.get_cursor()
        pos = tc.positionInBlock()
        text = tc.block().text()
        if forward:
            for _ in range(count):
                pos = text.find(key, pos + 1)
                if pos == -1:
                    break
            else:
                if not greedy:
                    pos -= 1
        else:
            for _ in range(count):
                pos = text.rfind(key, 0, pos)
                if pos == -1:
                    break
            else:
                if not greedy:
                    pos += 1
        if pos >= 0:
            tc.setPosition(tc.block().position() + pos, move_mode)
            return tc
        return None

    @to_char_motion({'f'}, 'Go to the <count>th <char> to the right')
    def _to_char_right_greedy(
            self, count: int, key: str,
            move_mode: QTC.MoveMode) -> Optional[QTextCursor]:
        return self._to_char_generic(True, True, count, key, move_mode)

    @to_char_motion({'F'}, 'Go to the <count>th <char> to the left')
    def _to_char_left_greedy(self, count: int, key: str,
                             move_mode: QTC.MoveMode) -> Optional[QTextCursor]:
        return self._to_char_generic(False, True, count, key, move_mode)

    @to_char_motion({'t'},
                    'Go to one char before the <count>th <char> to the right')
    def _to_char_right(self, count: int, key: str,
                       move_mode: QTC.MoveMode) -> Optional[QTextCursor]:
        return self._to_char_generic(True, False, count, key, move_mode)

    @to_char_motion({'T'},
                    'Go to one char before the <count>th <char> to the left')
    def _to_char_left(self, count: int, key: str,
                      move_mode: QTC.MoveMode) -> Optional[QTextCursor]:
        return self._to_char_generic(False, False, count, key, move_mode)

    # TEXT OBJECT SELECTIONS

    @text_object({'p'}, 'Select a paragraph')
    def _text_obj_paragraph(self, tc: QTC, select_full: bool) -> None:
        tc.movePosition(QTC.StartOfBlock)
        tc.movePosition(QTC.EndOfBlock, QTC.KeepAnchor)
        if select_full:
            tc.movePosition(QTC.NextBlock, QTC.KeepAnchor)
            block = tc.block()
            while block.isValid():
                if not block.text().strip():
                    tc.movePosition(QTC.NextBlock, QTC.KeepAnchor)
                else:
                    break
                block = block.next()

    @text_object({'s'}, 'Select a sentence')
    def _text_obj_sentence(self, tc: QTC, select_full: bool) -> None:
        pos = tc.positionInBlock()
        text = tc.block().text()
        start_pos = max(text.rfind('.', 0, pos), text.rfind('?', 0, pos),
                        text.rfind('!', 0, pos))
        end_poses = [
            text.find('.', pos),
            text.find('?', pos),
            text.find('!', pos)
        ]
        if max(end_poses) != -1:
            end_pos = min(p for p in end_poses if p != -1)
        else:
            end_pos = -1
        if start_pos == -1:
            tc.movePosition(QTC.StartOfBlock)
        else:
            start_pos += 1
            while start_pos < len(text) and text[start_pos].isspace():
                start_pos += 1
            tc.setPosition(tc.block().position() + start_pos)
        if end_pos == -1:
            tc.movePosition(QTC.EndOfBlock, QTC.KeepAnchor)
        else:
            end_pos += 1
            if select_full:
                while end_pos < len(text) and text[end_pos].isspace():
                    end_pos += 1
            tc.setPosition(tc.block().position() + end_pos, QTC.KeepAnchor)

    @text_object({'w'}, 'Select a word')
    def _text_obj_word(self, tc: QTC, select_full: bool) -> None:
        tc.movePosition(QTC.StartOfWord)
        tc.movePosition(QTC.EndOfWord, QTC.KeepAnchor)
        if select_full:
            tc.movePosition(QTC.NextWord, QTC.KeepAnchor)

    # OPERATORS

    @operator({'d'}, 'Delete a chunk of text')
    def _delete_op(self, tc: QTC) -> None:
        clipboard = QtGui.QGuiApplication.clipboard()
        clipboard.setText(tc.selectedText())
        tc.removeSelectedText()

    @operator({'c'}, 'Delete a chunk of text and switch to insert mode')
    def _change_op(self, tc: QTC) -> None:
        self._delete_op(tc)
        self.activate_insert_mode()

    @operator({'gu'}, 'Switch a chunk of text to lower case characters')
    def _lower_case_op(self, tc: QTC) -> None:
        new_text = tc.selectedText().lower()
        tc.removeSelectedText()
        tc.insertText(new_text)

    @operator({'gU'}, 'Switch a chunk of text to upper case characters')
    def _upper_case_op(self, tc: QTC) -> None:
        new_text = tc.selectedText().upper()
        tc.removeSelectedText()
        tc.insertText(new_text)

    @operator({'y'}, 'Copy a chunk of text')
    def _yank_op(self, tc: QTC) -> None:
        clipboard = QtGui.QGuiApplication.clipboard()
        clipboard.setText(tc.selectedText())

    # OTHER

    def key_pressed(self, event: QtGui.QKeyEvent) -> None:
        """
        # Syntax Rules
        H, zz
        [partial-key] <action>

        9w, 3gg
        [count=1] [partial-key] <action>

        12Fj
        [count=1] <action> <target-char>

        dw, gU4w, gU4gg
        [count=1] [partial-key] <action> [count=1] [partial-key] <action>

        dfJ, gu5Fw
        [count=1] [partial-key] <action> [count=1] <action> <target-char>

        gUiw, das
        [partial-key] <action> <modifier> <target-obj>
        """
        def select_between(tc: QTextCursor,
                           start_char: str,
                           end_char: str,
                           select_inside: bool = True) -> None:
            pos = tc.positionInBlock()
            text = tc.block().text()
            start_pos = text.rfind(start_char, 0, pos)
            if start_pos == -1:
                return
            end_pos = text.find(end_char, pos)
            if end_pos == -1:
                return
            if select_inside:
                start_pos += len(start_char)
            else:
                end_pos += len(end_char)
                while end_pos < len(text) and text[end_pos].isspace():
                    end_pos += 1
            tc.setPosition(tc.block().position() + start_pos)
            tc.setPosition(tc.block().position() + end_pos, QTC.KeepAnchor)

        # Encode key
        if event.key() in {
                Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt, Qt.Key_AltGr,
                Qt.Key_Meta
        }:
            return
        mods = int(event.modifiers())
        if mods & int(Qt.AltModifier):
            self.clear()
            return
        if mods & int(Qt.ControlModifier):
            if (mods & ~int(Qt.ControlModifier)
                    & ~int(Qt.ShiftModifier)) == 0 \
                    and event.key() not in {Qt.Key_Control, Qt.Key_Shift}:
                if event.key() == Qt.Key_Tab:
                    key = '<c-tab>'
                elif event.key() == Qt.Key_Backtab:
                    key = '<cs-tab>'
                else:
                    key = f'<c-{chr(event.nativeVirtualKey())}>'
        else:

            if mods == Qt.NoModifier and event.key() in KEYS:
                key = KEYS[Qt.Key(event.key())]
            elif event.text():
                key = event.text()
            else:
                self.clear()
                return None

        if self.partial_key:
            key = self.partial_key + key
            self.partial_key = ''

        if len(self.ops) == 2:
            # == Run operation up to character ==
            if self.ops[1] in to_char_motions:
                op, motion = self.ops
                tc = to_char_motions[motion](self, self.count, key,
                                             QTC.KeepAnchor)
                if tc is not None and tc.hasSelection():
                    tc.beginEditBlock()
                    operators[op](self, tc)
                    tc.endEditBlock()
                self.clear()
            # == Run operation on text object ==
            elif self.ops[1] in text_object_modifiers:
                if key in text_objects or key in text_object_wrappers:
                    # Select the text
                    op, mod = self.ops
                    select_full = mod == text_object_select_full
                    tc = self.get_cursor()
                    tc.beginEditBlock()
                    # Paragraph
                    if key in text_objects:
                        text_objects[key](self, tc, select_full)
                    # Character pairs
                    elif key in text_object_wrappers:
                        start, end = text_object_wrappers[key]
                        select_between(tc, start, end, select_full)
                    else:
                        # TODO: warn
                        pass
                    # Do the thing
                    operators[op](self, tc)
                    tc.endEditBlock()
                self.clear()
            else:
                # TODO: warn
                self.clear()
        elif len(self.ops) == 1:
            # == Go to character ==
            if self.ops[0] in to_char_motions:
                tc = to_char_motions[self.ops[0]](self, self.count, key,
                                                  QTC.MoveAnchor)
                if tc:
                    self.set_cursor(tc)
                self.clear()
            # == In operation ==
            elif self.ops[0] in operators:
                if key.isdigit():
                    self.counts[-1] += key
                elif key in partial_keys:
                    self.partial_key = key
                elif key in to_char_motions or key in text_object_modifiers:
                    self.ops.append(key)
                # Run operation on <count> lines
                elif key == self.ops[0]:
                    tc = self.get_cursor()
                    tc.beginEditBlock()
                    tc.movePosition(QTC.StartOfBlock)
                    tc.movePosition(QTC.NextBlock,
                                    QTC.KeepAnchor,
                                    n=self.count)
                    operators[key](self, tc)
                    tc.endEditBlock()
                    self.clear()
                # Run operation on motion
                elif key in motions:
                    tc = motions[key](self, QTC.KeepAnchor)
                    tc.beginEditBlock()
                    operators[self.ops[0]](self, tc)
                    tc.endEditBlock()
                    self.clear()
                # Run operation on <count> motions
                elif key in count_motions:
                    tc = count_motions[key](self, self.count, QTC.KeepAnchor)
                    tc.beginEditBlock()
                    operators[self.ops[0]](self, tc)
                    tc.endEditBlock()
                    self.clear()
                # Invalid key
                else:
                    self.clear()
        elif not self.ops:
            if key.isdigit() and (key != '0' or self.counts[-1]):
                self.counts[-1] += key
            elif key in partial_keys:
                self.partial_key = key
            elif key in to_char_motions:
                self.ops.append(key)
            # == Run simple command ==
            elif key in commands:
                commands[key](self)
                self.clear()
            # == Run simple motion ==
            elif key in motions:
                tc = motions[key](self, QTC.MoveAnchor)
                self.set_cursor(tc)
                self.clear()
            # == Run <count> commands ==
            elif key in count_commands:
                count_commands[key](self, self.count)
                self.clear()
            # == Run <count> motions ==
            elif key in count_motions:
                tc = count_motions[key](self, self.count, QTC.MoveAnchor)
                self.set_cursor(tc)
                self.clear()
            # == Start an operation ==
            elif key in operators:
                self.ops.append(key)
                self.counts.append('')
            else:
                self.clear()
        else:
            # TODO: warn
            self.clear()
        return None