Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/gleplot/gui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,14 @@
are imported.
"""

__all__: list = []
__all__ = ["open_editor"]


def __getattr__(name: str):
# Lazy re-export: keeps `import gleplot.gui` PySide6-free while letting
# embedders write `from gleplot.gui import open_editor`.
if name == "open_editor":
from gleplot.gui.app import open_editor

return open_editor
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
110 changes: 108 additions & 2 deletions src/gleplot/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
returns ``0`` immediately without showing the window or entering the event
loop. Packaging CI uses this to verify the frozen app imports and builds its
main window without a display.

An optional positional argument names a ``.gle`` file to open on launch
(``gleplot-gui figure.gle``). It is opened *after* the window is shown, and
is ignored entirely under ``--smoke-test`` (smoke-test only verifies
construction; see :func:`main`).
"""

from __future__ import annotations
Expand Down Expand Up @@ -40,6 +45,79 @@ def _set_window_icon(app: QApplication) -> None:
pass


def open_editor(
path: Optional[str] = None,
*,
parent=None,
settings=None,
gle_executable: Optional[str] = None,
) -> MainWindow:
"""Open a gleplot editor window inside an existing ``QApplication``.

This is the supported embedding API for host applications (e.g. analysis
programs that export ``.gle`` figures and want to hand them to the editor
in-process). Unlike :func:`main`, it never creates a ``QApplication``,
never mutates application-global identity (name, organization, app icon),
and never runs an event loop — the host owns all of that.

Parameters
----------
path : str, optional
A ``.gle`` file to open. May show the same modal prompts as
File ▸ Open (programmatic-file warning, parse-failure fallback).
parent : QWidget, optional
Qt parent for the window.
settings : QSettings, optional
Injected settings store for recent-files/last-dir state; ``None``
uses gleplot's own ``QSettings("gleplot", "gleplot")``.
gle_executable : str, optional
Explicit GLE binary for preview rendering. When given it overrides
gleplot's persisted GLE-path preference (via
:func:`gleplot.compiler.set_gle_path_override`, which is
process-global) so the editor compiles with the same binary as the
host. ``None`` leaves gleplot's own discovery in effect.

Returns
-------
MainWindow
The shown editor window. The caller must keep a reference — Qt does
not keep parentless windows alive on the Python side.

Raises
------
RuntimeError
If no ``QApplication`` exists. Creating one here would be a trap:
the host is expected to own the application and its event loop.
"""
if QApplication.instance() is None:
raise RuntimeError(
"open_editor() requires an existing QApplication; it is an "
"embedding API. Use gleplot.gui.app.main() to run standalone."
)

window = MainWindow(parent=parent, settings=settings)

if gle_executable:
# MainWindow.__init__ applied gleplot's own persisted override;
# re-apply the host's choice (session-only, not persisted).
window.apply_gle_executable(gle_executable)

# Window-local icon only: setWindowIcon on the QApplication would leak
# gleplot branding onto the host's windows.
try:
icon_path = Path(__file__).resolve().parent / "assets" / "gleplot.png"
if icon_path.is_file():
window.setWindowIcon(QIcon(str(icon_path)))
except Exception:
pass

if path is not None:
window.open_path(str(path))

window.show()
return window


def main(argv: Optional[Sequence[str]] = None) -> int:
"""Launch the gleplot GUI editor.

Expand All @@ -49,16 +127,38 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
Parameters
----------
argv : sequence of str, optional
Command-line arguments. Defaults to ``sys.argv``.
Command-line arguments. Defaults to ``sys.argv``, in which case
``argv[0]`` (the program name) is skipped when looking for a file
argument. Callers passing an explicit ``argv`` are expected to pass
only real arguments (no leading program name), matching how this
module's own tests invoke ``main([...])`` — so no leading element is
skipped in that case. The first element that isn't the
``--smoke-test`` flag is treated as a ``.gle`` file path to open.

Returns
-------
int
The application's exit code.
The application's exit code: ``2`` if a file argument was given but
does not exist (checked before any ``QApplication`` is created), the
Qt event loop's exit code if this call owns the application, or
``0`` for ``--smoke-test`` or when reusing an existing application.
"""
args = list(argv) if argv is not None else sys.argv
scan = args[1:] if argv is None else args

smoke_test = "--smoke-test" in args
file_arg = next((a for a in scan if a != "--smoke-test"), None)

# Under --smoke-test, ignore any file argument entirely: smoke-test only
# verifies that construction succeeds, and never shows the window or
# opens anything (see module docstring).
file_path: Optional[Path] = None
if file_arg is not None and not smoke_test:
file_path = Path(file_arg)
if not file_path.is_file():
print(f"gleplot-gui: no such file: {file_arg}", file=sys.stderr)
return 2

if smoke_test:
# Run headless so this works without a display (e.g. packaging CI).
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
Expand All @@ -83,6 +183,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:

window.show()

if file_path is not None:
# After show(): modal prompts the open may raise (programmatic-file
# question, parse-failure fallback) should appear over a visible
# window rather than an invisible one.
window.open_path(str(file_path))

