from __future__ import annotations
import logging
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Literal
import wx
@dataclass
[docs]
class ProgressHandle:
"""Simple wrapper around wx.ProgressDialog for easier mocking in tests."""
[docs]
dialog: wx.ProgressDialog
[docs]
def update(self, value: int, message: str | None = None) -> bool:
"""Update progress and return wx's continuation flag."""
if message is None:
cont, _ = self.dialog.Update(value)
else:
cont, _ = self.dialog.Update(value, message)
return bool(cont)
[docs]
def value(self) -> int:
"""Return current progress value."""
return int(self.dialog.GetValue())
[docs]
def close(self) -> None:
"""Destroy the wrapped progress dialog."""
self.dialog.Destroy()
[docs]
class DialogStyles:
"""Named style presets used across dialog helpers and call sites."""
[docs]
OK_CANCEL = wx.OK | wx.CANCEL
[docs]
ICON_QUESTION = wx.ICON_QUESTION
[docs]
ICON_WARNING = wx.ICON_WARNING
[docs]
ICON_ERROR = wx.ICON_ERROR
[docs]
YES_NO_QUESTION = wx.YES_NO | wx.ICON_QUESTION
[docs]
YES_NO_DEFAULT_YES = wx.YES_NO | wx.YES_DEFAULT
[docs]
YES_NO_DEFAULT_NO = wx.YES_NO | wx.NO_DEFAULT
[docs]
YES_NO_QUESTION_DEFAULT_YES = wx.YES_NO | wx.ICON_QUESTION | wx.YES_DEFAULT
[docs]
YES_NO_QUESTION_DEFAULT_NO = wx.YES_NO | wx.ICON_QUESTION | wx.NO_DEFAULT
[docs]
OK_INFO = wx.OK | wx.ICON_INFORMATION
[docs]
OK_CANCEL_WARNING = wx.OK | wx.CANCEL | wx.ICON_WARNING
[docs]
class DialogProvider:
"""Wrapper around common wx dialogs used in the viewer.
This class centralizes dialog interactions so tests can inject a mock
implementation with deterministic answers.
"""
[docs]
def ask_file_open(
self,
message: str,
wildcard: str = "all (*.*)|*.*",
default_path: str = "",
style: int | None = None,
parent=None,
) -> str | None:
if style is None:
style = wx.FD_OPEN
dlg = wx.FileDialog(parent, message, defaultDir=default_path, wildcard=wildcard, style=style)
if dlg.ShowModal() == wx.ID_CANCEL:
dlg.Destroy()
return None
path = dlg.GetPath()
dlg.Destroy()
return path
[docs]
def ask_file_open_with_filter(
self,
message: str,
wildcard: str = "all (*.*)|*.*",
default_path: str = "",
style: int | None = None,
parent=None,
) -> tuple[str, int] | None:
"""Open a file dialog and return (path, filter_index) or None on cancel."""
if style is None:
style = wx.FD_OPEN
dlg = wx.FileDialog(parent, message, defaultDir=default_path, wildcard=wildcard, style=style)
if dlg.ShowModal() == wx.ID_CANCEL:
dlg.Destroy()
return None
path = dlg.GetPath()
filter_index = int(dlg.GetFilterIndex())
dlg.Destroy()
return path, filter_index
[docs]
def ask_file_save(
self,
message: str,
wildcard: str = "all (*.*)|*.*",
default_path: str = "",
default_file: str = "",
style: int | None = None,
parent=None,
) -> str | None:
if style is None:
style = wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT
dlg = wx.FileDialog(
parent,
message,
defaultDir=default_path,
defaultFile=default_file,
wildcard=wildcard,
style=style,
)
if dlg.ShowModal() == wx.ID_CANCEL:
dlg.Destroy()
return None
path = dlg.GetPath()
dlg.Destroy()
return path
[docs]
def ask_directory(
self,
message: str,
default_path: str = "",
style: int = wx.DD_DEFAULT_STYLE,
parent=None,
) -> str | None:
dlg = wx.DirDialog(parent, message, defaultPath=default_path, style=style)
if dlg.ShowModal() == wx.ID_CANCEL:
dlg.Destroy()
return None
path = dlg.GetPath()
dlg.Destroy()
return path
[docs]
def ask_yes_no(
self,
message: str,
caption: str = "",
style: int = DialogStyles.YES_NO_QUESTION,
parent=None,
) -> bool:
dlg = wx.MessageDialog(parent, message, caption, style=style)
ret = dlg.ShowModal()
dlg.Destroy()
return ret == wx.ID_YES
[docs]
def show_message(
self,
message: str,
caption: str = "",
style: int = DialogStyles.OK_INFO,
parent=None,
) -> int:
dlg = wx.MessageDialog(parent, message, caption, style=style)
ret = dlg.ShowModal()
dlg.Destroy()
return ret
[docs]
def ask_ok_cancel(
self,
message: str,
caption: str = "",
style: int = DialogStyles.OK_CANCEL,
parent=None,
) -> bool:
dlg = wx.MessageDialog(parent, message, caption, style=style)
ret = dlg.ShowModal()
dlg.Destroy()
return ret == wx.ID_OK
[docs]
def ask_single_choice(
self,
message: str,
caption: str,
choices: list[str],
preselected: int | None = None,
style: int = wx.CHOICEDLG_STYLE,
parent=None,
) -> str | None:
dlg = wx.SingleChoiceDialog(parent, message, caption, choices, style=style)
if preselected is not None:
dlg.SetSelection(preselected)
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return None
selection = dlg.GetStringSelection()
dlg.Destroy()
return selection
[docs]
def ask_multi_choice(
self,
message: str,
caption: str,
choices: list[str],
preselected: list[int] | tuple[int, ...] | None = None,
style: int = wx.CHOICEDLG_STYLE,
parent=None,
) -> list[int] | None:
dlg = wx.MultiChoiceDialog(parent, message, caption, choices, style=style)
if preselected:
dlg.SetSelections(list(preselected))
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return None
selection = list(dlg.GetSelections())
dlg.Destroy()
return selection
[docs]
def ask_text(
self,
message: str,
caption: str = "",
default: str = "",
style: int = DialogStyles.OK_CANCEL,
parent=None,
) -> str | None:
dlg = wx.TextEntryDialog(parent, message, caption, value=default, style=style)
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return None
value = dlg.GetValue()
dlg.Destroy()
return value
[docs]
def ask_float(
self,
message: str,
caption: str = "",
default: float | str = "",
style: int = DialogStyles.OK_CANCEL,
parent=None,
) -> float | None:
value = self.ask_text(message, caption, default=str(default), style=style, parent=parent)
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
logging.warning("Invalid float value for %s: %r", caption or message, value)
return None
[docs]
def ask_integer(
self,
message: str,
prompt: str = "",
caption: str = "",
default: int = 0,
min_value: int = 0,
max_value: int = 0,
parent=None,
) -> int | None:
dlg = wx.NumberEntryDialog(parent, message, prompt, caption, default, min_value, max_value)
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return None
try:
value = int(dlg.GetValue())
except (TypeError, ValueError):
logging.warning("Invalid integer value for %s: %r", caption or message, dlg.GetValue())
dlg.Destroy()
return None
dlg.Destroy()
return value
[docs]
def create_progress(
self,
title: str,
message: str,
maximum: int,
style: int = wx.PD_APP_MODAL | wx.PD_AUTO_HIDE,
parent=None,
) -> ProgressHandle:
dlg = wx.ProgressDialog(title, message, maximum=maximum, parent=parent, style=style)
return ProgressHandle(dlg)
@contextmanager
[docs]
def show_busy(self, message: str, title: str = '', parent=None):
"""Context manager that shows an indeterminate progress bar while work runs."""
dlg = wx.ProgressDialog(
title or message,
message,
maximum=100,
parent=parent,
style=wx.PD_APP_MODAL | wx.PD_AUTO_HIDE,
)
dlg.Pulse()
try:
yield dlg
finally:
dlg.Destroy()
[docs]
def ask_confirmation(
self,
message: str,
caption: str = "",
default: Literal["yes", "no"] = "yes",
parent=None,
) -> bool:
style = (
DialogStyles.YES_NO_QUESTION_DEFAULT_YES
if default == "yes"
else DialogStyles.YES_NO_QUESTION_DEFAULT_NO
)
return self.ask_yes_no(message, caption=caption, style=style, parent=parent)