Repository: napari/napari

Issue:
When starting the console, the terminal should have the focus
## 🚀 Feature

After starting the console in napari, the keyboard-focus should be in the console window.

## Motivation

Whenever I start the console, I start typing and depending on which key I hit first, random stuff happens in napari. The reason is that the keyboard focus is not set to the console.

## Pitch

You can spare confusion in hundreds or thousands of napari users by setting the focus to the terminal after opening it.

## Alternatives

<!-- A clear and concise description of any alternative solutions or features you've considered, if any. -->

## Additional context

<!-- Add any other context or screenshots about the feature request here. -->



Discussion:
I agree this would be a nice behavior change.

The way I can see to do this right now involves changing [`toggle_console_visibility` here](https://github.com/napari/napari/blob/28e203c22d74b2e5642070ab71f1149dc7c74846/napari/_qt/qt_viewer.py#L906) like so:

```python
...
        if viz:
            self.dockConsole.raise_()
            self.console._control.setFocus()
...
```

I don't really like this though because it's reaching _through_ `napari_console.QtConsole` into `qtconsole` to reach `_control`.

Without going all the way upstream to `qtconsole` I think it might make more sense to add `self.setFocusProxy(self._control)` in napari-console and use `self.console.setFocus()` in napari `toggle_console_visibility`.

Maybe I am overthinking this, so please forgive my inexperience with the code if this is way off base!

Resolving patch:
diff --git a/napari/_qt/qt_viewer.py b/napari/_qt/qt_viewer.py
--- a/napari/_qt/qt_viewer.py
+++ b/napari/_qt/qt_viewer.py
@@ -916,6 +916,7 @@ def toggle_console_visibility(self, event=None):
 
         if viz:
             self.dockConsole.raise_()
+            self.dockConsole.setFocus()
 
         self.viewerButtons.consoleButton.setProperty(
             'expanded', self.dockConsole.isVisible()
diff --git a/napari/_qt/widgets/qt_viewer_dock_widget.py b/napari/_qt/widgets/qt_viewer_dock_widget.py
--- a/napari/_qt/widgets/qt_viewer_dock_widget.py
+++ b/napari/_qt/widgets/qt_viewer_dock_widget.py
@@ -285,6 +285,7 @@ def _on_visibility_changed(self, visible):
 
     def setWidget(self, widget):
         widget._parent = self
+        self.setFocusProxy(widget)
         super().setWidget(widget)
 
 
diff --git a/napari/utils/_testsupport.py b/napari/utils/_testsupport.py
--- a/napari/utils/_testsupport.py
+++ b/napari/utils/_testsupport.py
@@ -1,7 +1,10 @@
 import collections
 import gc
 import os
+import platform
+import subprocess
 import sys
+import time
 import warnings
 from contextlib import suppress
 from typing import TYPE_CHECKING
@@ -359,3 +362,41 @@ def MouseEvent():
             'dims_point',
         ],
     )
+
+
+@pytest.fixture
+def linux_wm():
+    """Start a WM in the background for tests that need a WM.
+
+    This is required for tests that depend on UI events (e.g. setFocus).
+    See https://github.com/pytest-dev/pytest-qt/issues/206.
+
+    This will only run on Linux and in CI (determined by CI env var) - this is
+    unnecessary/irrelevant on macOS and Windows.
+
+    The window manager start command can be set via env var `WM_START_CMD`
+    (default is `herbstluftwm` - make sure it's installed).
+    """
+    wm_start_cmd = os.environ.get("WM_START_CMD", "herbstluftwm")
+    proc = None
+    if platform.system() == "Linux" and os.environ.get("CI"):
+        proc = subprocess.Popen([wm_start_cmd])
+        # give the WM 1s to start up and check it's still running
+        time.sleep(1)
+        if proc.poll() is not None:
+            raise RuntimeError(
+                f"window manager '{wm_start_cmd}' process [{proc.pid}] exited, "
+                f"return code: {proc.returncode}"
+            )
+
+    yield proc
+
+    if proc:
+        proc.terminate()
+        try:
+            proc.wait(timeout=20)
+        except subprocess.TimeoutExpired:
+            proc.kill()
+            raise RuntimeError(
+                f"failed to terminate window manager '{wm_start_cmd}'"
+            )


