Repository: hanjinliu/tabulous (language: python)

Issue:
Refactor all the commands
Currently, contextmenu, command palette and keybindings are all implemented separately.
This is messy and makes it harder to extend functionalities.


Discussion / hints:

Fix patch:
diff --git a/rst/main/quickstart.rst b/rst/main/quickstart.rst
index ecb4ff5a..deb54216 100644
--- a/rst/main/quickstart.rst
+++ b/rst/main/quickstart.rst
@@ -251,4 +251,5 @@ to register custom key combo.
 Command palette
 ===============
 
-``Ctrl+Shift+P`` opens a command palette widget. You can search for a variety of registered commands.
+``Ctrl+Shift+P`` or ``F1`` opens a command palette widget. You can search for a variety of
+registered commands.
diff --git a/tabulous/__init__.py b/tabulous/__init__.py
index f3779673..d43ab1b0 100644
--- a/tabulous/__init__.py
+++ b/tabulous/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "0.3.1"
+__version__ = "0.4.0.dev0"
 
 from tabulous.widgets import TableViewer, TableViewerWidget
 from tabulous.core import (
diff --git a/tabulous/_qt/_table/_dtype.py b/tabulous/_dtype.py
similarity index 100%
rename from tabulous/_qt/_table/_dtype.py
rename to tabulous/_dtype.py
diff --git a/tabulous/_qt/_console.py b/tabulous/_qt/_console.py
index 9778e119..6a6834cd 100644
--- a/tabulous/_qt/_console.py
+++ b/tabulous/_qt/_console.py
@@ -154,7 +154,11 @@ def setBuffer(self, code: str) -> None:
         cursor.insertText(lines[-1])
         return None
 
-    @_keymap.bind("Ctrl+I")
+    def insertText(self, text: str) -> None:
+        cursor = self._control.textCursor()
+        cursor.insertText(text)
+        return None
+
     def setTempText(self, text: str | None = None) -> None:
         if text is None:
             text = f"viewer.data.loc[...]"
diff --git a/tabulous/_qt/_icons/palette.svg b/tabulous/_qt/_icons/palette.svg
new file mode 100644
index 00000000..f3a8324a
--- /dev/null
+++ b/tabulous/_qt/_icons/palette.svg
@@ -0,0 +1,12 @@
+<svg width="75" height="70"
+    xmlns="http://www.w3.org/2000/svg"
+    xmlns:xlink="http://www.w3.org/1999/xlink" overflow="hidden">
+    <defs>
+        <clipPath id="clip0">
+            <rect x="702" y="622" width="75" height="70"/>
+        </clipPath>
+    </defs>
+    <g clip-path="url(#clip0)" transform="translate(-702 -622)">
+        <path d="M728.919 666.307C725.245 666.307 722.266 669.285 722.266 672.96 722.266 676.634 725.245 679.613 728.919 679.613 732.594 679.613 735.572 676.634 735.572 672.96 735.572 669.285 732.593 666.307 728.919 666.307ZM714.672 658.923C712.633 658.923 710.98 660.576 710.98 662.615 710.98 664.654 712.633 666.307 714.672 666.307 716.711 666.307 718.363 664.654 718.363 662.615 718.363 660.576 716.711 658.923 714.672 658.923ZM721.466 641.183C718.734 641.183 716.519 643.397 716.519 646.129 716.519 648.861 718.734 651.076 721.466 651.076 724.198 651.076 726.412 648.861 726.412 646.129 726.412 643.397 724.198 641.183 721.466 641.183ZM758.042 636.42C754.367 636.42 751.389 639.399 751.389 643.074 751.389 646.748 754.367 649.727 758.042 649.727 761.716 649.727 764.695 646.748 764.695 643.074 764.695 639.399 761.716 636.42 758.042 636.42ZM738.974 629.758C735.3 629.758 732.321 632.736 732.321 636.411 732.321 640.085 735.3 643.064 738.974 643.064 742.648 643.064 745.627 640.085 745.627 636.411 745.627 632.736 742.648 629.758 738.974 629.758ZM744.325 623.712C758.678 624.79 771.126 633.831 774.882 647.396 776.721 654.04 776.214 660.826 773.836 666.996L772.932 668.796 772.403 667.812C768.559 663.375 761.88 661.364 755.252 663.199 746.414 665.645 741.014 674.003 743.191 681.865 743.735 683.831 744.701 685.579 745.983 687.058L748.731 689.157 748.565 689.22C729.126 694.602 709.391 684.602 704.486 666.885 699.581 649.167 711.364 630.442 730.803 625.06 733.233 624.388 735.667 623.955 738.077 623.748 740.185 623.567 742.275 623.559 744.325 623.712Z" stroke="#000000" stroke-width="1.33333" stroke-miterlimit="8" fill="#000000" fill-rule="evenodd"/>
+    </g>
+</svg>
diff --git a/tabulous/_qt/_icons/tile.svg b/tabulous/_qt/_icons/tile.svg
new file mode 100644
index 00000000..d91a6ab0
--- /dev/null
+++ b/tabulous/_qt/_icons/tile.svg
@@ -0,0 +1,1 @@
+<svg width="79" height="83" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" overflow="hidden"><defs><clipPath id="clip0"><rect x="509" y="615" width="79" height="83"/></clipPath></defs><g clip-path="url(#clip0)" transform="translate(-509 -615)"><path d="M513 624 542.3 624" stroke="#000000" stroke-width="8" stroke-miterlimit="8" fill="none" fill-rule="evenodd"/><path d="M512 624.528C512 620.923 514.923 618 518.528 618L537.472 618C541.077 618 544 620.923 544 624.528L544 687.472C544 691.077 541.077 694 537.472 694L518.528 694C514.923 694 512 691.077 512 687.472Z" stroke="#000000" stroke-width="6" stroke-miterlimit="8" fill="none" fill-rule="evenodd"/><path d="M553 624 582.3 624" stroke="#000000" stroke-width="8" stroke-miterlimit="8" fill="none" fill-rule="evenodd"/><path d="M552 624.528C552 620.923 554.923 618 558.528 618L577.472 618C581.077 618 584 620.923 584 624.528L584 687.472C584 691.077 581.077 694 577.472 694L558.528 694C554.923 694 552 691.077 552 687.472Z" stroke="#000000" stroke-width="6" stroke-miterlimit="8" fill="none" fill-rule="evenodd"/></g></svg>
diff --git a/tabulous/_qt/_icons/untile.svg b/tabulous/_qt/_icons/untile.svg
new file mode 100644
index 00000000..7c873e8d
--- /dev/null
+++ b/tabulous/_qt/_icons/untile.svg
@@ -0,0 +1,1 @@
+<svg width="77" height="83" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" overflow="hidden"><defs><clipPath id="clip0"><rect x="603" y="615" width="77" height="83"/></clipPath></defs><g clip-path="url(#clip0)" transform="translate(-603 -615)"><path d="M608 628 675.955 628" stroke="#000000" stroke-width="4" stroke-miterlimit="8" fill="none" fill-rule="evenodd"/><path d="M607 630.102C607 623.971 611.971 619 618.102 619L664.898 619C671.029 619 676 623.971 676 630.102L676 682.898C676 689.029 671.029 694 664.898 694L618.102 694C611.971 694 607 689.029 607 682.898Z" stroke="#000000" stroke-width="6" stroke-miterlimit="8" fill="none" fill-rule="evenodd"/><path d="M609 624 673.565 624" stroke="#000000" stroke-width="8" stroke-miterlimit="8" fill="none" fill-rule="evenodd"/></g></svg>
diff --git a/tabulous/_qt/_keymap/_keymap.py b/tabulous/_qt/_keymap/_keymap.py
index 2ef4cbef..5d43872f 100644
--- a/tabulous/_qt/_keymap/_keymap.py
+++ b/tabulous/_qt/_keymap/_keymap.py
@@ -593,8 +593,12 @@ def _press_one_key(self, key: QtKeys) -> bool:
                 # Don't lose the combo if only a modifier is pressed
                 last = self.last_pressed
                 if key.key != ExtKey.No:
+                    repress = self._current_map is not None
                     current.deactivate()
                     self.initialize()
+                    if repress:
+                        # if key combo is initialized half-way, repress the last key
+                        return self._press_one_key(key)
 
                 if last is not None and last.key == ExtKey.No and key.key != ExtKey.No:
                     return self._press_one_key(key)
@@ -626,7 +630,7 @@ def activate(self, key: KeyType) -> None:
         key = QtKeys(key)
 
         old = self.current_map
-        current = self.current_map[QtKeys(key)]
+        current = self.current_map[key]
         if not isinstance(current, QtKeyMap):
             self.initialize()
             raise KeyError(f"Key {key} is not a child")
diff --git a/tabulous/_qt/_mainwindow/_base.py b/tabulous/_qt/_mainwindow/_base.py
index ac3a6351..97cafc4b 100644
--- a/tabulous/_qt/_mainwindow/_base.py
+++ b/tabulous/_qt/_mainwindow/_base.py
@@ -115,10 +115,6 @@ def keyPressEvent(self, a0: QtGui.QKeyEvent) -> None:
             return super().keyPressEvent(a0)
         return None
 
-    def keyReleaseEvent(self, a0: QtGui.QKeyEvent) -> None:
-        self._keymap.release_key()
-        return None
-
     def showKeyMap(self) -> None:
         """Show keymap viewer widget."""
         if self._keymap_widget is None:
diff --git a/tabulous/_qt/_mainwindow/_command_palette.py b/tabulous/_qt/_mainwindow/_command_palette.py
index d5466799..f9bb34f2 100644
--- a/tabulous/_qt/_mainwindow/_command_palette.py
+++ b/tabulous/_qt/_mainwindow/_command_palette.py
@@ -1,469 +1,74 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any, Callable
+import logging
 from qt_command_palette import get_palette
+from tabulous import commands as cmds
+from tabulous._utils import get_config
+from ._mainwidgets import QMainWindow, QMainWidget
 
 if TYPE_CHECKING:
     from ._base import _QtMainWidgetBase
-    from tabulous._qt._table import QSpreadSheet
+    from tabulous.widgets import TableViewerBase
 
-palette = get_palette("tabulous")
+logger = logging.getLogger("tabulous")
 
-default_group = palette.add_group("")
-file_group = palette.add_group("File")
-table_group = palette.add_group("Table")
-analysis_group = palette.add_group("Analysis")
-view_group = palette.add_group("View")
-plot_group = palette.add_group("Plot")
-selection_group = palette.add_group("Selection")
-spreadsheet_group = palette.add_group("Spreadsheet")
 
+def _command_to_function(
+    f: Callable[[TableViewerBase], Any]
+) -> Callable[[_QtMainWidgetBase], Any]:
+    def wrapper(self: _QtMainWidgetBase):
+        logger.debug(f"Command: {f.__module__.split('.')[-1]}.{f.__name__}")
+        return f(self._table_viewer)
 
-@default_group.register("Show key map widget")
-def show_keymap(self: _QtMainWidgetBase):
-    """Show the widget to search for key bindings."""
-    self.showKeyMap()
+    wrapper.__doc__ = f.__doc__
+    return wrapper
 
 
-@default_group.register("Close window")
-def close_window(self: _QtMainWidgetBase):
-    """Close this window."""
-    self.close()
+def load_all_commands():
 
+    palette = get_palette("tabulous")
 
-@default_group.register("New window")
-def new_window(self: _QtMainWidgetBase):
-    """Create a new window."""
-    viewer = self._table_viewer.__class__()
-    return viewer._qwidget.activateWindow()
+    window_group = palette.add_group("Window")
+    file_group = palette.add_group("File")
+    table_group = palette.add_group("Table")
+    tab_group = palette.add_group("Tab")
+    analysis_group = palette.add_group("Analysis")
+    view_group = palette.add_group("View")
+    plot_group = palette.add_group("Plot")
+    selection_group = palette.add_group("Selection")
 
+    _groups = {
+        "window": window_group,
+        "file": file_group,
+        "table": table_group,
+        "tab": tab_group,
+        "analysis": analysis_group,
+        "view": view_group,
+        "plot": plot_group,
+        "selection": selection_group,
+    }
 
-@default_group.register("Toggle toolbar visibility")
-def toggle_toolbar(self: _QtMainWidgetBase):
-    """Show or collapse the toolbar."""
-    self.toggleToolBarVisibility()
+    kb = get_config().keybindings.copy()
 
+    for mod, cmd in cmds.iter_commands():
+        group = _groups[mod]
+        group.register(_command_to_function(cmd), desc=cmd.__doc__)
+        if seq := kb.pop(f"{mod}.{cmd.__name__}", None):
+            f = _command_to_function(cmd)
+            if isinstance(seq, str):
+                QMainWidget._keymap.bind(seq)(f)
+                QMainWindow._keymap.bind(seq)(f)
+            elif isinstance(seq, list):
+                for s in seq:
+                    QMainWidget._keymap.bind(s)(f)
+                    QMainWindow._keymap.bind(s)(f)
 
-@default_group.register("Toggle fullscreen")
-def toggle_fullscreen(self: _QtMainWidgetBase):
-    """Enable or disable fullscreen mode."""
-    if self.isFullScreen():
-        self.showNormal()
-    else:
-        self.showFullScreen()
+    if kb:
+        import warnings
 
+        keys = ", ".join(kb.keys())
+        warnings.warn(f"Unrecognized commands: {keys}")
 
-@default_group.register("Undo")
-def undo(self: _QtMainWidgetBase):
-    """Undo last action."""
-    self._table_viewer.current_table.undo_manager.undo()
 
-
-@default_group.register("Redo")
-def redo(self: _QtMainWidgetBase):
-    """Redo last undone action."""
-    self._table_viewer.current_table.undo_manager.redo()
-
-
-@file_group.register("Open a file as a table")
-def open_table(self: _QtMainWidgetBase):
-    """Select a file in the file dialog and open it as a Table."""
-    self._toolbar.open_table()
-
-
-@file_group.register("Open a file as a spreadsheet")
-def open_spreadsheet(self: _QtMainWidgetBase):
-    """Select a file in the file dialog and open it as a Spreadsheet."""
-    self._toolbar.open_spreadsheet()
-
-
-@file_group.register("Save current table/spreadsheet")
-def save_table(self: _QtMainWidgetBase):
-    """Save the current table or spreadsheet as a file."""
-    self._toolbar.save_table()
-
-
-@file_group.register("Open sample data")
-def open_sample(self: _QtMainWidgetBase):
-    """Open a seaborn sample data as a Table."""
-    self._toolbar.open_sample()
-
-
-@table_group.register("Copy current table as a new Table")
-def copy_as_table(self: _QtMainWidgetBase):
-    """Copy the data of the current table as a new Table."""
-    self._toolbar.copy_as_table()
-
-
-@table_group.register("Copy current table as a new Spreadsheet")
-def copy_as_spreadsheet(self: _QtMainWidgetBase):
-    """Copy the data of the current table as a new Spreadsheet."""
-    self._toolbar.copy_as_spreadsheet()
-
-
-@table_group.register("Group by")
-def groupby(self: _QtMainWidgetBase):
-    """Run groupby function on the current table by arbitrary columns."""
-    self._toolbar.groupby()
-
-
-@table_group.register("Switch header and the top row")
-def switch_header(self: _QtMainWidgetBase):
-    """Convert the top row to header or vice versa."""
-    self._toolbar.switch_header()
-
-
-@table_group.register("Concatenate tables")
-def concat(self: _QtMainWidgetBase):
-    """Concatenate multiple tables into one table."""
-    self._toolbar.concat()
-
-
-@table_group.register("Pivot table")
-def pivot(self: _QtMainWidgetBase):
-    """Pivot the current table."""
-    self._toolbar.pivot()
-
-
-@table_group.register("Melt table")
-def melt(self: _QtMainWidgetBase):
-    """Melt the current table."""
-    self._toolbar.melt()
-
-
-@table_group.register("Find item")
-def find_item(self: _QtMainWidgetBase):
-    """Open the finder widget to find table items by its text or value."""
-    self._toolbar.find_item()
-
-
-@table_group.register("Sort table")
-def sort_table(self: _QtMainWidgetBase):
-    """Sort the current table by arbitrary columns."""
-    self._toolbar.sort_table()
-
-
-@table_group.register("Generate random values")
-def random(self: _QtMainWidgetBase):
-    """Open the random value generator widget."""
-    self._toolbar.random()
-
-
-@analysis_group.register("Summarize table")
-def summarize_table(self: _QtMainWidgetBase):
-    """Summarize the current table by mean, std, etc."""
-    self._toolbar.summarize_table()
-
-
-@analysis_group.register("Evaluate expression")
-def eval(self: _QtMainWidgetBase):
-    """Evaluate an expression on the current table."""
-    self._toolbar.eval()
-
-
-@analysis_group.register("Filter table")
-def filter(self: _QtMainWidgetBase):
-    """Filter the current table by arbitrary conditions."""
-    self._toolbar.filter()
-
-
-@analysis_group.register("Open optimizer widget")
-def optimize(self: _QtMainWidgetBase):
-    """Open the optimizer widget to run scipy.minimize on the table."""
-    self._toolbar.optimize()
-
-
-@analysis_group.register("Open statistics test widget")
-def stats_test(self: _QtMainWidgetBase):
-    """Open the statistics test widget to run scipy.stats tests."""
-    self._toolbar.stats_test()
-
-
-@analysis_group.register("Open scikit-learn widget")
-def sklearn_analysis(self: _QtMainWidgetBase):
-    """Open the scikit-learn widget to run clustering, decomposition, etc."""
-    self._toolbar.sklearn_analysis()
-
-
-@analysis_group.register("Toggle QtConsole")
-def toggle_console(self: _QtMainWidgetBase):
-    """Show or hide the console widget visibility."""
-    self._toolbar.toggle_console()
-
-
-@view_group.register("Popup table")
-def view_popup(self: _QtMainWidgetBase):
-    """View the current table in a popup window."""
-    self._toolbar.change_view_mode("popup")
-
-
-@view_group.register("Split table horizontally")
-def view_horizontal(self: _QtMainWidgetBase):
-    """Split the current table horizontally."""
-    self._toolbar.change_view_mode("horizontal")
-
-
-@view_group.register("Split table vertically")
-def view_vertical(self: _QtMainWidgetBase):
-    """Split the current table vertically."""
-    self._toolbar.change_view_mode("vertical")
-
-
-@view_group.register("Reset view")
-def view_normal(self: _QtMainWidgetBase):
-    """Reset the view mode of the current table."""
-    self._toolbar.change_view_mode("normal")
-
-
-@plot_group.register("Run plt.plot")
-def plot(self: _QtMainWidgetBase):
-    """Open a dialog to run plt.plot."""
-    self._toolbar.plot()
-
-
-@plot_group.register("Run plt.scatter")
-def scatter(self: _QtMainWidgetBase):
-    """Open a dialog to run plt.scatter."""
-    self._toolbar.scatter()
-
-
-@plot_group.register("Run plt.errorbar")
-def errorbar(self: _QtMainWidgetBase):
-    """Open a dialog to run plt.errorbar."""
-    self._toolbar.errorbar()
-
-
-@plot_group.register("Run plt.hist")
-def hist(self: _QtMainWidgetBase):
-    """Open a dialog to run plt.hist."""
-    self._toolbar.hist()
-
-
-@plot_group.register("Run sns.swarmplot")
-def swarmplot(self: _QtMainWidgetBase):
-    """Open a dialog to run sns.swarmplot."""
-    self._toolbar.swarmplot()
-
-
-@plot_group.register("Run sns.barplot")
-def barplot(self: _QtMainWidgetBase):
-    """Open a dialog to run sns.barplot."""
-    self._toolbar.barplot()
-
-
-@plot_group.register("Run sns.boxplot")
-def boxplot(self: _QtMainWidgetBase):
-    """Open a dialog to run sns.boxplot."""
-    self._toolbar.boxplot()
-
-
-@plot_group.register("Run sns.boxenplot")
-def boxenplot(self: _QtMainWidgetBase):
-    """Open a dialog to run sns.boxenplot."""
-    self._toolbar.boxenplot()
-
-
-@plot_group.register("Create a new figure canvas")
-def new_figure(self: _QtMainWidgetBase):
-    """Create a new figure canvas in the side area."""
-    self._toolbar.new_figure()
-
-
-@selection_group.register("Copy cells")
-def copy(self: _QtMainWidgetBase):
-    """Copy the selected cells to the clipboard as tab-separated text."""
-    self._table_viewer.copy_data()
-
-
-@selection_group.register("Copy cells with headers")
-def copy_with_header(self: _QtMainWidgetBase):
-    """Copy the selected cells and headers to the clipboard as tab-separated text."""
-    self._table_viewer.copy_data(header=True)
-
-
-@selection_group.register("Copy as a new Table")
-def copy_as_new_table(self: _QtMainWidgetBase):
-    """Copy the selected cells and create a new table."""
-    self._table_viewer.current_table._qwidget._copy_as_new_table("table")
-
-
-@selection_group.register("Copy as a new SpreadSheet")
-def copy_as_new_spreadsheet(self: _QtMainWidgetBase):
-    """Copy the selected cells and create a new spreadsheet."""
-    self._table_viewer.current_table._qwidget._copy_as_new_table("spreadsheet")
-
-
-@selection_group.register("Copy as tab separated text")
-def copy_as_tab(self: _QtMainWidgetBase):
-    """Copy the selected cells to the clipboard as tab-separated text."""
-    self._table_viewer.current_table._qwidget.copyToClipboard(headers=False, sep="\t")
-
-
-@selection_group.register("Copy as tab separated text with headers")
-def copy_as_tab_with_header(self: _QtMainWidgetBase):
-    """Copy the selected cells and headers to the clipboard as tab-separated text."""
-    self._table_viewer.current_table._qwidget.copyToClipboard(headers=True, sep="\t")
-
-
-@selection_group.register("Copy as comma separated text")
-def copy_as_comma(self: _QtMainWidgetBase):
-    """Copy the selected cells to the clipboard as comma-separated text."""
-    self._table_viewer.current_table._qwidget.copyToClipboard(headers=False, sep=",")
-
-
-@selection_group.register("Copy as comma separated text with headers")
-def copy_as_comma_with_header(self: _QtMainWidgetBase):
-    """Copy the selected cells and headers to the clipboard as comma-separated text."""
-    self._table_viewer.current_table._qwidget.copyToClipboard(headers=True, sep=",")
-
-
-@selection_group.register("Copy as literal")
-def copy_as_literal(self: _QtMainWidgetBase):
-    """Copy the selected cells as a python literal for use in the console."""
-    self._table_viewer.current_table._qwidget._copy_as_literal()
-
-
-@selection_group.register("Paste from comma separated text")
-def paste_from_comma(self: _QtMainWidgetBase):
-    """Paste comma-separated text from the clipboard at the selection."""
-    self._table_viewer.current_table._qwidget.pasteFromClipBoard(sep=",")
-
-
-@selection_group.register("Paste from numpy-style text")
-def paste_from_numpy(self: _QtMainWidgetBase):
-    """Paste numpy-style text from the clipboard at the selection."""
-    self._table_viewer.current_table._qwidget._paste_numpy_str()
-
-
-@selection_group.register("Add highlight")
-def add_highlight(self: _QtMainWidgetBase):
-    """Add highlight at the selection."""
-    qwidget = self._table_viewer.current_table._qwidget
-    qwidget.setHighlights(qwidget.highlights() + qwidget.selections())
-
-
-@selection_group.register("Select all")
-def select_all(self: _QtMainWidgetBase):
-    """Select all the cells in the table."""
-    self._table_viewer.current_table._qwidget._qtable_view.selectAll()
-
-
-@selection_group.register("Cut selected cells")
-def cut(self: _QtMainWidgetBase):
-    """Cut the selected cells to the clipboard as tab-separated text."""
-    qwidget = self._table_viewer.current_table._qwidget
-    qwidget.copyToClipboard(headers=False)
-    return qwidget.deleteValues()
-
-
-@selection_group.register("Paste data")
-def paste(self: _QtMainWidgetBase):
-    """Paste data from the clipboard at the selection."""
-    self._table_viewer.paste_data()
-
-
-@selection_group.register("Delete selected cells")
-def delete(self: _QtMainWidgetBase):
-    """Delete the selected cells."""
-    self._table_viewer.current_table._qwidget.deleteValues()
-
-
-def _get_spreadsheet(self: _QtMainWidgetBase) -> QSpreadSheet:
-    from tabulous._qt._table import QSpreadSheet
-
-    qwidget = self._table_viewer.current_table._qwidget
-    if isinstance(qwidget, QSpreadSheet):
-        return qwidget
-    raise TypeError("This action is only available for a Spreadsheet")
-
-
-def _get_selected_column(qspreadsheet: QSpreadSheet) -> int:
-    colsel = qspreadsheet._qtable_view._selection_model._col_selection_indices
-    if len(colsel) == 0:
-        raise ValueError("No columns selected")
-    if len(colsel) > 1:
-        raise ValueError("Multiple columns selected")
-    idx = next(iter(colsel))
-    rng = qspreadsheet._qtable_view._selection_model.ranges[idx]
-    if rng[1].start != rng[1].stop - 1:
-        raise ValueError("Multiple columns selected")
-
-    return rng[1].start
-
-
-@spreadsheet_group.register("Insert row above")
-def insert_row_above(self: _QtMainWidgetBase):
-    """Insert a row above the current selection"""
-    qspreadsheet = _get_spreadsheet(self)
-    qspreadsheet._insert_row_above(
-        qspreadsheet._qtable_view._selection_model.current_index.row
-    )
-
-
-@spreadsheet_group.register("Insert row below")
-def insert_row_below(self: _QtMainWidgetBase):
-    """Insert a row below the current selection"""
-    qspreadsheet = _get_spreadsheet(self)
-    qspreadsheet._insert_row_below(
-        qspreadsheet._qtable_view._selection_model.current_index.row
-    )
-
-
-@spreadsheet_group.register("Insert column left")
-def insert_column_left(self: _QtMainWidgetBase):
-    """Insert a column on the left side of the current selection"""
-    qspreadsheet = _get_spreadsheet(self)
-    qspreadsheet._insert_column_left(
-        qspreadsheet._qtable_view._selection_model.current_index.column
-    )
-
-
-@spreadsheet_group.register("Insert column right")
-def insert_column_right(self: _QtMainWidgetBase):
-    """Insert a column on the right side of the current selection"""
-    qspreadsheet = _get_spreadsheet(self)
-    qspreadsheet._insert_column_right(
-        qspreadsheet._qtable_view._selection_model.current_index.column
-    )
-
-
-@spreadsheet_group.register("Remove selected rows")
-def remove_row(self: _QtMainWidgetBase):
-    """Remove all the selected rows."""
-    qspreadsheet = _get_spreadsheet(self)
-    qspreadsheet._remove_selected_rows(
-        qspreadsheet._qtable_view._selection_model.current_index.row
-    )
-
-
-@spreadsheet_group.register("Remove selected columns")
-def remove_column(self: _QtMainWidgetBase):
-    """Remove all the selected columns."""
-    qspreadsheet = _get_spreadsheet(self)
-    qspreadsheet._remove_selected_columns(
-        qspreadsheet._qtable_view._selection_model.current_index.column
-    )
-
-
-@spreadsheet_group.register("Set column dtype")
-def set_column_dtype(self: _QtMainWidgetBase):
-    """Open a dialog to set the dtype to the selected column."""
-    qspreadsheet = _get_spreadsheet(self)
-    index = _get_selected_column(qspreadsheet)
-    qspreadsheet._set_column_dtype_with_dialog(index)
-
-
-@spreadsheet_group.register("Set foreground color")
-def set_foreground_color(self: _QtMainWidgetBase):
-    """Set the foreground color of the selected cells."""
-    qspreadsheet = _get_spreadsheet(self)
-    index = _get_selected_column(qspreadsheet)
-    qspreadsheet._set_foreground_colormap_with_dialog(index)
-
-
-@spreadsheet_group.register("Set background color")
-def set_background_color(self: _QtMainWidgetBase):
-    """Set the background color of the selected cells."""
-    qspreadsheet = _get_spreadsheet(self)
-    index = _get_selected_column(qspreadsheet)
-    qspreadsheet._set_background_colormap_with_dialog(index)
+load_all_commands()
diff --git a/tabulous/_qt/_mainwindow/_keycombo.py b/tabulous/_qt/_mainwindow/_keycombo.py
index 113ccd95..db9763e1 100644
--- a/tabulous/_qt/_mainwindow/_keycombo.py
+++ b/tabulous/_qt/_mainwindow/_keycombo.py
@@ -1,4 +1,5 @@
 from __future__ import annotations
+
 from ._mainwidgets import QMainWindow, QMainWidget, _QtMainWidgetBase
 
 # fmt: off
@@ -10,124 +11,6 @@ def _(self: _QtMainWidgetBase):
     return self.setFocus()
 
 
-@QMainWidget._keymap.bind("Ctrl+K, Ctrl+T")
-@QMainWindow._keymap.bind("Ctrl+K, Ctrl+T")
-def _(self: _QtMainWidgetBase):
-    """Toggle toolbar visibility."""
-    return self.toggleToolBarVisibility()
-
-@QMainWidget._keymap.bind("Ctrl+K, E")
-@QMainWindow._keymap.bind("Ctrl+K, E")
-def _(self: _QtMainWidgetBase):
-    """Toggle table editability."""
-    table = self._table_viewer.current_table
-    try:
-        table.editable = not table.editable
-        self._tablestack._notifier.setVisible(False)
-    except Exception:
-        pass
-    self.setCellFocus()
-
-@QMainWidget._keymap.bind("Ctrl+K, Delete")
-@QMainWindow._keymap.bind("Ctrl+K, Delete")
-def _(self: _QtMainWidgetBase):
-    """Delete current table."""
-    table = self._table_viewer.current_table
-    if table is not None:
-        idx = self._table_viewer.tables.index(table)
-        del self._table_viewer.tables[idx]
-
-@QMainWidget._keymap.bind("Ctrl+K, V", mode="vertical", desc="Vertical dual view mode.")
-@QMainWindow._keymap.bind("Ctrl+K, V", mode="vertical", desc="Vertical dual view mode.")
-@QMainWidget._keymap.bind("Ctrl+K, H", mode="horizontal", desc="Horizontal dual view mode.")
-@QMainWindow._keymap.bind("Ctrl+K, H", mode="horizontal", desc="Horizontal dual view mode.")
-@QMainWidget._keymap.bind("Ctrl+K, P", mode="popup", desc="Popup view mode.")
-@QMainWindow._keymap.bind("Ctrl+K, P", mode="popup", desc="Popup view mode.")
-@QMainWidget._keymap.bind("Ctrl+K, N", mode="normal", desc="Reset view mode.")
-@QMainWindow._keymap.bind("Ctrl+K, N", mode="normal", desc="Reset view mode.")
-def _(self: _QtMainWidgetBase, mode):
-    self._table_viewer.current_table.view_mode = mode
-    return None
-
-@QMainWindow._keymap.bind("Ctrl+Shift+C")
-def _(self: _QtMainWidgetBase):
-    """Toggle embeded console visibility."""
-    return self.toggleConsoleVisibility()
-
-
-@QMainWidget._keymap.bind("Ctrl+Shift+P")
-@QMainWindow._keymap.bind("Ctrl+Shift+P")
-def _(self: _QtMainWidgetBase):
-    """Show command palette."""
-    return self.showCommandPalette()
-
-@QMainWidget._keymap.bind("Ctrl+0")
-@QMainWindow._keymap.bind("Ctrl+0")
-def _(self: _QtMainWidgetBase):
-    """Focus on a cell in the current table."""
-    return self.setCellFocus()
-
-
-@QMainWidget._keymap.bind("Ctrl+Shift+F")
-@QMainWindow._keymap.bind("Ctrl+Shift+F")
-def _(self: _QtMainWidgetBase):
-    """Move focus between table and console."""
-    table = self._table_viewer.current_table
-    if table is None:
-        return
-    if table._qwidget._qtable_view.hasFocus():
-        console = self._console_widget
-        if console is not None and console.isActive():
-            console.setFocus()
-    else:
-        self.setCellFocus()
-    return
-
-
-@QMainWidget._keymap.bind("Ctrl+I")
-@QMainWindow._keymap.bind("Ctrl+I")
-def _(self: _QtMainWidgetBase):
-    """Insert data reference to the console."""
-    console = self._console_widget
-    if console is not None and console.isActive():
-        console.setTempText(f"{console._current_data_identifier}[...]")
-    return None
-
-@QMainWidget._keymap.bind("Ctrl+N")
-@QMainWindow._keymap.bind("Ctrl+N")
-def _(self: _QtMainWidgetBase):
-    """New spreadsheet"""
-    self._table_viewer.add_spreadsheet(name="New")
-    return self.setCellFocus()
-
-
-@QMainWindow._keymap.bind("Ctrl+Shift+N")
-def _(self: _QtMainWidgetBase):
-    """New window"""
-    viewer = self._table_viewer.__class__()
-    return viewer._qwidget.activateWindow()
-
-
-@QMainWidget._keymap.bind("Ctrl+O")
-@QMainWindow._keymap.bind("Ctrl+O")
-def _(self: _QtMainWidgetBase):
-    """Open a file as a table."""
-    return self.openFromDialog(type="table")
-
-@QMainWidget._keymap.bind("Ctrl+K, Ctrl+O")
-@QMainWindow._keymap.bind("Ctrl+K, Ctrl+O")
-def _(self: _QtMainWidgetBase):
-    """Open a file as a table."""
-    return self.openFromDialog(type="spreadsheet")
-
-
-@QMainWidget._keymap.bind("Ctrl+S")
-@QMainWindow._keymap.bind("Ctrl+S")
-def _(self: _QtMainWidgetBase):
-    """Save current table."""
-    return self._toolbar.save_table()
-
-
 @QMainWindow._keymap.bind("Alt")
 def _(self: _QtMainWidgetBase):
     """Move focus to toolbar."""
@@ -135,7 +18,7 @@ def _(self: _QtMainWidgetBase):
     self.setFocus()
 
 
-@QMainWindow._keymap.bind("Alt, F", index=0, desc="Move focus to `File` menu tab.")
+@QMainWindow._keymap.bind("Alt, H", index=0, desc="Move focus to `Home` menu tab.")
 @QMainWindow._keymap.bind("Alt, T", index=1, desc="Move focus to `Table` menu tab.")
 @QMainWindow._keymap.bind("Alt, A", index=2, desc="Move focus to `Analyze` menu tab.")
 @QMainWindow._keymap.bind("Alt, V", index=3, desc="Move focus to `View` menu tab.")
@@ -145,7 +28,7 @@ def _(self: QMainWindow, index: int):
     self._toolbar.currentToolBar().showTabTooltips()
 
 
-@QMainWindow._keymap.bind("Alt, F, {}")
+@QMainWindow._keymap.bind("Alt, H, {}")
 @QMainWindow._keymap.bind("Alt, T, {}")
 @QMainWindow._keymap.bind("Alt, A, {}")
 @QMainWindow._keymap.bind("Alt, V, {}")
@@ -159,121 +42,4 @@ def _(self: QMainWindow, key: str):
 
     self._toolbar.currentToolBar().clickButton((index - 1) % 10, ignore_index_error=True)
 
-
-@QMainWindow._keymap.bind("Ctrl+Tab")
-def _(self: QMainWindow):
-    """Activate the new tab."""
-    num = self._tablestack.count()
-    if num == 0:
-        return None
-    idx = self._tablestack.currentIndex() + 1
-    if idx >= num:
-        idx = 0
-    return self._tablestack.setCurrentIndex(idx)
-
-
-@QMainWidget._keymap.bind("Ctrl+K, Shift+?")
-@QMainWindow._keymap.bind("Ctrl+K, Shift+?")
-def _(self: _QtMainWidgetBase):
-    """Open a keymap viewer."""
-    return self.showKeyMap()
-
-@QMainWidget._keymap.bind("Alt+Left")
-@QMainWindow._keymap.bind("Alt+Left")
-def _(self: _QtMainWidgetBase):
-    """Activate the previous table."""
-    num = self._tablestack.count()
-    if num == 0:
-        return None
-    src = self._tablestack.currentIndex()
-    if src == 0:
-        return
-    self._tablestack.setCurrentIndex(src - 1)
-    return self.setCellFocus()
-
-@QMainWidget._keymap.bind("Alt+Right")
-@QMainWindow._keymap.bind("Alt+Right")
-def _(self: _QtMainWidgetBase):
-    """Activate the next table."""
-    num = self._tablestack.count()
-    if num == 0:
-        return None
-    src = self._tablestack.currentIndex()
-    if src == num - 1:
-        return
-    self._tablestack.setCurrentIndex(src + 1)
-    return self.setCellFocus()
-
-@QMainWidget._keymap.bind("Alt+Shift+Left")
-@QMainWindow._keymap.bind("Alt+Shift+Left")
-def _(self: _QtMainWidgetBase):
-    """Swap the current table with the previous one."""
-    num = self._tablestack.count()
-    if num == 0:
-        return None
-    src = self._tablestack.currentIndex()
-    if src == 0:
-        return
-    self._table_viewer.tables.move(src, src - 1)
-
-@QMainWidget._keymap.bind("Alt+Shift+Right")
-@QMainWindow._keymap.bind("Alt+Shift+Right")
-def _(self: _QtMainWidgetBase):
-    """Swap the current table with the next one."""
-    num = self._tablestack.count()
-    if num == 0:
-        return None
-    src = self._tablestack.currentIndex()
-    if src == num - 1:
-        return
-    self._table_viewer.tables.move(src + 1, src)
-
-@QMainWidget._keymap.bind("Ctrl+F")
-@QMainWindow._keymap.bind("Ctrl+F")
-def _(self: _QtMainWidgetBase):
-    """Open finder."""
-    self._toolbar.find_item()
-
-@QMainWidget._keymap.bind("Ctrl+K, ^")
-@QMainWindow._keymap.bind("Ctrl+K, ^")
-def _(self: _QtMainWidgetBase):
-    """Tile tables."""
-    num = self._tablestack.count()
-    if num < 2:
-        return None
-    idx = self._tablestack.currentIndex()
-    if idx < num - 1:
-        indices = [idx, idx + 1]
-    else:
-        indices = [idx - 1, idx]
-    all_indices = []
-    for i in indices:
-        all_indices.extend(self._tablestack.tiledIndices(i))
-
-    all_indices = list(set(all_indices))
-    return self._tablestack.tileTables(all_indices, orientation="horizontal")
-
-@QMainWidget._keymap.bind("Ctrl+K, \\")
-@QMainWindow._keymap.bind("Ctrl+K, \\")
-def _(self: _QtMainWidgetBase):
-    """Untile tables."""
-    num = self._tablestack.count()
-    if num < 2:
-        return None
-    idx = self._tablestack.currentIndex()
-    self._tablestack.untileTable(idx)
-
-@QMainWindow._keymap.bind("F11")
-def _(self: QMainWindow):
-    """Toggle fullscreen mode."""
-    if self.isFullScreen():
-        self.showNormal()
-    else:
-        self.showFullScreen()
-
-@QMainWindow._keymap.bind("Ctrl+W")
-def _(self: QMainWindow):
-    """Close the window."""
-    return self.close(ask=None)
-
 # fmt: on
diff --git a/tabulous/_qt/_table/__init__.py b/tabulous/_qt/_table/__init__.py
index e569a2aa..cb5e1a11 100644
--- a/tabulous/_qt/_table/__init__.py
+++ b/tabulous/_qt/_table/__init__.py
@@ -11,4 +11,5 @@
     "QMutableTable",
     "QTableGroupBy",
     "QTableDisplay",
+    "QTableGroup",
 ]
diff --git a/tabulous/_qt/_table/_base/_header_view.py b/tabulous/_qt/_table/_base/_header_view.py
index 8d44798e..9cfbe2b2 100644
--- a/tabulous/_qt/_table/_base/_header_view.py
+++ b/tabulous/_qt/_table/_base/_header_view.py
@@ -50,6 +50,7 @@ def _on_section_clicked(self, logicalIndex) -> None:
 
     def _show_context_menu(self, pos: QtCore.QPoint) -> None:
         index = self.logicalIndexAt(pos)
+        self._on_section_pressed(index)
         return self.execContextMenu(self.viewport().mapToGlobal(pos), index)
 
     def _iter_selections(self) -> Iterator[slice]:
diff --git a/tabulous/_qt/_table/_base/_item_model.py b/tabulous/_qt/_table/_base/_item_model.py
index 5bf517ac..b67d4248 100644
--- a/tabulous/_qt/_table/_base/_item_model.py
+++ b/tabulous/_qt/_table/_base/_item_model.py
@@ -3,13 +3,12 @@
 import warnings
 from qtpy import QtCore, QtGui, QtWidgets as QtW
 from qtpy.QtCore import Qt, Signal
-import numpy as np
 import pandas as pd
 
-from ._text_formatter import DefaultFormatter
 from ._line_edit import QCellLiteralEdit
-from .._dtype import isna
+from tabulous._dtype import isna
 from tabulous.color import normalize_color, ColorType
+from tabulous._text_formatter import DefaultFormatter
 from tabulous._map_model import TableMapping
 
 if TYPE_CHECKING:
diff --git a/tabulous/_qt/_table/_base/_keycombo.py b/tabulous/_qt/_table/_base/_keycombo.py
index 3d3c903a..dceac045 100644
--- a/tabulous/_qt/_table/_base/_keycombo.py
+++ b/tabulous/_qt/_table/_base/_keycombo.py
@@ -1,9 +1,4 @@
-from ._table_base import QBaseTable, QMutableTable
-
-QBaseTable._keymap.bind("Ctrl+C", QBaseTable.copyToClipboard, headers=False)
-QBaseTable._keymap.bind("Ctrl+C, Ctrl+H", QBaseTable.copyToClipboard, headers=True)
-QBaseTable._keymap.bind("Ctrl+H", QBaseTable.undoStackView)
-QBaseTable._keymap.bind("Ctrl+Shift+X", QBaseTable._copy_as_new_table)
+from ._table_base import QBaseTable
 
 
 @QBaseTable._keymap.bind("Up", dr=-1, dc=0)
@@ -43,11 +38,6 @@ def _(self: QBaseTable, row, column):
     return self.moveToItem(row, column, clear_selection=False)
 
 
-@QBaseTable._keymap.bind("Ctrl+A")
-def _(self: QBaseTable):
-    return self._qtable_view.selectAll()
-
-
 @QBaseTable._keymap.bind("Ctrl+Alt+Up", dr=-1, dc=0)
 @QBaseTable._keymap.bind("Ctrl+Alt+Down", dr=1, dc=0)
 @QBaseTable._keymap.bind("Ctrl+Alt+Left", dr=0, dc=-1)
@@ -64,46 +54,3 @@ def _(self: QBaseTable, dr, dc):
     dh = dc * 75 * self._qtable_view._zoom
     vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), vbar.value() + dv)))
     hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), hbar.value() + dh)))
