Willy-JL / F95Checker

GNU General Public License v3.0
101 stars 16 forks source link

High DPI #24

Open dcnieho opened 1 year ago

dcnieho commented 1 year ago

I found your post about this program in the pyimgui issues, and want to use it as a basis for developing my own GUI (for a very different program :p).

I am on Windows 10, and have a High DPI screen (scale factor 2.5). So the interface is tiny. I have previously worked with imgui in C++ where one could set a global scaling. It seems the version exposed by pyimgui is too old (or that particular function not wrapped). If it would be available, something like the below patch should do the trick.

diff --git a/modules/gui.py b/modules/gui.py
index 244539c..f57e020 100644
--- a/modules/gui.py
+++ b/modules/gui.py
@@ -147,6 +147,13 @@ class MainGUI():
         self.screen_pos = glfw.get_window_pos(self.window)
         if globals.settings.start_in_tray:
             self.minimize()
+
+        highDPIscaleFactor = 1.0
+        xscale,yscale = glfw.get_monitor_content_scale(glfw.get_primary_monitor())
+        if xscale > 1 or yscale > 1:
+            highDPIscaleFactor = xscale
+            glfw.window_hint(glfw.SCALE_TO_MONITOR, gl.GL_TRUE)
+
         icon_path = globals.self_path / "resources/icons/icon.png"
         self.icon_texture = imagehelper.ImageHelper(icon_path)
         glfw.set_window_icon(self.window, 1, Image.open(icon_path))
@@ -188,6 +195,8 @@ class MainGUI():
         imgui.style.frame_border_size = 1.6
         imgui.style.colors[imgui.COLOR_TABLE_BORDER_STRONG] = (0, 0, 0, 0)
         self.refresh_styles()
+        if highDPIscaleFactor > 1.0:
+            imgui.style.scale_all_sizes(highDPIscaleFactor)
         # Custom checkbox style
         imgui._checkbox = imgui.checkbox
         def checkbox(label: str, state: bool):

imgui.style.scale_all_sizes() doesn't exist. I'm posting this here for when it becomes available.

Thanks for the great work and highlighting it in the pyimgui forums so that i could find it. I look forward designing my program with your code as an example

dcnieho commented 1 year ago

Ah, i see you have globals.settings.interface_scaling that would do the trick here. It is unclear to me however where you read the value from the db, i only see various places where its updated.

Willy-JL commented 1 year ago

It is unclear to me however where you read the value from the db, i only see various places where its updated.

Here, the db returns a sqlite3.Row item (because I told it to), which can be converted to a dict that will be in format {"column_name": "value"} for each row. In my case the settings table is a single row, so a single db fetch and a single row converted gets me a simple dict with {"setting_1": "value_1", "setting_2": "value_2"...}. This is then passed to the settings dataclass: Settings(**settings), so each of the dict keys will go to fill an argument in the Settings constructor. Settings is a dataclass that has the same property names as the db settings columns, so it is 1:1. I made it like this so it is easily scalable, I just add the column definition in the database, and add its equivalent to the dataclass. Also my helper function for creating the table handles adding missing or edited columns in case I change the table definition throughout the updates.

Ah, i see you have globals.settings.interface_scaling that would do the trick here.

Yes that is how I do it currently, but this way it is left handled completely by the user. Maybe having it autodetect a more appropriate default value, then letting the user control it, might be better. I'll look into the snippet you sent and see what could be done.

PS: also as a sidenote there's a code block format available for diff files:

```diff
stuff here
```
diff --git a/modules/gui.py b/modules/gui.py
index 244539c..f57e020 100644
--- a/modules/gui.py
+++ b/modules/gui.py
@@ -147,6 +147,13 @@ class MainGUI():
         self.screen_pos = glfw.get_window_pos(self.window)
         if globals.settings.start_in_tray:
             self.minimize()
+
+        highDPIscaleFactor = 1.0
+        xscale,yscale = glfw.get_monitor_content_scale(glfw.get_primary_monitor())
+        if xscale > 1 or yscale > 1:
+            highDPIscaleFactor = xscale
+            glfw.window_hint(glfw.SCALE_TO_MONITOR, gl.GL_TRUE)
+
         icon_path = globals.self_path / "resources/icons/icon.png"
         self.icon_texture = imagehelper.ImageHelper(icon_path)
         glfw.set_window_icon(self.window, 1, Image.open(icon_path))