Test patch:
diff --git a/.github/workflows/test_comprehensive.yml b/.github/workflows/test_comprehensive.yml
--- a/.github/workflows/test_comprehensive.yml
+++ b/.github/workflows/test_comprehensive.yml
@@ -85,6 +85,12 @@ jobs:
           powershell gl-ci-helpers/appveyor/install_opengl.ps1
           if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1}
 
+      # run a minimal wm for linux tests that depend on UI events (e.g. setFocus)
+      # see https://github.com/pytest-dev/pytest-qt/issues/206
+      - name: Install WM on Linux
+        if: runner.os == 'Linux'
+        run: sudo apt-get update && sudo apt-get install -y herbstluftwm
+
       - name: Install dependencies
         run: |
           pip install --upgrade pip
@@ -145,6 +151,10 @@ jobs:
 
       - uses: tlambert03/setup-qt-libs@v1
 
+      - name: Install WM
+        if: runner.os == 'Linux'
+        run: sudo apt-get update && sudo apt-get install -y herbstluftwm
+
       - name: Install this commit
         run: |
           pip install --upgrade pip
diff --git a/.github/workflows/test_prereleases.yml b/.github/workflows/test_prereleases.yml
--- a/.github/workflows/test_prereleases.yml
+++ b/.github/workflows/test_prereleases.yml
@@ -39,6 +39,12 @@ jobs:
           powershell gl-ci-helpers/appveyor/install_opengl.ps1
           if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1}
 
+      # run a minimal wm for linux tests that depend on UI events (e.g. setFocus)
+      # see https://github.com/pytest-dev/pytest-qt/issues/206
+      - name: Install WM on Linux
+        if: runner.os == 'Linux'
+        run: sudo apt-get update && sudo apt-get install -y herbstluftwm
+
       - name: Install dependencies
         run: |
           pip install --upgrade pip
diff --git a/.github/workflows/test_pull_requests.yml b/.github/workflows/test_pull_requests.yml
--- a/.github/workflows/test_pull_requests.yml
+++ b/.github/workflows/test_pull_requests.yml
@@ -114,6 +114,12 @@ jobs:
           powershell gl-ci-helpers/appveyor/install_opengl.ps1
           if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1}
 
+      # run a minimal wm for linux tests that depend on UI events (e.g. setFocus)
+      # see https://github.com/pytest-dev/pytest-qt/issues/206
+      - name: Install WM on Linux
+        if: runner.os == 'Linux'
+        run: sudo apt-get update && sudo apt-get install -y herbstluftwm
+
       # tox and tox-gh-actions will take care of the "actual" installation
       # of python dependendencies into a virtualenv.  see tox.ini for more
       - name: Install dependencies
@@ -170,6 +176,12 @@ jobs:
 
       - uses: tlambert03/setup-qt-libs@v1
 
+      # run a minimal wm for linux tests that depend on UI events (e.g. setFocus)
+      # see https://github.com/pytest-dev/pytest-qt/issues/206
+      - name: Install WM
+        if: runner.os == 'Linux'
+        run: sudo apt-get update && sudo apt-get install -y herbstluftwm
+
       - name: Install this commit
         run: |
           pip install --upgrade pip
diff --git a/napari/_qt/_tests/test_qt_viewer.py b/napari/_qt/_tests/test_qt_viewer.py
--- a/napari/_qt/_tests/test_qt_viewer.py
+++ b/napari/_qt/_tests/test_qt_viewer.py
@@ -65,6 +65,23 @@ def test_qt_viewer_toggle_console(make_napari_viewer):
     assert view.dockConsole.widget() is view.console
 
 
+@skip_local_popups
+def test_qt_viewer_console_focus(qtbot, make_napari_viewer, linux_wm):
+    """Test console has focus when instantiating from viewer."""
+    viewer = make_napari_viewer(show=True)
+    view = viewer.window._qt_viewer
+    assert not view.console.hasFocus(), "console has focus before being shown"
+
+    view.toggle_console_visibility(None)
+
+    def console_has_focus():
+        assert (
+            view.console.hasFocus()
+        ), "console does not have focus when shown"
+
+    qtbot.waitUntil(console_has_focus)
+
+
 @pytest.mark.parametrize('layer_class, data, ndim', layer_test_data)
 def test_add_layer(make_napari_viewer, layer_class, data, ndim):