# SPDX-FileCopyrightText: 2022 Chris Heywood
#
# SPDX-License-Identifier: GPL-3.0-or-later

from gettext import gettext as _
import gi

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

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

from dataclasses import dataclass
import logging
from typing import Any, Optional
import webbrowser

from iotas.attachment import Attachment
from iotas.category_header_bar import CategoryHeaderBar
from iotas.category_treeview_list_store import CategoryTreeViewListStore
from iotas.config_manager import ConfigManager, HeaderBarVisibility
import iotas.const as const
from iotas.editor_rename_header_bar import EditorRenameHeaderBar
from iotas.editor_search_header_bar import EditorSearchHeaderBar
from iotas.editor_text_view import EditorTextView
from iotas.export_dialog import ExportDialog
from iotas.focus_mode_helper import FocusModeHelper
from iotas.font_size_selector import FontSizeSelector
from iotas.formatting_header_bar import FormattingHeaderBar
from iotas.note import Note
from iotas.outline_dialog import OutlineDialog
from iotas.outline_generator import OutlineHeading, OutlineGenerator
from iotas.render_search_header_bar import RenderSearchHeaderBar
from iotas.sync_manager import SyncManager
from iotas.text_utils import (
    parse_any_url_at_iter,
    parse_markdown_automatic_link,
    parse_markdown_inline_link,
)
from iotas.theme_selector import ThemeSelector
from iotas.ui_utils import add_mouse_button_accel, is_likely_mobile_device


@dataclass
class EditorSessionDetails:
    note: Note
    scroll_position: float
    cursor_position: int
    direct_switching: Optional[bool] = None


