Add adaptive window sizing for small screens

- Add window_utils.py with functions to fit windows to screen size
- Use Windows API (SPI_GETWORKAREA) to get work area without taskbar
- Make main windows resizable: main, config, operations, content, restoration
- Automatically resize windows that exceed available screen space

Fixes issue where windows were too large for 1080p and smaller screens.
Related to upstream issue #175.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Igor Velkov 2026-01-09 04:10:57 +02:00
parent f70d63a235
commit f4c22507da
4 changed files with 184 additions and 1 deletions

View file

@ -28,6 +28,7 @@ from ofunctions.threading import threaded
from ofunctions.misc import BytesConverter
import FreeSimpleGUI as sg
import _tkinter
from npbackup.gui.window_utils import fit_window_to_screen
import npbackup.configuration
import npbackup.common
from resources.customization import (
@ -419,8 +420,11 @@ def ls_window(parent_window: sg.Window, repo_config: dict, snapshot_id: str) ->
layout=layout,
grab_anywhere=True,
keep_on_top=False,
resizable=True,
enable_close_attempted_event=True,
finalize=True,
)
fit_window_to_screen(window)
# Reclaim memory from thread result
# Note from v3 dev: This doesn't actually improve memory usage
@ -493,8 +497,14 @@ def restore_window(
layout = [[sg.Column(left_col, element_justification="C")]]
window = sg.Window(
_t("main_gui.restoration"), layout=layout, grab_anywhere=True, keep_on_top=False
_t("main_gui.restoration"),
layout=layout,
grab_anywhere=True,
keep_on_top=False,
resizable=True,
finalize=True,
)
fit_window_to_screen(window)
result = None
while True:
event, values = window.read()
@ -1126,11 +1136,14 @@ def _main_gui(viewer_mode: bool):
alpha_channel=1.0,
default_button_element_size=(16, 1),
right_click_menu=right_click_menu,
resizable=True,
finalize=True,
)
# Auto reisze table to window size
window["snapshot-list"].expand(True, True)
# Fit window to screen if too large
fit_window_to_screen(window)
window.read(timeout=0.01)
if not config_file and not full_config and not viewer_mode:

View file

@ -18,6 +18,7 @@ import FreeSimpleGUI as sg
import textwrap
from datetime import datetime, timezone
from ruamel.yaml.comments import CommentedMap
from npbackup.gui.window_utils import fit_window_to_screen
from npbackup import configuration
from ofunctions.misc import get_key_from_value, BytesConverter
from npbackup.core.i18n_helper import _t
@ -2626,9 +2627,11 @@ Google Cloud storage: GOOGLE_PROJECT_ID GOOGLE_APPLICATION_CREDENTIALS\n\
alpha_channel=1.0,
default_button_element_size=(16, 1),
right_click_menu=right_click_menu,
resizable=True,
finalize=True,
enable_close_attempted_event=True,
)
fit_window_to_screen(window)
# Init fresh config objects
BAD_KEYS_FOUND_IN_CONFIG = set()

View file

@ -16,6 +16,7 @@ from logging import getLogger
from collections import namedtuple
from ofunctions.misc import BytesConverter
import FreeSimpleGUI as sg
from npbackup.gui.window_utils import fit_window_to_screen
from npbackup.configuration import (
get_repo_config,
get_repo_list,
@ -444,6 +445,7 @@ def operations_gui(full_config: dict) -> dict:
alpha_channel=1.0,
default_button_element_size=(20, 1),
right_click_menu=right_click_menu,
resizable=True,
finalize=True,
)
@ -451,6 +453,7 @@ def operations_gui(full_config: dict) -> dict:
# Auto reisze table to window size
window["repo-and-group-list"].expand(True, True)
fit_window_to_screen(window)
while True:
event, values = window.read()

View file

@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
#
# Window size utilities for small screens
# Ensures windows fit within screen bounds with proper margins
import os
import FreeSimpleGUI as sg
from typing import Tuple, Optional
# Fallback margin for window decorations (used if system method fails)
FALLBACK_MARGIN = 60
def get_work_area() -> Optional[Tuple[int, int]]:
"""
Get work area size (screen minus taskbar) using OS-specific methods.
Returns None if not available.
"""
if os.name == "nt":
try:
import ctypes
from ctypes import wintypes
class RECT(ctypes.Structure):
_fields_ = [
("left", wintypes.LONG),
("top", wintypes.LONG),
("right", wintypes.LONG),
("bottom", wintypes.LONG),
]
rect = RECT()
# SPI_GETWORKAREA = 0x0030
ctypes.windll.user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(rect), 0)
width = rect.right - rect.left
height = rect.bottom - rect.top
if width > 0 and height > 0:
return width, height
except Exception:
pass
return None
def get_screen_size() -> Tuple[int, int]:
"""Get screen dimensions."""
try:
# Create a small temporary window to get screen size
temp_window = sg.Window(
"", [[]], alpha_channel=0, finalize=True, no_titlebar=True, size=(50, 50)
)
screen_width, screen_height = temp_window.get_screen_size()
temp_window.close()
return screen_width, screen_height
except Exception:
# Fallback to minimum supported resolution
return 1024, 768
def get_available_size(margin: int = 0) -> Tuple[int, int]:
"""
Get available window size accounting for decorations and taskbar.
Uses OS work area if available, otherwise falls back to screen size minus margin.
Args:
margin: Additional pixels to subtract for window decorations
Returns:
Tuple of (width, height) in pixels
"""
# Try OS-specific work area first (excludes taskbar)
work_area = get_work_area()
if work_area:
return work_area[0] - margin, work_area[1] - margin
# Fallback: full screen minus default margin
screen_width, screen_height = get_screen_size()
return screen_width - FALLBACK_MARGIN - margin, screen_height - FALLBACK_MARGIN - margin
def fit_window_to_screen(
window: sg.Window,
margin: int = 0,
center: bool = True
) -> None:
"""
Resize window to fit within screen bounds if it's too large.
Should be called after window.finalize() or window.read(timeout=0).
Args:
window: The window to resize
margin: Pixels margin for decorations
center: Whether to center the window after resizing
"""
try:
available_width, available_height = get_available_size(margin)
current_size = window.size
if current_size is None:
return
current_width, current_height = current_size
new_width = min(current_width, available_width)
new_height = min(current_height, available_height)
if new_width < current_width or new_height < current_height:
window.set_size((new_width, new_height))
if center:
center_window(window)
except Exception:
# Silently fail - window sizing is not critical
pass
def center_window(window: sg.Window) -> None:
"""Center window on screen."""
try:
screen_width, screen_height = get_screen_size()
window_size = window.size
if window_size:
win_width, win_height = window_size
x = (screen_width - win_width) // 2
y = (screen_height - win_height) // 2
window.move(x, y)
except Exception:
pass
def make_scrollable_layout(
layout: list,
max_size: Optional[Tuple[int, int]] = None,
margin: int = 0
) -> list:
"""
Wrap layout in a scrollable column if it would exceed screen size.
Args:
layout: The original layout
max_size: Maximum size (width, height) or None to use screen size
margin: Margin for decorations
Returns:
Layout wrapped in scrollable Column if needed, otherwise original layout
"""
if max_size is None:
max_size = get_available_size(margin)
max_width, max_height = max_size
# Wrap in scrollable column with size limit
scrollable_layout = [
[
sg.Column(
layout,
scrollable=True,
vertical_scroll_only=False,
size=(max_width, max_height),
expand_x=True,
expand_y=True,
)
]
]
return scrollable_layout