@@ -188,6 +195,8 @@ class MainGUI():
         imgui.style.frame_border_size = 1.6
         imgui.style.colors[imgui.COLOR_TABLE_BORDER_STRONG] = (0, 0, 0, 0)
         self.refresh_styles()
+        if highDPIscaleFactor > 1.0:
+            imgui.style.scale_all_sizes(highDPIscaleFactor)
         # Custom checkbox style
         imgui._checkbox = imgui.checkbox
         def checkbox(label: str, state: bool):
dcnieho commented 1 year ago

@Willy-JL Thanks for the explanation, thats a very clean way of doing it. I was planning to go with db, but realize now its a clean way of persisting info across runs and indeed a nice way of setting things up. Thanks also for the pointer about the diff highlighter for code blocks, i wasn't aware.

I guess imgui.style.scale_all_sizes, even if implemented, is very much what you do not want to do. Somehow setting globals.settings.interface_scaling on first run to a better default, e.g. a value gotten using glfw.get_monitor_content_scale() might be better.

Hmm, and note that there was something stupid in the above quick diff: glfw.window_hint(glfw.SCALE_TO_MONITOR, gl.GL_TRUE) should of course be issued before opening the window. That takes care of scaling the main window size on first run.

dcnieho commented 1 year ago

Still untested, but i have written the following to get the current monitor that a screen is on:

def get_current_monitor(wx, wy, ww, wh):
    import ctypes
    # so we always return something sensible
    monitor = glfw.get_primary_monitor()
    bestoverlap = 0
    for mon in glfw.get_monitors():
        monitor_area = glfw.get_monitor_workarea(mon)
        mx, my = monitor_area[0], monitor_area[1]
        mw, mh = monitor_area[2], monitor_area[3]

        overlap = \
            max(0, min(wx + ww, mx + mw) - max(wx, mx)) * \
            max(0, min(wy + wh, my + mh) - max(wy, my))

        if bestoverlap < overlap:
            bestoverlap = overlap
            monitor = mon

    return monitor, ctypes.cast(ctypes.pointer(monitor), ctypes.POINTER(ctypes.c_long)).contents.value

The first return argument can then be used to determine scaling of the display:

xscale, yscale = glfw.get_monitor_content_scale(mon)
self.size_mult = max(xscale, yscale)

and the second return to keep track of whether a position change entailed moving to another monitor.

The ctypes stuff is a little ugly, but the alternative would be to use monitor user data to identify monitors uniquely, which would necessitate a new monitor callback. And thats broken in the glfw wrapper: https://github.com/FlorianRhiem/pyGLFW/issues/66

My logic here is that i will not make scaling configurable, but simply autodetected like this. May be seen as a loss of functionality for some, but should do the trick for me

dcnieho commented 1 year ago

Ok, taking the approach of taking scaling control away from the user, i've got this figured out. It works 1. when dragging between monitors with different DPI scaling, and 2. when starting app on a monitor with a different scaling than where it was last closed.

Here's the parts. imgui setup in gui.py:

size = tuple()
        pos = tuple()
        old_scale = 1.
        is_default = False
        try:
            # Get window size
            with open(imgui.io.ini_file_name, "r") as f:
                ini = f.read()
            imgui.load_ini_settings_from_memory(ini)
            config = configparser.RawConfigParser()
            config.optionxform=str
            config.read_string(ini)
            try:
                size = tuple(int(x) for x in config["Window][glassesValidator"]["Size"].split(","))
            except Exception:
                pass
            try:
                pos = tuple(int(x) for x in config["Window][glassesValidator"]["ScreenPos"].split(","))
            except Exception:
                pass
            try:
                old_scale = config.getfloat("Window][glassesValidator","Scale",1.)
            except Exception:
                pass
        except Exception:
            pass
        if not all([isinstance(x, int) for x in size]) or not len(size) == 2:
            size = (1280, 720)
            is_default = True

        # Setup GLFW window
        self.window: glfw._GLFWwindow = utils.impl_glfw_init(*size, "glassesValidator")
        if all([isinstance(x, int) for x in pos]) and len(pos) == 2 and utils.validate_geometry(*pos, *size):
            glfw.set_window_pos(self.window, *pos)
        self.screen_pos = glfw.get_window_pos(self.window)
        self.screen_size= glfw.get_window_size(self.window)

        # Determine what monitor we're (mostly) on, for scaling
        mon, self.monitor = utils.get_current_monitor(*self.screen_pos, *self.screen_size)
        # apply scaling
        xscale, yscale = glfw.get_monitor_content_scale(mon)
        self.size_mult = max(xscale, yscale)
        if is_default:
            glfw.set_window_size(self.window, int(self.screen_size[0]*self.size_mult), int(self.screen_size[1]*self.size_mult))
        elif self.size_mult is not old_scale:
            glfw.set_window_size(self.window, int(self.screen_size[0]/old_scale*self.size_mult), int(self.screen_size[1]/old_scale*self.size_mult))
        self.last_size_mult = self.size_mult