@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
    TOAST_DURATION = 3.0
    HEADERBAR_HIDE_DELAY = 2000
    MARGIN_BELOW_CURSOR = 22

    _top_bar_revealer: Gtk.Revealer = Gtk.Template.Child()
    _bottom_bar_revealer: Gtk.Revealer = Gtk.Template.Child()

    _title_label: Gtk.Label = Gtk.Template.Child()
    _is_dirty: Gtk.Label = Gtk.Template.Child()
    _headerbar_stack: Gtk.Stack = Gtk.Template.Child()
    _main_header_bar: Adw.HeaderBar = Gtk.Template.Child()
    _rename_header_bar: EditorRenameHeaderBar = Gtk.Template.Child()
    _search_header_bar: EditorSearchHeaderBar = Gtk.Template.Child()
    _category_header_bar: CategoryHeaderBar = Gtk.Template.Child()
    _formatting_header_bar: FormattingHeaderBar = Gtk.Template.Child()
    _render_search_header_bar: RenderSearchHeaderBar = Gtk.Template.Child()

    _menu_button: Gtk.MenuButton = Gtk.Template.Child()
    _title_button: Gtk.Button = Gtk.Template.Child()
    _render_edit_stack: Gtk.Stack = Gtk.Template.Child()
    _render_edit_button_stack: Gtk.Stack = Gtk.Template.Child()
    _render_button: Gtk.Button = Gtk.Template.Child()
    _edit_button: Gtk.Button = Gtk.Template.Child()
    _sourceview: EditorTextView = Gtk.Template.Child()
    _editor_scrolledwin: Gtk.ScrolledWindow = Gtk.Template.Child()
    _read_only_image: Gtk.Image = Gtk.Template.Child()
    _render_loading: Adw.StatusPage = Gtk.Template.Child()
    _toast_overlay: Adw.ToastOverlay = Gtk.Template.Child()

    def __init__(self) -> None:
        super().__init__()

        self.__active = False
        self.__note: Optional[Note] = None
        self.__note_had_edit = False
        self.__updating_from_remote = False
        self.__sync_authenticated = False
        self.__buffer_handlers: list[int] = []
        self.__current_note_handlers: list[int] = []
        # Any types to accommodate for lazy loading
        self.__render_view: Optional[Any] = None
        self.__html_generator: Optional[Any] = None
        self.__focus_mode_action: Gio.SimpleAction
        self.__cancel_action: Gio.SimpleAction
        self.__previous_sessions: list[EditorSessionDetails] = []
        self.__outline_dialog: Optional[OutlineDialog] = None
        self.__hiding_bars_timeout: Optional[int] = None
        self.__pointer_position: Graphene.Point = Graphene.Point.zero()

        self.__outline_generator = OutlineGenerator()

        self.__config_manager = ConfigManager.get_default()

        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")

        self._category_header_bar.connect("categories-changed", self.__on_category_changed)
        self._category_header_bar.connect("abort", self.__on_abort_category_change)

        self._search_header_bar.connect(
            "resumed", lambda _o: self.__enter_search(resuming=True, for_replace=False)
        )
        self._search_header_bar.connect(
            "open-for-replace", lambda _o: self.__enter_search(resuming=False, for_replace=True)
        )
        self._search_header_bar.bind_property(
            "replace-visible",
            self._sourceview,
            "accommodate-double-header-bar",
            GObject.BindingFlags.SYNC_CREATE,
        )

        self._render_search_header_bar.connect(
            "resumed", lambda _o: self.__enter_search(resuming=True, for_replace=False)
        )

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

        self._formatting_header_bar.connect(
            "height-change", lambda _o: self.__update_toolbar_underlay_margins()
        )

        self._render_edit_button_stack.set_visible(self.__config_manager.markdown_render_enabled)

        self.__categories_model = None

        style_manager = Adw.StyleManager.get_default()
        style_manager.connect("notify::dark", lambda _o, _v: self.__update_scheme_and_dark_style())
        style_manager.connect(
            "notify::high-contrast", lambda _o, _v: self.__update_scheme_and_dark_style()
        )

        self.__config_manager.connect_changed(
            ConfigManager.MARKDOWN_DETECT_SYNTAX,
            self.__on_detect_syntax_changed,
        )
        self.__config_manager.connect_changed(
            ConfigManager.EDITOR_THEME,
            self.__on_theme_changed,
        )
        self.__config_manager.connect_changed(
            ConfigManager.MARKDOWN_RENDER,
            self.__on_render_enabled_changed,
        )
        self.__config_manager.connect_changed(
            ConfigManager.MARKDOWN_KEEP_WEBKIT_PROCESS,
            self.__on_keep_webkit_changed,
        )
        self.__config_manager.connect_changed(
            ConfigManager.MARKDOWN_TEX_SUPPORT,
            self.__on_tex_support_changed,
        )
        self.__config_manager.connect_changed(
            ConfigManager.LINE_LENGTH,
            self.__refresh_line_length_from_setting,
        )

        self.__push_font_updates()
        style_manager.connect(
            "notify::monospace-font-name", lambda _o, _v: self.__push_font_updates()
        )
        style_manager.connect(
            "notify::document-font-name", lambda _o, _v: self.__push_font_updates()
        )
        self.__config_manager.connect_changed(
            ConfigManager.USE_MONOSPACE_FONT,
            self.__push_font_updates,
        )
        self.__config_manager.connect_changed(
            ConfigManager.MARKDOWN_USE_MONOSPACE_FONT,
            self.__push_font_updates,
        )

        self.__config_manager.connect_changed(
            ConfigManager.EDITOR_FORMATTING_BAR_VISIBILTY,
            self.__update_for_formatting_bar_setting_change,
        )
        self.__cache_formatting_bar_visibility()

        self.__config_manager.connect_changed(
            ConfigManager.EDITOR_HEADER_BAR_VISIBILTY,
            self.__update_for_header_bar_setting_change,
        )
        self.__cache_header_bar_visibility()

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

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

        :param CategoryTreeViewListStore model: Tree model
        :param SyncManager sync_manager: Sync manager
        """
        self.__setup_actions()
        self._category_header_bar.setup(model)
        self.__sync_manager = sync_manager

        self._search_header_bar.setup(self._sourceview)
        self._render_search_header_bar.setup()
        self.__font_size_selector.setup()
        self.__refresh_line_length_from_setting()
        self._formatting_header_bar.setup(self._sourceview)

        window = self.get_root()
        window.connect("notify::fullscreened", self.__on_window_fullscreened)
        self._sourceview.setup()

    def init_note(self, note: Note, restore_session: Optional[EditorSessionDetails]) -> None:
        """Initialise editing session.

        :param Note note: Note to edit
        :param EditorSessionDetails restore_session: Session to restore, optional
        """
        self.current_note = note
        self.note_had_edit = False
        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
        self._formatting_header_bar.prepare()
        buffer.set_text(note.content)

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

        if self.__config_manager.spelling_enabled:
            GLib.idle_add(delayed_enable_spellchecker)

        self.__buffer_handlers = []
        handler_id = buffer.connect("changed", lambda _o: self.__on_buffer_changed())
        self.__buffer_handlers.append(handler_id)

        self.__setup_syntax_detection()
        self.__setup_note_signals()
        self.__update_title()
        self.__update_dirty()

        self._read_only_image.set_visible(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)

        if self._render_edit_stack.get_visible_child() == self.__render_view:
            self.__toggle_render()
        else:
            # This only sets the widget visibility; there's logic below which determines whether,
            # based on configuration, the bottom bar is shown
            self._formatting_header_bar.set_visible(True)

        # By popping this note off our previous sessions it allows returning from the index to
        # switching between two notes
        self.__flush_note_from_previous_sessions(note)

        if restore_session:
            buffer = self._sourceview.get_buffer()
            cursor_iter = buffer.get_iter_at_offset(restore_session.cursor_position)
            scroll_position = restore_session.scroll_position
            GLib.idle_add(self._editor_scrolledwin.get_vadjustment().set_value, scroll_position)
        else:
            # Set scroll to top
            cursor_iter = buffer.get_start_iter()
            scroll_position = 0
        self._editor_scrolledwin.get_vadjustment().set_value(scroll_position)

        buffer.place_cursor(cursor_iter)

        self.__update_scheme_and_dark_style()

        if not (restore_session and restore_session.direct_switching):
            self._top_bar_revealer.set_reveal_child(True)
            if (
                self.__config_manager.markdown_detect_syntax
                and self.__formatting_bar_visibility_setting != HeaderBarVisibility.DISABLED
            ):
                self._bottom_bar_revealer.set_reveal_child(True)
            elif self._bottom_bar_revealer.get_reveal_child():
                self._bottom_bar_revealer.set_reveal_child(False)
            GLib.idle_add(self.__update_toolbar_underlay_margins)

            self.__check_and_hide_bars_after_delay()

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

        # Handle initialising in view mode
        if (
            self.__config_manager.markdown_render_enabled
            and self.__config_manager.markdown_default_to_render
            and note.content.strip() != ""
        ):
            # Prevent a flash of the editor while loading rendered view
            self._sourceview.set_visible(False)
            # The idle avoids locked high CPU usage after first render
            GLib.idle_add(self.__toggle_render)

        if is_likely_mobile_device():
            if self.__config_manager.editor_formatting_bar_visibility in (
                HeaderBarVisibility.AUTO_HIDE,
                HeaderBarVisibility.AUTO_HIDE_FULLSCREEN_ONLY,
            ):
                logging.warning("Auto hiding currently provides a poor experience on mobile")

        if restore_session:
            self.__restore_session_location(restore_session)

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

        self.__close_any_header_bar(after_exit=True)

        self.__store_session_details()

        if self.__outline_dialog:
            self.__outline_dialog.close()

        if self.current_note.title_is_top_line:
            self.current_note.title_is_top_line = False

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

        self.__focus_mode_helper.active = False
        self._bottom_bar_revealer.set_visible(True)
        self._search_header_bar.disable_actions()
        self._render_search_header_bar.disable_actions()
        if self.__hiding_bars_timeout:
            GLib.source_remove(self.__hiding_bars_timeout)
            self.__hiding_bars_timeout = None

        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 self.__config_manager.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, checking_after_exit: bool = False) -> None:
        """Close any open header bar or exit the editor."""
        if not self.__close_any_header_bar(after_exit=False):
            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."""
        # Don't focus if showing render view
        if self._render_edit_stack.get_visible_child() != self._editor_scrolledwin:
            return

        # Don't focus if search etc toolbar is active
        if (
            self._top_bar_revealer.get_reveal_child()
            and self._headerbar_stack.get_visible_child() != self._main_header_bar
        ):
            return

        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)

    def get_previous_sessions(self) -> list[EditorSessionDetails]:
        """Get previous detail for previous sessions.

        :return: List of session details
        :rtype: list[EditorSessionDetails]
        """
        return list(self.__previous_sessions)

    def show_downloaded_attachment_in_render(self, attachment: Attachment) -> None:
        """Update render to display newly available attachment.

        :param Attachment attachment: Attachment to show
        """
        if self.__note is None or attachment.note_id != self.__note.id:
            return
        if self._render_edit_stack.get_visible_child() != self.__render_view:
            return
        logging.debug(f"Attachment '{attachment.filename}' downloaded")
        self.__render_view.show_attached_image(attachment)

    # Return type intentionally skipped to allow for lazy loading WebKit
    def ensure_webkit_initialised(self) -> Any:
        """Ensure WebKit is initialised."""
        if self.__render_view is None:
            self.__init_webkit()
        return self.__render_view

    def ensure_html_generator_initialised(self) -> Any:
        """Ensure the HTML generator is initialised."""
        if self.__html_generator is None:
            self.__initialise_html_generator()
            self.__push_font_updates()

        return self.__html_generator

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

    @active.setter
    def set_active(self, value: bool) -> None:
        self.__active = value
        self.__enable_actions(value)
        if value and self.__config_manager.markdown_detect_syntax:
            self._formatting_header_bar.active = True
        else:
            self._formatting_header_bar.active = False

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

    @current_note.setter
    def set_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

    @GObject.Property(type=bool, default=False)
    def note_had_edit(self) -> bool:
        """Get whether note was modified during session.

        Determines whether excerpt will later be updated.

        :returns: Whether modified
        :rtype: bool
        """
        return self.__note_had_edit

    @note_had_edit.setter
    def set_note_had_edit(self, value: bool) -> None:
        """Set whether note was modified during session."""
        self.__note_had_edit = value

    @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 the click is in the visible top row of the buffer where the show-triggering motion area
        # will be don't hide otherwise we immediately hide and re-show, which is glitchy.
        if not self.__click_in_top_margin(gesture):
            self.__check_and_hide_header_bar()

        if not self.__click_in_bottom_margin(gesture):
            self.__check_and_hide_formatting_bar()

        event = gesture.get_current_event()
        if event is None:
            return
        state = event.get_modifier_state()
        if state & Gdk.ModifierType.CONTROL_MASK and state & Gdk.ModifierType.BUTTON1_MASK:
            _interpreted, x, y = gesture.get_point(None)
            self.__check_and_handle_link_click(x, y)

    @Gtk.Template.Callback()
    def _on_top_margin_motion(
        self, _controller: Gtk.EventControllerMotion, x: float, y: float
    ) -> None:
        self.__process_top_margin_input(x, y)

    @Gtk.Template.Callback()
    def _on_top_margin_click(
        self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float
    ) -> None:
        self.__process_top_margin_input(x, y)

    @Gtk.Template.Callback()
    def _on_bottom_margin_motion(
        self, _controller: Gtk.EventControllerMotion, x: float, y: float
    ) -> None:
        self.__process_bottom_margin_input(x, y)

    @Gtk.Template.Callback()
    def _on_bottom_margin_click(
        self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float
    ) -> None:
        self.__process_bottom_margin_input(x, y)

    @Gtk.Template.Callback()
    def _on_key_pressed(
        self,
        controller: Gtk.EventControllerKey,
        keyval: int,
        keycode: int,
        state: Gdk.ModifierType,
    ) -> bool:
        # Prevent some keys from resulting in the bars re-hiding
        if keyval not 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,
        ):
            self.__check_and_hide_header_bar()
            self.__check_and_hide_formatting_bar()

        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", lambda _o, _p: self.__apply_rename())
        action_group.add_action(action)
        app.set_accels_for_action("editor.rename", ["F2"])

        action = Gio.SimpleAction.new("edit-category")
        action.connect("activate", lambda _o, _p: 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", lambda _o, _p: 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", lambda _o, _p: self.__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", lambda _o, _p: self.cancel())
        action_group.add_action(action)
        self.__cancel_action = action
        app.set_accels_for_action("editor.go-back-or-cancel", ["Escape"])

        action = Gio.SimpleAction.new("clear-category")
        action.connect("activate", lambda _o, _p: 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", lambda _o, _v: 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", lambda _o, _p: self.__on_export_note())
        action_group.add_action(action)
        app.set_accels_for_action("editor.export-note", ["<Control><Shift>s"])

        action = Gio.SimpleAction.new("show-outline")
        action.connect("activate", lambda _o, _p: self.__show_outline())
        action_group.add_action(action)
        app.set_accels_for_action("editor.show-outline", ["<Control>j"])

        action = Gio.SimpleAction.new("delete-note")
        action.connect("activate", lambda _o, _p: self.emit("note-deleted", self.__note))
        action_group.add_action(action)

        action = Gio.SimpleAction.new("increase-line-length")
        action.connect("activate", lambda _o, _p: 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", lambda _o, _p: 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("toggle-line-length-limit")
        action.connect("activate", lambda _o, _p: self.__on_line_length_limit_toggle())
        action_group.add_action(action)
        app.set_accels_for_action("editor.toggle-line-length-limit", ["<Shift><Control>l"])

        action = Gio.SimpleAction.new("increase-font-size")
        action.connect("activate", lambda _o, _v: self.__font_size_selector.increase())
        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", lambda _o, _v: self.__font_size_selector.decrease())
        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", lambda _o, _v: self.__font_size_selector.reset())
        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

        action = Gio.SimpleAction.new("show-menu")
        action.connect("activate", lambda _o, _p: self._menu_button.popup())
        action_group.add_action(action)
        app.set_accels_for_action("editor.show-menu", ["F10"])

        action = Gio.SimpleAction.new("focus-editor")
        action.connect("activate", lambda _o, _p: self.__focus_editor())
        action_group.add_action(action)
        app.set_accels_for_action("editor.focus-editor", ["<Alt>e"])

        action = Gio.SimpleAction.new("focus-header-bar")
        action.connect("activate", lambda _o, _p: self.__focus_header_bar())
        action_group.add_action(action)
        app.set_accels_for_action("editor.focus-header-bar", ["<Alt>h"])

        action = Gio.SimpleAction.new("focus-formatting-bar")
        action.connect("activate", lambda _o, _p: self.__focus_formatting_bar())
        action_group.add_action(action)
        app.set_accels_for_action("editor.focus-formatting-bar", ["<Alt>f"])

        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_syntax_detection(self) -> None:
        buffer = self._sourceview.get_buffer()
        if self.__config_manager.markdown_detect_syntax:
            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_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.begin_user_action()
        buffer.insert(match_end, "x" if new_value else " ")

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

    def __on_webview_loaded(self, _obj: GObject.Object) -> None:
        assert self.__render_view
        if self.__render_view.exporting:
            self.__render_view.exporting = False
        else:
            self._render_edit_button_stack.set_visible_child(self._edit_button)
            if not self.__render_view.get_visible():
                self.__render_view.set_visible(True)
            self._render_edit_stack.set_visible_child(self.__render_view)

    def __on_enter_search(self) -> None:
        if self._search_header_bar.active:
            self._search_header_bar.refocus_search_and_select()
        else:
            self.__enter_search(resuming=False, for_replace=False)

    def __on_title_renamed(self, _obj: GObject.Object, new_title: str) -> None:
        assert self.__note
        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()
            self.note_had_edit = True
            # 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_bars_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_bars_after_delay()
        self._sourceview.grab_focus()

    def __on_clear_category(self) -> None:
        if not self._top_bar_revealer.get_reveal_child():
            return
        if self._headerbar_stack.get_visible_child() == self._category_header_bar:
            self._category_header_bar.clear_and_apply()

    def __on_export_note(self) -> None:
        assert self.__note
        if self.__html_generator is None:
            self.__initialise_html_generator()
            self.__push_font_updates()

        dialog = ExportDialog(
            [self.__note],
            self.__html_generator,
            self.__sync_manager.download_attachment if self.__sync_manager.authenticated else None,
            self.ensure_webkit_initialised,
            note_content_populator=None,
        )
        dialog.present(self)

    def __on_detect_syntax_changed(self) -> None:
        if self.active:
            self.__setup_syntax_detection()
            if self.__config_manager.markdown_detect_syntax:
                if self.__formatting_bar_visibility_setting != HeaderBarVisibility.DISABLED:
                    self.__show_formatting_bar()
                self._formatting_header_bar.active = True
            else:
                self.__hide_formatting_bar()
                self._formatting_header_bar.active = False

    def __on_theme_changed(self) -> None:
        if self.active and self.__config_manager.markdown_detect_syntax:
            self.__update_scheme_and_dark_style()

    def __on_render_enabled_changed(self) -> None:
        enabled = self.__config_manager.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) -> None:
        keep = self.__config_manager.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) -> None:
        assert self.__note
        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_increase_line_length(self) -> None:
        setting_max = self.__config_manager.line_length_max
        current_length = self.__config_manager.line_length
        if current_length == setting_max:
            logging.info("Line length limit already disabled")
            return
        else:
            new_length = current_length + self.LINE_LENGTH_STEP
        if new_length < self.get_width() - 2 * self._sourceview.STANDARD_X_MARGIN:
            self.__config_manager.line_length = new_length
            logging.info(f"Line length increased to {new_length}px")
        else:
            self.__config_manager.line_length = setting_max
            self.__notify_line_length_limit_disabled()

    def __on_decrease_line_length(self) -> None:
        setting_max = self.__config_manager.line_length_max
        current_length = self.__config_manager.line_length
        new_length = current_length - self.LINE_LENGTH_STEP
        if current_length == setting_max:
            new_length = min(self.get_width(), self.__config_manager.default_line_length)
            self.__config_manager.line_length = new_length
            self.__notify_line_length_limit_enabled(new_length)
        elif new_length >= self.LINE_LENGTH_STEP:
            self.__config_manager.line_length = new_length
            logging.info(f"Line length decreased to {new_length}px")

    def __on_line_length_limit_toggle(self) -> None:
        setting_maximum = self.__config_manager.line_length_max
        if self.__config_manager.line_length == setting_maximum:
            new_length = self.__config_manager.default_line_length
            self.__config_manager.line_length = new_length
            self.__notify_line_length_limit_enabled(new_length)
        else:
            self.__config_manager.line_length = setting_maximum
            self.__notify_line_length_limit_disabled()

    def __on_window_fullscreened(self, window: Gtk.Window, _param: GObject.ParamSpec) -> None:
        if not self.active:
            return

        if self._render_edit_stack.get_visible_child() == self.__render_view:
            self.__render_view.update_style()

        if self.__header_bar_visibility_setting == HeaderBarVisibility.AUTO_HIDE_FULLSCREEN_ONLY:
            if window.is_fullscreen():
                self.__hide_header_bar()
            else:
                self.__show_header_bar()

        if self.__config_manager.markdown_detect_syntax:
            setting = self.__formatting_bar_visibility_setting
            if setting == HeaderBarVisibility.AUTO_HIDE_FULLSCREEN_ONLY:
                if window.is_fullscreen():
                    self.__hide_formatting_bar()
                else:
                    self.__show_formatting_bar()

        self.__update_toolbar_underlay_margins()

    def __on_render_single_click(self) -> None:
        self.__check_and_hide_header_bar()

    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_header_bar()
            return Gdk.EVENT_STOP
        else:
            return Gdk.EVENT_PROPAGATE

    def __on_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_bars_after_delay()
        self._sourceview.grab_focus()

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

    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) -> 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 __on_buffer_changed(self) -> None:
        if self.__updating_from_remote:
            return

        # Witnessed when closing for conflict
        if not self.__note:
            return

        self.__note.content = self.__get_content()
        renaming = self._headerbar_stack.get_visible_child() == self._rename_header_bar
        if self.__note.title_is_top_line and not renaming:
            self.__note.update_title_from_top_line()
        self.__note.flag_changed()
        self.note_had_edit = True

        # Maintain a margin between the cursor and the bottom of the window
        self.__check_and_scroll_for_cursor()

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

    def set_header_bar_height(self, height: int) -> None:
        """Set header bar height for top margin.

        :param int height: Bar height
        """
        self._sourceview.header_bar_height = height

    def __check_and_scroll_for_cursor(self) -> None:
        buffer = self._sourceview.get_buffer()
        insert = buffer.get_iter_at_mark(buffer.get_insert())

        rect = self._sourceview.get_iter_location(insert)
        adj = self._editor_scrolledwin.get_vadjustment()
        lowest_visible = self._sourceview.get_top_margin() + rect.y + rect.height
        visible_below_cursor = adj.get_value() + adj.get_page_size() - lowest_visible
        if visible_below_cursor < self.MARGIN_BELOW_CURSOR:
            scroll_to = lowest_visible + self.MARGIN_BELOW_CURSOR - adj.get_page_size()
            adj.set_value(scroll_to)

    def __notify_line_length_limit_enabled(self, new_length: int) -> None:
        logging.info(f"Line length re-enabled, to {new_length}px")
        # Translators: Description, notification, {0} is a number
        msg = _("Line length now {0}px").format(new_length)
        self.__show_toast(msg)

    def __notify_line_length_limit_disabled(self) -> None:
        logging.info("Line length limit disabled")
        # Translators: Description, notification
        self.__show_toast(_("Line length limit disabled"))

    def __show_toast(self, msg: str) -> None:
        if not self.active:
            logging.debug(f"Not showing toast as editor inactive: {msg}")
            return

        toast = Adw.Toast.new(msg)
        toast.set_priority(Adw.ToastPriority.HIGH)
        toast.set_timeout(self.TOAST_DURATION)

        self._toast_overlay.dismiss_all()
        self._toast_overlay.add_toast(toast)

    def __init_webkit(self) -> 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:
            self.__initialise_html_generator()
            assert self.__html_generator  # mypy

        app = Gio.Application.get_default()
        track_timing = app.debug_session or app.development_mode

        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.__config_manager, track_timing)

        self._render_edit_stack.add_child(self.__render_view)

        gesture = Gtk.GestureSingle()
        gesture.connect("end", lambda _o, _s: 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)

        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 __close_any_header_bar(self, after_exit: bool) -> bool:
        closed_headerbar = False
        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)
            if not after_exit:
                self._sourceview.grab_focus()
                self.__check_and_hide_bars_after_delay()
            closed_headerbar = True
        elif self._headerbar_stack.get_visible_child() == self._search_header_bar:
            self.__exit_search()
            closed_headerbar = True
        elif self._headerbar_stack.get_visible_child() == self._render_search_header_bar:
            self.__exit_render_search()
            closed_headerbar = True

        return closed_headerbar

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

        handler_id = self.__note.connect("notify::dirty", lambda _o, _v: self.__update_dirty())
        self.__current_note_handlers.append(handler_id)
        handler_id = self.__note.connect("notify::title", lambda _o, _v: self.__update_title())
        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) -> str:
        buffer = self._sourceview.get_buffer()
        start, end = buffer.get_bounds()
        return buffer.get_text(start, end, True)

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

    def __update_dirty(self) -> None:
        assert self.__note
        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 self.__note and 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, export_format=None)
            self.__updating_from_remote = False
            self.set_sensitive(True)

            # Update outline from remote
            if self.__outline_dialog:
                self.__show_outline()

    def __get_scroll_position(self) -> float:
        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.0

    def __set_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) -> None:
        """Show the category editing."""
        assert self.__note
        if not self._top_bar_revealer.get_reveal_child():
            self.__show_header_bar()
        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 __apply_rename(self) -> None:
        """Show the title rename entry."""
        assert self.__note
        if self.__note.read_only:
            return
        if not self._top_bar_revealer.get_reveal_child():
            self.__show_header_bar()
        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._top_bar_revealer.get_reveal_child():
            self.__show_header_bar()

    def __exit_search(self) -> None:
        """Stop searching within note."""
        self._headerbar_stack.set_visible_child(self._main_header_bar)
        self.__check_and_hide_bars_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_bars_after_delay()
        self._render_search_header_bar.exit()

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

        if not self.__config_manager.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._bottom_bar_revealer.get_reveal_child():
                self._bottom_bar_revealer.set_visible(False)

            if self.__render_view is not None:
                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:
            assert self.__render_view
            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_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._bottom_bar_revealer.set_visible(True)
            self.__update_formatting_bar_visibility()
            self.__check_and_hide_bars_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:
        assert self.__render_view
        if (
            not self.__config_manager.markdown_render_enabled
            or not self.__config_manager.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.set_visible(False)

    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 = self.__config_manager.editor_theme
        if style_manager.get_dark():
            scheme_id = f"{scheme_id}-dark"

        scheme = None
        if style_manager.get_high_contrast():
            high_contrast_id = f"{scheme_id}-high-contrast"
            scheme = style_scheme_manager.get_scheme(high_contrast_id)
            if not scheme:
                logging.debug(f"Scheme '{high_contrast_id}' doesn't exist")

        if scheme is None:
            scheme = style_scheme_manager.get_scheme(scheme_id)
        buffer.set_style_scheme(scheme)

    def __refresh_line_length_from_setting(self) -> None:
        length = self.__config_manager.line_length
        length_for_view = -1 if length == self.__config_manager.line_length_max else length
        self._sourceview.line_length = length_for_view

    def __push_font_updates(self) -> None:
        family = self.__get_editor_font_family()
        self._sourceview.update_font_family(family)

        if self.__html_generator is not None:
            family = self.__get_render_font_family()
            self.__html_generator.update_font_family(family)
            if self.__render_view:
                self.__render_view.update_style()

    def __get_render_font_family(self) -> str:
        return self.__get_font_family(self.__config_manager.markdown_use_monospace_font)

    def __get_editor_font_family(self) -> str:
        return self.__get_font_family(self.__config_manager.use_monospace_font)

    def __get_font_family(self, monospace: bool) -> str:
        style_manager = Adw.StyleManager.get_default()
        if monospace:
            font = style_manager.props.monospace_font_name
        else:
            font = style_manager.props.document_font_name
        font_description = Pango.font_description_from_string(font)
        return font_description.get_family()

    def __show_header_bar(self) -> None:
        """Show the headerbar."""
        self._top_bar_revealer.set_reveal_child(True)

    def __check_and_hide_bars_after_delay(self) -> None:
        if self.__hiding_bars_timeout is not None:
            return

        if self.__both_toolbars_hidden():
            return

        if self.__neither_toolbar_auto_hiding():
            return

        def callback():
            if self.active:
                self.__check_and_hide_header_bar()
                self.__check_and_hide_formatting_bar()
            self.__hiding_bars_timeout = None

        self.__hiding_bars_timeout = GLib.timeout_add(self.HEADERBAR_HIDE_DELAY, callback)

    def __both_toolbars_hidden(self) -> bool:
        return (
            not self._top_bar_revealer.get_reveal_child()
            and not self._bottom_bar_revealer.get_reveal_child()
        )

    def __neither_toolbar_auto_hiding(self) -> bool:
        return (
            self.__formatting_bar_visibility_setting
            == self.__header_bar_visibility_setting
            == HeaderBarVisibility.ALWAYS_VISIBLE
        )

    def __hide_header_bar(self) -> None:
        if self._headerbar_stack.get_visible_child() != self._main_header_bar:
            return
        elif self._menu_button.get_popover().get_visible():
            return

        if self._top_bar_revealer.get_reveal_child():
            self._top_bar_revealer.set_reveal_child(False)
            self.focus_textview_if_editing()

    def __show_formatting_bar(self) -> None:
        self._bottom_bar_revealer.set_reveal_child(True)

    def __hide_formatting_bar(self) -> None:
        self._bottom_bar_revealer.set_reveal_child(False)
        self.focus_textview_if_editing()

    def __check_and_hide_formatting_bar(self) -> bool:
        if not self._bottom_bar_revealer.get_reveal_child():
            return False

        if not self.__config_manager.markdown_detect_syntax:
            return False

        setting = self.__formatting_bar_visibility_setting
        hide = False
        if setting in (HeaderBarVisibility.AUTO_HIDE, HeaderBarVisibility.DISABLED):
            hide = True
        elif setting == HeaderBarVisibility.AUTO_HIDE_FULLSCREEN_ONLY:
            hide = self.get_root().is_fullscreen()

        if hide:
            self.__hide_formatting_bar()
        return hide

    def __check_and_show_formatting_bar(self) -> None:
        if self.__hiding_bars_timeout:
            GLib.source_remove(self.__hiding_bars_timeout)
            self.__hiding_bars_timeout = None
        if self._bottom_bar_revealer.get_reveal_child():
            return
        if not self.active or self._render_edit_stack.get_visible_child() == self.__render_view:
            return
        if not self.__config_manager.markdown_detect_syntax:
            return

        if self.__formatting_bar_visibility_setting != HeaderBarVisibility.DISABLED:
            self.__show_formatting_bar()

    def __check_and_hide_header_bar(self) -> None:
        if not self._top_bar_revealer.get_reveal_child():
            return

        hide = False
        if self.__header_bar_visibility_setting == HeaderBarVisibility.AUTO_HIDE:
            hide = True
        elif self.__header_bar_visibility_setting == HeaderBarVisibility.AUTO_HIDE_FULLSCREEN_ONLY:
            hide = self.get_root().is_fullscreen()

        if hide:
            self.__hide_header_bar()

    def __check_and_show_header_bar(self) -> None:
        if self.__hiding_bars_timeout:
            GLib.source_remove(self.__hiding_bars_timeout)
            self.__hiding_bars_timeout = None
        if not self._top_bar_revealer.get_reveal_child():
            self.__show_header_bar()

    def __update_for_formatting_bar_setting_change(self) -> None:
        self.__cache_formatting_bar_visibility()
        if self.active:
            self.__update_toolbar_underlay_margins()
            self.__update_formatting_bar_visibility()

    def __process_top_margin_input(self, x: float, y: float) -> None:
        location = Graphene.Point.init(Graphene.Point.alloc(), x, y)
        if not location.near(self.__pointer_position, 1.0):
            self.__pointer_position = location
            self.__check_and_show_header_bar()

    def __process_bottom_margin_input(self, x: float, y: float) -> None:
        self.__check_and_show_formatting_bar()
        location = Graphene.Point.init(Graphene.Point.alloc(), x, y)
        if not location.near(self.__pointer_position, 1.0):
            self.__pointer_position = location
            self.__check_and_show_formatting_bar()

    def __update_toolbar_underlay_margins(self) -> None:
        pad = self.__config_manager.editor_header_bar_visible_for_window_state
        if pad:
            margin = self._sourceview.padded_header_bar_height
        else:
            margin = 0
        if self._render_edit_stack.get_margin_top() != margin:
            self._render_edit_stack.set_margin_top(margin)

        pad = self.__config_manager.editor_formatting_bar_visible_for_window_state
        if pad:
            margin = self._formatting_header_bar.get_property("height-request")
        else:
            margin = 0
        if self._editor_scrolledwin.get_margin_bottom() != margin:
            self._editor_scrolledwin.set_margin_bottom(margin)

    def __update_formatting_bar_visibility(self) -> None:
        if not self.active or self._render_edit_stack.get_visible_child() == self.__render_view:
            return

        if not self.__config_manager.markdown_detect_syntax:
            return

        setting = self.__formatting_bar_visibility_setting
        if self._bottom_bar_revealer.get_reveal_child():
            self.__check_and_hide_formatting_bar()
        else:
            show = False
            if setting == HeaderBarVisibility.ALWAYS_VISIBLE:
                show = True
            elif setting == HeaderBarVisibility.AUTO_HIDE_FULLSCREEN_ONLY:
                show = not self.get_root().is_fullscreen()
            if show:
                self.__show_formatting_bar()

    def __update_for_header_bar_setting_change(self) -> None:
        self.__cache_header_bar_visibility()

        if not self.active:
            return

        setting = self.__header_bar_visibility_setting
        if self._top_bar_revealer.get_reveal_child():
            self.__check_and_hide_header_bar()
        else:
            show = False
            if setting == HeaderBarVisibility.ALWAYS_VISIBLE:
                show = True
            elif setting == HeaderBarVisibility.AUTO_HIDE_FULLSCREEN_ONLY:
                show = not self.get_root().is_fullscreen()
            if show:
                self.__show_header_bar()
        self.__update_toolbar_underlay_margins()

    def __cache_header_bar_visibility(self) -> None:
        self.__header_bar_visibility_setting = self.__config_manager.editor_header_bar_visibility

    def __cache_formatting_bar_visibility(self) -> None:
        self.__formatting_bar_visibility_setting = (
            self.__config_manager.editor_formatting_bar_visibility
        )

    def __continue_loading_webkit(self) -> None:
        assert self.__note

        self.ensure_webkit_initialised()

        assert self.__render_view

        if self.__sync_manager.authenticated:
            download_func = self.__sync_manager.download_attachment
        else:
            download_func = None
        self.__render_view.render(
            self.__note,
            export_format=None,
            attachment_download_func=download_func,
            scroll_position=self.__get_scroll_position(),
        )
        self._search_header_bar.disable_actions()
        self.__focus_mode_action.set_enabled(False)

    def __click_in_top_margin(self, gesture: Gtk.Gesture) -> bool:
        interpreted, _, y = gesture.get_point(None)
        if not interpreted:
            return False
        if not self._top_bar_revealer.get_reveal_child():
            return False

        return y < self._sourceview.get_property("top-margin")

    def __click_in_bottom_margin(self, gesture: Gtk.Gesture) -> bool:
        interpreted, _, y = gesture.get_point(None)
        if not interpreted:
            return False
        if not self._bottom_bar_revealer.get_reveal_child():
            return False

        return y > self._sourceview.get_height() - self._sourceview.get_property("top-margin")

    def __focus_editor(self) -> None:
        # Don't focus if showing render view
        if self._render_edit_stack.get_visible_child() != self._editor_scrolledwin:
            return

        self._sourceview.grab_focus()

    def __focus_header_bar(self) -> None:
        self.__show_header_bar()
        if self._headerbar_stack.get_visible_child() == self._rename_header_bar:
            self._rename_header_bar.focus()
        elif self._headerbar_stack.get_visible_child() == self._search_header_bar:
            self._search_header_bar.refocus_search_and_select()
        elif self._headerbar_stack.get_visible_child() == self._category_header_bar:
            self._category_header_bar.focus()
        elif self._headerbar_stack.get_visible_child() == self._render_search_header_bar:
            self._render_search_header_bar.focus()
        else:
            self._title_button.grab_focus()

    def __focus_formatting_bar(self) -> None:
        if not self.__config_manager.markdown_detect_syntax:
            return
        if self.__formatting_bar_visibility_setting == HeaderBarVisibility.DISABLED:
            return
        self.__show_formatting_bar()
        self._formatting_header_bar.focus()

    def __check_and_handle_link_click(self, x: float, y: float) -> None:
        link = None

        buffer_x, buffer_y = self._sourceview.window_to_buffer_coords(
            Gtk.TextWindowType.TEXT,
            x,
            y,
        )
        over_text, iter = self._sourceview.get_iter_at_location(buffer_x, buffer_y)
        if not over_text:
            logging.debug("Not over text for URL ctrl-click")
            return

        # Check and open basic link
        result = parse_any_url_at_iter(iter, http_only=True)
        if result:
            link = result.link
        else:
            # Check for a markdown inline and automatic links
            classes = iter.get_buffer().get_context_classes_at_iter(iter)
            if "inline-link" in classes:
                inline_result = parse_markdown_inline_link(iter)
                if inline_result:
                    link = inline_result.link
            elif "automatic-link" in classes:
                auto_result = parse_markdown_automatic_link(iter)
                if auto_result:
                    link = auto_result.link

        if link:
            logging.info(f"Opening URI from ctrl-click: {link}")
            webbrowser.open(link)
            self.__show_toast(_("Opening link in browser"))

    def __store_session_details(self) -> None:
        scroll_position = self._editor_scrolledwin.get_vadjustment().get_value()

        buffer = self._sourceview.get_buffer()
        insert_iter = buffer.get_iter_at_mark(buffer.get_insert())
        cursor_location = insert_iter.get_offset()

        if len(self.__previous_sessions) and self.__previous_sessions[-1].note == self.current_note:
            return

        details = EditorSessionDetails(
            self.current_note,
            scroll_position,
            cursor_location,
        )
        self.__previous_sessions.append(details)
        if len(self.__previous_sessions) > 2:
            self.__previous_sessions = self.__previous_sessions[-2:]

    def __flush_note_from_previous_sessions(self, note: Note) -> None:
        if self.__previous_sessions and self.__previous_sessions[-1].note == note:
            del self.__previous_sessions[-1]

    def __restore_session_location(self, session: EditorSessionDetails) -> None:
        self.__set_scroll_position(session.scroll_position)
        buffer = self._sourceview.get_buffer()
        cursor_iter = buffer.get_iter_at_offset(session.cursor_position)
        buffer.place_cursor(cursor_iter)

    def __show_outline(self) -> None:
        assert self.__note

        if self.__outline_dialog:
            self.__outline_dialog.close()

        self.__enable_actions(False)

        dialog = OutlineDialog()
        dialog.present(self)

        def populate(headings: list[OutlineHeading]) -> None:
            # This makes a crude assumption that if the locale is RTL the document contents are RTL.
            # Likely better than ignoring RTL but worse than knowing the document direction.
            rtl = self.get_default_direction() == Gtk.TextDirection.RTL
            dialog.populate(headings, rtl)

        self.__outline_generator.generate(self.__note, populate)

        def jump_to(dialog: OutlineDialog, line: int) -> None:
            if self._render_edit_stack.get_visible_child() == self._editor_scrolledwin:
                buffer = self._sourceview.get_buffer()
                success, line_iter = buffer.get_iter_at_line(line)
                buffer.place_cursor(line_iter)
                self._sourceview.jump_to_insertion_point()
            else:
                assert self.__render_view
                self.__render_view.scroll_to_heading(line)

        dialog.connect("jump-to", jump_to)

        def outline_closed(dialog: OutlineDialog) -> None:
            dialog.disconnect_by_func(jump_to)
            dialog.disconnect_by_func(outline_closed)
            self.__outline_dialog = None
            self.__enable_actions(True)

        dialog.connect("closed", outline_closed)
        self.__outline_dialog = dialog

    def __initialise_html_generator(self) -> None:
        if self.__html_generator:
            return

        from iotas.template_html_generator import TemplateHtmlGenerator

        self.__html_generator = TemplateHtmlGenerator(
            self.__config_manager, const.PKGDATADIR, self._sourceview.padded_header_bar_height
        )