-
-
-@QMutableTable._keymap.bind("Ctrl+X")
-def _(self: QMutableTable):
-    self.copyToClipboard(headers=False)
-    return self.deleteValues()
-
-
-QMutableTable._keymap.bind("Ctrl+V", QMutableTable.pasteFromClipBoard)
-QMutableTable._keymap.bind("Delete", QMutableTable.deleteValues)
-QMutableTable._keymap.bind("Backspace", QMutableTable.deleteValues)
-QMutableTable._keymap.bind("Ctrl+Z", QMutableTable.undo)
-QMutableTable._keymap.bind("Ctrl+Y", QMutableTable.redo)
-
-
-@QMutableTable._keymap.bind("F6")
-def _(self: QMutableTable):
-    """Show the traceback dialog."""
-    qtable_view = self._qtable_view
-    idx = qtable_view._selection_model.current_index
-    if slot := qtable_view._table_map.get_by_dest(idx, None):
-        if slot._current_error is not None:
-            slot.raise_in_msgbox()
-
-
-@QBaseTable._keymap.bind("Menu")
-def _(self: QBaseTable):
-    """Show context menu at the current index."""
-    r, c = self._qtable_view._selection_model.current_index
-    if r >= 0 and c >= 0:
-        index = self._qtable_view.model().index(r, c)
-        rect = self._qtable_view.visualRect(index)
-        self.showContextMenu(rect.center())
-    elif r < 0:
-        header = self._qtable_view.horizontalHeader()
-        rect = header.visualRectAtIndex(c)
-        header._show_context_menu(rect.center())
-    elif c < 0:
-        header = self._qtable_view.verticalHeader()
-        rect = header.visualRectAtIndex(r)
-        header._show_context_menu(rect.center())
-    else:
-        raise RuntimeError("Invalid index")
diff --git a/tabulous/_qt/_table/_base/_table_base.py b/tabulous/_qt/_table/_base/_table_base.py
index d76d5631..5a4590d4 100644
--- a/tabulous/_qt/_table/_base/_table_base.py
+++ b/tabulous/_qt/_table/_base/_table_base.py
@@ -18,15 +18,15 @@
     QVerticalHeaderLineEdit,
     QCellLiteralEdit,
 )
