Repository: napari/napari

Issue:
Allow scaling of font to accommodate hidpi systems
On my hidpi system, Napari's fonts are very small.

Ideally, one would be able to zoom the interface by typing, e.g., Ctrl-+, but that is not currently implemented.

As a start, I would like to ask whether we can replace absolute font-sizes (and perhaps other sizes) in the .qss files with relative sizes (`em` instead of `pt` or `px`).  With this in place, I'd only have to add a single specifier to `02_custom.qss` to increase app-wide font-size.

There is a note in `00_base.qss` already saying:

> might be possible to convert px to em by 1px = 0.0625em

but I'm not sure where that came from.

If you think this is a reasonable request, I can poke around and see if I can make it work.


Discussion:
Hi @stefanv!  Thanks for raising this.  a couple comments:

First, we learned & changed a couple things in #820 about high DPI scaling.  If you're creating the napari viewer from within an IPython session (having used `%gui qt` or `--gui qt`) rather than directly from the command line, then can you check which version of IPython you're on?  I made a PR to IPython (https://github.com/ipython/ipython/pull/12314) that should fix high DPI scaling when IPython has created the Qt app.  (I'm not quite sure how the IPython backports work... but it's something to check).  Napari >= 0.3.2 should scale well.

(All that said, it's possible that the high DPI scaling _is_ working, but you still just would like to be able to scale the fonts.  The full on ctrl +/- zooming would be amazing, but I suspect we've got a lot of work to do on our styles to be able to do that).