add the below to pos_callback:

# check if we moved to another monitor
            mon, mon_id = utils.get_current_monitor(*self.screen_pos, *self.screen_size)
            if mon_id != self.monitor:
                self.monitor = mon_id
                # update scaling
                xscale, yscale = glfw.get_monitor_content_scale(mon)
                if scale := max(xscale, yscale):
                    self.size_mult = scale
                    # resize window if needed
                    if self.size_mult != self.last_size_mult:
                        self.new_screen_size = int(self.screen_size[0]/self.last_size_mult*self.size_mult), int(self.screen_size[1]/self.last_size_mult*self.size_mult)

(NB: apparently we can't call glfw.set_window_size() from inside the poss callback, it got ignored. Therefore, in main loop:

if self.repeat_chars:
              for char in self.input_chars:
                  imgui.io.add_input_character(char)
              self.repeat_chars = False
          self.input_chars.clear()
          glfw.poll_events()
          self.impl.process_inputs()
+            # if there's a queued window resize, execute
+            if self.new_screen_size[0]!=0 and self.new_screen_size!=self.screen_size:
+                glfw.set_window_size(self.window, *self.new_screen_size)
+                glfw.poll_events()
          if not self.focused and glfw.get_window_attrib(self.window, glfw.HOVERED):

at the end of the main loop, the check is changed to:

if self.size_mult != self.last_size_mult:
                    self.refresh_fonts()
                    self.refresh_styles()
                    self.last_size_mult = self.size_mult

save last scale to imgui ini file (by the way, saving code much simplified, can use configparser for that):

def save_imgui_ini(self, path: str | pathlib.Path = None):
        if path is None:
            path = imgui.io.ini_file_name
        imgui.save_ini_settings_to_disk(str(path))
        ini = imgui.save_ini_settings_to_memory()

        # add some of our own stuff we want to persist
        try:
            config = configparser.RawConfigParser()
            config.optionxform=str
            config.read_string(ini)
            config["Window][glassesValidator"]["ScreenPos"] = f"{self.screen_pos[0]},{self.screen_pos[1]}"
            config["Window][glassesValidator"]["Scale"] = f"{self.size_mult}"
            with open(str(path), "w") as f:
                config.write(f)
        except Exception:
            pass    # already saved with imgui.save_ini_settings_to_disk above

and last the function in utils to check what monitor we're on:

def get_current_monitor(wx, wy, ww, wh):
    import ctypes
    # so we always return something sensible
    monitor = glfw.get_primary_monitor()
    bestoverlap = 0
    for mon in glfw.get_monitors():
        monitor_area = glfw.get_monitor_workarea(mon)
        mx, my = monitor_area[0], monitor_area[1]
        mw, mh = monitor_area[2], monitor_area[3]

        overlap = \
            max(0, min(wx + ww, mx + mw) - max(wx, mx)) * \
            max(0, min(wy + wh, my + mh) - max(wy, my))

        if bestoverlap < overlap:
            bestoverlap = overlap
            monitor = mon

    return monitor, ctypes.cast(ctypes.pointer(monitor), ctypes.POINTER(ctypes.c_long)).contents.value

the glfw wrapper is now fixed so that set_monitor_user_pointer and get_monitor_user_pointer now work so perhaps that ctype stuff can be replaced, will look at it later