# Orca
#
# Copyright 2005-2009 Sun Microsystems Inc.
# Copyright 2011-2023 Igalia, S.L.
# Copyright 2023 GNOME Foundation Inc.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.

# pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-public-methods
# pylint: disable=too-many-lines

"""Provides Orca-controlled navigation for tabular content."""

# This has to be the first non-docstring line in the module to make linters happy.
from __future__ import annotations

__id__        = "$Id$"
__version__   = "$Revision$"
__date__      = "$Date$"
__copyright__ = "Copyright (c) 2005-2009 Sun Microsystems Inc." \
                "Copyright (c) 2011-2023 Igalia, S.L." \
                "Copyright (c) 2023 GNOME Foundation Inc."
__license__   = "LGPL"

from typing import TYPE_CHECKING

from . import cmdnames
from . import dbus_service
from . import debug
from . import focus_manager
from . import input_event
from . import input_event_manager
from . import keybindings
from . import messages
from . import settings_manager
from .ax_object import AXObject
from .ax_table import AXTable
from .ax_text import AXText
from .ax_utilities import AXUtilities

if TYPE_CHECKING:
    import gi
    gi.require_version("Atspi", "2.0")
    from gi.repository import Atspi

    from .input_event import InputEvent
    from .scripts import default

class TableNavigator:
    """Provides Orca-controlled navigation for tabular content."""

    def __init__(self) -> None:
        self._previous_reported_row: int | None = None
        self._previous_reported_col: int | None = None
        self._last_input_event: InputEvent | None = None
        self._enabled: bool = True

        # To make it possible for focus mode to suspend this navigation without
        # changing the user's preferred setting.
        self._suspended: bool = False
        self._handlers: dict = self.get_handlers(True)
        self._bindings: keybindings.KeyBindings = keybindings.KeyBindings()

        msg = "TABLE NAVIGATOR: Registering D-Bus commands."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        controller = dbus_service.get_remote_controller()
        controller.register_decorated_module("TableNavigator", self)

    def get_bindings(
        self, refresh: bool = False, is_desktop: bool = True
    ) -> keybindings.KeyBindings:
        """Returns the table-navigator keybindings."""

        if refresh:
            msg = f"TABLE NAVIGATOR: Refreshing bindings. Is desktop: {is_desktop}"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            self._bindings.remove_key_grabs("TABLE NAVIGATOR: Refreshing bindings.")
            self._setup_bindings()
        elif self._bindings.is_empty():
            self._setup_bindings()

        return self._bindings

    def get_handlers(self, refresh: bool = False) -> dict[str, input_event.InputEventHandler]:
        """Returns the table-navigator handlers."""

        if refresh:
            msg = "TABLE NAVIGATOR: Refreshing handlers."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            self._setup_handlers()

        return self._handlers

    def is_enabled(self) -> bool:
        """Returns true if table-navigation support is enabled."""

        return self._enabled

    def last_input_event_was_navigation_command(self) -> bool:
        """Returns true if the last input event was a navigation command."""

        if self._last_input_event is None:
            return False

        manager = input_event_manager.get_manager()
        result = manager.last_event_equals_or_is_release_for_event(self._last_input_event)
        if self._last_input_event is not None:
            string = self._last_input_event.as_single_line_string()
        else:
            string = "None"

        msg = f"TABLE NAVIGATOR: Last navigation event ({string}) is last input event: {result}"
        debug.print_message(debug.LEVEL_INFO, msg, True)
        return result

    def _setup_bindings(self) -> None:
        """Sets up the table-navigation key bindings."""

        self._bindings = keybindings.KeyBindings()

        self._bindings.add(
            keybindings.KeyBinding(
                "t",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.ORCA_SHIFT_MODIFIER_MASK,
                self._handlers["table_navigator_toggle_enabled"],
                1,
                not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "Left",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.SHIFT_ALT_MODIFIER_MASK,
                self._handlers["table_cell_left"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "Right",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.SHIFT_ALT_MODIFIER_MASK,
                self._handlers["table_cell_right"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "Up",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.SHIFT_ALT_MODIFIER_MASK,
                self._handlers["table_cell_up"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "Down",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.SHIFT_ALT_MODIFIER_MASK,
                self._handlers["table_cell_down"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "Home",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.SHIFT_ALT_MODIFIER_MASK,
                self._handlers["table_cell_first"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "End",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.SHIFT_ALT_MODIFIER_MASK,
                self._handlers["table_cell_last"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "Left",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.ORCA_ALT_SHIFT_MODIFIER_MASK,
                self._handlers["table_cell_beginning_of_row"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "Right",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.ORCA_ALT_SHIFT_MODIFIER_MASK,
                self._handlers["table_cell_end_of_row"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "Up",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.ORCA_ALT_SHIFT_MODIFIER_MASK,
                self._handlers["table_cell_top_of_column"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "Down",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.ORCA_ALT_SHIFT_MODIFIER_MASK,
                self._handlers["table_cell_bottom_of_column"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "r",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.ORCA_SHIFT_MODIFIER_MASK,
                self._handlers["set_dynamic_column_headers_row"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "r",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.ORCA_SHIFT_MODIFIER_MASK,
                self._handlers["clear_dynamic_column_headers_row"],
                2,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "c",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.ORCA_SHIFT_MODIFIER_MASK,
                self._handlers["set_dynamic_row_headers_column"],
                1,
                self._enabled and not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "c",
                keybindings.DEFAULT_MODIFIER_MASK,
                keybindings.ORCA_SHIFT_MODIFIER_MASK,
                self._handlers["clear_dynamic_row_headers_column"],
                2,
                self._enabled and not self._suspended))

        # This pulls in the user's overrides to alternative keys.
        self._bindings = settings_manager.get_manager().override_key_bindings(
            self._handlers, self._bindings, False)

        msg = f"TABLE NAVIGATOR: Bindings set up. Suspended: {self._suspended}"
        debug.print_message(debug.LEVEL_INFO, msg, True)

    def _setup_handlers(self) -> None:
        """Sets up the table-navigator input event handlers."""

        self._handlers = {}

        self._handlers["table_navigator_toggle_enabled"] = \
            input_event.InputEventHandler(
                self.toggle_enabled,
                cmdnames.TABLE_NAVIGATION_TOGGLE,
                enabled=not self._suspended)

        self._handlers["table_cell_left"] = \
            input_event.InputEventHandler(
                self.move_left,
                cmdnames.TABLE_CELL_LEFT,
                enabled=self._enabled and not self._suspended)

        self._handlers["table_cell_right"] = \
            input_event.InputEventHandler(
                self.move_right,
                cmdnames.TABLE_CELL_RIGHT,
                enabled=self._enabled and not self._suspended)

        self._handlers["table_cell_up"] = \
            input_event.InputEventHandler(
                self.move_up,
                cmdnames.TABLE_CELL_UP,
                enabled=self._enabled and not self._suspended)

        self._handlers["table_cell_down"] = \
            input_event.InputEventHandler(
                self.move_down,
                cmdnames.TABLE_CELL_DOWN,
                enabled=self._enabled and not self._suspended)

        self._handlers["table_cell_first"] = \
            input_event.InputEventHandler(
                self.move_to_first_cell,
                cmdnames.TABLE_CELL_FIRST,
                enabled=self._enabled and not self._suspended)

        self._handlers["table_cell_last"] = \
            input_event.InputEventHandler(
                self.move_to_last_cell,
                cmdnames.TABLE_CELL_LAST,
                enabled=self._enabled and not self._suspended)

        self._handlers["table_cell_beginning_of_row"] = \
            input_event.InputEventHandler(
                self.move_to_beginning_of_row,
                cmdnames.TABLE_CELL_BEGINNING_OF_ROW,
                enabled=self._enabled and not self._suspended)

        self._handlers["table_cell_end_of_row"] = \
            input_event.InputEventHandler(
                self.move_to_end_of_row,
                cmdnames.TABLE_CELL_END_OF_ROW,
                enabled=self._enabled and not self._suspended)

        self._handlers["table_cell_top_of_column"] = \
            input_event.InputEventHandler(
                self.move_to_top_of_column,
                cmdnames.TABLE_CELL_TOP_OF_COLUMN,
                enabled=self._enabled and not self._suspended)

        self._handlers["table_cell_bottom_of_column"] = \
            input_event.InputEventHandler(
                self.move_to_bottom_of_column,
                cmdnames.TABLE_CELL_BOTTOM_OF_COLUMN,
                enabled=self._enabled and not self._suspended)

        self._handlers["set_dynamic_column_headers_row"] = \
            input_event.InputEventHandler(
                self.set_dynamic_column_headers_row,
                cmdnames.DYNAMIC_COLUMN_HEADER_SET,
                enabled=self._enabled and not self._suspended)

        self._handlers["clear_dynamic_column_headers_row"] = \
            input_event.InputEventHandler(
                self.clear_dynamic_column_headers_row,
                cmdnames.DYNAMIC_COLUMN_HEADER_CLEAR,
                enabled=self._enabled and not self._suspended)

        self._handlers["set_dynamic_row_headers_column"] = \
            input_event.InputEventHandler(
                self.set_dynamic_row_headers_column,
                cmdnames.DYNAMIC_ROW_HEADER_SET,
                enabled=self._enabled and not self._suspended)

        self._handlers["clear_dynamic_row_headers_column"] = \
            input_event.InputEventHandler(
                self.clear_dynamic_row_headers_column,
                cmdnames.DYNAMIC_ROW_HEADER_CLEAR,
                enabled=self._enabled and not self._suspended)

        msg = f"TABLE NAVIGATOR: Handlers set up. Suspended: {self._suspended}"
        debug.print_message(debug.LEVEL_INFO, msg, True)

    def refresh_bindings_and_grabs(self, script: default.Script, reason: str = "") -> None:
        """Refreshes table navigation bindings and grabs for script."""

        msg = "TABLE NAVIGATOR: Refreshing bindings and grabs"
        if reason:
            msg += f": {reason}"
        debug.print_message(debug.LEVEL_INFO, msg, True)

        for binding in self._bindings.key_bindings:
            script.key_bindings.remove(binding, include_grabs=True)

        self._handlers = self.get_handlers(True)
        self._bindings = self.get_bindings(True)

        for binding in self._bindings.key_bindings:
            script.key_bindings.add(binding, include_grabs=not self._suspended)

    @dbus_service.command
    def toggle_enabled(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Toggles table navigation."""

        self._enabled = not self._enabled

        tokens = ["TABLE NAVIGATOR: toggle_enabled. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if notify_user:
            if self._enabled:
                script.present_message(messages.TABLE_NAVIGATION_ENABLED)
            else:
                script.present_message(messages.TABLE_NAVIGATION_DISABLED)

        self._last_input_event = None
        self.refresh_bindings_and_grabs(script, "toggling table navigation")
        return True

    def suspend_commands(self, script: default.Script, suspended: bool, reason: str = "") -> None:
        """Suspends table navigation independent of the enabled setting."""

        if suspended == self._suspended:
            return

        msg = f"TABLE NAVIGATOR: Suspended: {suspended}"
        if reason:
            msg += f": {reason}"
        debug.print_message(debug.LEVEL_INFO, msg, True)
        self._suspended = suspended
        self.refresh_bindings_and_grabs(script, f"Suspended changed to {suspended}")

    def _is_blank(self, obj: Atspi.Accessible) -> bool:
        """Returns True if obj is empty or consists of only whitespace."""

        if AXUtilities.is_focusable(obj):
            tokens = ["TABLE NAVIGATOR:", obj, "is not blank: it is focusable"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False

        if AXObject.get_name(obj):
            tokens = ["TABLE NAVIGATOR:", obj, "is not blank: it has a name"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False

        if AXObject.get_child_count(obj):
            for child in AXObject.iter_children(obj):
                if not self._is_blank(child):
                    tokens = ["TABLE NAVIGATOR:", obj, "is not blank:", child, "is not blank"]
                    debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                    return False
            return True

        if not AXText.is_whitespace_or_empty(obj):
            tokens = ["TABLE NAVIGATOR:", obj, "is not blank: it has text"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False

        tokens = ["TABLE NAVIGATOR: Treating", obj, "as blank"]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return True

    def _get_current_cell(self) -> Atspi.Accessible:
        """Returns the current cell."""

        cell = focus_manager.get_manager().get_locus_of_focus()

        # We might have nested cells. So far this has only been seen in Gtk, where the
        # parent of a table cell is also a table cell. From the user's perspective, we
        # are on the parent. This check also covers Writer documents in which the caret
        # is likely in a paragraph child of the cell.
        parent = AXObject.get_parent(cell)
        if AXUtilities.is_table_cell_or_header(parent):
            cell = parent

        # And we might instead be in some deeply-nested elements which display text in
        # a web table, so we do one more check.
        if not AXUtilities.is_table_cell_or_header(cell):
            cell = AXObject.find_ancestor(cell, AXUtilities.is_table_cell_or_header)

        tokens = ["TABLE NAVIGATOR: Current cell is", cell]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return cell

    def _get_cell_coordinates(self, cell: Atspi.Accessible) -> tuple:
        """Returns the coordinates of cell, possibly adjusted for linear movement."""

        row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
        if self._previous_reported_row is None or self._previous_reported_col is None:
            return row, col

        # If we're in a cell that spans multiple rows and/or columns, the coordinates will refer to
        # the upper left cell in the spanned range(s). We're storing the last row and column that
        # we presented in order to facilitate more linear movement. Therefore, if the cell at the
        # stored coordinates is the same as cell, we prefer the stored coordinates.
        last_cell = AXTable.get_cell_at(
            AXTable.get_table(cell), self._previous_reported_row, self._previous_reported_col)
        if last_cell == cell:
            return self._previous_reported_row, self._previous_reported_col

        return row, col

    @dbus_service.command
    def move_left(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Moves to the cell on the left."""

        tokens = ["TABLE NAVIGATOR: move_left. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        if AXTable.is_start_of_row(current):
            if notify_user:
                script.present_message(messages.TABLE_ROW_BEGINNING)
            return True

        row, col = self._get_cell_coordinates(current)
        cell = AXTable.get_cell_on_left(current)

        if self.get_skip_blank_cells():
            while cell and self._is_blank(cell) and not AXTable.is_start_of_row(cell):
                cell = AXTable.get_cell_on_left(cell)

        self._present_cell(script, cell, row, col - 1, current, notify_user)
        return True

    @dbus_service.command
    def move_right(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Moves to the cell on the right."""

        tokens = ["TABLE NAVIGATOR: move_right. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        if AXTable.is_end_of_row(current):
            if notify_user:
                script.present_message(messages.TABLE_ROW_END)
            return True

        row, col = self._get_cell_coordinates(current)
        cell = AXTable.get_cell_on_right(current)

        if self.get_skip_blank_cells():
            while cell and self._is_blank(cell) and not AXTable.is_end_of_row(cell):
                cell = AXTable.get_cell_on_right(cell)

        self._present_cell(script, cell, row, col + 1, current, notify_user)
        return True

    @dbus_service.command
    def move_up(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Moves to the cell above."""

        tokens = ["TABLE NAVIGATOR: move_up. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        if AXTable.is_top_of_column(current):
            if notify_user:
                script.present_message(messages.TABLE_COLUMN_TOP)
            return True

        row, col = self._get_cell_coordinates(current)
        cell = AXTable.get_cell_above(current)

        if self.get_skip_blank_cells():
            while cell and self._is_blank(cell) and not AXTable.is_top_of_column(cell):
                cell = AXTable.get_cell_above(cell)

        self._present_cell(script, cell, row - 1, col, current, notify_user)
        return True

    @dbus_service.command
    def move_down(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Moves to the cell below."""

        tokens = ["TABLE NAVIGATOR: move_down. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        if AXTable.is_bottom_of_column(current):
            if notify_user:
                script.present_message(messages.TABLE_COLUMN_BOTTOM)
            return True

        row, col = self._get_cell_coordinates(current)
        cell = AXTable.get_cell_below(current)

        if self.get_skip_blank_cells():
            while cell and self._is_blank(cell) and not AXTable.is_bottom_of_column(cell):
                cell = AXTable.get_cell_below(cell)

        self._present_cell(script, cell, row + 1, col, current, notify_user)
        return True

    @dbus_service.command
    def move_to_first_cell(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Moves to the first cell."""

        tokens = ["TABLE NAVIGATOR: move_to_first_cell. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        table = AXTable.get_table(current)
        cell = AXTable.get_first_cell(table)
        self._present_cell(script, cell, 0, 0, current, notify_user)
        return True

    @dbus_service.command
    def move_to_last_cell(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Moves to the last cell."""

        tokens = ["TABLE NAVIGATOR: move_to_last_cell. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        table = AXTable.get_table(current)
        cell = AXTable.get_last_cell(table)
        self._present_cell(
            script, cell, AXTable.get_row_count(table), AXTable.get_column_count(table),
            current, notify_user
        )
        return True

    @dbus_service.command
    def move_to_beginning_of_row(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Moves to the beginning of the row."""

        tokens = ["TABLE NAVIGATOR: move_to_beginning_of_row. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        if AXTable.is_start_of_row(current):
            if notify_user:
                script.present_message(messages.TABLE_ROW_BEGINNING)
            return True

        cell = AXTable.get_start_of_row(current)
        row, col = self._get_cell_coordinates(cell)
        self._present_cell(script, cell, row, col, current, notify_user)
        return True

    @dbus_service.command
    def move_to_end_of_row(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Moves to the end of the row."""

        tokens = ["TABLE NAVIGATOR: move_to_end_of_row. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        if AXTable.is_end_of_row(current):
            if notify_user:
                script.present_message(messages.TABLE_ROW_END)
            return True

        cell = AXTable.get_end_of_row(current)
        row, col = self._get_cell_coordinates(cell)
        self._present_cell(script, cell, row, col, current, notify_user)
        return True

    @dbus_service.command
    def move_to_top_of_column(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Moves to the top of the column."""

        tokens = ["TABLE NAVIGATOR: move_to_top_of_column. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        if AXTable.is_top_of_column(current):
            if notify_user:
                script.present_message(messages.TABLE_COLUMN_TOP)
            return True

        row = self._get_cell_coordinates(current)[0]
        cell = AXTable.get_top_of_column(current)
        col = self._get_cell_coordinates(cell)[1]
        self._present_cell(script, cell, row, col, current, notify_user)
        return True

    @dbus_service.command
    def move_to_bottom_of_column(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Moves to the bottom of the column."""

        tokens = ["TABLE NAVIGATOR: move_to_bottom_of_column. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        if AXTable.is_bottom_of_column(current):
            if notify_user:
                script.present_message(messages.TABLE_COLUMN_BOTTOM)
            return True

        row = self._get_cell_coordinates(current)[0]
        cell = AXTable.get_bottom_of_column(current)
        col = self._get_cell_coordinates(cell)[1]
        self._present_cell(script, cell, row, col, current, notify_user)
        return True

    @dbus_service.command
    def set_dynamic_column_headers_row(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Sets the row for the dynamic header columns to the current row."""

        tokens = ["TABLE NAVIGATOR: set_dynamic_column_headers_row. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        table = AXTable.get_table(current)
        if table:
            row = AXTable.get_cell_coordinates(current)[0]
            AXTable.set_dynamic_column_headers_row(table, row)
            if notify_user:
                script.present_message(messages.DYNAMIC_COLUMN_HEADER_SET % (row + 1))

        return True

    @dbus_service.command
    def clear_dynamic_column_headers_row(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Clears the row for the dynamic column headers."""

        tokens = ["TABLE NAVIGATOR: clear_dynamic_column_headers_row. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        table = AXTable.get_table(focus_manager.get_manager().get_locus_of_focus())
        if table:
            AXTable.clear_dynamic_column_headers_row(table)
            if notify_user:
                script.interrupt_presentation()
                script.present_message(messages.DYNAMIC_COLUMN_HEADER_CLEARED)

        return True

    @dbus_service.command
    def set_dynamic_row_headers_column(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Sets the column for the dynamic row headers to the current column."""

        tokens = ["TABLE NAVIGATOR: set_dynamic_row_headers_column. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        table = AXTable.get_table(current)
        if table:
            column = AXTable.get_cell_coordinates(current)[1]
            AXTable.set_dynamic_row_headers_column(table, column)
            if notify_user:
                script.present_message(
                    messages.DYNAMIC_ROW_HEADER_SET % script.utilities.convert_column_to_string(
                        column + 1))

        return True

    @dbus_service.command
    def clear_dynamic_row_headers_column(
        self,
        script: default.Script,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True
    ) -> bool:
        """Clears the column for the dynamic row headers."""

        tokens = ["TABLE NAVIGATOR: clear_dynamic_row_headers_column. Script:", script,
                  "Event:", event, "notify_user:", notify_user]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._last_input_event = event
        current = self._get_current_cell()
        if current is None:
            if notify_user:
                script.present_message(messages.TABLE_NOT_IN_A)
            return True

        table = AXTable.get_table(focus_manager.get_manager().get_locus_of_focus())
        if table:
            AXTable.clear_dynamic_row_headers_column(table)
            if notify_user:
                script.interrupt_presentation()
                script.present_message(messages.DYNAMIC_ROW_HEADER_CLEARED)

        return True

    def _present_cell(
        self,
        script: default.Script,
        cell: Atspi.Accessible,
        row: int,
        col: int,
        previous_cell: Atspi.Accessible,
        notify_user: bool = True
    ) -> None:
        """Presents cell to the user."""

        if not AXUtilities.is_table_cell_or_header(cell):
            tokens = ["TABLE NAVIGATOR: ", cell, f"(row {row}, column {col}) is not cell or header"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return

        self._previous_reported_row = row
        self._previous_reported_col = col

        if script.utilities.grab_focus_when_setting_caret(cell):
            AXObject.grab_focus(cell)

        obj = AXObject.find_descendant(cell, AXObject.supports_text) or cell
        focus_manager.get_manager().set_locus_of_focus(None, obj, False)
        if AXObject.supports_text(obj) and not script.utilities.is_gui_cell(cell):
            script.utilities.set_caret_position(obj, 0)

        if not notify_user:
            msg = "TABLE NAVIGATOR: _present_cell called with notify_user=False"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return

        script.present_object(cell, offset=0, priorObj=previous_cell, interrupt=True)

        # TODO - JD: This should be part of the normal table cell presentation.
        if settings_manager.get_manager().get_setting("speakCellCoordinates"):
            script.present_message(
                messages.TABLE_CELL_COORDINATES % {"row" : row + 1, "column" : col + 1})

        # TODO - JD: Ditto.
        if settings_manager.get_manager().get_setting("speakCellSpan"):
            rowspan, colspan = AXTable.get_cell_spans(cell)
            if rowspan > 1 or colspan > 1:
                script.present_message(messages.cell_span(rowspan, colspan))

    @dbus_service.getter
    def get_skip_blank_cells(self) -> bool:
        """Returns whether blank cells should be skipped during navigation."""

        return settings_manager.get_manager().get_setting("skipBlankCells")

    @dbus_service.setter
    def set_skip_blank_cells(self, value: bool) -> bool:
        """Sets whether blank cells should be skipped during navigation."""

        if self.get_skip_blank_cells() == value:
            return True

        msg = f"TABLE NAVIGATOR: Setting skip blank cells to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        settings_manager.get_manager().set_setting("skipBlankCells", value)
        return True

_navigator : TableNavigator = TableNavigator()

def get_navigator() -> TableNavigator:
    """Returns the Table Navigator"""

    return _navigator
