from gettext import gettext as _
import gi

gi.require_version("GtkSource", "5")

from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, GtkSource, Pango

import logging
from typing import Optional

from iotas.category_header_bar import CategoryHeaderBar
from iotas.category_treeview_list_store import CategoryTreeViewListStore
import iotas.config_manager
from iotas.export_window import ExportWindow
from iotas.focus_mode_helper import FocusModeHelper
from iotas.font_size_selector import FontSizeSelector
from iotas.note import Note
from iotas.theme_selector import ThemeSelector
from iotas.ui_utils import add_mouse_button_accel


@Gtk.Template(resource_path="/org/gnome/World/Iotas/ui/editor.ui")
class Editor(Adw.Bin):
    __gtype_name__ = "Editor"
    __gsignals__ = {
        "note-modified": (GObject.SignalFlags.RUN_FIRST, None, (Note,)),
        "note-deleted": (GObject.SignalFlags.RUN_FIRST, None, (Note,)),
        "category-changed": (GObject.SignalFlags.RUN_FIRST, None, (Note, str)),
        "exit": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    LINE_LENGTH_STEP = 50
    SETTING_CHANGE_NOTIFICATION_DURATION = 3.0
    FONT_SETTING_KEYS = ["monospace-font-name", "document-font-name"]
    HEADERBAR_HIDE_DELAY = 2000

    _title_label = Gtk.Template.Child()
    _is_dirty = Gtk.Template.Child()
    _headerbar_stack = Gtk.Template.Child()
    _main_header_bar = Gtk.Template.Child()
    _rename_header_bar = Gtk.Template.Child()
    _search_header_bar = Gtk.Template.Child()
    _category_header_bar = Gtk.Template.Child()
    _render_search_header_bar = Gtk.Template.Child()

    _menu_button = Gtk.Template.Child()
    _render_edit_stack = Gtk.Template.Child()
    _render_edit_button_stack = Gtk.Template.Child()
    _render_button = Gtk.Template.Child()
    _edit_button = Gtk.Template.Child()
    _sourceview = Gtk.Template.Child()
    _editor_scrolledwin = Gtk.Template.Child()
    _render_loading = Gtk.Template.Child()
    _read_only_banner = Gtk.Template.Child()
    _timed_notification = Gtk.Template.Child()
    _toolbar_view = Gtk.Template.Child()
    _top_margin_box = Gtk.Template.Child()

    def __init__(self):
        super().__init__()

        self.__active = False
        self.__note = None
        self.__updating_from_remote = False
        self.__sync_authenticated = False
        self.__buffer_handlers = []
        self.__current_note_handlers = []
        self.__render_view = None
        self.__render_view_added = False
        self.__font_families = {}
        self.__html_generator = None
        self.__focus_mode_action = None
        self.__cancel_action = None

        self.__auto_hide_headerbar = False
        self.__auto_hide_headerbar_fullscreen_only = False
        self.__hiding_headerbar_timeout = None

        self._menu_button.get_popover().add_child(ThemeSelector(), "theme")
        self.__font_size_selector = FontSizeSelector()
        self._menu_button.get_popover().add_child(self.__font_size_selector, "fontsize")

        def update_top_margin_box_height(_view) -> None:
            margin = self._sourceview.get_property("top-margin")
            self._top_margin_box.set_property("height-request", margin)

        self._sourceview.connect("size-allocated", update_top_margin_box_height)

        self._category_header_bar.connect("categories-changed", self.__category_changed)
        self._category_header_bar.connect("abort", self.__abort_category_change)

        self._search_header_bar.connect("resumed", self.__on_search_resumed)
        self._search_header_bar.connect("open-for-replace", self.__on_enter_replace)

        self._render_search_header_bar.connect("resumed", self.__on_search_resumed)

        self._rename_header_bar.connect("renamed", self.__on_title_renamed)
        self._rename_header_bar.connect("cancelled", self.__on_rename_cancelled)

        self._render_edit_button_stack.set_visible(
            iotas.config_manager.get_markdown_render_enabled()
        )

        self.__categories_model = None

        style_manager = Adw.StyleManager.get_default()
        style_manager.connect("notify::dark", self.__on_dark_style_updated)

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.MARKDOWN_SYNTAX_HIGHLIGHTING}",
            self.__on_syntax_highlighting_changed,
        )
        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.EDITOR_THEME}",
            self.__on_theme_changed,
        )
        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.MARKDOWN_RENDER}",
            self.__on_render_enabled_changed,
        )
        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.MARKDOWN_KEEP_WEBKIT_PROCESS}",
            self.__on_keep_webkit_changed,
        )
        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.MARKDOWN_TEX_SUPPORT}",
            self.__on_tex_support_changed,
        )
        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.LINE_LENGTH}", self.__on_line_length_changed
        )

        # Use system setting for monospace font family
        self.__dbus_proxy = Gio.DBusProxy.new_for_bus_sync(
            bus_type=Gio.BusType.SESSION,
            flags=Gio.DBusProxyFlags.NONE,
            info=None,
            name="org.freedesktop.portal.Desktop",
            object_path="/org/freedesktop/portal/desktop",
            interface_name="org.freedesktop.portal.Settings",
            cancellable=None,
        )
        if self.__dbus_proxy is None:
            logging.warning("Unable to establish D-Bus proxy for FreeDesktop.org font setting")
        else:
            self.__load_font_family_from_setting()
            self.__dbus_proxy.connect_object("g-signal", self.__desktop_setting_changed, None)

        if self.__dbus_proxy is not None:
            iotas.config_manager.settings.connect(
                f"changed::{iotas.config_manager.USE_MONOSPACE_FONT}", self.__on_font_family_changed
            )
            iotas.config_manager.settings.connect(
                f"changed::{iotas.config_manager.MARKDOWN_USE_MONOSPACE_FONT}",
                self.__on_font_family_changed,
            )

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.HIDE_EDITOR_HEADERBAR}",
            self.__on_auto_hide_headerbar_changed,
        )
        self.__auto_hide_headerbar = iotas.config_manager.get_hide_editor_headerbar()

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.HIDE_HEADERBAR_WHEN_FULLSCREEN}",
            self.__on_auto_hide_headerbar_changed,
        )
        self.__auto_hide_headerbar_fullscreen_only = (
            iotas.config_manager.get_hide_editor_headerbar_when_fullscreen()
        )

        self.__focus_mode_helper = FocusModeHelper(self._sourceview.get_buffer())

    def setup(self, model: CategoryTreeViewListStore) -> None:
        """Perform initial setup.

        :param CategoryTreeViewListStore model: Tree model
        """
        self.__setup_actions()
        self._category_header_bar.setup(model)
        self._search_header_bar.setup(self._sourceview)
        self._render_search_header_bar.setup()
        self.__font_size_selector.setup()
        self.__refresh_line_length_from_setting()
        window = self.get_root()
        window.connect("notify::fullscreened", self.__on_window_fullscreened)

    def init_note(self, note: Note) -> None:
        """Initialise editing session.

        :param Note note: Note to edit
        """
        self.current_note = note
        buffer = self._sourceview.get_buffer()
        for handler_id in self.__buffer_handlers:
            buffer.disconnect(handler_id)
        self._sourceview.set_visible(True)
        self._sourceview.spellchecker_enabled = False
        buffer.set_text(note.content)

        def delayed_enable_spellchecker():
            self._sourceview.spellchecker_enabled = True

        if iotas.config_manager.get_spelling_enabled():
            GLib.idle_add(delayed_enable_spellchecker)

        self.__buffer_handlers = []
        handler_id = buffer.connect("changed", self.__buffer_changed)
        self.__buffer_handlers.append(handler_id)
        handler_id = buffer.connect_after("insert-text", self.__on_insert_text)
        self.__buffer_handlers.append(handler_id)

        self.__setup_buffer_highlighting()
        self.__setup_note_signals()
        self.__update_title()
        self.__update_dirty()

        self._read_only_banner.set_revealed(note.read_only)
        self._sourceview.set_editable(not note.read_only)
        self.__update_readonly_actions()

        # Handle case of leaflet swiped back to index while renaming or searching
        if self._headerbar_stack.get_visible_child() in (
            self._rename_header_bar,
            self._search_header_bar,
            self._category_header_bar,
            self._render_search_header_bar,
        ):
            self._headerbar_stack.set_visible_child(self._main_header_bar)

        self._sourceview.set_visible(True)
        if self._render_edit_stack.get_visible_child() == self.__render_view:
            self.__toggle_render()

        buffer.place_cursor(buffer.get_start_iter())

        # Set scroll to top
        self._editor_scrolledwin.get_vadjustment().set_value(0)

        self.__update_scheme_and_dark_style()

        self._toolbar_view.set_reveal_top_bars(True)

        self.__check_and_hide_headerbar_after_delay()

        if self.__focus_mode_action.get_state():
            self.__focus_mode_helper.active = True

        # Handle initialising in view mode
        if (
            iotas.config_manager.get_markdown_render_enabled()
            and iotas.config_manager.get_markdown_default_to_render()
        ):
            # Prevent a flash of the editor while loading rendered view
            self._sourceview.set_visible(False)
            self.__toggle_render()

    def close_note(self) -> None:
        """Close the current editing note."""

        if self.__hiding_headerbar_timeout:
            GLib.source_remove(self.__hiding_headerbar_timeout)
            self.__hiding_headerbar_timeout = None

        # During note creation we ignore sanitised titles returning from the server (to avoid
        # replacing a title which is being edited via the first buffer line with something the
        # server returns). To ensure that sanitisation runs we now flag the note dirty on close to
        # ensure sanitisation occurs.
        if self.current_note.title_is_top_line:
            self.current_note.title_is_top_line = False
            self.__note.flag_changed()
            GLib.idle_add(self.emit, "note-modified", self.__note)

        for handler in self.__current_note_handlers:
            self.__note.disconnect(handler)
        self.__current_note_handlers = []

        self.__focus_mode_helper.active = False

        self.current_note = None

        # If cleaning up WebKit process after each render do that now on closing the note.
        # Don't do this for other cases as the switch is visible as the navigation page
        # animates across.
        if not iotas.config_manager.get_markdown_keep_webkit_process():
            if self._render_edit_stack.get_visible_child() == self.__render_view:
                self._sourceview.set_visible(True)
                self.__toggle_render()

    def cancel(self) -> None:
        """Perform a cancel action.

        Action performed will depend on whether currently editing the title.
        """
        if self._headerbar_stack.get_visible_child() in (
            self._rename_header_bar,
            self._category_header_bar,
        ):
            self._headerbar_stack.set_visible_child(self._main_header_bar)
            self._sourceview.grab_focus()
            self.__check_and_hide_headerbar_after_delay()
        elif self._headerbar_stack.get_visible_child() == self._search_header_bar:
            self.__exit_search()
        elif self._headerbar_stack.get_visible_child() == self._render_search_header_bar:
            self.__exit_render_search()
        else:
            self._search_header_bar.disable_actions()
            self._render_search_header_bar.disable_actions()
            self.emit("exit")

    def set_sync_authenticated(self, authenticated: bool) -> None:
        """Set that an authenticated sync session exists.

        :param bool authenticated: Whether authenticated
        """
        self.__sync_authenticated = authenticated

    def focus_textview_if_editing(self) -> None:
        """Grab the focus to the TextView."""
        if self._render_edit_stack.get_visible_child() == self._editor_scrolledwin:
            self._sourceview.grab_focus()

    def update_for_dialog_visibility(self, visible: bool) -> None:
        """Update if dialogs visible, disabling actions.

        :param bool visible: Whether a dialog is visible
        """
        if self.active:
            self.__cancel_action.set_enabled(not visible)

    @GObject.Property(type=bool, default=False)
    def active(self) -> bool:
        return self.__active

    @active.setter
    def active(self, value: bool) -> None:
        self.__active = value
        self.__enable_actions(value)

    @GObject.Property(type=Note, default=None)
    def current_note(self) -> Note:
        return self.__note

    @current_note.setter
    def current_note(self, value: Note) -> None:
        self.__note = value

    @GObject.Property(type=bool, default=False)
    def editing(self) -> bool:
        """Whether editing.

        :returns: Whether editing
        :rtype: bool
        """
        # Using the editor having an active note to determine if
        return self.__note is not None

    @Gtk.Template.Callback()
    def _on_mouse_back_button_click(
        self, _gesture: Gtk.GestureClick, _n_press: int, _x: float, _y: float
    ) -> None:
        self.cancel()

    @Gtk.Template.Callback()
    def _on_editor_single_click(
        self, _gesture: Gtk.GestureSingle, _seq: Optional[Gdk.EventSequence] = None
    ) -> None:
        if self._toolbar_view.get_reveal_top_bars() and self.__headerbar_hiding_configured():
            self.__hide_headerbar()

    @Gtk.Template.Callback()
    def _on_top_margin_entered(
        self, _controller: Gtk.EventControllerMotion, _x: float, _y: float
    ) -> None:
        if not self._toolbar_view.get_reveal_top_bars():
            self.__show_headerbar()

    @Gtk.Template.Callback()
    def _on_top_margin_clicked(
        self, gesture: Gtk.GestureClick, n_press: int, _x: float, _y: float
    ) -> None:
        if not self._toolbar_view.get_reveal_top_bars():
            self.__show_headerbar()

    @Gtk.Template.Callback()
    def _on_key_pressed(
        self,
        controller: Gtk.EventControllerKey,
        keyval: int,
        keycode: int,
        state: Gdk.ModifierType,
    ) -> bool:
        if self._headerbar_stack.get_visible_child() != self._main_header_bar:
            return Gdk.EVENT_PROPAGATE

        if not self.__headerbar_hiding_configured():
            return Gdk.EVENT_PROPAGATE

        # Prevent some keys from resulting in the headerbar re-hiding
        if keyval in (
            Gdk.KEY_Tab,
            Gdk.KEY_ISO_Left_Tab,
            Gdk.KEY_Alt_L,
            Gdk.KEY_Alt_R,
            Gdk.KEY_Meta_L,
            Gdk.KEY_Meta_R,
            Gdk.KEY_Control_L,
            Gdk.KEY_Control_R,
            Gdk.KEY_Shift_L,
            Gdk.KEY_Shift_R,
        ):
            return Gdk.EVENT_PROPAGATE

        if self._toolbar_view.get_reveal_top_bars():
            self.__hide_headerbar()
            self.focus_textview_if_editing()
        return Gdk.EVENT_PROPAGATE

    def __setup_actions(self) -> None:
        action_group = Gio.SimpleActionGroup.new()
        app = Gio.Application.get_default()

        action = Gio.SimpleAction.new("rename")
        action.connect("activate", self.__do_rename)
        action_group.add_action(action)
        app.set_accels_for_action("editor.rename", ["F2"])

        action = Gio.SimpleAction.new("edit-category")
        action.connect("activate", self.__edit_category)
        action_group.add_action(action)
        app.set_accels_for_action("editor.edit-category", ["<Control>e"])

        action = Gio.SimpleAction.new("enter-search")
        action.connect("activate", self.__on_enter_search)
        action_group.add_action(action)
        app.set_accels_for_action("editor.enter-search", ["<Control>f"])

        action = Gio.SimpleAction.new("toggle-render")
        action.connect("activate", self.__on_toggle_render)
        action_group.add_action(action)
        app.set_accels_for_action("editor.toggle-render", ["<Control>d"])

        action = Gio.SimpleAction.new("go-back-or-cancel")
        action.connect("activate", self.__on_cancel)
        action_group.add_action(action)
        self.__cancel_action = action
        app.set_accels_for_action("editor.go-back-or-cancel", ["Escape", "<Alt>Left"])

        action = Gio.SimpleAction.new("clear-category")
        action.connect("activate", self.__on_clear_category)
        action_group.add_action(action)
        app.set_accels_for_action("editor.clear-category", ["<Shift>Delete"])

        action = Gio.SimpleAction.new("create-new-note")
        action.connect("activate", self.__on_create_new_note)
        action_group.add_action(action)
        app.set_accels_for_action("editor.create-new-note", ["<Control>n"])

        action = Gio.SimpleAction.new("export-note")
        action.connect("activate", self.__on_export_note)
        action_group.add_action(action)
        app.set_accels_for_action("editor.export-note", ["<Control><Shift>s"])

        action = Gio.SimpleAction.new("delete-note")
        action.connect("activate", self.__on_delete_note)
        action_group.add_action(action)

        action = Gio.SimpleAction.new("increase-line-length")
        action.connect("activate", self.__on_increase_line_length)
        action_group.add_action(action)
        app.set_accels_for_action("editor.increase-line-length", ["<Control>Up"])

        action = Gio.SimpleAction.new("decrease-line-length")
        action.connect("activate", self.__on_decrease_line_length)
        action_group.add_action(action)
        app.set_accels_for_action("editor.decrease-line-length", ["<Control>Down"])

        action = Gio.SimpleAction.new("increase-font-size")
        action.connect("activate", self.__on_increase_font_size)
        action_group.add_action(action)
        app.set_accels_for_action("editor.increase-font-size", ["<Control>plus", "<Control>equal"])

        action = Gio.SimpleAction.new("decrease-font-size")
        action.connect("activate", self.__on_decrease_font_size)
        action_group.add_action(action)
        app.set_accels_for_action(
            "editor.decrease-font-size", ["<Control>minus", "<Control>underscore"]
        )

        action = Gio.SimpleAction.new("reset-font-size")
        action.connect("activate", self.__on_reset_font_size)
        action_group.add_action(action)
        app.set_accels_for_action("editor.reset-font-size", ["<Control>0"])

        action = Gio.SimpleAction.new_stateful("focus-mode", None, GLib.Variant("b", False))
        action.connect("change-state", self.__on_focus_mode_toggle)
        action_group.add_action(action)
        app.set_accels_for_action("editor.focus-mode", ["<Control><Shift>f"])
        self.__focus_mode_action = action

        app.get_active_window().insert_action_group("font-size-selector", action_group)
        self.__action_group = action_group

        self.__action_group = action_group
        app.get_active_window().insert_action_group("editor", action_group)

    def __enable_actions(self, enabled: bool) -> None:
        """Toggle whether editor actions are enabled.

        :param bool enabled: New value
        """
        actions = self.__action_group.list_actions()
        for action in actions:
            self.__action_group.lookup_action(action).set_enabled(enabled)
        # Disable specific actions for read-only
        if enabled and self.__note is not None and self.__note.read_only:
            self.__update_readonly_actions()

    def __setup_buffer_highlighting(self) -> None:
        buffer = self._sourceview.get_buffer()
        if iotas.config_manager.get_markdown_syntax_hightlighting_enabled():
            language = GtkSource.LanguageManager.get_default().get_language("iotas-markdown")
            buffer.set_language(language)
            buffer.set_highlight_syntax(True)
        else:
            buffer.set_language(None)
            buffer.set_highlight_syntax(False)

    def __on_insert_text(
        self, _buffer: GtkSource.Buffer, location: Gtk.TextIter, char: str, length: int
    ) -> None:
        if self.__updating_from_remote:
            return

    def __on_checkbox_toggled(self, _obj: GObject.Object, line: int, new_value: bool) -> None:
        buffer = self._sourceview.get_buffer()

        def get_iter_inside_checkbox(line):
            (success, line_start) = buffer.get_iter_at_line(line)
            if not success:
                return None

            line_end = line_start.copy()
            line_end.forward_to_line_end()
            match = line_start.forward_search("[", Gtk.TextSearchFlags.VISIBLE_ONLY, line_end)
            if match is None:
                logging.warn(f"Couldn't find checkbox on line {line}")
                return None

            (_, match_end) = match
            return match_end

        match_end = get_iter_inside_checkbox(line)
        if match_end is None:
            return

        buffer.insert(match_end, "x" if new_value else " ")

        match_end = get_iter_inside_checkbox(line)
        if match_end is None:
            return
        match_end.forward_char()
        delete_end = match_end.copy()
        delete_end.forward_char()
        buffer.delete(match_end, delete_end)

    def __on_webview_loaded(self, _obj: GObject.Object) -> None:
        if not self.__render_view_added:
            # Attempt to reduce time spent with blank view visible
            self.__render_view.show()
            self._render_edit_stack.add_child(self.__render_view)
            self.__render_view_added = True

            gesture = Gtk.GestureSingle()
            gesture.connect("end", self.__on_render_single_click)
            self.__render_view.add_controller(gesture)

            controller = Gtk.EventControllerKey()
            controller.connect("key-pressed", self.__on_render_key_pressed)
            self.__render_view.add_controller(controller)

        if self.__render_view.exporting:
            self.__render_view.exporting = False
        else:
            self._render_edit_button_stack.set_visible_child(self._edit_button)
            self._render_edit_stack.set_visible_child(self.__render_view)

    def __on_enter_search(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        self.__enter_search(False, False)

    def __on_search_resumed(self, _object: GObject.Object) -> None:
        self.__enter_search(True, False)

    def __on_enter_replace(self, _object: GObject.Object) -> None:
        self.__enter_search(False, True)

    def __on_title_renamed(self, _obj: GObject.Object, new_title: str) -> None:
        if self.__note.title != new_title:
            self.__note.title = new_title
            if self.__note.title_is_top_line:
                self.__note.title_is_top_line = False
            self.__note.flag_changed()
            # Always called on UI thread
            self.emit("note-modified", self.__note)
        self._headerbar_stack.set_visible_child(self._main_header_bar)
        self.__check_and_hide_headerbar_after_delay()
        self._sourceview.grab_focus()

    def __on_rename_cancelled(self, _obj: GObject.Object) -> None:
        self._headerbar_stack.set_visible_child(self._main_header_bar)
        self.__check_and_hide_headerbar_after_delay()
        self._sourceview.grab_focus()

    def __on_toggle_render(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        self.__toggle_render()

    def __on_cancel(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        self.cancel()

    def __on_clear_category(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        if not self._toolbar_view.get_reveal_top_bars():
            return
        if self._headerbar_stack.get_visible_child() == self._category_header_bar:
            self._category_header_bar.clear_and_apply()

    def __on_export_note(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        if self.__html_generator is None:
            from iotas.html_generator import HtmlGenerator

            self.__html_generator = HtmlGenerator()
            self.__push_font_updates()

        window = ExportWindow(self.__note, self.__html_generator, self.__ensure_webkit_initialised)
        window.present(self)

    def __on_delete_note(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        self.emit("note-deleted", self.__note)

    def __on_syntax_highlighting_changed(self, _settings: Gio.Settings, _key: str) -> None:
        if self.active:
            self.__setup_buffer_highlighting()

    def __on_theme_changed(self, _settings: Gio.Settings, _key: str) -> None:
        if self.active and iotas.config_manager.get_markdown_syntax_hightlighting_enabled():
            self.__update_scheme_and_dark_style()

    def __on_render_enabled_changed(self, _settings: Gio.Settings, _key: str) -> None:
        enabled = iotas.config_manager.get_markdown_render_enabled()
        self._render_edit_button_stack.set_visible(enabled)
        if (
            not enabled
            and self.active
            and self._render_edit_stack.get_visible_child() == self.__render_view
        ):
            self.__toggle_render(force=True)

    def __on_keep_webkit_changed(self, _settings: Gio.Settings, _key: str) -> None:
        keep = iotas.config_manager.get_markdown_keep_webkit_process()
        if not keep and (
            not self.active
            or self._render_edit_stack.get_visible_child() == self._editor_scrolledwin
        ):
            if self.__render_view is not None:
                logging.debug("Calling terminate for WebKit process")
                self.__render_view.terminate_web_process()

    def __on_tex_support_changed(self, _settings: Gio.Settings, _key: str) -> None:
        if self.active and self._render_edit_stack.get_visible_child() == self.__render_view:
            self.__render_view.render_retaining_scroll(self.__note, None)

    def __on_line_length_changed(self, _settings: Gio.Settings, _key: str) -> None:
        length = iotas.config_manager.get_line_length()
        logging.debug(f"Line length now {length}px")
        self.__refresh_line_length_from_setting()

    def __on_increase_line_length(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        length = iotas.config_manager.get_line_length() + self.LINE_LENGTH_STEP
        if length < self.get_width():
            iotas.config_manager.set_line_length(length)
            self.__notify_line_length_change(length)

    def __on_decrease_line_length(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        length = iotas.config_manager.get_line_length() - self.LINE_LENGTH_STEP
        if length >= self.LINE_LENGTH_STEP:
            iotas.config_manager.set_line_length(length)
            self.__notify_line_length_change(length)

    def __on_font_family_changed(self, _settings: Gio.Settings, _key: str) -> None:
        self.__load_font_family_from_setting()

    def __on_auto_hide_headerbar_changed(self, _settings: Gio.Settings, _key: str) -> None:
        self.__auto_hide_headerbar = iotas.config_manager.get_hide_editor_headerbar()
        self.__auto_hide_headerbar_fullscreen_only = (
            iotas.config_manager.get_hide_editor_headerbar_when_fullscreen()
        )
        hiding = self.__headerbar_hiding_configured()
        if hiding and self._toolbar_view.get_reveal_top_bars():
            self.__hide_headerbar()
        elif not hiding and not self._toolbar_view.get_reveal_top_bars():
            self.__show_headerbar()

    def __on_window_fullscreened(self, window: Gtk.Window, _param: GObject.ParamSpec) -> None:
        fullscreen_hide = iotas.config_manager.get_hide_editor_headerbar_when_fullscreen()
        if window.is_fullscreen():
            if fullscreen_hide and self._toolbar_view.get_reveal_top_bars():
                self.__hide_headerbar()
                if self.current_note is not None:
                    self.focus_textview_if_editing()
        else:
            general_hide = iotas.config_manager.get_hide_editor_headerbar()
            if fullscreen_hide and not general_hide:
                self.__show_headerbar()

    def __on_render_single_click(
        self, _gesture: Gtk.GestureSingle, _seq: Optional[Gdk.EventSequence] = None
    ) -> None:
        if self._toolbar_view.get_reveal_top_bars() and self.__headerbar_hiding_configured():
            self.__hide_headerbar()

    def __on_render_key_pressed(
        self,
        controller: Gtk.EventControllerKey,
        keyval: int,
        keycode: int,
        state: Gdk.ModifierType,
    ) -> bool:
        if self._headerbar_stack.get_visible_child() != self._main_header_bar:
            return Gdk.EVENT_PROPAGATE

        result = self._render_search_header_bar.check_if_starting(controller, keyval, state)
        if result:
            self._render_search_header_bar.enter(self.__render_view, False, type_to_search=True)
            self._headerbar_stack.set_visible_child(self._render_search_header_bar)
            self.__show_headerbar()
        return result

    def __notify_line_length_change(self, length: int) -> None:
        # Translators: Description, notification, {0} is a number
        msg = _("Line length now {0}px").format(length)
        self.__notify_setting_change(msg)

    def __notify_font_size(self) -> None:
        size = iotas.config_manager.get_font_size()
        # Translators: Description, notification, {0} is a number
        msg = _("Font size now {0}pt").format(size)
        self.__notify_setting_change(msg)

    def __notify_setting_change(self, msg: str) -> None:
        self._timed_notification.show(
            msg,
            self.SETTING_CHANGE_NOTIFICATION_DURATION,
        )

    def __on_note_update_from_sync(
        self,
        _obj: GObject.Object,
        _value: GObject.ParamSpec,
    ) -> None:
        self.__update_title()

    def __on_note_dirty_changed(
        self,
        _obj: GObject.Object,
        _value: GObject.ParamSpec,
    ) -> None:
        self.__update_dirty()

    def __on_dark_style_updated(
        self,
        _obj: GObject.Object,
        _value: GObject.ParamSpec,
    ) -> None:
        self.__update_scheme_and_dark_style()

    def __on_increase_font_size(
        self,
        _obj: GObject.Object,
        _value: GObject.ParamSpec,
    ) -> None:
        if self.__font_size_selector.increase():
            self.__notify_font_size()

    def __on_decrease_font_size(
        self,
        _obj: GObject.Object,
        _value: GObject.ParamSpec,
    ) -> None:
        if self.__font_size_selector.decrease():
            self.__notify_font_size()

    def __on_reset_font_size(
        self,
        _obj: GObject.Object,
        _value: GObject.ParamSpec,
    ) -> None:
        self.__font_size_selector.reset()
        self.__notify_font_size()

    def __on_focus_mode_toggle(self, action: Gio.SimpleAction, state: bool) -> None:
        action.set_state(state)
        self.__focus_mode_helper.active = state

    def __on_create_new_note(
        self,
        _obj: GObject.Object,
        _value: GObject.ParamSpec,
    ) -> None:
        self.emit("exit")

        content = ""
        buffer = self._sourceview.get_buffer()
        if buffer.get_has_selection():
            (begin, end) = buffer.get_selection_bounds()
            content = buffer.get_slice(begin, end, False)
        param = GLib.Variant("s", content)

        self.activate_action("index.create-note-with-content", param)

    def __init_webkit(self, exporting: bool) -> None:
        # Lazy import module in an attempt to reduce startup performance hit on devices with
        # render view disabled
        from iotas.markdown_render_view import MarkdownRenderView

        if self.__html_generator is None:
            from iotas.html_generator import HtmlGenerator

            self.__html_generator = HtmlGenerator()

        self.__render_view = MarkdownRenderView()
        self.__render_view.connect("checkbox-toggled", self.__on_checkbox_toggled)
        self.__render_view.connect("loaded", self.__on_webview_loaded)
        self.__render_view.setup(self.__html_generator)

        self.__push_font_updates()

        self._render_search_header_bar.bind_property(
            "active", self.__render_view, "searching", GObject.BindingFlags.SYNC_CREATE
        )

        # Allow using the mouse back button to return to index, typically button #8
        def callback(gesture: Gtk.GestureClick, _n_press: int, _x: float, _y: float) -> None:
            if gesture.get_current_button() == 8:
                self.cancel()

        add_mouse_button_accel(self.__render_view, callback)

    def __setup_note_signals(self) -> None:
        self.__current_note_handlers = []

        handler_id = self.__note.connect("notify::dirty", self.__on_note_dirty_changed)
        self.__current_note_handlers.append(handler_id)
        handler_id = self.__note.connect("notify::title", self.__on_note_update_from_sync)
        self.__current_note_handlers.append(handler_id)

        handler_id = self.__note.connect("remote-content-update", self.__update_content_from_remote)
        self.__current_note_handlers.append(handler_id)

    def __get_content(self) -> None:
        buffer = self._sourceview.get_buffer()
        (start, end) = buffer.get_bounds()
        return buffer.get_text(start, end, True)

    def __buffer_changed(self, _buffer: GtkSource.Buffer) -> None:
        if self.__updating_from_remote:
            return

        self.__note.content = self.__get_content()
        if self.__note.title_is_top_line:
            self.__note.update_title_from_top_line()
        self.__note.flag_changed()

        GLib.idle_add(self.emit, "note-modified", self.__note)

    def __update_title(self) -> None:
        self._title_label.set_label(self.__note.title)

    def __update_dirty(self) -> None:
        show_dirty = self.__sync_authenticated and self.__note.dirty and self.__note.title != ""
        self._is_dirty.set_visible(show_dirty)

    def __update_content_from_remote(self, _note: Note) -> None:
        if self.__note and not self.__note.dirty:
            self.set_sensitive(False)
            self.__updating_from_remote = True
            editor_scroll_value = self._editor_scrolledwin.get_vadjustment().get_value()
            buffer = self._sourceview.get_buffer()
            insert_iter = buffer.get_iter_at_mark(buffer.get_insert())
            pre_offset = insert_iter.get_offset()
            buffer.set_text(self.__note.content)

            def delayed_editor_scroll_and_cursor_reset():
                self._editor_scrolledwin.get_vadjustment().set_value(editor_scroll_value)
                if len(self.__note.content) > pre_offset:
                    buffer.place_cursor(buffer.get_iter_at_offset(pre_offset))

            GLib.idle_add(delayed_editor_scroll_and_cursor_reset)
            if self._render_edit_stack.get_visible_child() == self.__render_view:
                self.__render_view.render_retaining_scroll(self.__note, None)
            self.__updating_from_remote = False
            self.set_sensitive(True)

    def __get_editor_scroll_position(self) -> None:
        adj = self._editor_scrolledwin.get_vadjustment()
        value = adj.get_value()
        upper = adj.get_upper()
        page_size = adj.get_page_size()
        if upper > page_size:
            return value / (upper - page_size)
        else:
            return 0

    def __set_editor_scroll_position(self, position: float) -> None:
        adj = self._editor_scrolledwin.get_vadjustment()
        upper = adj.get_upper()
        page_size = adj.get_page_size()
        adj.set_value((upper - page_size) * position)

    def __edit_category(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        """Show the category editing."""
        if not self._toolbar_view.get_reveal_top_bars():
            self.__show_headerbar()
        if self._headerbar_stack.get_visible_child() == self._category_header_bar:
            return
        elif self._headerbar_stack.get_visible_child() == self._search_header_bar:
            self.__exit_search()
        elif self._headerbar_stack.get_visible_child() == self._render_search_header_bar:
            self.__exit_render_search()
        self._headerbar_stack.set_visible_child(self._category_header_bar)
        self._category_header_bar.activate([self.__note])

    def __do_rename(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        """Show the title rename entry."""
        if self.__note.read_only:
            return
        if not self._toolbar_view.get_reveal_top_bars():
            self.__show_headerbar()
        if self._headerbar_stack.get_visible_child() == self._rename_header_bar:
            return
        elif self._headerbar_stack.get_visible_child() == self._search_header_bar:
            self.__exit_search()
        elif self._headerbar_stack.get_visible_child() == self._render_search_header_bar:
            self.__exit_render_search()
        self._headerbar_stack.set_visible_child(self._rename_header_bar)
        self._rename_header_bar.enter(self.__note.title)

    def __enter_search(self, resuming: bool, for_replace: bool) -> None:
        """Start searching within note."""
        if self._render_edit_stack.get_visible_child() == self._editor_scrolledwin:
            self._search_header_bar.enter(resuming, for_replace)
            self._headerbar_stack.set_visible_child(self._search_header_bar)
        else:
            self._render_search_header_bar.enter(self.__render_view, resuming, type_to_search=False)
            self._headerbar_stack.set_visible_child(self._render_search_header_bar)

        if not self._toolbar_view.get_reveal_top_bars():
            self.__show_headerbar()

    def __exit_search(self) -> None:
        """Stop searching within note."""
        self._headerbar_stack.set_visible_child(self._main_header_bar)
        self.__check_and_hide_headerbar_after_delay()
        self._sourceview.grab_focus()
        self._search_header_bar.exit()

    def __exit_render_search(self) -> None:
        self._headerbar_stack.set_visible_child(self._main_header_bar)
        self.__check_and_hide_headerbar_after_delay()
        self._render_search_header_bar.exit()

    def __toggle_render(self, force: bool = False) -> None:
        """Toggle whether showing rendered view."""

        if not iotas.config_manager.get_markdown_render_enabled() and not force:
            return

        if self._render_edit_stack.get_visible_child() == self._editor_scrolledwin:
            if self._headerbar_stack.get_visible_child() == self._search_header_bar:
                self.__exit_search()

            if self.__render_view_added:
                self.__continue_loading_webkit()
            else:
                self._render_edit_stack.set_visible_child(self._render_loading)
                # Push the WebKit initialisation down the road a little, giving the loading view
                # time to initialise. A cleaner way to do this will be welcomed.
                GLib.timeout_add(50, self.__continue_loading_webkit)
        else:
            if self._headerbar_stack.get_visible_child() == self._render_search_header_bar:
                self.__exit_render_search()
            self._render_edit_button_stack.set_visible_child(self._render_button)
            self.__set_editor_scroll_position(self.__render_view.scroll_position)
            self._sourceview.set_visible(True)
            self._render_edit_stack.set_visible_child(self._editor_scrolledwin)
            self.__check_and_terminate_webkit_process()
            self.__check_and_hide_headerbar_after_delay()
            self.focus_textview_if_editing()
            self._render_search_header_bar.disable_actions()
            self.__focus_mode_action.set_enabled(True)

    def __check_and_terminate_webkit_process(self) -> None:
        if (
            not iotas.config_manager.get_markdown_render_enabled()
            or not iotas.config_manager.get_markdown_keep_webkit_process()
        ):
            logging.debug("Terminating WebKit process")
            self.__render_view.terminate_web_process()
            # Reduce flash when next loading render view
            self.__render_view.hide()
            self._render_edit_stack.remove(self.__render_view)
            self.__render_view_added = False

    def __category_changed(self, _obj: CategoryHeaderBar) -> None:
        changeset = self._category_header_bar.take_changeset()
        if len(changeset) > 0:
            (note, old_category) = changeset[0]
            self.emit("category-changed", note, old_category)
        self._headerbar_stack.set_visible_child(self._main_header_bar)
        self.__check_and_hide_headerbar_after_delay()
        self._sourceview.grab_focus()

    def __abort_category_change(self, _obj: CategoryHeaderBar) -> None:
        self._headerbar_stack.set_visible_child(self._main_header_bar)
        self.__check_and_hide_headerbar_after_delay()
        self._sourceview.grab_focus()

    def __update_readonly_actions(self):
        editable = not not self.__note.read_only
        for action in ("rename", "edit-category", "clear-category", "delete-note"):
            self.__action_group.lookup_action(action).set_enabled(editable)

    def __update_scheme_and_dark_style(self) -> None:
        """Sync. dark style change to editor style."""
        buffer = self._sourceview.get_buffer()
        if not buffer:
            return

        style_manager = Adw.StyleManager.get_default()
        style_scheme_manager = GtkSource.StyleSchemeManager.get_default()

        scheme_id = iotas.config_manager.get_editor_theme()
        scheme_id = f"{scheme_id}-dark" if style_manager.get_dark() else scheme_id
        scheme = style_scheme_manager.get_scheme(scheme_id)
        buffer.set_style_scheme(scheme)

    def __refresh_line_length_from_setting(self) -> None:
        length = iotas.config_manager.get_line_length()
        self._sourceview.set_property("maximum-width", length)
        self._sourceview.queue_resize()

    def __desktop_setting_changed(
        self, _sender_name: str, _signal_name: str, _parameters: str, data: GLib.Variant
    ) -> None:
        if _parameters != "SettingChanged":
            return
        (path, setting_name, value) = data
        if path == "org.gnome.desktop.interface" and setting_name in self.FONT_SETTING_KEYS:
            if value.strip() != "":
                font_description = Pango.font_description_from_string(value)
                self.__font_families[setting_name] = font_description.get_family()
                self.__push_font_updates()

    def __process_desktop_settings(self, variant: GLib.Variant) -> None:
        if variant.get_type_string() != "(a{sa{sv}})":
            return
        for v in variant:
            for key, value in v.items():
                if key == "org.gnome.desktop.interface":
                    for font_key in self.FONT_SETTING_KEYS:
                        if font_key in value:
                            font_description = Pango.font_description_from_string(value[font_key])
                            self.__font_families[font_key] = font_description.get_family()
        self.__push_font_updates()

    def __push_font_updates(self) -> None:
        font_setting = self.__fetch_editor_font_setting_name()
        if font_setting in self.__font_families:
            family = self.__font_families[font_setting]
            self._sourceview.update_font_family(family)

        if self.__render_view is not None:
            font_setting = self.__fetch_render_font_setting_name()
            if font_setting in self.__font_families:
                family = self.__font_families[font_setting]
                if self.__html_generator is not None:
                    self.__html_generator.update_font_family(family)
                self.__render_view.update_style()

    def __fetch_editor_font_setting_name(self):
        return (
            "monospace-font-name"
            if iotas.config_manager.get_use_monospace_font()
            else "document-font-name"
        )

    def __fetch_render_font_setting_name(self):
        return (
            "monospace-font-name"
            if iotas.config_manager.get_markdown_use_monospace_font()
            else "document-font-name"
        )

    def __load_font_family_from_setting(self) -> None:
        if self.__dbus_proxy is None:
            return
        try:
            variant = self.__dbus_proxy.call_sync(
                method_name="ReadAll",
                parameters=GLib.Variant("(as)", ("org.gnome.desktop.*",)),
                flags=Gio.DBusCallFlags.NO_AUTO_START,
                timeout_msec=-1,
                cancellable=None,
            )
        except GLib.GError as e:
            logging.warning("Unable to access D-Bus FreeDesktop.org font family setting: %s", e)
            return
        self.__process_desktop_settings(variant)
        self.__push_font_updates()

    def __headerbar_hiding_configured(self) -> None:
        hiding = self.__auto_hide_headerbar
        if not hiding and self.__auto_hide_headerbar_fullscreen_only:
            hiding = self.get_root().is_fullscreen()
        return hiding

    def __show_headerbar(self) -> None:
        """Show the headerbar."""
        self._toolbar_view.set_reveal_top_bars(True)

    def __check_and_hide_headerbar_after_delay(self) -> None:
        if not self.__headerbar_hiding_configured():
            return
        if not self._toolbar_view.get_reveal_top_bars():
            return

        def hide():
            self.__hide_headerbar()
            self.__hiding_headerbar_timeout = None

        self.__hiding_headerbar_timeout = GLib.timeout_add(self.HEADERBAR_HIDE_DELAY, hide)

    def __hide_headerbar(self) -> None:
        """Hide the headerbar."""
        if self.__hiding_headerbar_timeout:
            GLib.source_remove(self.__hiding_headerbar_timeout)
            self.__hiding_headerbar_timeout = None

        if self._headerbar_stack.get_visible_child() != self._main_header_bar:
            return
        elif self._menu_button.get_popover().get_visible():
            return

        if self._toolbar_view.get_reveal_top_bars():
            self._toolbar_view.set_reveal_top_bars(False)
            self.focus_textview_if_editing()

    def __ensure_webkit_initialised(self, exporting: bool) -> None:
        if self.__render_view is None:
            self.__init_webkit(exporting)
        return self.__render_view

    def __continue_loading_webkit(self) -> None:
        self.__ensure_webkit_initialised(exporting=False)

        self.__render_view.render(
            self.__note,
            None,
            self.__get_editor_scroll_position(),
        )
        self._search_header_bar.disable_actions()
        self.__focus_mode_action.set_enabled(False)