-from tabulous._qt._table._dtype import isna
+from tabulous._dtype import isna
 from tabulous._qt._undo import QtUndoManager, fmt_slice
 from tabulous._qt._svg import QColoredSVGIcon
 from tabulous._qt._keymap import QtKeys, QtKeyMap
 from tabulous._qt._action_registry import QActionRegistry
 from tabulous.types import FilterType, ItemInfo, HeaderInfo, EvalInfo
 from tabulous.exceptions import SelectionRangeError, TableImmutableError
-from tabulous._selection_op import LocSelOp
-from tabulous._range import RectRange
+from tabulous._selection_op import ILocSelOp
+from tabulous import commands as cmds
 
 if TYPE_CHECKING:
     from ._delegate import TableItemDelegate
@@ -115,8 +115,6 @@ def __init__(
         self._qtable_view.rightClickedSignal.connect(self.showContextMenu)
         self._install_actions()
 
-        self.selectionChangedSignal.connect(self._try_update_console)
-
     def createHandle(self) -> QTableHandle:
         """Create custom handle."""
         return QTableHandle(Qt.Orientation.Horizontal, self)
@@ -128,6 +126,7 @@ def showContextMenu(self, pos: QtCore.QPoint) -> None:
 
         row, col = index.row(), index.column()
         sel_model = self._qtable_view._selection_model
+        sel_model.current_index = (row, col)
         highlight_model = self._qtable_view._highlight_model
 
         if sel_model._ctrl_on:
@@ -158,35 +157,35 @@ def showContextMenu(self, pos: QtCore.QPoint) -> None:
     def _install_actions(self) -> None:
         # fmt: off
         hheader = self._qtable_view.horizontalHeader()
-        hheader.registerAction("Color>Set foreground colormap")(self._set_foreground_colormap_with_dialog)
-        hheader.registerAction("Color>Reset foreground colormap")(self._reset_foreground_colormap)
-        hheader.registerAction("Color>Set background colormap")(self._set_background_colormap_with_dialog)
-        hheader.registerAction("Color>Reset background colormap")(self._reset_background_colormap)
-        hheader.registerAction("Formatter>Set text formatter")(self._set_text_formatter_with_dialog)
-        hheader.registerAction("Formatter>Reset text formatter")(self._reset_text_formatter)
+        hheader.registerAction("Color>Set foreground colormap")(lambda idx: cmds.selection.set_foreground_colormap(self.parentViewer()._table_viewer))
+        hheader.registerAction("Color>Reset foreground colormap")(lambda idx: cmds.selection.reset_foreground_colormap(self.parentViewer()._table_viewer))
+        hheader.registerAction("Color>Set background colormap")(lambda idx: cmds.selection.set_background_colormap(self.parentViewer()._table_viewer))
+        hheader.registerAction("Color>Reset background colormap")(lambda idx: cmds.selection.reset_background_colormap(self.parentViewer()._table_viewer))
+        hheader.registerAction("Formatter>Set text formatter")(lambda idx: cmds.selection.set_text_formatter(self.parentViewer()._table_viewer))
+        hheader.registerAction("Formatter>Reset text formatter")(lambda idx: cmds.selection.reset_text_formatter(self.parentViewer()._table_viewer))
         hheader.addSeparator()
 
-        self.registerAction("Copy")(lambda index: self.copyToClipboard(headers=False))
-        self.registerAction("Copy as ... > Tab separated text")(lambda index: self.copyToClipboard(headers=False, sep="\t"))
-        self.registerAction("Copy as ... > Tab separated text with headers")(lambda index: self.copyToClipboard(headers=True, sep="\t"))
-        self.registerAction("Copy as ... > Comma separated text")(lambda index: self.copyToClipboard(headers=False, sep=","))
-        self.registerAction("Copy as ... > Comma separated text with headers")(lambda index: self.copyToClipboard(headers=True, sep=","))
+        self.registerAction("Copy")(lambda idx: cmds.selection.copy_data_tab_separated(self.parentViewer()._table_viewer))
+        self.registerAction("Copy as ... > Tab separated text")(lambda idx: cmds.selection.copy_data_tab_separated(self.parentViewer()._table_viewer))
+        self.registerAction("Copy as ... > Tab separated text with headers")(lambda idx: cmds.selection.copy_data_with_header_tab_separated(self.parentViewer()._table_viewer))
+        self.registerAction("Copy as ... > Comma separated text")(lambda idx: cmds.selection.copy_data_comma_separated(self.parentViewer()._table_viewer))
+        self.registerAction("Copy as ... > Comma separated text with headers")(lambda idx: cmds.selection.copy_data_with_header_comma_separated(self.parentViewer()._table_viewer))
         self.addSeparator("Copy as ... ")
-        self.registerAction("Copy as ... > Markdown")(lambda index: self._copy_as_formated(format="markdown"))
-        self.registerAction("Copy as ... > Latex")(lambda index: self._copy_as_formated(format="latex"))
-        self.registerAction("Copy as ... > HTML")(lambda index: self._copy_as_formated(format="html"))
-        self.registerAction("Copy as ... > Literal")(lambda index: self._copy_as_literal())
+        self.registerAction("Copy as ... > Markdown")(lambda idx: cmds.selection.copy_as_markdown(self.parentViewer()._table_viewer))
+        self.registerAction("Copy as ... > Latex")(lambda idx: cmds.selection.copy_as_latex(self.parentViewer()._table_viewer))
+        self.registerAction("Copy as ... > HTML")(lambda idx: cmds.selection.copy_as_html(self.parentViewer()._table_viewer))
+        self.registerAction("Copy as ... > Literal")(lambda idx: cmds.selection.copy_as_literal(self.parentViewer()._table_viewer))
         self.addSeparator("Copy as ... ")
-        self.registerAction("Copy as ... > New table")(lambda index: self._copy_as_new_table("table"))
-        self.registerAction("Copy as ... > New spreadsheet")(lambda index: self._copy_as_new_table("spreadsheet"))
-        self.registerAction("Paste")(lambda index: self.pasteFromClipBoard())
-        self.registerAction("Paste from ... > Comma separated text")(lambda index: self.pasteFromClipBoard(sep=","))
-        self.registerAction("Paste from ... > numpy-style text")(lambda index: self._paste_numpy_str())
+        self.registerAction("Copy as ... > New table")(lambda idx: cmds.selection.copy_as_new_table(self.parentViewer()._table_viewer))
+        self.registerAction("Copy as ... > New spreadsheet")(lambda idx: cmds.selection.copy_as_new_spreadsheet(self.parentViewer()._table_viewer))
+        self.registerAction("Paste")(lambda idx: cmds.selection.paste_data_tab_separated(self.parentViewer()._table_viewer))
+        self.registerAction("Paste from ... > Comma separated text")(lambda idx: cmds.selection.paste_data_comma_separated(self.parentViewer()._table_viewer))
+        self.registerAction("Paste from ... > numpy-style text")(lambda idx: cmds.selection.paste_data_from_numpy_string(self.parentViewer()._table_viewer))
         self.addSeparator()
-        self.registerAction("Code ... > Data-changed signal")(lambda index: self._write_data_changed_signal())
-        self.registerAction("Code ... > Get slice")(lambda index: self._write_slice())
-        self.registerAction("Add highlight")(lambda index: self.setHighlights(self.highlights() + self.selections()))
-        self.registerAction("Delete highlight")(lambda index: self._delete_selected_highlights())
+        self.registerAction("Code ... > Data-changed signal")(lambda idx: cmds.selection.write_data_signal_in_console(self.parentViewer()._table_viewer))
+        self.registerAction("Code ... > Get slice")(lambda idx: cmds.selection.write_slice_in_console(self.parentViewer()._table_viewer))
+        self.registerAction("Add highlight")(lambda idx: cmds.selection.add_highlight(self.parentViewer()._table_viewer))
+        self.registerAction("Delete highlight")(lambda idx: self._delete_selected_highlights())
         self.addSeparator()
         # fmt: on
         return None
@@ -812,150 +811,12 @@ def _get_ref_expr(self, r: int, c: int) -> str | None:
             return slot.as_literal()
         return None
 
-    def _set_foreground_colormap_with_dialog(self, index: int) -> None:
-        """Set the foreground colormap from a GUI dialog."""
-        from ._colormap import exec_colormap_dialog
-
-        column_name = self._filtered_columns[index]
-        if cmap := exec_colormap_dialog(self.getDataFrame()[column_name], self):
-            self.setForegroundColormap(column_name, cmap)
-        return None
-
-    def _reset_foreground_colormap(self, index: int) -> None:
-        """Reset the foreground colormap at given index."""
-        column_name = self._filtered_columns[index]
-        return self.setForegroundColormap(column_name, None)
-
-    def _set_background_colormap_with_dialog(self, index: int) -> None:
-        """Set the background colormap from a GUI dialog."""
-        from ._colormap import exec_colormap_dialog
-
-        column_name = self._filtered_columns[index]
-        if cmap := exec_colormap_dialog(self.getDataFrame()[column_name], self):
-            self.setBackgroundColormap(column_name, cmap)
-        return None
-
-    def _reset_background_colormap(self, index: int) -> None:
-        """Reset the background colormap at given index."""
-        column_name = self._filtered_columns[index]
-        return self.setBackgroundColormap(column_name, None)
-
-    def _set_text_formatter_with_dialog(self, index: int) -> None:
-        """Set the text formatter at given index."""
-        from ._text_formatter import exec_formatter_dialog
-
-        column_name = self._filtered_columns[index]
-
-        if fmt := exec_formatter_dialog(self.getDataFrame()[column_name], self):
-            self.setTextFormatter(column_name, fmt)
-        return None
-
-    def _reset_text_formatter(self, index: int) -> None:
-        """Reset the text formatter at given index."""
-        column_name = self._filtered_columns[index]
-        return self.setTextFormatter(column_name, None)
-
     def _delete_selected_highlights(self) -> None:
         """Delete the selected highlight."""
         self._qtable_view._highlight_model.delete_selected()
         self._qtable_view._selection_model.set_ctrl(False)
         return None
 
-    def _try_update_console(self) -> None:
-        """Update the console if it is active."""
-        viewer = self._qtable_view.parentViewer()
-        console = viewer._console_widget
-        if console is None or not console.isActive():
-            return
-        selected = console.selectedText()
-        if selected.startswith(f"viewer.data.loc["):
-            sels = self.selections()
-            if len(sels) != 1:
-                return
-            op = LocSelOp.from_iloc(*sels[0], self.model().df)
-            console.setTempText(op.fmt("viewer.data"))
-        return None
-
-    def _write_data_changed_signal(self) -> None:
-        sels = self.selections()
-        if len(sels) != 1:
-            return
-        viewer = self._qtable_view.parentViewer()
-        console = viewer._console_widget
-        if console is None or not console.isActive():
-            delay = 500  # need delay to wait for the console to be activated
-        else:
-            delay = 0
-        viewer.setConsoleVisible(True)
-
-        def _update_console():
-            console = viewer._console_widget
-            df = self.model().df
-            rsl, csl = sels[0]
-            if rsl == slice(None) and csl == slice(None):
-                _getitem = ""
-            else:
-                r0 = str(rsl.start) if rsl.start is not None else ""
-                r1 = str(rsl.stop) if rsl.stop is not None else ""
-                c0 = str(csl.start) if csl.start is not None else ""
-                c1 = str(csl.stop) if csl.stop is not None else ""
-                _getitem = f"[{r0}:{r1}, {c0}:{c1}]"
-            text = (
-                f"@viewer.current_table.events.data{_getitem}.connect\n"
-                "def _on_data_changed(info: 'ItemInfo'):\n"
-                "    "
-            )
-            if buf := console.buffer():
-                if not buf.endswith("\n"):
-                    buf += "\n"
-                text = buf + text
-            console.setBuffer(text)
-            console.setFocus()
-            console.setTempText("...")
-
-        QtCore.QTimer.singleShot(delay, _update_console)
-        return None
-
-    def _write_slice(self) -> None:
-        sels = self.selections()
-        if len(sels) != 1:
-            return
-        viewer = self._qtable_view.parentViewer()
-        console = viewer._console_widget
-        if console is None or not console.isActive():
-            delay = 500  # need delay to wait for the console to be activated
-        else:
-            delay = 0
-        viewer.setConsoleVisible(True)
-
-        def _update_console():
-            console = viewer._console_widget
-            df = self.model().df
-            rsl, csl = sels[0]
-            if rsl.start is None:
-                rsl_str = f"slice({rsl.stop})"
-            elif rsl.stop is None:
-                rsl_str = f"slice({rsl.start}, None)"
-            else:
-                rsl_str = f"slice({rsl.start}, {rsl.stop})"
-            if csl.start is None:
-                csl_str = f"slice({csl.stop})"
-            elif csl.stop is None:
-                csl_str = f"slice({csl.start}, None)"
-            else:
-                csl_str = f"slice({csl.start}, {csl.stop})"
-
-            text = f"sl = ({rsl_str}, {csl_str})\n"
-            if buf := console.buffer():
-                if not buf.endswith("\n"):
-                    buf += "\n"
-                text = buf + text
-            console.setBuffer(text)
-            console.setFocus()
-
-        QtCore.QTimer.singleShot(delay, _update_console)
-        return None
-
 
 class QMutableTable(QBaseTable):
     """A mutable table widget."""
diff --git a/tabulous/_qt/_table/_base/_table_group.py b/tabulous/_qt/_table/_base/_table_group.py
index 8079c7eb..bf601884 100644
--- a/tabulous/_qt/_table/_base/_table_group.py
+++ b/tabulous/_qt/_table/_base/_table_group.py
@@ -48,8 +48,10 @@ def pop(self, index: int = -1) -> QBaseTable:
             index += self.count()
         out = self._tables.pop(index)
         table = self.widget(index)  # this is a copy
-        table.setParent(None)
+        table._selection_model.moving.disconnect(table._on_moving)
+        table._selection_model.moved.disconnect(table._on_moved)
         table.deleteLater()
+        table.setParent(None)
         return out
 
     def tableIndex(self, table: QBaseTable) -> int:
@@ -111,6 +113,14 @@ def _on_focus_changed(self, idx: int):
     def tableStack(self) -> QTabbedTableStack:
         return self.parent().parent()
 
+    def deleteLater(self) -> None:
+        for i in range(self.count()):
+            qtableview = self.widget(i)
+            qtableview._selection_model.moving.disconnect(qtableview._on_moving)
+            qtableview._selection_model.moved.disconnect(qtableview._on_moved)
+
+        return super().deleteLater()
+
     if TYPE_CHECKING:
 
         def widget(self, index: int) -> _QTableViewEnhanced:
diff --git a/tabulous/_qt/_table/_spreadsheet.py b/tabulous/_qt/_table/_spreadsheet.py
index d1736511..647334fe 100644
--- a/tabulous/_qt/_table/_spreadsheet.py
+++ b/tabulous/_qt/_table/_spreadsheet.py
@@ -13,11 +13,15 @@
 from magicgui import widgets as mWdg
 from collections_undo import arguments
 
+from tabulous.commands.selection import add_float_slider
+
+
 from ._base import AbstractDataFrameModel, QMutableSimpleTable
-from ._dtype import get_converter, get_dtype, DTypeMap, DefaultValidator
-from ._base._text_formatter import DefaultFormatter
+from tabulous._dtype import get_converter, get_dtype, DTypeMap, DefaultValidator
 from tabulous.color import normalize_color
 from tabulous.types import ItemInfo
+from tabulous import commands as cmds
+from tabulous._text_formatter import DefaultFormatter
 
 if TYPE_CHECKING:
     from magicgui.widgets._bases import ValueWidget
@@ -633,11 +637,20 @@ def _set_widget_at_index(self, r: int, c: int, widget: ValueWidget | None) -> No
             # equivalent to delete the widget
             return None
 
+        if widget.widget_type in ("CheckBox", "RadioButton"):
+            converter = lambda x: x != "False"
+        elif widget.widget_type in ("SpinBox", "Slider"):
+            converter = int
+        elif widget.widget_type in ("FloatSpinBox", "FloatSlider"):
+            converter = float
+        else:
+            converter = str
+
         def _sig():
             with widget.changed.blocked():
                 val = self.model().df.iat[r, c]
                 try:
-                    widget.value = val
+                    widget.value = converter(val)
                 except Exception:
                     self.setDataFrameValue(r, c, str(widget.value))
                     raise
@@ -715,75 +728,6 @@ def setColumnDtype(self, label: Hashable, dtype: Any | None) -> None:
     def setColumnDtype(self, label: Hashable, dtype: Any):
         return arguments(label, self._columns_dtype.get(label, None))
 
-    def _insert_row_above(self, row: int):
-        if not self.isEditable():
-            return self.tableStack().notifyEditability()
-        return self.insertRows(row, 1)
-
-    def _insert_row_below(self, row: int):
-        if not self.isEditable():
-            return self.tableStack().notifyEditability()
-        return self.insertRows(row + 1, 1)
-
-    def _insert_column_left(self, col: int):
-        if not self.isEditable():
-            return self.tableStack().notifyEditability()
-        return self.insertColumns(col, 1)
-
-    def _insert_column_right(self, col: int):
-        if not self.isEditable():
-            return self.tableStack().notifyEditability()
-        return self.insertColumns(col + 1, 1)
-
-    def _remove_this_row(self, row: int):
-        if not self.isEditable():
-            return self.tableStack().notifyEditability()
-        return self.removeRows(row, 1)
-
-    def _remove_selected_rows(self, row: int):
-        if not self.isEditable():
-            return self.tableStack().notifyEditability()
-        _, rng = self._qtable_view._selection_model.range_under_index(row, 0)
-        if rng is not None:
-            row_range = rng[0]
-            self.removeRows(row_range.start, row_range.stop - row_range.start)
-        return None
-
-    def _remove_this_column(self, col: int):
-        if not self.isEditable():
-            return self.tableStack().notifyEditability()
-        return self.removeColumns(col, 1)
-
-    def _remove_selected_columns(self, col: int):
-        if not self.isEditable():
-            return self.tableStack().notifyEditability()
-        _, rng = self._qtable_view._selection_model.range_under_index(0, col)
-        if rng is not None:
-            col_range = rng[1]
-            self.removeColumns(col_range.start, col_range.stop - col_range.start)
-        return None
-
-    def _set_column_dtype_with_dialog(self, col: int):
-        """
-        Set column specific dtype for data conversion and validation.
-
-        If a column in a spreadsheet is tagged with a dtype, table data will be
-        parsed according to the specified data type.
-        """
-        from ._dtype import QDtypeWidget
-
-        if out := QDtypeWidget.requestValue(self):
-            dtype_str, validation, formatting = out
-            if dtype_str == "unset":
-                dtype_str = None
-            colname = self._data_raw.columns[col]
-            self.setColumnDtype(colname, dtype_str)
-            if validation:
-                self._set_default_data_validator(colname)
-            if formatting:
-                self._set_default_text_formatter(colname)
-        return None
-
     def _set_default_data_validator(self, name: Hashable):
         """Set default data validator based on the dtype."""
         dtype = self._columns_dtype[name]
@@ -799,66 +743,43 @@ def _set_default_text_formatter(self, name: Hashable):
     def _install_actions(self):
         # fmt: off
         vheader = self._qtable_view.verticalHeader()
-        vheader.registerAction("Insert/Remove > Insert row above")(self._insert_row_above)
-        vheader.registerAction("Insert/Remove > Insert row below")(self._insert_row_below)
-        vheader.registerAction("Insert/Remove > Remove this row")(self._remove_this_row)
-        vheader.registerAction("Insert/Remove > Remove selected rows")(self._remove_selected_rows)
+        vheader.registerAction("Insert/Remove > Insert row above")(lambda idx: cmds.selection.insert_row_above(self.parentViewer()._table_viewer))
+        vheader.registerAction("Insert/Remove > Insert row below")(lambda idx: cmds.selection.insert_row_below(self.parentViewer()._table_viewer))
+        vheader.registerAction("Insert/Remove > Remove this row")(lambda idx: cmds.selection.remove_this_row(self.parentViewer()._table_viewer))
+        vheader.registerAction("Insert/Remove > Remove selected rows")(lambda idx: cmds.selection.remove_selected_rows(self.parentViewer()._table_viewer))
         vheader.addSeparator()
 
         hheader = self._qtable_view.horizontalHeader()
-        hheader.registerAction("Insert/Remove > Insert column left")(self._insert_column_left)
-        hheader.registerAction("Insert/Remove > Insert column right")(self._insert_column_right)
-        hheader.registerAction("Insert/Remove > Remove this column")(self._remove_this_column)
-        hheader.registerAction("Insert/Remove > Remove selected columns")(self._remove_selected_columns)
+        hheader.registerAction("Insert/Remove > Insert column left")(lambda idx: cmds.selection.insert_column_left(self.parentViewer()._table_viewer))
+        hheader.registerAction("Insert/Remove > Insert column right")(lambda idx: cmds.selection.insert_column_right(self.parentViewer()._table_viewer))
+        hheader.registerAction("Insert/Remove > Remove this column")(lambda idx: cmds.selection.remove_this_column(self.parentViewer()._table_viewer))
+        hheader.registerAction("Insert/Remove > Remove selected columns")(lambda idx: cmds.selection.remove_selected_columns(self.parentViewer()._table_viewer))
         hheader.addSeparator()
-        hheader.registerAction("Column dtype")(self._set_column_dtype_with_dialog)
+        hheader.registerAction("Column dtype")(lambda idx: cmds.selection.set_column_dtype(self.parentViewer()._table_viewer))
         hheader.addSeparator()
 
-        self.registerAction("Insert/Remove > Insert a row above")(lambda idx: self._insert_row_above(idx[0]))
-        self.registerAction("Insert/Remove > Insert a row below")(lambda idx: self._insert_row_below(idx[0]))
-        self.registerAction("Insert/Remove > Remove this row")(lambda idx: self._remove_this_row(idx[0]))
+        self.registerAction("Insert/Remove > Insert a row above")(lambda idx: cmds.selection.insert_row_above(self.parentViewer()._table_viewer))
+        self.registerAction("Insert/Remove > Insert a row below")(lambda idx: cmds.selection.insert_row_below(self.parentViewer()._table_viewer))
+        self.registerAction("Insert/Remove > Remove this row")(lambda idx: cmds.selection.remove_this_row(self.parentViewer()._table_viewer))
         self.addSeparator()
-        self.registerAction("Insert/Remove > Insert a column on the left")(lambda idx: self._insert_column_left(idx[1]))
-        self.registerAction("Insert/Remove > Insert a column on the right")(lambda idx: self._insert_column_right(idx[1]))
-        self.registerAction("Insert/Remove > Remove this column")(lambda idx: self._remove_this_column(idx[1]))
+        self.registerAction("Insert/Remove > Insert a column on the left")(lambda idx: cmds.selection.insert_column_left(self.parentViewer()._table_viewer))
+        self.registerAction("Insert/Remove > Insert a column on the right")(lambda idx: cmds.selection.insert_column_right(self.parentViewer()._table_viewer))
+        self.registerAction("Insert/Remove > Remove this column")(lambda idx: cmds.selection.remove_this_column(self.parentViewer()._table_viewer))
         self.addSeparator()
 
         super()._install_actions()
 
-        self.registerAction("Add widget > SpinBox")(lambda idx: self._set_widget_at_index(*idx, mWdg.SpinBox()))
-        self.registerAction("Add widget > Slider")(lambda idx: self._set_widget_at_index(*idx, mWdg.Slider()))
-        self.registerAction("Add widget > FloatSpinBox")(lambda idx: self._set_widget_at_index(*idx, mWdg.FloatSpinBox()))
-        self.registerAction("Add widget > FloatSlider")(lambda idx: self._set_widget_at_index(*idx, mWdg.FloatSlider()))
-        self.registerAction("Add widget > CheckBox")(lambda idx: self._set_widget_at_index(*idx, mWdg.CheckBox()))
-        self.registerAction("Add widget > LineEdit")(lambda idx: self._set_widget_at_index(*idx, mWdg.LineEdit()))
-        # fmt: on
-        return None
-
-    def _set_foreground_colormap_with_dialog(self, index: int):
-        return self._set_colormap(index, self.model()._foreground_colormap)
+        self.registerAction("Cell widget > SpinBox")(lambda idx: cmds.selection.add_spinbox(self.parentViewer()._table_viewer))
+        self.registerAction("Cell widget > Slider")(lambda idx: cmds.selection.add_slider(self.parentViewer()._table_viewer))
+        self.registerAction("Cell widget > FloatSpinBox")(lambda idx: cmds.selection.add_float_spinbox(self.parentViewer()._table_viewer))
+        self.registerAction("Cell widget > FloatSlider")(lambda idx: cmds.selection.add_float_slider(self.parentViewer()._table_viewer))
+        self.registerAction("Cell widget > CheckBox")(lambda idx: cmds.selection.add_checkbox(self.parentViewer()._table_viewer))
+        self.registerAction("Cell widget > RadioButton")(lambda idx: cmds.selection.add_radio_button(self.parentViewer()._table_viewer))
+        self.registerAction("Cell widget > LineEdit")(lambda idx: cmds.selection.add_line_edit(self.parentViewer()._table_viewer))
+        self.addSeparator("Cell widget ")
+        self.registerAction("Cell widget > Remove")(lambda idx: cmds.selection.remove_cell_widgets(self.parentViewer()._table_viewer))
 
-    def _set_background_colormap_with_dialog(self, index: int):
-        return self._set_colormap(index, self.model()._background_colormap)
-
-    def _set_colormap(self, index, colormap_dict: dict):
-        from ._base._colormap import exec_colormap_dialog
-
-        column_name = self._filtered_columns[index]
-        df = self.getDataFrame()
-        dtype: np.dtype = df.dtypes[column_name]
-        _converter = get_converter(dtype.kind)
-        if cmap := exec_colormap_dialog(df[column_name], self):
-
-            def _cmap(val):
-                try:
-                    _val = _converter(val)
-                except Exception:
-                    return None
-                else:
-                    return cmap(_val)
-
-            colormap_dict[column_name] = _cmap
-            self.refresh()
+        # fmt: on
         return None
 
 
diff --git a/tabulous/_qt/_table/_table.py b/tabulous/_qt/_table/_table.py
index f7188f75..908778fa 100644
--- a/tabulous/_qt/_table/_table.py
+++ b/tabulous/_qt/_table/_table.py
@@ -4,7 +4,7 @@
 from collections_undo import arguments
 
 from ._base import QMutableSimpleTable, DataFrameModel
-from ._dtype import convert_value
+from tabulous._dtype import convert_value
 
 
 class QTableLayer(QMutableSimpleTable):
diff --git a/tabulous/_qt/_table_stack/_start.py b/tabulous/_qt/_table_stack/_start.py
index 9bdea830..1b20d7a0 100644
--- a/tabulous/_qt/_table_stack/_start.py
+++ b/tabulous/_qt/_table_stack/_start.py
@@ -4,8 +4,9 @@
 from typing import TYPE_CHECKING
 from qtpy import QtWidgets as QtW, QtGui
 from qtpy.QtCore import Qt, Signal
-from .._clickable_label import QClickableLabel
-from ..._utils import load_file_open_path
+from tabulous._qt._clickable_label import QClickableLabel
+from tabulous._utils import load_file_open_path
+from tabulous import commands as cmds
 
 if TYPE_CHECKING:
     from .._mainwindow._base import _QtMainWidgetBase
@@ -29,9 +30,9 @@ def __init__(self, parent=None):
         self._open_new_btn = QClickableLabel("New Spreadsheet (Ctrl+N)")
         self._path_list = QPathList()
 
-        self._open_table_btn.clicked.connect(lambda: self.mainWidget().openFromDialog(type="table"))
-        self._open_spreadsheet_btn.clicked.connect(lambda: self.mainWidget().openFromDialog(type="spreadsheet"))
-        self._open_new_btn.clicked.connect(lambda: self.mainWidget().newSpreadSheet())
+        self._open_table_btn.clicked.connect(lambda: cmds.file.open_table(self.mainWidget()._table_viewer))
+        self._open_spreadsheet_btn.clicked.connect(lambda: cmds.file.open_spreadsheet(self.mainWidget()._table_viewer))
+        self._open_new_btn.clicked.connect(lambda: cmds.table.new_spreadsheet(self.mainWidget()._table_viewer))
         self._path_list.pathClicked.connect(lambda path: self.mainWidget()._table_viewer.open(path))
         # fmt: on
         _layout.addWidget(self._open_table_btn)
diff --git a/tabulous/_qt/_table_stack/_tabwidget.py b/tabulous/_qt/_table_stack/_tabwidget.py
index 7dc277b1..bb99829a 100644
--- a/tabulous/_qt/_table_stack/_tabwidget.py
+++ b/tabulous/_qt/_table_stack/_tabwidget.py
@@ -344,11 +344,9 @@ def tileTables(
                 table = self.untileTable(i)
             tables.append(table)
         group = QTableGroup(tables, ori)
-        widgets = [group.copy() for _ in indices]
 
-        for j, index in enumerate(indices):
-            wdt = widgets[j]
-            self.replaceWidget(index, wdt)
+        for index in indices:
+            self.replaceWidget(index, group.copy())
 
         self.setCurrentIndex(current_index)
         return None
@@ -404,18 +402,20 @@ def untileTable(self, index: int) -> QBaseTable:
                 count += 1
 
         unmerged = None
-        for i, tablegroup in enumerate(all_groups):
-            table = tablegroup.pop(target_idx)
+        for i, group in enumerate(all_groups):
+            table = group.pop(target_idx)
             j = appeared_idx[i]
             if j == index:
                 unmerged = table
                 self.replaceWidget(j, table)
             elif n_merged == 2:
-                self.replaceWidget(j, tablegroup.pop(0))
+                self.replaceWidget(j, group.pop(0))
 
         if unmerged is None:
             raise RuntimeError("Unmerging could not be resolved.")
         self.setCurrentIndex(current_index)
+        target_group.deleteLater()
+        self.parentWidget().setCellFocus()
         return unmerged
 
     def copyData(self, index: int):
diff --git a/tabulous/_qt/_toolbar/_toolbar_widget.py b/tabulous/_qt/_toolbar/_toolbar_widget.py
index a0267392..8de48ffd 100644
--- a/tabulous/_qt/_toolbar/_toolbar_widget.py
+++ b/tabulous/_qt/_toolbar/_toolbar_widget.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 from pathlib import Path
-from typing import Callable, Hashable, TYPE_CHECKING, NamedTuple, Union
+from typing import Callable, TYPE_CHECKING, Union
 from functools import partial
 import weakref
 from qtpy import QtWidgets as QtW, QtCore
@@ -8,23 +8,12 @@
 
 from tabulous._qt._svg import QColoredSVGIcon
 from tabulous._qt._multitips import QHasToolTip
-from . import _dialogs as _dlg
+from tabulous import commands as cmds
 
 
 if TYPE_CHECKING:
     from tabulous._qt._mainwindow import _QtMainWidgetBase
     from tabulous.widgets._mainwindow import TableViewerBase
-    from tabulous.widgets import TableBase
-
-# fmt: off
-SUMMARY_CHOICES = ["mean", "median", "std", "sem", "min", "max", "sum"]
-SAMPLE_CHOICES = [
-    "anagrams", "anscombe", "attention", "brain_networks", "car_crashes", "diamonds",
-    "dots", "dowjones", "exercise", "flights", "fmri", "geyser", "glue", "healthexp",
-    "iris", "mpg", "penguins", "planets", "seaice", "taxis", "tips", "titanic",
-]
-# fmt: on
-
 
 ICON_DIR = Path(__file__).parent.parent / "_icons"
 
@@ -139,7 +128,9 @@ def registerAction(self, tabname: str, f: Callable, icon: Union[str, Path]):
             self.addToolBar(tabname)
         toolbar = self._child_widgets[tabname]
         qicon = QColoredSVGIcon.fromfile(icon)
-        toolbar.appendAction(f, qicon)
+        fn = lambda: f(self.viewer)
+        fn.__doc__ = f.__doc__
+        toolbar.appendAction(fn, qicon)
         return
 
     def addSeparatorToChild(self, tabname: str) -> QAction:
@@ -151,418 +142,60 @@ def setToolButtonColor(self, color: str):
         for toolbar in self._child_widgets.values():
             toolbar.updateIconColor(color)
 
-    def open_table(self):
-        """Open a file as a table."""
-        return self.viewer._qwidget.openFromDialog(type="table")
-
-    def open_spreadsheet(self):
-        """Open a file as a spreadsheet."""
-        return self.viewer._qwidget.openFromDialog(type="spreadsheet")
-
-    def save_table(self):
-        """Save current table."""
-        return self.viewer._qwidget.saveFromDialog()
-
-    def open_sample(self):
-        """Open a seaborn sample data."""
-        out = _dlg.choose_one(choice={"choices": SAMPLE_CHOICES, "nullable": False})
-        if out is not None:
-            self.viewer.open_sample(out)
-
-    def copy_as_table(self):
-        """Make a copy of the current table."""
-        viewer = self.viewer
-        table = viewer.current_table
-        if table is not None:
-            viewer.add_table(table.data, name=f"{table.name}-copy")
-
-    def copy_as_spreadsheet(self):
-        """Make a copy of the current table."""
-        viewer = self.viewer
-        table = viewer.current_table
-        if table is not None:
-            viewer.add_spreadsheet(table.data, name=f"{table.name}-copy")
-
-    def groupby(self):
-        """Group table data by its column value."""
-        table = self.viewer.current_table
-        if table is not None:
-            out = _dlg.groupby(
-                df={"bind": table.data},
-                by={"choices": list(table.data.columns), "widget_type": "Select"},
-                parent=self,
-            )
-            if out is not None:
-                self.viewer.add_groupby(out, name=f"{table.name}-groupby")
-
-    def switch_header(self):
-        """Switch header and the first row."""
-        table = self.viewer.current_table
-        if table is not None:
-            table._qwidget._switch_head_and_index(axis=1)
-
-    def concat(self):
-        """Concatenate tables."""
-        out = _dlg.concat(
-            viewer={"bind": self.viewer},
-            names={
-                "value": [self.viewer.current_table.name],
-                "widget_type": "Select",
-                "choices": [t.name for t in self.viewer.tables],
-            },
-            axis={"choices": [("vertical", 0), ("horizontal", 1)]},
-            parent=self,
-        )
-        if out is not None:
-            self.viewer.add_table(out, name=f"concat")
-
-    def pivot(self):
-        """Pivot a table."""
-        table = self.viewer.current_table
-        if table is None:
-            return
-        col = list(table.data.columns)
-        if len(col) < 3:
-            raise ValueError("Table must have at least three columns.")
-        out = _dlg.pivot(
-            df={"bind": table.data},
-            index={"choices": col, "value": col[0]},
-            columns={"choices": col, "value": col[1]},
-            values={"choices": col, "value": col[2]},
-        )
-        if out is not None:
-            self.viewer.add_table(out, name=f"{table.name}-pivot")
-
-    def melt(self):
-        """Unpivot a table."""
-        table = self.viewer.current_table
-        if table is None:
-            return
-        out = _dlg.melt(
-            df={"bind": table.data},
-            id_vars={"choices": list(table.data.columns), "widget_type": "Select"},
-            parent=self,
-        )
-        if out is not None:
-            self.viewer.add_table(out, name=f"{table.name}-melt")
-
-    def summarize_table(self):
-        """Summarize current table."""
-        table = self.viewer.current_table
-        if table is None:
-            return
-        out = _dlg.summarize_table(
-            df={"bind": table.data},
-            methods={"choices": SUMMARY_CHOICES, "widget_type": "Select"},
-            parent=self,
-        )
-        if out is not None:
-            df, new = out
-            if new:
-                self.viewer.add_table(df, name=f"{table.name}-summary")
-            else:
-                from .._table import QTableLayer
-
-                qtable = QTableLayer(data=df)
-                table.add_side_widget(qtable, name="summary")
-
-    def toggle_console(self):
-        """Toggle embedded console."""
-        return self.parent().toggleConsoleVisibility()
-
-    def find_item(self):
-        """Toggle finder"""
-        return self.parent()._tablestack.openFinderDialog()
-
-    def sort_table(self):
-        """Add sorted table."""
-        table = self.viewer.current_table
-        if table is None:
-            return
-        out = _dlg.sort(
-            df={"bind": table.data},
-            by={"choices": list(table.data.columns), "widget_type": "Select"},
-            ascending={"text": "Sort in ascending order."},
-            parent=self,
-        )
-        if out is not None:
-            self.viewer.add_table(out, name=f"{table.name}-sorted")
-
-    def random(self):
-        """Add random data to the specified data range."""
-        table = self.viewer.current_table
-        if table is None:
-            return
-        from ._random_data import RandomGeneratorDialog
-
-        dlg = RandomGeneratorDialog()
-        dlg.native.setParent(self, dlg.native.windowFlags())
-        dlg._selection_wdt._read_selection(table)
-        dlg.show()
-
-        @dlg.called.connect
-        def _on_called():
-            val = dlg.get_value(table._qwidget.model().df)
-            rsl, csl, data = val
-            table.cell[rsl, csl] = data
-
-    def filter(self):
-        """Apply filter to the current table."""
-        return self.parent()._tablestack.openFilterDialog()
-
-    def eval(self):
-        """Evaluate a Python expression."""
-        return self.parent()._tablestack.openEvalDialog()
-
-    def optimize(self):
-        """Open the optimizer widget."""
-        from ._optimizer import OptimizerWidget
-
-        tablestack = self.parent()._tablestack
-        ol = tablestack._overlay
-        ol.show()
-
-        ol.addWidget(OptimizerWidget.new().native)
-        ol.setTitle("Optimization")
-
-    def stats_test(self):
-        from ._statistics import StatsTestDialog
-
-        dlg = StatsTestDialog()
-        dlg.native.setParent(self, dlg.native.windowFlags())
-        dlg.show()
-
-    def sklearn_analysis(self):
-        from ._sklearn import SkLearnContainer
-
-        tablestack = self.parent()._tablestack
-        ol = tablestack._overlay
-        ol.show()
-
-        ol.addWidget(SkLearnContainer.new().native)
-        ol.setTitle("scikit-learn analysis")
-
-    def change_view_mode(self, view_mode: str):
-        """Change view mode."""
-        table = self.viewer.current_table
-        if table is None:
-            return
-        table.view_mode = view_mode
-
-    def plot(self):
-        """Plot curve."""
-        return self._plot_xy(_dlg.plot)
-
-    def scatter(self):
-        """Scatter plot."""
-        return self._plot_xy(_dlg.scatter)
-
-    def errorbar(self):
-        """Errorbar plot."""
-        table = self.viewer.current_table
-        if table is None:
-            return
-
-        _dlg.errorbar(
-            ax={"bind": table.plt.gca()},
-            table={"bind": table},
-            alpha={"min": 0, "max": 1, "step": 0.05},
-            parent=self,
-        )
-
-    def hist(self):
-        """Histogram."""
-        table = self.viewer.current_table
-        if table is None:
-            return
-
-        _dlg.hist(
-            ax={"bind": table.plt.gca()},
-            table={"bind": table},
-            alpha={"min": 0, "max": 1, "step": 0.05},
-            histtype={
-                "choices": ["bar", "step", "stepfilled", "barstacked"],
-                "value": "bar",
-            },
-            parent=self,
-        )
-
-    def swarmplot(self):
-        """Swarm plot."""
-        return self._plot_sns(_dlg.swarmplot)
-
-    def barplot(self):
-        """Bar plot."""
-        return self._plot_sns(_dlg.barplot)
-
-    def boxplot(self):
-        """Box plot."""
-        return self._plot_sns(_dlg.boxplot)
-
-    def boxenplot(self):
-        """Boxen plot."""
-        return self._plot_sns(_dlg.boxenplot)
-
-    def new_figure(self):
-        """Add a new figure."""
-        return self.viewer.current_table.plt.new_widget()
-
-    def _plot_xy(self, dialog):
-        table = self.viewer.current_table
-        if table is None:
-            return
-
-        dialog(
-            ax={"bind": table.plt.gca()},
-            x={"format": "iloc"},
-            y={"format": "iloc"},
-            table={"bind": table},
-            alpha={"min": 0, "max": 1, "step": 0.05},
-            parent=self,
-        )
-
-    def _plot_sns(self, dialog):
-        table = self.viewer.current_table
-        if table is None:
-            return
-
-        names, csel = _get_selected_ranges_and_column_names(table)
-
-        # infer x, y
-        if len(names) == 0:
-            raise ValueError("Table must have at least one column.")
-        elif len(names) == 1:
-            x = {"bind": None}
-            y = {"bind": None}
-        else:
-            for i, dtype in enumerate(table.data.dtypes):
-                if dtype == "categorical":
-                    x = {"choices": names, "value": names[i], "nullable": True}
-                    j = 0 if i > 0 else 1
-                    y = {"choices": names, "value": names[j], "nullable": True}
-                    break
-            else:
-                x = {"choices": names, "value": None, "nullable": True}
-                y = {"choices": names, "value": None, "nullable": True}
-
-        if dialog(
-            ax={"bind": table.plt.gca()},
-            x=x,
-            y=y,
-            table={"bind": table},
-            csel={"bind": csel},
-            hue={"choices": names, "nullable": True},
-            parent=self,
-        ):
-            table.plt.draw()
-
+    # fmt: off
     def initToolbar(self):
         """Add tool buttons"""
 
-        self.registerAction("File", self.open_table, ICON_DIR / "open_table.svg")
-        self.registerAction(
-            "File", self.open_spreadsheet, ICON_DIR / "open_spreadsheet.svg"
-        )
-        self.registerAction("File", self.save_table, ICON_DIR / "save_table.svg")
-        self.addSeparatorToChild("File")
-        self.registerAction("File", self.open_sample, ICON_DIR / "open_sample.svg")
-
-        self.registerAction("Table", self.copy_as_table, ICON_DIR / "copy_as_table.svg")
-        self.registerAction(
-            "Table", self.copy_as_spreadsheet, ICON_DIR / "copy_as_spreadsheet.svg"
-        )
+        self.registerAction("Home", cmds.file.open_table, ICON_DIR / "open_table.svg")
+        self.registerAction("Home", cmds.file.open_spreadsheet, ICON_DIR / "open_spreadsheet.svg")
+        self.registerAction("Home", cmds.file.save_table, ICON_DIR / "save_table.svg")
+        self.addSeparatorToChild("Home")
+        self.registerAction("Home", cmds.file.open_sample, ICON_DIR / "open_sample.svg")
+        self.addSeparatorToChild("Home")
+        self.registerAction("Home", cmds.window.toggle_console, ICON_DIR / "toggle_console.svg")
+        self.registerAction("Home", cmds.window.show_command_palette, ICON_DIR / "palette.svg")
+
+        self.registerAction("Table", cmds.table.copy_as_table, ICON_DIR / "copy_as_table.svg")
+        self.registerAction("Table", cmds.table.copy_as_spreadsheet, ICON_DIR / "copy_as_spreadsheet.svg")
         self.addSeparatorToChild("Table")
-        self.registerAction("Table", self.groupby, ICON_DIR / "groupby.svg")
-        self.registerAction("Table", self.switch_header, ICON_DIR / "switch_header.svg")
-        self.registerAction("Table", self.concat, ICON_DIR / "concat.svg")
-        self.registerAction("Table", self.pivot, ICON_DIR / "pivot.svg")
-        self.registerAction("Table", self.melt, ICON_DIR / "melt.svg")
+        self.registerAction("Table", cmds.table.groupby, ICON_DIR / "groupby.svg")
+        self.registerAction("Table", cmds.table.switch_header, ICON_DIR / "switch_header.svg")
+        self.registerAction("Table", cmds.table.concat, ICON_DIR / "concat.svg")
+        self.registerAction("Table", cmds.table.pivot, ICON_DIR / "pivot.svg")
+        self.registerAction("Table", cmds.table.melt, ICON_DIR / "melt.svg")
         self.addSeparatorToChild("Table")
-        self.registerAction("Table", self.find_item, ICON_DIR / "find_item.svg")
-        self.registerAction("Table", self.sort_table, ICON_DIR / "sort_table.svg")
+        self.registerAction("Table", cmds.table.show_finder_widget, ICON_DIR / "find_item.svg")
+        self.registerAction("Table", cmds.table.sort_table, ICON_DIR / "sort_table.svg")
         self.addSeparatorToChild("Table")
-        self.registerAction("Table", self.random, ICON_DIR / "random.svg")
+        self.registerAction("Table", cmds.table.random, ICON_DIR / "random.svg")
 
-        self.registerAction(
-            "Analyze", self.summarize_table, ICON_DIR / "summarize_table.svg"
-        )
-        self.registerAction("Analyze", self.eval, ICON_DIR / "eval.svg")
-        self.registerAction("Analyze", self.filter, ICON_DIR / "filter.svg")
-        self.addSeparatorToChild("Analyze")
-        self.registerAction("Analyze", self.optimize, ICON_DIR / "optimize.svg")
-        self.registerAction("Analyze", self.stats_test, ICON_DIR / "stats_test.svg")
-        self.registerAction(
-            "Analyze", self.sklearn_analysis, ICON_DIR / "sklearn_analysis.svg"
-        )
+        self.registerAction("Analyze", cmds.analysis.summarize_table, ICON_DIR / "summarize_table.svg")
+        self.registerAction("Analyze", cmds.analysis.show_eval_widget, ICON_DIR / "eval.svg")
+        self.registerAction("Analyze", cmds.analysis.show_filter_widget, ICON_DIR / "filter.svg")
         self.addSeparatorToChild("Analyze")
-        self.registerAction(
-            "Analyze", self.toggle_console, ICON_DIR / "toggle_console.svg"
-        )
-
-        self.registerAction(
-            "View", partial(self.change_view_mode, "popup"), ICON_DIR / "view_popup.svg"
-        )
-        self.registerAction(
-            "View",
-            partial(self.change_view_mode, "horizontal"),
-            ICON_DIR / "view_dual_h.svg",
-        )
-        self.registerAction(
-            "View",
-            partial(self.change_view_mode, "vertical"),
-            ICON_DIR / "view_dual_v.svg",
-        )
-        self.registerAction(
-            "View",
-            partial(self.change_view_mode, "normal"),
-            ICON_DIR / "view_reset.svg",
-        )
-
-        self.registerAction("Plot", self.plot, ICON_DIR / "plot.svg")
-        self.registerAction("Plot", self.scatter, ICON_DIR / "scatter.svg")
-        self.registerAction("Plot", self.errorbar, ICON_DIR / "errorbar.svg")
-        self.registerAction("Plot", self.hist, ICON_DIR / "hist.svg")
+        self.registerAction("Analyze", cmds.analysis.show_optimizer_widget, ICON_DIR / "optimize.svg")
+        self.registerAction("Analyze", cmds.analysis.show_stats_widget, ICON_DIR / "stats_test.svg")
+        self.registerAction("Analyze", cmds.analysis.show_sklearn_widget, ICON_DIR / "sklearn_analysis.svg")
+
+        self.registerAction("View", cmds.view.set_popup_mode, ICON_DIR / "view_popup.svg")
+        self.registerAction("View", cmds.view.set_dual_h_mode, ICON_DIR / "view_dual_h.svg")
+        self.registerAction("View", cmds.view.set_dual_v_mode, ICON_DIR / "view_dual_v.svg")
+        self.registerAction("View", cmds.view.reset_view_mode, ICON_DIR / "view_reset.svg")
+        self.addSeparatorToChild("View")
+        self.registerAction("View", cmds.tab.tile_tables, ICON_DIR / "tile.svg")
+        self.registerAction("View", cmds.tab.untile_table, ICON_DIR / "untile.svg")
+
+        self.registerAction("Plot", cmds.plot.plot, ICON_DIR / "plot.svg")
+        self.registerAction("Plot", cmds.plot.scatter, ICON_DIR / "scatter.svg")
+        self.registerAction("Plot", cmds.plot.errorbar, ICON_DIR / "errorbar.svg")
+        self.registerAction("Plot", cmds.plot.hist, ICON_DIR / "hist.svg")
         self.addSeparatorToChild("Plot")
-        self.registerAction("Plot", self.swarmplot, ICON_DIR / "swarmplot.svg")
-        self.registerAction("Plot", self.barplot, ICON_DIR / "barplot.svg")
-        self.registerAction("Plot", self.boxplot, ICON_DIR / "boxplot.svg")
-        self.registerAction("Plot", self.boxenplot, ICON_DIR / "boxenplot.svg")
+        self.registerAction("Plot", cmds.plot.swarmplot, ICON_DIR / "swarmplot.svg")
+        self.registerAction("Plot", cmds.plot.barplot, ICON_DIR / "barplot.svg")
+        self.registerAction("Plot", cmds.plot.boxplot, ICON_DIR / "boxplot.svg")
+        self.registerAction("Plot", cmds.plot.boxenplot, ICON_DIR / "boxenplot.svg")
         self.addSeparatorToChild("Plot")
-        self.registerAction("Plot", self.new_figure, ICON_DIR / "new_figure.svg")
+        self.registerAction("Plot", cmds.plot.new_figure, ICON_DIR / "new_figure.svg")
 
         return
-
-
-class PlotInfo(NamedTuple):
-    names: list[Hashable]
-    columns: slice
-
-
-def _get_selected_ranges_and_column_names(
-    table: TableBase,
-) -> PlotInfo:
-    sels = table.selections
-    if len(sels) == 1 and _selection_area(sels[0]) == 1:
-        nrow = len(table.index)
-        infos = PlotInfo(
-            names=list(table.columns),
-            columns=slice(0, nrow),
-        )
-    else:
-        names = []
-        sl = sels[0][0]
-        columns = table.columns
-        for sel in sels:
-            if sel[0] == sl:
-                csel = sel[1]
-                for i in range(csel.start, csel.stop):
-                    names.append(columns[i])
-            else:
-                raise ValueError("Selections must be in the same rows")
-        infos = PlotInfo(names=names, columns=sl)
-
-    return infos
-
-
-def _selection_area(sel: tuple[slice, slice]) -> int:
-    return (sel[0].stop - sel[0].start) * (sel[1].stop - sel[1].start)
+    # fmt: on
diff --git a/tabulous/_selection_model.py b/tabulous/_selection_model.py
index 38dedc43..b02bd034 100644
--- a/tabulous/_selection_model.py
+++ b/tabulous/_selection_model.py
@@ -250,6 +250,15 @@ def current_range(self) -> tuple[slice, slice] | None:
             return self._ranges[-1]
         return None
 
+    def iter_all_indices(self) -> Iterator[tuple[int, int]]:
+        """Iterate all the indices (int, int) in all the selection ranges."""
+        nr = self._row_count_getter()
+        nc = self._col_count_getter()
+        for rng in self.ranges:
+            for r in range(*rng[0].indices(nr)):
+                for c in range(*rng[1].indices(nc)):
+                    yield r, c
+
     @property
     def start(self) -> Index | None:
         """The selection starting index."""
diff --git a/tabulous/_qt/_table/_base/_text_formatter.py b/tabulous/_text_formatter.py
similarity index 99%
rename from tabulous/_qt/_table/_base/_text_formatter.py
rename to tabulous/_text_formatter.py
index 2cb8bb13..541595b8 100644
--- a/tabulous/_qt/_table/_base/_text_formatter.py
+++ b/tabulous/_text_formatter.py
@@ -7,7 +7,7 @@
 import pandas as pd
 from magicgui.widgets import RadioButtons
 
-from tabulous._qt._table._dtype import get_dtype, isna
+from tabulous._dtype import get_dtype, isna
 from tabulous.widgets import Table
 
 __all__ = ["exec_formatter_dialog"]
diff --git a/tabulous/_utils.py b/tabulous/_utils.py
index 7bad7dce..bc89a0f7 100644
--- a/tabulous/_utils.py
+++ b/tabulous/_utils.py
@@ -11,6 +11,7 @@
 TXT_PATH = Path(user_state_dir("tabulous", "tabulous", "history.txt"))
 CONFIG_PATH = Path(user_config_dir("tabulous", "tabulous", "config.toml"))
 CELL_NAMESPACE_PATH = Path(user_state_dir("tabulous", "tabulous", "cell_namespace.py"))
+SETTINGS_JSON = Path(user_state_dir("tabulous", "tabulous", "settings.json"))
 
 
 def warn_on_exc(default=None):
@@ -87,6 +88,17 @@ def load_cell_namespace() -> MappingProxyType:
     return MappingProxyType(ns)
 
 
+def prep_default_keybindings() -> dict[str, str | list[str]]:
+    from tabulous.commands import DEFAULT_KEYBINDING_SETTING
+
+    kb = {}
+    for cmd, seq in DEFAULT_KEYBINDING_SETTING:
+        mod = cmd.__module__.split(".")[-1]
+        name = cmd.__name__
+        kb[f"{mod}.{name}"] = seq
+    return kb
+
+
 @dataclass
 class ConsoleNamespace:
     """Default namespace of the console."""
@@ -134,6 +146,9 @@ class TabulousConfig:
     table: Table = field(default_factory=Table)
     cell: Cell = field(default_factory=Cell)
     window: Window = field(default_factory=Window)
+    keybindings: dict[str, str | list[str]] = field(
+        default_factory=prep_default_keybindings
+    )
 
     @classmethod
     def from_toml(cls, path: Path = CONFIG_PATH) -> TabulousConfig:
@@ -150,11 +165,14 @@ def from_toml(cls, path: Path = CONFIG_PATH) -> TabulousConfig:
         table = tm.get("table", {})
         cell = tm.get("cell", {})
         window = tm.get("window", {})
+        if not (kb := tm.get("keybindings", {})):
+            kb = prep_default_keybindings()
         return cls(
             ConsoleNamespace(**_as_fields(console_namespace, ConsoleNamespace)),
             Table(**_as_fields(table, Table)),
             Cell(**_as_fields(cell, Cell)),
             Window(**_as_fields(window, Window)),
+            kb,
         )
 
     def as_toml(self):
diff --git a/tabulous/commands/__init__.py b/tabulous/commands/__init__.py
new file mode 100644
index 00000000..a86fdf26
--- /dev/null
+++ b/tabulous/commands/__init__.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+from typing import Iterator
+
+from . import file, plot, selection, tab, table, view, analysis, window
+from types import FunctionType, ModuleType
+
+_SUB_MODULES: list[ModuleType] = [
+    file,
+    plot,
+    selection,
+    tab,
+    table,
+    view,
+    analysis,
+    window,
+]
+
+
+def iter_commands() -> Iterator[tuple[str, FunctionType]]:
+    for mod in _SUB_MODULES:
+        for obj in vars(mod).values():
+            if isinstance(obj, FunctionType) and not obj.__name__.startswith("_"):
+                yield mod.__name__.split(".")[-1], obj
+
+
+DEFAULT_KEYBINDING_SETTING: list[tuple[FunctionType, str]] = [
+    (window.toggle_toolbar, "Ctrl+K, Ctrl+T"),
+    (window.toggle_console, "Ctrl+Shift+C"),
+    (window.show_command_palette, ["F1", "Ctrl+Shift+P"]),
+    (window.focus_table, "Ctrl+0"),
+    (window.new_window, "Ctrl+Shift+N"),
+    (window.toggle_fullscreen, "F11"),
+    (window.close_window, "Ctrl+W"),
+    (window.show_keymap, "Ctrl+K, Shift+?"),
+    (window.toggle_focus, "Ctrl+Shift+F"),
+    (tab.activate_left, "Alt+Left"),
+    (tab.activate_right, "Alt+Right"),
+    (tab.swap_tab_with_left, "Alt+Shift+Left"),
+    (tab.swap_tab_with_right, "Alt+Shift+Right"),
+    (tab.tile_with_adjacent_table, "Ctrl+K, ^"),
+    (tab.untile_table, "Ctrl+K, \\"),
+    (view.set_popup_mode, "Ctrl+K, P"),
+    (view.set_dual_v_mode, "Ctrl+K, V"),
+    (view.set_dual_h_mode, "Ctrl+K, H"),
+    (view.reset_view_mode, "Ctrl+K, N"),
+    (table.toggle_editability, "Ctrl+K, E"),
+    (table.new_spreadsheet, "Ctrl+N"),
+    (table.show_finder_widget, "Ctrl+F"),
+    (table.delete_table, "Ctrl+Delete"),
+    (table.show_undo_stack_view, "Ctrl+H"),
+    (table.undo_table, "Ctrl+Z"),
+    (table.redo_table, "Ctrl+Y"),
+    (file.open_table, "Ctrl+O"),
+    (file.open_spreadsheet, "Ctrl+K, Ctrl+O"),
+    (file.save_table, "Ctrl+S"),
+    (selection.show_context_menu, "Menu"),
+    (selection.raise_slot_error, "F6"),
+    (selection.copy_data_tab_separated, "Ctrl+C"),
+    (selection.copy_data_with_header_tab_separated, "Ctrl+C, Ctrl+H"),
+    (selection.copy_as_new_table, "Ctrl+Shift+X"),
+    (selection.select_all, "Ctrl+A"),
+    (selection.cut_data, "Ctrl+X"),
+    (selection.paste_data_tab_separated, "Ctrl+V"),
+    (selection.delete_values, ["Delete", "Backspace"]),
+]
diff --git a/tabulous/_qt/_table/_base/_colormap.py b/tabulous/commands/_colormap.py
similarity index 99%
rename from tabulous/_qt/_table/_base/_colormap.py
rename to tabulous/commands/_colormap.py
index f21fb34c..25ed110c 100644
--- a/tabulous/_qt/_table/_base/_colormap.py
+++ b/tabulous/commands/_colormap.py
@@ -2,7 +2,7 @@
 from typing import Callable, Hashable, Sequence, TYPE_CHECKING, TypeVar
 import numpy as np
 import pandas as pd
-from .._dtype import isna
+from tabulous._dtype import isna
 
 if TYPE_CHECKING:
     from pandas.core.dtypes.dtypes import CategoricalDtype
diff --git a/tabulous/_qt/_toolbar/_dialogs.py b/tabulous/commands/_dialogs.py
similarity index 79%
rename from tabulous/_qt/_toolbar/_dialogs.py
rename to tabulous/commands/_dialogs.py
index 841a11d4..c53de7f7 100644
--- a/tabulous/_qt/_toolbar/_dialogs.py
+++ b/tabulous/commands/_dialogs.py
@@ -1,6 +1,7 @@
 from functools import partial
 from typing import List, Union
 from typing_extensions import Annotated
+
 import logging
 import numpy as np
 import pandas as pd
@@ -11,7 +12,7 @@
 from tabulous._selection_op import SelectionOperator
 from tabulous._magicgui import dialog_factory, dialog_factory_mpl, Axes
 
-from ._plot_models import PlotModel, ScatterModel, HistModel
+from ._plot_models import PlotModel, BarModel, ScatterModel, HistModel
 
 
 logger = logging.getLogger(__name__)
@@ -68,6 +69,21 @@ def plot(
     return True
 
 
+@dialog_factory_mpl
+def bar(
+    ax: Axes,
+    x: SelectionOperator,
+    y: SelectionOperator,
+    table: TableBase,
+    alpha: float = 1.0,
+    ref: bool = False,
+):
+    model = BarModel(ax, x, y, table=table, alpha=alpha, ref=ref)
+    model.add_data()
+    table.plt.draw()
+    return True
+
+
 @dialog_factory_mpl
 def scatter(
     ax: Axes,
@@ -256,6 +272,53 @@ def choose_one(choice: str):
     return choice
 
 
+@dialog_factory
+def choose_multiple(choices: List):
+    return choices
+
+
+@dialog_factory
+def spinbox(min: str = "0", max: str = "1000", step: str = "") -> dict:
+    min = int(min)
+    max = int(max)
+    step = int(step) if step != "" else None
+    return dict(min=min, max=max, step=step)
+
+
+@dialog_factory
+def float_spinbox(min: str = "0.0", max: str = "1000.0", step: str = "") -> dict:
+    min = float(min)
+    max = float(max)
+    step = float(step) if step != "" else None
+    return dict(min=min, max=max, step=step)
+
+
+@dialog_factory
+def slider(min: str = "0", max: str = "1000", step: str = "") -> dict:
+    min = int(min)
+    max = int(max)
+    step = int(step) if step != "" else None
+    return dict(min=min, max=max, step=step)
+
+
+@dialog_factory
+def float_slider(min: str = "0.0", max: str = "1000.0", step: str = "") -> dict:
+    min = float(min)
+    max = float(max)
+    step = float(step) if step != "" else None
+    return dict(min=min, max=max, step=step)
+
+
+@dialog_factory
+def checkbox(text: str, checked: bool = True) -> dict:
+    return dict(text=text, value=checked)
+
+
+@dialog_factory
+def radio_button(text: str, checked: bool = True) -> dict:
+    return dict(text=text, value=checked)
+
+
 __void = object()
 
 
diff --git a/tabulous/_qt/_toolbar/_optimizer.py b/tabulous/commands/_optimizer.py
similarity index 100%
rename from tabulous/_qt/_toolbar/_optimizer.py
rename to tabulous/commands/_optimizer.py
diff --git a/tabulous/_qt/_toolbar/_plot_models.py b/tabulous/commands/_plot_models.py
similarity index 92%
rename from tabulous/_qt/_toolbar/_plot_models.py
rename to tabulous/commands/_plot_models.py
index 41eeb97d..31d20093 100644
--- a/tabulous/_qt/_toolbar/_plot_models.py
+++ b/tabulous/commands/_plot_models.py
@@ -155,15 +155,20 @@ class XYDataModel(AbstractDataModel[_T]):
 
     def add_data(self):
         _mpl_widget = weakref.ref(self.table.plt.gcw())
-        _artist_refs: list[weakref.ReferenceType[_T]] = []
+        _artists: list[_T] = []
         for x, y in self._iter_data():
             label_name = y.name
             artist = self.update_ax(x, y, label=label_name)
-            _artist_refs.append(weakref.ref(artist))
+            _artists.append(artist)
 
         if not self.ref:
             # if plot does not refer the table data, there's nothing to be done
             return
+
+        _artist_refs: list[weakref.ReferenceType[_T]] = []
+        for artist in _artists:
+            _artist_refs.append(weakref.ref(artist))
+
         plot_ref = PlotRef(_mpl_widget, _artist_refs)
 
         def _on_data_updated():
@@ -201,7 +206,7 @@ def _iter_data(self) -> Iterator[tuple[pd.Series, pd.Series]]:
 
         yslice = self.y_selection.as_iloc_slices(data)
         ydata_all = data.iloc[yslice]
-
+        # TODO: support row vector
         if self.x_selection is None:
             xdata = pd.Series(np.arange(len(ydata_all)), name="X")
         else:
@@ -237,6 +242,26 @@ def update_artist(self, artist: Line2D, x: pd.Series, y: pd.Series):
         return artist.set_data(x, y)
 
 
+@dataclass
+class BarModel(XYDataModel["BarContainer"]):
+    ax: Axes
+    x_selection: SelectionOperator | None
+    y_selection: SelectionOperator
+    table: TableBase
+    alpha: float = 1.0
+    ref: bool = False
+
+    def update_ax(self, x, y, label=None) -> BarContainer:
+        return self.ax.bar(x, y, alpha=self.alpha, label=label, picker=True)
+
+    def update_artist(self, artist: BarContainer, x: pd.Series, y: pd.Series):
+        for patch in artist.patches:
+            width = patch.get_width()
+            patch.set_x(x - width / 2)
+            patch.set_height(y)
+        return None
+
+
 @dataclass
 class ScatterModel(XYDataModel["PathCollection"]):
     ax: Axes
diff --git a/tabulous/_qt/_toolbar/_random_data.py b/tabulous/commands/_random_data.py
similarity index 86%
rename from tabulous/_qt/_toolbar/_random_data.py
rename to tabulous/commands/_random_data.py
index 8ef49133..0c6e4e00 100644
--- a/tabulous/_qt/_toolbar/_random_data.py
+++ b/tabulous/commands/_random_data.py
@@ -73,9 +73,23 @@ def generate(self, shape: tuple[int, int]) -> np.ndarray:
         )
 
 
+class PoissonRandomGenerator(_RandomGenerator):
+    def prepare_widgets(self) -> list[Widget]:
+        return [
+            LineEdit(value="1.0", label="mean"),
+        ]
+
+    def generate(self, shape: tuple[int, int]) -> np.ndarray:
+        return np.random.poisson(
+            float(self[0].value),
+            size=shape,
+        )
+
+
 class Generator(Enum):
     uniform = "uniform"
     normal = "normal"
+    poisson = "poisson"
     choices = "choices"
 
 
@@ -89,6 +103,7 @@ def __init__(self):
         )
         self._uniform_wdt = UniformRandomGenerator(label="parameters")
         self._normal_wdt = NormalRandomGenerator(label="parameters")
+        self._poisson_wdt = PoissonRandomGenerator(label="parameters")
         self._choices_wdt = ChoiceRandomGenerator(label="parameters")
         self._call_button = PushButton(text="Generate data")
 
@@ -98,6 +113,7 @@ def __init__(self):
                 self._radio_buttons,
                 self._uniform_wdt,
                 self._normal_wdt,
+                self._poisson_wdt,
                 self._choices_wdt,
                 self._call_button,
             ]
@@ -111,6 +127,7 @@ def __init__(self):
     def _on_choice_changed(self, choice: Generator):
         self._uniform_wdt.visible = choice == Generator.uniform
         self._normal_wdt.visible = choice == Generator.normal
+        self._poisson_wdt.visible = choice == Generator.poisson
         self._choices_wdt.visible = choice == Generator.choices
 
     def _current_widget(self) -> _RandomGenerator:
@@ -118,6 +135,8 @@ def _current_widget(self) -> _RandomGenerator:
             return self._uniform_wdt
         elif self._radio_buttons.value is Generator.normal:
             return self._normal_wdt
+        elif self._radio_buttons.value is Generator.poisson:
+            return self._poisson_wdt
         elif self._radio_buttons.value is Generator.choices:
             return self._choices_wdt
         else:
diff --git a/tabulous/_qt/_toolbar/_sklearn/__init__.py b/tabulous/commands/_sklearn/__init__.py
similarity index 100%
rename from tabulous/_qt/_toolbar/_sklearn/__init__.py
rename to tabulous/commands/_sklearn/__init__.py
diff --git a/tabulous/_qt/_toolbar/_sklearn/_models.py b/tabulous/commands/_sklearn/_models.py
similarity index 100%
rename from tabulous/_qt/_toolbar/_sklearn/_models.py
rename to tabulous/commands/_sklearn/_models.py
diff --git a/tabulous/_qt/_toolbar/_sklearn/_widget.py b/tabulous/commands/_sklearn/_widget.py
similarity index 100%
rename from tabulous/_qt/_toolbar/_sklearn/_widget.py
rename to tabulous/commands/_sklearn/_widget.py
diff --git a/tabulous/_qt/_toolbar/_statistics.py b/tabulous/commands/_statistics.py
similarity index 100%
rename from tabulous/_qt/_toolbar/_statistics.py
rename to tabulous/commands/_statistics.py
diff --git a/tabulous/commands/_utils.py b/tabulous/commands/_utils.py
new file mode 100644
index 00000000..7d7c0b29
--- /dev/null
+++ b/tabulous/commands/_utils.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from tabulous.widgets import TableBase, TableViewerBase, Table, SpreadSheet
+
+
+def get_table(viewer: TableViewerBase) -> TableBase:
+    """Safely get the current table of the given viewer."""
+    if table := viewer.current_table:
+        return table
+    raise ValueError("No table found in the viewer.")
+
+
+def get_mutable_table(viewer: TableViewerBase) -> Table | SpreadSheet:
+    """Safely get the current mutable-type table of the given viewer."""
+    table = get_table(viewer)
+    if table.table_type in ("Table", "SpreadSheet"):
+        return table
+    from tabulous.exceptions import TableImmutableError
+
+    raise TableImmutableError(f"Table {table.name!r} is immutable.")
+
+
+def get_spreadsheet(viewer: TableViewerBase) -> SpreadSheet:
+    """Safely get the current mutable-type table of the given viewer."""
+    table = get_table(viewer)
+    if table.table_type == "SpreadSheet":
+        return table
+
+    raise TypeError(f"Table {table.name!r} is not a spreadsheet.")
+
+
+def get_selected_row(viewer: TableViewerBase) -> int:
+    table = get_table(viewer)
+    selected = table.index.selected
+    if len(selected) == 0:
+        raise ValueError("No index selected")
+    if len(selected) > 1:
+        raise ValueError("Multiple indices selected")
+
+    return selected[0].start
+
+
+def get_selected_column(viewer: TableViewerBase) -> int:
+    table = get_table(viewer)
+    selected = table.columns.selected
+    if len(selected) == 0:
+        raise ValueError("No columns selected")
+    if len(selected) > 1:
+        raise ValueError("Multiple columns selected")
+
+    return selected[0].start
diff --git a/tabulous/commands/analysis.py b/tabulous/commands/analysis.py
new file mode 100644
index 00000000..345dbe5b
--- /dev/null
+++ b/tabulous/commands/analysis.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+from typing import TYPE_CHECKING
+from . import _dialogs, _utils
+
+if TYPE_CHECKING:
+    from tabulous.widgets._mainwindow import TableViewerBase
+
+SUMMARY_CHOICES = ["mean", "median", "std", "sem", "min", "max", "sum"]
+
+
+def summarize_table(viewer: TableViewerBase):
+    """Summarize table data"""
+    table = _utils.get_table(viewer)
+    out = _dialogs.summarize_table(
+        df={"bind": table.data},
+        methods={"choices": SUMMARY_CHOICES, "widget_type": "Select"},
+        parent=viewer._qwidget,
+    )
+    if out is not None:
+        df, new = out
+        if new:
+            viewer.add_table(df, name=f"{table.name}-summary")
+        else:
+            from tabulous._qt._table import QTableLayer
+
+            qtable = QTableLayer(data=df)
+            table.add_side_widget(qtable, name="summary")
+
+
+def show_filter_widget(viewer: TableViewerBase):
+    """Apply filter to the table data"""
+    return viewer._qwidget._tablestack.openFilterDialog()
+
+
+def show_eval_widget(viewer: TableViewerBase):
+    """Evaluate a Python expression"""
+    return viewer._qwidget._tablestack.openEvalDialog()
+
+
+def show_optimizer_widget(viewer: TableViewerBase):
+    """Open optimizer widget"""
+    from ._optimizer import OptimizerWidget
+
+    tablestack = viewer._qwidget._tablestack
+    ol = tablestack._overlay
+    ol.show()
+
+    ol.addWidget(OptimizerWidget.new().native)
+    ol.setTitle("Optimization")
+
+
+def show_stats_widget(viewer: TableViewerBase):
+    """Open scipy.stats widget"""
+    from ._statistics import StatsTestDialog
+
+    dlg = StatsTestDialog()
+    dlg.native.setParent(viewer._qwidget, dlg.native.windowFlags())
+    dlg.show()
+
+
+def show_sklearn_widget(viewer: TableViewerBase):
+    """Open scikit-learn widget"""
+    from ._sklearn import SkLearnContainer
+
+    tablestack = viewer._qwidget._tablestack
+    ol = tablestack._overlay
+    ol.show()
+
+    ol.addWidget(SkLearnContainer.new().native)
+    ol.setTitle("scikit-learn analysis")
diff --git a/tabulous/commands/file.py b/tabulous/commands/file.py
new file mode 100644
index 00000000..881a4810
--- /dev/null
+++ b/tabulous/commands/file.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+from typing import TYPE_CHECKING
+from ._dialogs import choose_one
+
+if TYPE_CHECKING:
+    from tabulous.widgets._mainwindow import TableViewerBase
+
+__all__ = ["open_table", "open_spreadsheet", "save_table", "open_sample"]
+
+# fmt: off
+SAMPLE_CHOICES = [
+    "anagrams", "anscombe", "attention", "brain_networks", "car_crashes", "diamonds",
+    "dots", "dowjones", "exercise", "flights", "fmri", "geyser", "glue", "healthexp",
+    "iris", "mpg", "penguins", "planets", "seaice", "taxis", "tips", "titanic",
+]
+# fmt: on
+
+
+def open_table(viewer: TableViewerBase):
+    """Open a file as a table"""
+    return viewer._qwidget.openFromDialog(type="table")
+
+
+def open_spreadsheet(viewer: TableViewerBase):
+    """Open a file as a spreadsheet"""
+    return viewer._qwidget.openFromDialog(type="spreadsheet")
+
+
+def save_table(viewer: TableViewerBase):
+    """Save current table data"""
+    return viewer._qwidget.saveFromDialog()
+
+
+def open_sample(viewer: TableViewerBase):
+    """Open sample data"""
+    out = choose_one(choice={"choices": SAMPLE_CHOICES, "nullable": False})
+    if out is not None:
+        viewer.open_sample(out)
diff --git a/tabulous/commands/plot.py b/tabulous/commands/plot.py
new file mode 100644
index 00000000..57b2fbdc
--- /dev/null
+++ b/tabulous/commands/plot.py
@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+from typing import Hashable, TYPE_CHECKING, NamedTuple
+from . import _dialogs
+
+
+if TYPE_CHECKING:
+    from tabulous.widgets import TableBase, TableViewerBase
+
+
+def plot(viewer: TableViewerBase):
+    """Run plt.plot"""
+    return _plot_xy(viewer, _dialogs.plot)
+
+
+def bar(viewer: TableViewerBase):
+    """Run plt.bar"""
+    return _plot_xy(viewer, _dialogs.bar)
+
+
+def scatter(viewer: TableViewerBase):
+    """Run plt.scatter"""
+    return _plot_xy(viewer, _dialogs.scatter)
+
+
+def errorbar(viewer: TableViewerBase):
+    """Run plt.errorbar"""
+    table = viewer.current_table
+    if table is None:
+        return
+
+    _dialogs.errorbar(
+        ax={"bind": table.plt.gca()},
+        table={"bind": table},
+        alpha={"min": 0, "max": 1, "step": 0.05},
+        parent=viewer._qwidget,
+    )
+
+
+def hist(viewer: TableViewerBase):
+    """Run plt.hist"""
+    table = viewer.current_table
+    if table is None:
+        return
+
+    _dialogs.hist(
+        ax={"bind": table.plt.gca()},
+        table={"bind": table},
+        alpha={"min": 0, "max": 1, "step": 0.05},
+        histtype={
+            "choices": ["bar", "step", "stepfilled", "barstacked"],
+            "value": "bar",
+        },
+        parent=viewer._qwidget,
+    )
+
+
+def swarmplot(viewer: TableViewerBase):
+    """Run sns.swarmplot"""
+    return _plot_sns(viewer, _dialogs.swarmplot)
+
+
+def barplot(viewer: TableViewerBase):
+    """Run sns.barplot"""
+    return _plot_sns(viewer, _dialogs.barplot)
+
+
+def boxplot(viewer: TableViewerBase):
+    """Run sns.boxplot"""
+    return _plot_sns(viewer, _dialogs.boxplot)
+
+
+def boxenplot(viewer: TableViewerBase):
+    """Run sns.boxenplot"""
+    return _plot_sns(viewer, _dialogs.boxenplot)
+
+
+def new_figure(viewer: TableViewerBase):
+    """New figure canvas"""
+    return viewer.current_table.plt.new_widget()
+
+
+def _plot_xy(viewer: TableViewerBase, dialog):
+    table = viewer.current_table
+    if table is None:
+        return
+
+    dialog(
+        ax={"bind": table.plt.gca()},
+        x={"format": "iloc"},
+        y={"format": "iloc"},
+        table={"bind": table},
+        alpha={"min": 0, "max": 1, "step": 0.05},
+        parent=viewer._qwidget,
+    )
+
+
+def _plot_sns(viewer: TableViewerBase, dialog):
+    table = viewer.current_table
+    if table is None:
+        return
+
+    names, csel = _get_selected_ranges_and_column_names(table)
+
+    # infer x, y
+    if len(names) == 0:
+        raise ValueError("Table must have at least one column.")
+    elif len(names) == 1:
+        x = {"bind": None}
+        y = {"bind": None}
+    else:
+        for i, dtype in enumerate(table.data.dtypes):
+            if dtype == "categorical":
+                x = {"choices": names, "value": names[i], "nullable": True}
+                j = 0 if i > 0 else 1
+                y = {"choices": names, "value": names[j], "nullable": True}
+                break
+        else:
+            x = {"choices": names, "value": None, "nullable": True}
+            y = {"choices": names, "value": None, "nullable": True}
+
+    if dialog(
+        ax={"bind": table.plt.gca()},
+        x=x,
+        y=y,
+        table={"bind": table},
+        csel={"bind": csel},
+        hue={"choices": names, "nullable": True},
+        parent=viewer._qwidget,
+    ):
+        table.plt.draw()
+
+
+class PlotInfo(NamedTuple):
+    names: list[Hashable]
+    columns: slice
+
+
+def _get_selected_ranges_and_column_names(
+    table: TableBase,
+) -> PlotInfo:
+    sels = table.selections
+    if len(sels) == 1 and _selection_area(sels[0]) == 1:
+        nrow = len(table.index)
+        infos = PlotInfo(
+            names=list(table.columns),
+            columns=slice(0, nrow),
+        )
+    else:
+        names = []
+        sl = sels[0][0]
+        columns = table.columns
+        for sel in sels:
+            if sel[0] == sl:
+                csel = sel[1]
+                for i in range(csel.start, csel.stop):
+                    names.append(columns[i])
+            else:
+                raise ValueError("Selections must be in the same rows")
+        infos = PlotInfo(names=names, columns=sl)
+
+    return infos
+
+
+def _selection_area(sel: tuple[slice, slice]) -> int:
+    return (sel[0].stop - sel[0].start) * (sel[1].stop - sel[1].start)
diff --git a/tabulous/commands/selection.py b/tabulous/commands/selection.py
new file mode 100644
index 00000000..cc036e31
--- /dev/null
+++ b/tabulous/commands/selection.py
@@ -0,0 +1,495 @@
+from __future__ import annotations
+from typing import TYPE_CHECKING
+from magicgui import widgets as mwdg
+from . import _utils, _dialogs
+
+if TYPE_CHECKING:
+    from tabulous.widgets import TableViewerBase
+
+
+def copy_data_tab_separated(viewer: TableViewerBase):
+    """Copy cells (tab separated)"""
+    viewer.copy_data(headers=False, sep="\t")
+
+
+def copy_data_with_header_tab_separated(viewer: TableViewerBase):
+    """Copy cells with headers (tab separated)"""
+    viewer.copy_data(headers=True, sep="\t")
+
+
+def copy_data_comma_separated(viewer: TableViewerBase):
+    """Copy cells (comma separated)"""
+    viewer.copy_data(headers=False, sep=",")
+
+
+def copy_data_with_header_comma_separated(viewer: TableViewerBase):
+    """Copy cells with headers (comma separated)"""
+    viewer.copy_data(headers=True, sep=",")
+
+
+def copy_as_literal(viewer: TableViewerBase):
+    """Copy as literal"""
+    _utils.get_table(viewer)._qwidget._copy_as_literal()
+
+
+def copy_as_markdown(viewer: TableViewerBase):
+    """Copy as markdown text"""
+    _utils.get_table(viewer)._qwidget._copy_as_formated("markdown")
+
+
+def copy_as_latex(viewer: TableViewerBase):
+    """Copy as LaTeX text"""
+    _utils.get_table(viewer)._qwidget._copy_as_formated("latex")
+
+
+def copy_as_html(viewer: TableViewerBase):
+    """Copy as HTML text"""
+    _utils.get_table(viewer)._qwidget._copy_as_formated("html")
+
+
+def copy_as_new_table(viewer: TableViewerBase):
+    """Copy as new table"""
+    _utils.get_table(viewer)._qwidget._copy_as_new_table(type_="table")
+
+
+def copy_as_new_spreadsheet(viewer: TableViewerBase):
+    """Copy as new spreadsheet"""
+    _utils.get_table(viewer)._qwidget._copy_as_new_table(type_="spreadsheet")
+
+
+def select_all(viewer: TableViewerBase):
+    """Select all the cells"""
+    _utils.get_table(viewer)._qwidget._qtable_view.selectAll()
+
+
+def cut_data(viewer: TableViewerBase):
+    """Cut selected cells"""
+    qtable = _utils.get_mutable_table(viewer)._qwidget
+    qtable.copyToClipboard(headers=False)
+    qtable.deleteValues()
+
+
+def paste_data_tab_separated(viewer: TableViewerBase):
+    """Paste from tab separated text"""
+    _utils.get_mutable_table(viewer)._qwidget.pasteFromClipBoard(sep="\t")
+
+
+def paste_data_comma_separated(viewer: TableViewerBase):
+    """Paste from comma separated text"""
+    _utils.get_mutable_table(viewer)._qwidget.pasteFromClipBoard(sep=",")
+
+
+def paste_data_from_numpy_string(viewer: TableViewerBase):
+    """Paste from numpy-style text"""
+    _utils.get_mutable_table(viewer)._qwidget._paste_numpy_str()
+
+
+def delete_values(viewer: TableViewerBase):
+    """Delete selected cells"""
+    _utils.get_mutable_table(viewer)._qwidget.deleteValues()
+
+
+def add_highlight(viewer: TableViewerBase):
+    """Add highlight to cells"""
+    qwidget = _utils.get_table(viewer)._qwidget
+    qwidget.setHighlights(qwidget.highlights() + qwidget.selections())
+
+
+def delete_selected_highlight(viewer: TableViewerBase):
+    """Delete selected highlight"""
+    table = _utils.get_table(viewer)._qwidget
+    table._qtable_view._highlight_model.delete_selected()
+    table._qtable_view._selection_model.set_ctrl(False)
+
+
+def show_context_menu(viewer: TableViewerBase):
+    """Execute context menu"""
+    qtable = _utils.get_table(viewer)._qwidget
+    r, c = qtable._qtable_view._selection_model.current_index
+    if r >= 0 and c >= 0:
+        index = qtable._qtable_view.model().index(r, c)
+        rect = qtable._qtable_view.visualRect(index)
+        qtable.showContextMenu(rect.center())
+    elif r < 0:
+        header = qtable._qtable_view.horizontalHeader()
+        rect = header.visualRectAtIndex(c)
+        header._show_context_menu(rect.center())
+    elif c < 0:
+        header = qtable._qtable_view.verticalHeader()
+        rect = header.visualRectAtIndex(r)
+        header._show_context_menu(rect.center())
+    else:
+        raise RuntimeError("Invalid index")
+
+
+def raise_slot_error(viewer: TableViewerBase):
+    """Show traceback at the cell"""
+    qtable_view = _utils.get_table(viewer)._qwidget._qtable_view
+    idx = qtable_view._selection_model.current_index
+    if slot := qtable_view._table_map.get_by_dest(idx, None):
+        if slot._current_error is not None:
+            slot.raise_in_msgbox()
+
+
+def notify_editability(viewer: TableViewerBase):
+    """Notify that current table is not editable."""
+    viewer._qwidget._tablestack.notifyEditability()
+
+
+def set_column_dtype(viewer: TableViewerBase):
+    """Set column specific dtype for data conversion and validation."""
+    from tabulous._dtype import QDtypeWidget
+
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    col = _utils.get_selected_column(viewer)
+    if out := QDtypeWidget.requestValue(viewer._qwidget):
+        dtype_str, validation, formatting = out
+        if dtype_str == "unset":
+            dtype_str = None
+        colname = sheet._data_raw.columns[col]
+        sheet.setColumnDtype(colname, dtype_str)
+        if validation:
+            sheet._set_default_data_validator(colname)
+        if formatting:
+            sheet._set_default_text_formatter(colname)
+    return None
+
+
+def insert_row_above(viewer: TableViewerBase):
+    """Insert a row above"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    if not sheet.isEditable():
+        return notify_editability()
+    row, _ = sheet._qtable_view._selection_model.current_index
+    return sheet.insertRows(row, 1)
+
+
+def insert_row_below(viewer: TableViewerBase):
+    """Insert a row below"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    if not sheet.isEditable():
+        return notify_editability()
+    row, _ = sheet._qtable_view._selection_model.current_index
+    return sheet.insertRows(row + 1, 1)
+
+
+def insert_column_left(viewer: TableViewerBase):
+    """Insert a column left"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    if not sheet.isEditable():
+        return notify_editability()
+    _, col = sheet._qtable_view._selection_model.current_index
+    return sheet.insertColumns(col, 1)
+
+
+def insert_column_right(viewer: TableViewerBase):
+    """Insert a column right"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    if not sheet.isEditable():
+        return notify_editability()
+    _, col = sheet._qtable_view._selection_model.current_index
+    return sheet.insertColumns(col + 1, 1)
+
+
+def remove_this_row(viewer: TableViewerBase):
+    """Remove this row"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    if not sheet.isEditable():
+        return notify_editability()
+    row, _ = sheet._qtable_view._selection_model.current_index
+    return sheet.removeRows(row, 1)
+
+
+def remove_this_column(viewer: TableViewerBase):
+    """Remove this column"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    if not sheet.isEditable():
+        return notify_editability()
+    _, col = sheet._qtable_view._selection_model.current_index
+    return sheet.removeColumns(col, 1)
+
+
+def remove_selected_rows(viewer: TableViewerBase):
+    """Remove selected rows"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    if not sheet.isEditable():
+        return notify_editability()
+    row, _ = sheet._qtable_view._selection_model.current_index
+    _, rng = sheet._qtable_view._selection_model.range_under_index(row, 0)
+    if rng is not None:
+        row_range = rng[0]
+        sheet.removeRows(row_range.start, row_range.stop - row_range.start)
+    return None
+
+
+def remove_selected_columns(viewer: TableViewerBase):
+    """Remove selected columns"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    if not sheet.isEditable():
+        return notify_editability()
+    _, col = sheet._qtable_view._selection_model.current_index
+    _, rng = sheet._qtable_view._selection_model.range_under_index(0, col)
+    if rng is not None:
+        col_range = rng[1]
+        sheet.removeColumns(col_range.start, col_range.stop - col_range.start)
+    return None
+
+
+def set_foreground_colormap(viewer: TableViewerBase) -> None:
+    """Set foreground colormap"""
+    from ._colormap import exec_colormap_dialog
+
+    sheet = _utils.get_table(viewer)._qwidget
+    index = _utils.get_selected_column(viewer)
+
+    column_name = sheet._filtered_columns[index]
+    if cmap := exec_colormap_dialog(sheet.getDataFrame()[column_name], sheet):
+        sheet.setForegroundColormap(column_name, cmap)
+    return None
+
+
+def reset_foreground_colormap(viewer: TableViewerBase) -> None:
+    """Reset foreground colormap"""
+    sheet = _utils.get_table(viewer)._qwidget
+    index = _utils.get_selected_column(viewer)
+    column_name = sheet._filtered_columns[index]
+    return sheet.setForegroundColormap(column_name, None)
+
+
+def set_background_colormap(viewer: TableViewerBase) -> None:
+    """Set background colormap"""
+    from ._colormap import exec_colormap_dialog
+
+    sheet = _utils.get_table(viewer)._qwidget
+    index = _utils.get_selected_column(viewer)
+    column_name = sheet._filtered_columns[index]
+    if cmap := exec_colormap_dialog(sheet.getDataFrame()[column_name], sheet):
+        sheet.setBackgroundColormap(column_name, cmap)
+    return None
+
+
+def reset_background_colormap(viewer: TableViewerBase) -> None:
+    """Reset background colormap"""
+    sheet = _utils.get_table(viewer)._qwidget
+    index = _utils.get_selected_column(viewer)
+    column_name = sheet._filtered_columns[index]
+    return sheet.setBackgroundColormap(column_name, None)
+
+
+def set_text_formatter(viewer: TableViewerBase) -> None:
+    """Set text formatter"""
+    from tabulous._text_formatter import exec_formatter_dialog
+
+    sheet = _utils.get_table(viewer)._qwidget
+    index = _utils.get_selected_column(viewer)
+    column_name = sheet._filtered_columns[index]
+
+    if fmt := exec_formatter_dialog(sheet.getDataFrame()[column_name], sheet):
+        sheet.setTextFormatter(column_name, fmt)
+    return None
+
+
+def reset_text_formatter(viewer: TableViewerBase) -> None:
+    """Reset text formatter"""
+    sheet = _utils.get_table(viewer)._qwidget
+    index = _utils.get_selected_column(viewer)
+    column_name = sheet._filtered_columns[index]
+    return sheet.setTextFormatter(column_name, None)
+
+
+def write_data_signal_in_console(viewer: TableViewerBase):
+    """Write data signal connection to console"""
+    from qtpy import QtCore
+
+    table = _utils.get_table(viewer)
+    sels = table.selections
+    if len(sels) != 1:
+        return
+    qviewer = viewer.native
+    console = qviewer._console_widget
+    if console is None or not console.isActive():
+        delay = 500  # need delay to wait for the console to be activated
+    else:
+        delay = 0
+    qviewer.setConsoleVisible(True)
+
+    def _update_console():
+        console = qviewer._console_widget
+        rsl, csl = sels[0]
+        if rsl == slice(None) and csl == slice(None):
+            _getitem = ""
+        else:
+            r0 = str(rsl.start) if rsl.start is not None else ""
+            r1 = str(rsl.stop) if rsl.stop is not None else ""
+            c0 = str(csl.start) if csl.start is not None else ""
+            c1 = str(csl.stop) if csl.stop is not None else ""
+            _getitem = f"[{r0}:{r1}, {c0}:{c1}]"
+        text = (
+            f"@viewer.current_table.events.data{_getitem}.connect\n"
+            "def _on_data_changed(info: 'ItemInfo'):\n"
+            "    "
+        )
+        if buf := console.buffer():
+            if not buf.endswith("\n"):
+                buf += "\n"
+            text = buf + text
+        console.setBuffer(text)
+        console.setFocus()
+        console.setTempText("...")
+
+    QtCore.QTimer.singleShot(delay, _update_console)
+    return None
+
+
+def write_slice_in_console(viewer: TableViewerBase) -> None:
+    """Write data slice to console"""
+    from qtpy import QtCore
+
+    table = _utils.get_table(viewer)
+    sels = table.selections
+    if len(sels) != 1:
+        return
+    qviewer = viewer.native
+    console = qviewer._console_widget
+    if console is None or not console.isActive():
+        delay = 500  # need delay to wait for the console to be activated
+    else:
+        delay = 0
+    qviewer.setConsoleVisible(True)
+
+    def _update_console():
+        console = qviewer._console_widget
+        rsl, csl = sels[0]
+        if rsl.start is None:
+            rsl_str = f"slice({rsl.stop})"
+        elif rsl.stop is None:
+            rsl_str = f"slice({rsl.start}, None)"
+        else:
+            rsl_str = f"slice({rsl.start}, {rsl.stop})"
+        if csl.start is None:
+            csl_str = f"slice({csl.stop})"
+        elif csl.stop is None:
+            csl_str = f"slice({csl.start}, None)"
+        else:
+            csl_str = f"slice({csl.start}, {csl.stop})"
+
+        text = f"sl = ({rsl_str}, {csl_str})\n"
+        if buf := console.buffer():
+            if not buf.endswith("\n"):
+                buf += "\n"
+            text = buf + text
+        console.setBuffer(text)
+        console.setFocus()
+
+    QtCore.QTimer.singleShot(delay, _update_console)
+    return None
+
+
+def write_data_reference_in_console(viewer: TableViewerBase) -> None:
+    """Write data reference to console"""
+    from qtpy import QtCore
+
+    table = _utils.get_table(viewer)
+    sels = table.selections
+    if len(sels) != 1:
+        return
+    qviewer = viewer.native
+    console = qviewer._console_widget
+    if console is None or not console.isActive():
+        delay = 500  # need delay to wait for the console to be activated
+    else:
+        delay = 0
+    qviewer.setConsoleVisible(True)
+
+    def _update_console():
+        console = qviewer._console_widget
+        rsl, csl = sels[0]
+        r0 = rsl.start if rsl.start is not None else ""
+        r1 = rsl.stop if rsl.stop is not None else ""
+        c0 = csl.start if csl.start is not None else ""
+        c1 = csl.stop if csl.stop is not None else ""
+
+        text = f"viewer.data.iloc[{r0}:{r1}, {c0}:{c1}]"
+        console.insertText(text)
+        console.setFocus()
+
+    QtCore.QTimer.singleShot(delay, _update_console)
+    return None
+
+
+def add_spinbox(viewer: TableViewerBase) -> None:
+    """Add Spinbox"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    kwargs = _dialogs.spinbox(parent=viewer.native)
+    with sheet._mgr.merging():
+        for r, c in sheet._qtable_view._selection_model.iter_all_indices():
+            sheet._set_widget_at_index(r, c, mwdg.SpinBox(**kwargs))
+    return None
+
+
+def add_float_spinbox(viewer: TableViewerBase) -> None:
+    """Add FloatSpinBox"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    kwargs = _dialogs.float_spinbox(parent=viewer.native)
+    with sheet._mgr.merging():
+        for r, c in sheet._qtable_view._selection_model.iter_all_indices():
+            sheet._set_widget_at_index(r, c, mwdg.FloatSpinBox(**kwargs))
+    return None
+
+
+def add_slider(viewer: TableViewerBase) -> None:
+    """Add Slider"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    kwargs = _dialogs.slider(parent=viewer.native)
+    with sheet._mgr.merging():
+        for r, c in sheet._qtable_view._selection_model.iter_all_indices():
+            sheet._set_widget_at_index(r, c, mwdg.Slider(**kwargs))
+    return None
+
+
+def add_float_slider(viewer: TableViewerBase) -> None:
+    """Add FloatSlider"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    kwargs = _dialogs.float_slider(parent=viewer.native)
+    with sheet._mgr.merging():
+        for r, c in sheet._qtable_view._selection_model.iter_all_indices():
+            sheet._set_widget_at_index(r, c, mwdg.FloatSlider(**kwargs))
+    return None
+
+
+def add_checkbox(viewer: TableViewerBase) -> None:
+    """Add CheckBox"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    kwargs = _dialogs.checkbox(parent=viewer.native)
+    with sheet._mgr.merging():
+        for r, c in sheet._qtable_view._selection_model.iter_all_indices():
+            sheet._set_widget_at_index(r, c, mwdg.CheckBox(**kwargs))
+    return None
+
+
+def add_radio_button(viewer: TableViewerBase) -> None:
+    """Add RadioButton"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    kwargs = _dialogs.radio_button(parent=viewer.native)
+    with sheet._mgr.merging():
+        for r, c in sheet._qtable_view._selection_model.iter_all_indices():
+            sheet._set_widget_at_index(r, c, mwdg.RadioButton(**kwargs))
+    return None
+
+
+def add_line_edit(viewer: TableViewerBase) -> None:
+    """Add LineEdit"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    with sheet._mgr.merging():
+        for r, c in sheet._qtable_view._selection_model.iter_all_indices():
+            sheet._set_widget_at_index(r, c, mwdg.LineEdit())
+    return None
+
+
+def remove_cell_widgets(viewer: TableViewerBase) -> None:
+    """Remove cell widgets"""
+    sheet = _utils.get_spreadsheet(viewer)._qwidget
+    with sheet._mgr.merging():
+        for r, c in sheet._qtable_view._selection_model.iter_all_indices():
+            sheet._set_widget_at_index(r, c, None)
+    return None
diff --git a/tabulous/commands/tab.py b/tabulous/commands/tab.py
new file mode 100644
index 00000000..71f5a7b1
--- /dev/null
+++ b/tabulous/commands/tab.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+from typing import TYPE_CHECKING
+from . import _dialogs
+
+if TYPE_CHECKING:
+    from tabulous.widgets import TableViewerBase
+
+
+def activate_left(viewer: TableViewerBase):
+    """Activate left table."""
+    num = len(viewer.tables)
+    if num == 0:
+        return None
+    src = viewer.current_index
+    if src == 0:
+        return None
+    viewer.current_index = src - 1
+    return viewer.native.setCellFocus()
+
+
+def activate_right(viewer: TableViewerBase):
+    """Activate right table."""
+    num = len(viewer.tables)
+    if num == 0:
+        return None
+    src = viewer.current_index
+    if src == num - 1:
+        return None
+    viewer.current_index = src + 1
+    return viewer.native.setCellFocus()
+
+
+def swap_tab_with_left(viewer: TableViewerBase):
+    """Swap the current tab with the left one"""
+    num = len(viewer.tables)
+    if num == 0:
+        return None
+    src = viewer.current_index
+    if src == 0:
+        return None
+    viewer.tables.move(src, src - 1)
+
+
+def swap_tab_with_right(viewer: TableViewerBase):
+    """Swap the current tab with the right one"""
+    num = len(viewer.tables)
+    if num == 0:
+        return None
+    src = viewer.current_index
+    if src == num - 1:
+        return None
+    viewer.tables.move(src + 1, src)
+
+
+def tile_tables(viewer: TableViewerBase):
+    """Tile tabs"""
+    choices = [(table.name, idx) for idx, table in enumerate(viewer.tables)]
+    out = _dialogs.choose_multiple(
+        choices={"choices": choices, "widget_type": "Select"},
+        parent=viewer.native,
+    )
+    if out:
+        viewer.tables.tile(out)
+
+
+def tile_with_adjacent_table(viewer: TableViewerBase):
+    """Tile adjacent tabs"""
+    num = len(viewer.tables)
+    if num < 2:
+        return None
+    idx = viewer.current_index
+    if idx < num - 1:
+        indices = [idx, idx + 1]
+    else:
+        indices = [idx - 1, idx]
+    all_indices = []
+    for i in indices:
+        all_indices.extend(viewer.native._tablestack.tiledIndices(i))
+
+    all_indices = list(set(all_indices))
+    return viewer.tables.tile(all_indices, orientation="horizontal")
+
+
+def untile_table(viewer: TableViewerBase):
+    """Untile current tab"""
+    viewer.tables.untile(viewer.current_index)
diff --git a/tabulous/commands/table.py b/tabulous/commands/table.py
new file mode 100644
index 00000000..5ea2ac65
--- /dev/null
+++ b/tabulous/commands/table.py
@@ -0,0 +1,155 @@
+from __future__ import annotations
+from typing import TYPE_CHECKING
+from . import _dialogs, _utils
+
+if TYPE_CHECKING:
+    from tabulous.widgets import TableViewerBase
+
+
+def new_spreadsheet(viewer: TableViewerBase):
+    """Add an empty spreadsheet."""
+    viewer.add_spreadsheet()
+
+
+def copy_as_table(viewer: TableViewerBase):
+    """Copy current table as a new table"""
+    table = _utils.get_table(viewer)
+    viewer.add_table(table.data, name=f"{table.name}-copy")
+
+
+def copy_as_spreadsheet(viewer: TableViewerBase):
+    """Copy current table as a new spreadsheet"""
+    table = _utils.get_table(viewer)
+    viewer.add_spreadsheet(table.data, name=f"{table.name}-copy")
+
+
+def groupby(viewer: TableViewerBase):
+    """Group table data by its column(s)"""
+    table = _utils.get_table(viewer)
+    out = _dialogs.groupby(
+        df={"bind": table.data},
+        by={"choices": list(table.data.columns), "widget_type": "Select"},
+        parent=viewer._qwidget,
+    )
+    if out is not None:
+        viewer.add_groupby(out, name=f"{table.name}-groupby")
+
+
+def switch_header(viewer: TableViewerBase):
+    """Switch header and the top row"""
+    table = _utils.get_mutable_table(viewer)
+    table._qwidget._switch_head_and_index(axis=1)
+
+
+def concat(viewer: TableViewerBase):
+    """Concatenate table data"""
+    out = _dialogs.concat(
+        viewer={"bind": viewer},
+        names={
+            "value": [viewer.current_table.name],
+            "widget_type": "Select",
+            "choices": [t.name for t in viewer.tables],
+        },
+        axis={"choices": [("vertical", 0), ("horizontal", 1)]},
+        parent=viewer._qwidget,
+    )
+    if out is not None:
+        viewer.add_table(out, name=f"concat")
+
+
+def pivot(viewer: TableViewerBase):
+    """Pivot current table data"""
+    table = _utils.get_table(viewer)
+    col = list(table.data.columns)
+    if len(col) < 3:
+        raise ValueError("Table must have at least three columns.")
+    out = _dialogs.pivot(
+        df={"bind": table.data},
+        index={"choices": col, "value": col[0]},
+        columns={"choices": col, "value": col[1]},
+        values={"choices": col, "value": col[2]},
+        parent=viewer._qwidget,
+    )
+    if out is not None:
+        viewer.add_table(out, name=f"{table.name}-pivot")
+
+
+def melt(viewer: TableViewerBase):
+    """Melt (unpivot) current table data"""
+    table = _utils.get_table(viewer)
+    out = _dialogs.melt(
+        df={"bind": table.data},
+        id_vars={"choices": list(table.data.columns), "widget_type": "Select"},
+        parent=viewer._qwidget,
+    )
+    if out is not None:
+        viewer.add_table(out, name=f"{table.name}-melt")
+
+
+def show_finder_widget(viewer: TableViewerBase):
+    """Toggle finder widget"""
+    return viewer._qwidget._tablestack.openFinderDialog()
+
+
+def sort_table(viewer: TableViewerBase):
+    """Sort table data"""
+    table = _utils.get_table(viewer)
+    out = _dialogs.sort(
+        df={"bind": table.data},
+        by={"choices": list(table.data.columns), "widget_type": "Select"},
+        ascending={"text": "Sort in ascending order."},
+        parent=viewer._qwidget,
+    )
+    if out is not None:
+        viewer.add_table(out, name=f"{table.name}-sorted")
+
+
+def random(viewer: TableViewerBase):
+    """Generate random values"""
+    table = viewer.current_table
+    if table is None:
+        return
+    from ._random_data import RandomGeneratorDialog
+
+    dlg = RandomGeneratorDialog()
+    dlg.native.setParent(viewer._qwidget, dlg.native.windowFlags())
+    dlg._selection_wdt._read_selection(table)
+    dlg.show()
+
+    @dlg.called.connect
+    def _on_called():
+        val = dlg.get_value(table._qwidget.model().df)
+        rsl, csl, data = val
+        table.cell[rsl, csl] = data
+
+
+def delete_table(viewer: TableViewerBase) -> None:
+    """Delete current table."""
+    idx = viewer.current_index
+    del viewer.tables[idx]
+
+
+def toggle_editability(viewer: TableViewerBase):
+    """Toggle table editability."""
+    table = viewer.current_table
+    try:
+        table.editable = not table.editable
+        viewer.native._tablestack._notifier.setVisible(False)
+    except Exception:
+        pass
+    viewer.native.setCellFocus()
+
+
+def show_undo_stack_view(viewer: TableViewerBase):
+    """Show undo stack view"""
+    _utils.get_table(viewer)._qwidget.undoStackView()
+
+
+def undo_table(viewer: TableViewerBase):
+    """Undo table operation"""
+    _utils.get_mutable_table(viewer)._qwidget.undo()
+
+
+def redo_table(viewer: TableViewerBase):
+    """Redo table operation"""
+    _utils.get_mutable_table(viewer)._qwidget.redo()
diff --git a/tabulous/commands/view.py b/tabulous/commands/view.py
new file mode 100644
index 00000000..4a5c5463
--- /dev/null
+++ b/tabulous/commands/view.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from . import _utils
+
+if TYPE_CHECKING:
+    from tabulous.widgets import TableViewerBase
+
+
+def set_popup_mode(viewer: TableViewerBase):
+    """Popup table."""
+    table = _utils.get_table(viewer)
+    table.view_mode = "popup"
+
+
+def set_dual_v_mode(viewer: TableViewerBase):
+    """Vertical dual-view"""
+    table = _utils.get_table(viewer)
+    table.view_mode = "vertical"
+
+
+def set_dual_h_mode(viewer: TableViewerBase):
+    """Horizontal dual-view"""
+    table = _utils.get_table(viewer)
+    table.view_mode = "horizontal"
+
+
+def reset_view_mode(viewer: TableViewerBase):
+    """Reset view mode"""
+    table = _utils.get_table(viewer)
+    table.view_mode = "normal"
diff --git a/tabulous/commands/window.py b/tabulous/commands/window.py
new file mode 100644
index 00000000..e6603764
--- /dev/null
+++ b/tabulous/commands/window.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from tabulous.widgets import TableViewerBase
+
+
+def show_keymap(viewer: TableViewerBase):
+    """Show key map widget"""
+    viewer._qwidget.showKeyMap()
+
+
+def close_window(viewer: TableViewerBase):
+    """Close window"""
+    viewer._qwidget.close()
+
+
+def new_window(viewer: TableViewerBase):
+    """Create a new window"""
+    new = viewer.__class__()
+    return new._qwidget.activateWindow()
+
+
+def toggle_toolbar(viewer: TableViewerBase):
+    """Toggle toolbar visibility"""
+    viewer._qwidget.toggleToolBarVisibility()
+
+
+def toggle_console(viewer: TableViewerBase):
+    """Toggle QtConsole visibility"""
+    return viewer._qwidget.toggleConsoleVisibility()
+
+
+def toggle_fullscreen(viewer: TableViewerBase):
+    """Toggle fullscreen"""
+    if viewer._qwidget.isFullScreen():
+        viewer._qwidget.showNormal()
+    else:
+        viewer._qwidget.showFullScreen()
+
+
+def show_command_palette(viewer: TableViewerBase):
+    """Show the command palette."""
+    viewer._qwidget.showCommandPalette()
+
+
+def focus_table(viewer: TableViewerBase):
+    """Move focus to the table."""
+    viewer.native.setCellFocus()
+
+
+def toggle_focus(viewer: TableViewerBase):
+    """Toggle focus between table and command palette."""
+    table = viewer.current_table
+    qviewer = viewer._qwidget
+    if table is None:
+        return
+    if table._qwidget._qtable_view.hasFocus():
+        console = qviewer._console_widget
+        if console is not None and console.isActive():
+            console.setFocus()
+    else:
+        qviewer.setCellFocus()
+    return
diff --git a/tabulous/widgets/_component.py b/tabulous/widgets/_component.py
index c9254e98..1fadbd8e 100644
--- a/tabulous/widgets/_component.py
+++ b/tabulous/widgets/_component.py
@@ -111,7 +111,7 @@ def __setitem__(self, key: int | slice, value: Any):
         table = self.parent
         if not table.editable:
             raise TableImmutableError("Table is not editable.")
-        qtable = table._qwidget
+
         if isinstance(key, slice):
             start, stop, step = key.indices(len(self._get_axis()))
             for i, idx in enumerate(range(start, stop, step)):
@@ -188,6 +188,13 @@ def _set_value(self, idx: int, val: Any):
     def _get_header(self) -> QDataFrameHeaderView:
         return self.parent._qwidget._qtable_view.verticalHeader()
 
+    @property
+    def selected(self) -> list[slice]:
+        """Return the selected row ranges."""
+        smodel = self.parent._qwidget._qtable_view._selection_model
+        out = [smodel.ranges[i][0] for i in smodel._row_selection_indices]
+        return out
+
 
 class HorizontalHeaderInterface(_HeaderInterface):
     """The interface for the horizontal header of the tablelist."""
@@ -201,6 +208,13 @@ def _set_value(self, idx: int, val: Any):
     def _get_header(self) -> QDataFrameHeaderView:
         return self.parent._qwidget._qtable_view.horizontalHeader()
 
+    @property
+    def selected(self) -> list[slice]:
+        """Return the selected row ranges."""
+        smodel = self.parent._qwidget._qtable_view._selection_model
+        out = [smodel.ranges[i][1] for i in smodel._col_selection_indices]
+        return out
+
 
 class CellInterface(Component["TableBase"]):
     """The interface for editing cell as if it was manually edited."""
diff --git a/tabulous/widgets/_mainwindow.py b/tabulous/widgets/_mainwindow.py
index 2efd7674..5e3d1615 100644
--- a/tabulous/widgets/_mainwindow.py
+++ b/tabulous/widgets/_mainwindow.py
@@ -380,11 +380,12 @@ def copy_data(
         selections: SelectionType | _SingleSelection | None = None,
         *,
         headers: bool = False,
+        sep: str = "\t",
     ) -> None:
         """Copy selected cells to clipboard."""
         if selections is not None:
             self.current_table.selections = selections
-        return self.current_table._qwidget.copyToClipboard(headers=headers)
+        return self.current_table._qwidget.copyToClipboard(headers=headers, sep=sep)
 
     def paste_data(
         self,


Test patch:
diff --git a/tests/test_finder.py b/tests/test_finder.py
index 834bbbdf..158d9053 100644
--- a/tests/test_finder.py
+++ b/tests/test_finder.py
@@ -1,4 +1,5 @@
 from tabulous import TableViewer
+from tabulous import commands as cmds
 import pandas as pd
 from ._utils import selection_equal
 
@@ -7,7 +8,7 @@ def test_find_by_value():
     layer = viewer.add_table(
         pd.DataFrame({'a': [1, 2, 3], 'b': [2, 3, 2], 'c': ["a", "2", "2"]})
     )
-    finder = viewer._qwidget._toolbar.find_item()  # show the finder widget
+    finder = cmds.table.show_finder_widget(viewer)
     finder.searchBox().setText("2")
 
     finder.findNext()
@@ -28,7 +29,7 @@ def test_find_by_text():
     layer = viewer.add_spreadsheet(
         pd.DataFrame({'a': ["aa", "bb", "cc"], 'b': ["bc", "cc", "cc"]})
     )
-    finder = viewer._qwidget._toolbar.find_item()  # show the finder widget
+    finder = cmds.table.show_finder_widget(viewer)
     finder.searchBox().setText("cc")
 
     finder.findNext()
@@ -49,7 +50,7 @@ def test_find_by_partial_text():
     layer = viewer.add_spreadsheet(
         pd.DataFrame({'a': ["aa", "bb", "cc"], 'b': ["bc", "cc", "cb"]})
     )
-    finder = viewer._qwidget._toolbar.find_item()  # show the finder widget
+    finder = cmds.table.show_finder_widget(viewer)
     finder.searchBox().setText("b")
     finder.cbox_match.setCurrentIndex(2)  # partial match
 
@@ -72,7 +73,7 @@ def test_find_by_regex():
     layer = viewer.add_spreadsheet(
         pd.DataFrame({'a': ["a123a", "b321b", "c2h2c"], 'b': ["a442a", "1cc2", "a12a"]})
     )
-    finder = viewer._qwidget._toolbar.find_item()  # show the finder widget
+    finder = cmds.table.show_finder_widget(viewer)
     finder.searchBox().setText(r"a\d+a")
     finder.cbox_match.setCurrentIndex(3)  # regex
 
@@ -95,7 +96,7 @@ def test_find_by_eval():
     layer = viewer.add_spreadsheet(
         pd.DataFrame({'a': ["0.13", "a", "2.5"], 'b': ["0.32", "-1.2", "0.54"]})
     )
-    finder = viewer._qwidget._toolbar.find_item()  # show the finder widget
+    finder = cmds.table.show_finder_widget(viewer)
     finder.searchBox().setText("0 < float(x) < 1")
     finder.cbox_match.setCurrentIndex(4)  # eval