from gettext import gettext as _
import gi

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

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

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

from iotas.category_header_bar import CategoryHeaderBar
from iotas.category_treeview_list_store import CategoryTreeViewListStore
import iotas.config_manager
from iotas.config_manager import HeaderBarVisibility
from iotas import const
from iotas.export_dialog import ExportDialog
from iotas.focus_mode_helper import FocusModeHelper
from iotas.font_size_selector import FontSizeSelector
from iotas.note import Note
from iotas.outline_dialog import OutlineDialog
from iotas.outline_generator import OutlineHeading, OutlineGenerator
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
    FONT_SETTING_KEYS = ["monospace-font-name", "document-font-name"]
    HEADERBAR_HIDE_DELAY = 2000
    MARGIN_BELOW_CURSOR = 22

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

    _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()
    _formatting_header_bar = Gtk.Template.Child()
    _render_search_header_bar = Gtk.Template.Child()

    _menu_button = Gtk.Template.Child()
    _title_button = Gtk.Template.Child()
    _content_overlay = 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()
    _read_only_image = Gtk.Template.Child()
    _render_loading = Gtk.Template.Child()
    _toast_overlay = Gtk.Template.Child()
    _top_margin_box = Gtk.Template.Child()
    _bottom_margin_box = Gtk.Template.Child()

    def __init__(self) -> None:
        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.__queued_toasts = []
        self.__render_view = None
        self.__font_families = {}
        self.__html_generator = None
        self.__focus_mode_action = None
        self.__cancel_action = None
        self.__previous_sessions = []
        self.__pointer_cursor_y = None
        self.__outline_dialog = None

        self.__hiding_bars_timeout = None

        self.__outline_generator = OutlineGenerator()

        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._sourceview.connect("margins-updated", lambda _o: self.__update_margin_box_heights())

        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._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(
            iotas.config_manager.get_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()
        )

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.MARKDOWN_DETECT_SYNTAX}",
            lambda _o, _k: self.__on_detect_syntax_changed(),
        )
        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.EDITOR_THEME}",
            lambda _o, _k: self.__on_theme_changed(),
        )
        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.MARKDOWN_RENDER}",
            lambda _o, _k: self.__on_render_enabled_changed(),
        )
        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.MARKDOWN_KEEP_WEBKIT_PROCESS}",
            lambda _o, _k: self.__on_keep_webkit_changed(),
        )
        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.MARKDOWN_TEX_SUPPORT}",
            lambda _o, _k: self.__on_tex_support_changed(),
        )
        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.LINE_LENGTH}",
            lambda _o, _k: self.__refresh_line_length_from_setting(),
        )

        # 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}",
                lambda _o, _k: self.__load_font_family_from_setting(),
            )
            iotas.config_manager.settings.connect(
                f"changed::{iotas.config_manager.MARKDOWN_USE_MONOSPACE_FONT}",
                lambda _o, _k: self.__load_font_family_from_setting(),
            )

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.EDITOR_FORMATTING_BAR_VISIBILTY}",
            lambda _o, _k: self.__update_for_formatting_bar_setting_change(),
        )
        self.__cache_formatting_bar_visibility()

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.EDITOR_HEADER_BAR_VISIBILTY}",
            lambda _o, _k: 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) -> 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()
        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
        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 iotas.config_manager.get_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 (
                iotas.config_manager.get_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)
            self.__update_margin_box_heights()
            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 (
            iotas.config_manager.get_markdown_render_enabled()
            and iotas.config_manager.get_markdown_default_to_render()
            and note.content.strip() != ""
        ):
            # Prevent a flash of the editor while loading rendered view
            self._sourceview.set_visible(False)
            self.__toggle_render()

        if is_likely_mobile_device():
            if iotas.config_manager.get_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."""

        self.__store_session_details()

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

        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.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_bars_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."""
        # 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_session(self) -> Optional[EditorSessionDetails]:
        """Get previous session details.

        :return: Session details
        :rtype: EditorSessionDetails, optional
        """
        if self.__previous_sessions:
            return self.__previous_sessions[-1]
        else:
            return None

    @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)
        if value and iotas.config_manager.get_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) -> 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 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.__check_and_show_header_bar()

    @Gtk.Template.Callback()
    def _on_top_margin_clicked(
        self, gesture: Gtk.GestureClick, n_press: int, _x: float, _y: float
    ) -> None:
        self.__check_and_show_header_bar()

    @Gtk.Template.Callback()
    def _on_bottom_margin_motion(
        self, _controller: Gtk.EventControllerMotion, _x: float, _y: float
    ) -> None:
        self.__check_and_show_formatting_bar()

    @Gtk.Template.Callback()
    def _on_motion(self, _controller: Gtk.EventControllerMotion, _x: float, y: float) -> None:
        self.__pointer_cursor_y = y

    @Gtk.Template.Callback()
    def _on_motion_leave(self, _controller: Gtk.EventControllerMotion) -> None:
        self.__pointer_cursor_y = None

    @Gtk.Template.Callback()
    def _on_bottom_margin_clicked(
        self, gesture: Gtk.GestureClick, n_press: int, _x: float, _y: float
    ) -> None:
        self.__check_and_show_formatting_bar()

    @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,
        ):
            check_mouse_cursor = True
            self.__check_and_hide_header_bar(check_mouse_cursor)
            self.__check_and_hide_formatting_bar(check_mouse_cursor)

        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 iotas.config_manager.get_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:
        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:
        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_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:
        if self.__html_generator is None:
            from iotas.html_generator import HtmlGenerator

            self.__html_generator = HtmlGenerator(iotas.config_manager, const.PKGDATADIR)
            self.__push_font_updates()

        dialog = ExportDialog(self.__note, self.__html_generator, self.__ensure_webkit_initialised)
        dialog.present(self)

    def __on_detect_syntax_changed(self) -> None:
        if self.active:
            self.__setup_syntax_detection()
            if iotas.config_manager.get_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 iotas.config_manager.get_markdown_detect_syntax():
            self.__update_scheme_and_dark_style()

    def __on_render_enabled_changed(self) -> 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) -> 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) -> 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_increase_line_length(self) -> None:
        setting_max = iotas.config_manager.get_line_length_max()
        current_length = iotas.config_manager.get_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_MARGIN:
            iotas.config_manager.set_line_length(new_length)
            logging.info(f"Line length increased to {new_length}px")
        else:
            iotas.config_manager.set_line_length(setting_max)
            self.__notify_line_length_limit_disabled()

    def __on_decrease_line_length(self) -> None:
        setting_max = iotas.config_manager.get_line_length_max()
        current_length = iotas.config_manager.get_line_length()
        new_length = current_length - self.LINE_LENGTH_STEP
        if current_length == setting_max:
            new_length = min(self.get_width(), iotas.config_manager.get_default_line_length())
            iotas.config_manager.set_line_length(new_length)
            self.__notify_line_length_limit_enabled(new_length)
        elif new_length >= self.LINE_LENGTH_STEP:
            iotas.config_manager.set_line_length(new_length)
            logging.info(f"Line length decreased to {new_length}px")

    def __on_line_length_limit_toggle(self) -> None:
        setting_maximum = iotas.config_manager.get_line_length_max()
        if iotas.config_manager.get_line_length() == setting_maximum:
            new_length = iotas.config_manager.get_default_line_length()
            iotas.config_manager.set_line_length(new_length)
            self.__notify_line_length_limit_enabled(new_length)
        else:
            iotas.config_manager.set_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.__header_bar_visibility_setting == HeaderBarVisibility.AUTO_HIDE_FULLSCREEN_ONLY:
            if window.is_fullscreen():
                self.__hide_header_bar()
            else:
                self.__show_header_bar()

        if iotas.config_manager.get_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 result

    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()
        if self.__note.title_is_top_line:
            self.__note.update_title_from_top_line()
        self.__note.flag_changed()

        # 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 __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:
        toast = Adw.Toast.new(msg)
        toast.set_priority(Adw.ToastPriority.HIGH)
        toast.set_timeout(self.TOAST_DURATION)
        toast.connect("dismissed", self.__untrack_dismissed_toast)

        # TODO change to ToastOverlay.dismiss_all with adwaita 1.7+
        # self._toast_overlay.dismiss_all()
        self.__dismiss_any_toast()

        self._toast_overlay.add_toast(toast)
        self.__queued_toasts.append(toast)

    # Ensure that when eg. bumping through font sizes the toast for the previous size doesn't get
    # shown after the final size. Upcoming adwaita will provide cleaner method via dismiss_all.
    def __dismiss_any_toast(self) -> None:
        for toast in self.__queued_toasts:
            toast.dismiss()

    def __untrack_dismissed_toast(self, toast: Adw.Toast) -> None:
        try:
            self.__queued_toasts.remove(toast)
        except ValueError:
            pass

    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:
            from iotas.html_generator import HtmlGenerator

            self.__html_generator = HtmlGenerator(iotas.config_manager, const.PKGDATADIR)

        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._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 __setup_note_signals(self) -> None:
        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) -> None:
        buffer = self._sourceview.get_buffer()
        (start, end) = buffer.get_bounds()
        return buffer.get_text(start, end, True)

    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 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, 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) -> 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_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."""
        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."""
        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 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._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:
            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:
        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.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 = iotas.config_manager.get_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 = iotas.config_manager.get_line_length()
        length_for_view = -1 if length == iotas.config_manager.get_line_length_max() else length
        self._sourceview.line_length = length_for_view

    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 __show_header_bar(self) -> None:
        """Show the headerbar."""
        self._top_bar_revealer.set_reveal_child(True)
        self.__update_margin_box_heights()

    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():
            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()

        self.__update_margin_box_heights()

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

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

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

        if not iotas.config_manager.get_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 and check_mouse_cursor_over_bar:
            y = self.__pointer_cursor_y
            if y is not None:
                hide = y < self.get_height() - self._sourceview.get_property("top-margin")

        if hide:
            self.__hide_formatting_bar()
        return hide

    def __check_and_show_formatting_bar(self) -> bool:
        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 iotas.config_manager.get_markdown_detect_syntax():
            return

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

    def __check_and_hide_header_bar(self, check_mouse_cursor_over_bar: bool = False) -> 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 and check_mouse_cursor_over_bar:
            y = self.__pointer_cursor_y
            if y is not None:
                hide = y > self._sourceview.get_property("top-margin")

        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 __update_toolbar_underlay_margins(self) -> None:
        pad = iotas.config_manager.get_editor_header_bar_visible_for_window_state()
        margin = const.HEADER_BAR_HEIGHT if pad else 0
        if self._content_overlay.get_margin_top() != margin:
            self._content_overlay.set_margin_top(margin)

        pad = iotas.config_manager.get_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 iotas.config_manager.get_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 = HeaderBarVisibility(
            iotas.config_manager.get_editor_header_bar_visibility()
        )

    def __cache_formatting_bar_visibility(self) -> None:
        self.__formatting_bar_visibility_setting = HeaderBarVisibility(
            iotas.config_manager.get_editor_formatting_bar_visibility()
        )

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

    def __continue_loading_webkit(self) -> None:
        self.__ensure_webkit_initialised()

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

    def __update_margin_box_heights(self) -> None:
        margin = self._sourceview.get_property("top-margin")
        # When the bar is revealed the margin area is shrunk to allow interaction with the top of
        # the buffer
        height = 1 if self._top_bar_revealer.get_reveal_child() else margin
        self._top_margin_box.set_property("height-request", height)

        height = 1 if self._bottom_bar_revealer.get_reveal_child() else margin
        self._bottom_margin_box.set_property("height-request", height)

    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 iotas.config_manager.get_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:
                result = parse_markdown_inline_link(iter)
                if result:
                    link = result.link
            elif "automatic-link" in classes:
                result = parse_markdown_automatic_link(iter)
                if result:
                    link = 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 self.current_note in [session.note for session in self.__previous_sessions]:
            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:
        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:
                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