if owns_app:
return app.exec()
return 0
Expand Down
79 changes: 72 additions & 7 deletions src/gleplot/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ def __init__(
# pinned binary is in effect for the very first render.
compiler.set_gle_path_override(self._read_gle_override())

#: When True, :meth:`closeEvent` skips the dirty-document confirmation
#: (set transiently by :meth:`force_close` for embedder shutdown).
self._force_close = False

# GLE-preview mode state. ``_gle_preview_path`` is the .gle file being
# previewed (None => document mode). ``_gle_temp_dirs`` accumulates the
# mkdtemp'd compile output directories we own and must clean up.
Expand Down Expand Up @@ -311,7 +315,13 @@ def _create_actions(self) -> None:
self.preview_controller.render_format == "svg"
)
self.action_vector_preview.setEnabled(self.preview_controller.svg_available)
if not self.preview_controller.svg_available:
if self.preview_controller.svg_available:
self.action_vector_preview.setToolTip(
"Sharper zooming, but boxes/legends drawn over data may not "
"occlude it correctly (Qt SVG clip-path limitation). Switch "
"back to PNG if the preview looks wrong."
)
else:
self.action_vector_preview.setToolTip(
"SVG preview is unavailable in this session; showing PNG."
)
Expand Down Expand Up @@ -500,6 +510,18 @@ def _on_render_skipped_empty(self) -> None:
def _on_toggle_vector_preview(self, checked: bool) -> None:
"""View ▸ Vector preview (SVG): switch ``render_format`` on toggle."""
self.preview_controller.render_format = "svg" if checked else "png"
if checked and self.preview_controller.render_format == "svg":
# Honest caveat on opt-in: QtSvg has no real clip-path support, so
# occlusion GLE achieves via clip regions (a legend or text box
# over data) renders wrongly — content bleeds through the box.
# This cannot be detected mechanically (every Cairo SVG carries
# such clips; only visual overlap makes it wrong), hence a
# message rather than a validation rule.
self.statusBar().showMessage(
"Vector preview on — boxes/legends over data may not occlude "
"correctly; switch back to PNG if the preview looks wrong.",
_STATUS_MS,
)

def _on_svg_fallback_activated(self, reason: str) -> None:
"""SVG rendering failed permanently this session; reflect it in the UI.
Expand Down Expand Up @@ -699,6 +721,17 @@ def _on_open(self) -> None:
return
self._dispatch_open(chosen)

def open_path(self, path_str: str) -> None:
"""Open ``path_str`` in the editor (public entry point).

This is the supported way for embedders (e.g. host applications that
create a :class:`MainWindow` inside their own ``QApplication``) to load
a ``.gle`` file after construction. Interactive: may show modal dialogs
(programmatic-file prompt, parse-failure fallback offer), exactly like
File ▸ Open.
"""
self._dispatch_open(path_str)

def _dispatch_open(self, path_str: str) -> None:
"""Open ``path_str`` as an editable ``.gle`` figure (native format).

Expand Down Expand Up @@ -1028,13 +1061,27 @@ def _confirm_discard_if_dirty(self, action_desc: str) -> bool:
# ------------------------------------------------------------------
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
"""Confirm discard on a dirty document, then tear down the render engine."""
if not self._confirm_discard_if_dirty("Exit"):
if not self._force_close and not self._confirm_discard_if_dirty("Exit"):
event.ignore()
return
self._cleanup_gle_temp_dirs()
self.preview_controller.shutdown()
event.accept()

def force_close(self) -> None:
"""Close unconditionally, skipping the dirty-document confirmation.

For embedders tearing down editor windows at host-application
shutdown, where a modal prompt cannot be answered — and would hang a
headless (offscreen) test run. Normal interactive closes still get
the confirmation via :meth:`closeEvent`.
"""
self._force_close = True
try:
self.close()
finally:
self._force_close = False

# ------------------------------------------------------------------
# GLE setup (executable configuration)
# ------------------------------------------------------------------
Expand Down Expand Up @@ -1070,7 +1117,29 @@ def _on_gle_setup(self) -> None:
return # cancelled — leave the setting untouched

self._write_gle_override(chosen)
compiler.set_gle_path_override(chosen)
self.apply_gle_executable(chosen)

resolved = compiler.find_gle()
self.statusBar().showMessage(
f"GLE: {resolved}" if resolved else "GLE: not found", _STATUS_MS,
)

def apply_gle_executable(self, path: Optional[str]) -> None:
"""Apply a GLE-binary override for this process and refresh the window.

Pushes ``path`` into :func:`gleplot.compiler.set_gle_path_override`
(process-global), re-resolves, propagates the result to the preview
controller's cached path and the status-bar label, and re-renders so
the change takes effect immediately. ``None``/empty restores
auto-detection.

Unlike Tools ▸ GLE Setup…, this does **not** persist the choice to
gleplot's settings — embedders (host applications opening the editor
in-process via :func:`gleplot.gui.app.open_editor`) use it to impose
their own configured binary for the session without overwriting the
user's standalone-gleplot preference.
"""
compiler.set_gle_path_override(path or None)

# Re-resolve and propagate: the preview controller caches the path.
resolved = compiler.find_gle()
Expand All @@ -1082,10 +1151,6 @@ def _on_gle_setup(self) -> None:
if not self.is_gle_preview_mode:
self.preview_controller.request_render()

self.statusBar().showMessage(
f"GLE: {resolved}" if resolved else "GLE: not found", _STATUS_MS,
)

# ------------------------------------------------------------------
# Dialogs
# ------------------------------------------------------------------
Expand Down
Loading
Loading