Second, I did play with this briefly [in a previous PR](https://github.com/napari/napari/pull/1017#issuecomment-593449227), and I think that's where the comment came from.  But it was left as an experiment for a later date.  If you can confirm that you're on either the newer IPython (or running a newer version of napari directly) it be awesome if you had time to play around with it!
@tlambert03 Thanks for the quick response.  No, I'm not running from within IPython, just a script that invokes Napari (latest master).
ok thanks.  I'm not 100% sure what that [`Qt.AA_EnableHighDpiScaling`](https://doc.qt.io/qt-5/highdpi.html#high-dpi-support-in-qt) setting is doing under the hood, but from the docs:

> Enables high-DPI scaling in Qt on supported platforms (see also High DPI Displays). Supported platforms are X11, Windows and Android. Enabling makes Qt scale the main (device independent) coordinate system according to display scale factors provided by the operating system. This corresponds to setting the QT_AUTO_SCREEN​_SCALE_FACTOR environment variable to 1.

Would be curious to know what platform you're on (and maybe see a screenshot).  Are all the widgets and sliders sized nicely?  Just not the fonts?  In any case, to respond to your original question, if we're not hitting the right scaling on all displays, then this is absolutely a reasonable request and I'd love to hear what you learn if you have time to play with relative style units.
QSS is definitely not a full CSS implementation... so there are limitations for sure.  somewhat confusingly, the [Qt docs here](https://doc.qt.io/qt-5/stylesheet-reference.html#list-of-property-types) state that "Font Size" is of type "`Length`"   Which then says:
>The supported units are:
>  **px**: pixels
>  **pt**: the size of one point (i.e., 1/72 of an inch)
>  **em**: the em width of the font (i.e., the width of 'M')
>  **ex**: the ex width of the font (i.e., the height of 'x')
>
>  However, Qt is limited to font sizes in pt and px and any other size must be in px, em or ex.

seems to contradict itself 🤔
Ah, right---I read that in the docs but didn't click that that would invalidate my suggestion.

OK, so you put me on the right track.  I remembered having had the same qt font issue with krita, and sure enough launching with:

```
QT_SCALE_FACTOR=1.3 python ...
```

Rescales everything nicely.  Thanks!
OK, not perfectly ;)  But much better!  (This is with 1.5 scale factor.)

![2020-07-10_16:35:55](https://user-images.githubusercontent.com/45071/87166376-58d24880-c280-11ea-9bc6-0fec891737ad.png)
![2020-07-10_16:37:47](https://user-images.githubusercontent.com/45071/87166383-5a9c0c00-c280-11ea-81a1-de00497f4a3c.png)

ahh, interesting.  now I have questions!  Is this because the `Qt.AA_EnableHighDpiScaling` can only use integer ratios or something and/or do you have a slightly unusual system scale factor?  Can you see a way in which we could make setting this easier for users?
also curious, how do other python qt apps look on your system like Spyder or something
I think the scale factor can be a float.

This should become easier in the latest Qt:

> Added the QT_ENABLE_HIGHDPI_SCALING environment variable, enabling high-DPI scaling based on display DPI. This replaces QT_AUTO_SCREEN_SCALE_FACTOR (now deprecated), and corresponds to the Qt::AA_EnableHighDpiScaling application attribute.

Neither QT_AUTO_SCREEN_SCALE_FACTOR nor QT_ENABLE_HIGHDPI_SCALING change things on my system, though.
> also curious, how do other python qt apps look on your system like Spyder or something

I don't have any others installed, but as mentioned I see the same with Krita.
Also curious @stefanv, it seems that the your `QT_SCALE_FACTOR=1.3` fix changes the cropping on Chelsea for some reason??? Is that real?
![2020-07-11_18:24:42](https://user-images.githubusercontent.com/45071/87228798-61964d80-c358-11ea-9415-2ea390ae14af.png)

With scaling factor 1.8.
Would it make sense or be simpler to have this functionality (essentially zooming the UI, including both fonts and buttons, icons, width of panel, etc) to be a setting in the Preferences pane?

Resolving patch:
diff --git a/examples/new_theme.py b/examples/new_theme.py
--- a/examples/new_theme.py
+++ b/examples/new_theme.py
@@ -27,6 +27,7 @@
 blue_theme.foreground = [45, 52, 71]  # or as list
 blue_theme.primary = '#50586c'  # or as hexes
 blue_theme.current = 'orange'  # or as color name
+blue_theme.font_size = '10pt'  # you can provide a font size in points (pt) for the application
 
 register_theme('blue', blue_theme, "custom")
 
diff --git a/napari/_qt/qt_main_window.py b/napari/_qt/qt_main_window.py
--- a/napari/_qt/qt_main_window.py
+++ b/napari/_qt/qt_main_window.py
@@ -681,6 +681,7 @@ def _connect_theme(self, theme):
         theme.events.warning.connect(self._update_theme_no_event)
         theme.events.current.connect(self._update_theme_no_event)
         theme.events.icon.connect(self._update_theme_no_event)
+        theme.events.font_size.connect(self._update_theme_no_event)
         theme.events.canvas.connect(
             lambda _: self._qt_viewer.canvas._set_theme_change(
                 get_settings().appearance.theme
@@ -705,6 +706,7 @@ def _disconnect_theme(self, theme):
         theme.events.warning.disconnect(self._update_theme_no_event)
         theme.events.current.disconnect(self._update_theme_no_event)
         theme.events.icon.disconnect(self._update_theme_no_event)
+        theme.events.font_size.disconnect(self._update_theme_no_event)
         theme.events.canvas.disconnect(
             lambda _: self._qt_viewer.canvas._set_theme_change(
                 get_settings().appearance.theme
@@ -1312,14 +1314,12 @@ def _update_theme(self, event=None):
         with contextlib.suppress(AttributeError, RuntimeError):
             value = event.value if event else settings.appearance.theme
             self._qt_viewer.viewer.theme = value
+            actual_theme_name = value
             if value == "system":
-                # system isn't a theme, so get the name and set style sheet
+                # system isn't a theme, so get the name
                 actual_theme_name = get_system_theme()
-                self._qt_window.setStyleSheet(
-                    get_stylesheet(actual_theme_name)
-                )
-            else:
-                self._qt_window.setStyleSheet(get_stylesheet(value))
+            # set the style sheet with the theme name
+            self._qt_window.setStyleSheet(get_stylesheet(actual_theme_name))
 
     def _status_changed(self, event):
         """Update status bar.
diff --git a/napari/utils/theme.py b/napari/utils/theme.py
--- a/napari/utils/theme.py
+++ b/napari/utils/theme.py
@@ -2,6 +2,7 @@
 # pygments - see here for examples https://help.farbox.com/pygments.html
 import logging
 import re
+import sys
 import warnings
 from ast import literal_eval
 from contextlib import suppress
@@ -64,6 +65,8 @@ class Theme(EventedModel):
         Color used to indicate something is wrong or could stop functionality.
     current : Color
         Color used to highlight Qt widget.
+    font_size : str
+        Font size (in points, pt) used in the application.
     """
 
     id: str
@@ -81,6 +84,7 @@ class Theme(EventedModel):
     warning: Color
     error: Color
     current: Color
+    font_size: str = '12pt' if sys.platform == 'darwin' else '9pt'
 
     @validator("syntax_style", pre=True, allow_reuse=True)
     def _ensure_syntax_style(value: str) -> str:
@@ -94,6 +98,16 @@ def _ensure_syntax_style(value: str) -> str:
         )
         return value
 
+    @validator("font_size", pre=True)
+    def _ensure_font_size(value: str) -> str:
+        assert value.endswith('pt'), trans._(
+            "Font size must be in points (pt).", deferred=True
+        )
+        assert int(value[:-2]) > 0, trans._(
+            "Font size must be greater than 0.", deferred=True
+        )
+        return value
+
     def to_rgb_dict(self) -> Dict[str, Any]:
         """
         This differs from baseclass `dict()` by converting colors to rgb.
@@ -105,12 +119,24 @@ def to_rgb_dict(self) -> Dict[str, Any]:
         }
 
 
+increase_pattern = re.compile(r"{{\s?increase\((\w+),?\s?([-\d]+)?\)\s?}}")
+decrease_pattern = re.compile(r"{{\s?decrease\((\w+),?\s?([-\d]+)?\)\s?}}")
 gradient_pattern = re.compile(r'([vh])gradient\((.+)\)')
 darken_pattern = re.compile(r'{{\s?darken\((\w+),?\s?([-\d]+)?\)\s?}}')
 lighten_pattern = re.compile(r'{{\s?lighten\((\w+),?\s?([-\d]+)?\)\s?}}')
 opacity_pattern = re.compile(r'{{\s?opacity\((\w+),?\s?([-\d]+)?\)\s?}}')
 
 
+def decrease(font_size: str, pt: int):
+    """Decrease fontsize."""
+    return f"{int(font_size[:-2]) - int(pt)}pt"
+
+
+def increase(font_size: str, pt: int):
+    """Increase fontsize."""
+    return f"{int(font_size[:-2]) + int(pt)}pt"
+
+
 def darken(color: Union[str, Color], percentage=10):
     if isinstance(color, str) and color.startswith('rgb('):
         color = literal_eval(color.lstrip('rgb(').rstrip(')'))
@@ -162,6 +188,14 @@ def gradient(stops, horizontal=True):
 
 
 def template(css: str, **theme):
+    def _increase_match(matchobj):
+        font_size, to_add = matchobj.groups()
+        return increase(theme[font_size], to_add)
+
+    def _decrease_match(matchobj):
+        font_size, to_subtract = matchobj.groups()
+        return decrease(theme[font_size], to_subtract)
+
     def darken_match(matchobj):
         color, percentage = matchobj.groups()
         return darken(theme[color], percentage)
@@ -180,6 +214,8 @@ def gradient_match(matchobj):
         return gradient(stops, horizontal)
 
     for k, v in theme.items():
+        css = increase_pattern.sub(_increase_match, css)
+        css = decrease_pattern.sub(_decrease_match, css)
         css = gradient_pattern.sub(gradient_match, css)
         css = darken_pattern.sub(darken_match, css)
         css = lighten_pattern.sub(lighten_match, css)
@@ -364,6 +400,7 @@ def rebuild_theme_settings():
     syntax_style='native',
     console='rgb(18, 18, 18)',
     canvas='black',
+    font_size='12pt' if sys.platform == 'darwin' else '9pt',
 )
 LIGHT = Theme(
     id='light',
@@ -381,6 +418,7 @@ def rebuild_theme_settings():
     syntax_style='default',
     console='rgb(255, 255, 255)',
     canvas='white',
+    font_size='12pt' if sys.platform == 'darwin' else '9pt',
 )
 
 register_theme('dark', DARK, "builtin")


Test patch:
diff --git a/napari/utils/_tests/test_theme.py b/napari/utils/_tests/test_theme.py
--- a/napari/utils/_tests/test_theme.py
+++ b/napari/utils/_tests/test_theme.py
@@ -132,6 +132,22 @@ def test_theme(color):
     theme.background = color
 
 
+@pytest.mark.skipif(
+    os.getenv('CI') and sys.version_info < (3, 9),
+    reason="Testing theme on CI is extremely slow ~ 15s per test."
+    "Skip for now until we find the reason",
+)
+def test_theme_font_size():
+    theme = get_theme("dark")
+    theme.font_size = "15pt"
+    assert theme.font_size == "15pt"
+
+    with pytest.raises(ValidationError):
+        theme.font_size = "0pt"
+    with pytest.raises(ValidationError):
+        theme.font_size = "12px"
+
+
 def test_theme_syntax_highlight():
     theme = get_theme("dark")
     with pytest.raises(ValidationError):