Repository: napari/napari

Issue:
Split canvas color from theme
# Description
<!-- What does this pull request (PR) do? Why is it necessary? -->
<!-- Tell us about your new feature, improvement, or fix! -->

As discussed in #2074 this PR split canvas color from the theme. Some type of data looks better with black background (for example multichannel confocal data).

I need to use the light theme because part of QWidgets after applying the napari style are illegible. 

## Type of change
<!-- Please delete options that are not relevant. -->
- [ ] Bug-fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

# References
<!-- What resources, documentation, and guides were used in the creation of this PR? -->
<!-- If this is a bug-fix or otherwise resolves an issue, reference it here with "closes #(issue)" -->

# How has this been tested?
<!-- Please describe the tests that you ran to verify your changes. -->
- [ ] example: the test suite for my feature covers cases x, y, and z
- [ ] example: all tests pass with my change

## Final checklist:
- [ ] My PR is the minimum possible work for the desired functionality
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] I have added tests that prove my fix is effective or that my feature works



Discussion:
# [Codecov](https://codecov.io/gh/napari/napari/pull/2103?src=pr&el=h1) Report
> Merging [#2103](https://codecov.io/gh/napari/napari/pull/2103?src=pr&el=desc) (9ad4fec) into [master](https://codecov.io/gh/napari/napari/commit/51774e291f076e0416ed5b141bff4b51471de91b?el=desc) (51774e2) will **increase** coverage by `0.00%`.
> The diff coverage is `89.47%`.

[![Impacted file tree graph](https://codecov.io/gh/napari/napari/pull/2103/graphs/tree.svg?width=650&height=150&src=pr&token=1LfdDeezHt)](https://codecov.io/gh/napari/napari/pull/2103?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##           master    #2103   +/-   ##
=======================================
  Coverage   79.91%   79.92%           
=======================================
  Files         393      393           
  Lines       33693    33729   +36     
=======================================
+ Hits        26927    26959   +32     
- Misses       6766     6770    +4     
```


| [Impacted Files](https://codecov.io/gh/napari/napari/pull/2103?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [napari/components/viewer\_model.py](https://codecov.io/gh/napari/napari/pull/2103/diff?src=pr&el=tree#diff-bmFwYXJpL2NvbXBvbmVudHMvdmlld2VyX21vZGVsLnB5) | `86.96% <73.33%> (-0.51%)` | :arrow_down: |
| [napari/\_qt/qt\_main\_window.py](https://codecov.io/gh/napari/napari/pull/2103/diff?src=pr&el=tree#diff-bmFwYXJpL19xdC9xdF9tYWluX3dpbmRvdy5weQ==) | `84.41% <100.00%> (+0.18%)` | :arrow_up: |
| [napari/\_qt/qt\_resources/build\_icons.py](https://codecov.io/gh/napari/napari/pull/2103/diff?src=pr&el=tree#diff-bmFwYXJpL19xdC9xdF9yZXNvdXJjZXMvYnVpbGRfaWNvbnMucHk=) | `91.02% <100.00%> (ø)` | |
| [napari/\_qt/qt\_viewer.py](https://codecov.io/gh/napari/napari/pull/2103/diff?src=pr&el=tree#diff-bmFwYXJpL19xdC9xdF92aWV3ZXIucHk=) | `74.57% <100.00%> (+0.21%)` | :arrow_up: |
| [napari/\_tests/test\_viewer.py](https://codecov.io/gh/napari/napari/pull/2103/diff?src=pr&el=tree#diff-bmFwYXJpL190ZXN0cy90ZXN0X3ZpZXdlci5weQ==) | `95.61% <100.00%> (+0.56%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/napari/napari/pull/2103?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/napari/napari/pull/2103?src=pr&el=footer). Last update [51774e2...68a6b0e](https://codecov.io/gh/napari/napari/pull/2103?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

I'm adding this to `0.4.4` if it's ok that we spend a little time thinking about this. One thing I'd like to think about is us adding a small `Canvas` object, so we could do `viewer.canvas.color = ...` and `viewer.canvas.size ...` etc. One question though is what we'll do when we have multiple canvases, should we then have a list there etc?
> I'm adding this to `0.4.4` if it's ok that we spend a little time thinking about this.

I'm ok with this. 

> One question though is what we'll do when we have multiple canvases, should we then have a list there etc?

I think that in the case of multiple canvases it will be one of the smallest changes. But maybe it is a good point to start a discussion.  


> I certainly like the ability to override the theme canvas (as needed by @Czaki), but I'm not so sure I agree that it needed to be completely unsettable in the theme. Would there be a way to allow both while still satisfying your use case @Czaki?

yeah, it would be great if we could do this without adding `viewer.canvas_color`, which I don't think we'll want in long term. I'm going to bump this to 0.4.5 as it probably needs more discussion.
> yeah, it would be great if we could do this without adding `viewer.canvas_color`, which I don't think we'll want in long term. I'm going to bump this to 0.4.5 as it probably needs more discussion.

So could we also bump #2074 functions by one version? Also #2140 will not be solved fast. 

I strongly prefer to have option to set background to black for confocal data even with light theme. 
> I strongly prefer to have option to set background to black for confocal data even with light theme.

this part is great.  I too would like the possibility of overriding the theme.  I'd just like us to be able to do it as an _override_ ... not just to discontinue support for theme canvas colors altogether
> this part is great. I too would like the possibility of overriding the theme. I'd just like us to be able to do it as an _override_ ... not just to discontinue support for theme canvas colors altogether

But this force to manually overwrite after every theme is toggled. 
> But this force to manually overwrite after every theme is toggled.

does it? You don't think we can come up with any way to default to the theme color, but once an override has been provided, use it?
i feel like we can probably come up with _something_ ... such as `viewer._canvas_override` or something like that.  
> `viewer._canvas_override`

I could implement something like this, but I prefer to make it public attribute 

From user interface it may be a list of basic colors in a view menu? 
> So could we also bump #2074 functions by one version? Also #2140 will not be solved fast.

Those PRs are already set for `0.4.6`, so should be fine to leave (normal policy would have been two releases so for `0.4.5` for something changed in `0.4.3` but you made them three when added which I think was fine). We can resolve this during next release though.

> I could implement something like this, but I prefer to make it public attribute

I don't want to add a public attribute that we know we will change, especially when it seems like there are deeper issues at play like #2140.

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
@@ -4,7 +4,7 @@
 from typing import TYPE_CHECKING, Optional
 
 import numpy as np
-from qtpy.QtCore import QCoreApplication, QObject, QSize, Qt
+from qtpy.QtCore import QCoreApplication, QObject, Qt
 from qtpy.QtGui import QCursor, QGuiApplication
 from qtpy.QtWidgets import QFileDialog, QSplitter, QVBoxLayout, QWidget
 
@@ -178,11 +178,9 @@ def __init__(self, viewer, welcome=False):
             'standard': QCursor(),
         }
 
-        self._update_canvas_background()
         self._on_active_layer_change()
 
         self.viewer.events.active_layer.connect(self._on_active_layer_change)
-        self.viewer.events.theme.connect(self._update_canvas_background)
         self.viewer.camera.events.interactive.connect(self._on_interactive)
         self.viewer.cursor.events.style.connect(self._on_cursor)
         self.viewer.cursor.events.size.connect(self._on_cursor)
@@ -242,10 +240,7 @@ def _create_canvas(self) -> None:
             parent=self,
             size=self.viewer._canvas_size[::-1],
         )
-        self.canvas.events.ignore_callback_errors = False
         self.canvas.events.draw.connect(self.dims.enable_play)
-        self.canvas.native.setMinimumSize(QSize(200, 200))
-        self.canvas.context.set_depth_func('lequal')
 
         self.canvas.connect(self.on_mouse_move)
         self.canvas.connect(self.on_mouse_press)
@@ -255,6 +250,8 @@ def _create_canvas(self) -> None:
         self.canvas.connect(self.on_mouse_wheel)
         self.canvas.connect(self.on_draw)
         self.canvas.connect(self.on_resize)
+        self.canvas.bgcolor = get_theme(self.viewer.theme)['canvas']
+        self.viewer.events.theme.connect(self.canvas._on_theme_change)
 
     def _add_visuals(self, welcome: bool) -> None:
         """Add visuals for axes, scale bar, and welcome text.
@@ -563,10 +560,6 @@ def _on_cursor(self, event):
 
         self.canvas.native.setCursor(q_cursor)
 
-    def _update_canvas_background(self, event=None):
-        """Update the napari GUI theme."""
-        self.canvas.bgcolor = get_theme(self.viewer.theme)['canvas']
-
     def toggle_console_visibility(self, event=None):
         """Toggle console visible and not visible.
 
diff --git a/napari/_vispy/vispy_canvas.py b/napari/_vispy/vispy_canvas.py
--- a/napari/_vispy/vispy_canvas.py
+++ b/napari/_vispy/vispy_canvas.py
@@ -1,5 +1,6 @@
 """VispyCanvas class.
 """
+from qtpy.QtCore import QSize
 from vispy.scene import SceneCanvas
 
 from .utils_gl import get_max_texture_sizes
@@ -23,7 +24,8 @@ def __init__(self, *args, **kwargs):
         # Since the base class is frozen we must create this attribute
         # before calling super().__init__().
         self.max_texture_sizes = None
-
+        self._last_theme_color = None
+        self._background_color_override = None
         super().__init__(*args, **kwargs)
 
         # Call get_max_texture_sizes() here so that we query OpenGL right
@@ -32,6 +34,37 @@ def __init__(self, *args, **kwargs):
         # using an lru_cache.
         self.max_texture_sizes = get_max_texture_sizes()
 
+        self.events.ignore_callback_errors = False
+        self.native.setMinimumSize(QSize(200, 200))
+        self.context.set_depth_func('lequal')
+
+    @property
+    def background_color_override(self):
+        return self._background_color_override
+
+    @background_color_override.setter
+    def background_color_override(self, value):
+        self._background_color_override = value
+        self.bgcolor = value or self._last_theme_color
+
+    def _on_theme_change(self, event):
+        from ..utils.theme import get_theme
+
+        # store last requested theme color, in case we need to reuse it
+        # when clearing the background_color_override, without needing to
+        # keep track of the viewer.
+        self._last_theme_color = get_theme(event.value)['canvas']
+        self.bgcolor = self._last_theme_color
+
+    @property
+    def bgcolor(self):
+        SceneCanvas.bgcolor.fget(self)
+
+    @bgcolor.setter
+    def bgcolor(self, value):
+        _value = self._background_color_override or value
+        SceneCanvas.bgcolor.fset(self, _value)
+
     def _process_mouse_event(self, event):
         """Ignore mouse wheel events which have modifiers."""
         if event.type == 'mouse_wheel' and len(event.modifiers) > 0:


Test patch: