#
##
## SPDX-FileCopyrightText: © 2007-2024 Benedict Verhegghe <bverheg@gmail.com>
## SPDX-License-Identifier: GPL-3.0-or-later
##
## This file is part of pyFormex 3.5 (Thu Feb 8 19:11:13 CET 2024)
## pyFormex is a tool for generating, manipulating and transforming 3D
## geometrical models by sequences of mathematical operations.
## Home page: https://pyformex.org
## Project page: https://savannah.nongnu.org/projects/pyformex/
## Development: https://gitlab.com/bverheg/pyformex
## Distributed under the GNU General Public License version 3 or later.
##
## This program is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation, either version 3 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program. If not, see http://www.gnu.org/licenses/.
##
"""Graphical User Interface for pyFormex.
This module contains the main functions responsible for constructing
and starting the pyFormex GUI.
"""
import sys
import os
import datetime
import pyformex as pf
from pyformex import process
from pyformex import utils
from pyformex.gui import QtCore, QtGui, QtWidgets, QPixmap
from pyformex.gui import signals
from pyformex.gui import qtgl
from pyformex.gui import qtutils
from pyformex.gui import menu
from pyformex.gui import menus
from pyformex.gui import appmenu
from pyformex.gui import toolbar
from pyformex.gui import viewport
from pyformex.gui import pyconsole
from pyformex.gui import guifunc
from pyformex.gui import guiscript
from pyformex.gui import widgets
from pyformex.gui import drawlock
from pyformex.gui import views
from pyformex.gui import projectmgr as pm
from pyformex.opengl import canvas_settings
QKEY = QtCore.Qt.Key
#####################################
################# GUI ###############
#####################################
easter_egg = ''
[docs]def splitXgeometry(geometry):
"""Split an X11 window geometry string in its components.
Parameters
----------
geometry: str
A string in X11 window geometry format: WxH+X+Y, where W, H are the
width and height of the window, and X,Y are the position of the top
left corner. The +Y or +X+Y parts may be missing and will thendefault
to 0.
Returns
-------
(W, H, X, Y)
A tuple of four ints.
Examples
--------
>>> splitXgeometry('1000x800+20')
(1000, 800, 20, 0)
"""
wh, *xy = geometry.split('+')
w, h = wh.split('x')
if len(xy) == 0:
x, y = 0, 0
elif len(xy) == 1:
x, y = xy[0], 0
else:
x, y = xy[:2]
return int(w), int(h), int(x), int(y)
[docs]def Xgeometry(w, h, x=0, y=0):
"""Return an X11 window geometry string.
Parameters
----------
(w, h, x, y): tuple of int
The width, height, xpos and ypos to pack into an X11 geometry string.
Returns
-------
str:
A string of the format WxH+X+Y.
Examples
--------
>>> Xgeometry(1000, 800, 20, 0)
'1000x800+20+0'
"""
return f"{w}x{h}+{x}+{y}"
#########################################################################
## The File watcher ##
######################
[docs]class FileWatcher(QtCore.QFileSystemWatcher):
"""Watch for changes in files and then execute an associated function.
"""
def __init__(self, *args):
super().__init__(*args)
self.filesWatched = {}
[docs] def addWatch(self, path, func):
"""Watch for changes in file and the execute func.
When the specified file is changed, func is executed.
Parameters
----------
path: :term:`path_like`
The path of the file to be watched.
func: callable
The function to be called when the file changes. The path
is passed as an argument.
"""
self.filesWatched[path] = func
self.addPath(path)
self.fileChanged.connect(self.onFileChanged)
[docs] def removeWatch(self, path):
"""Remove the watch for a file path
Parameters
----------
path: :term:`path_like`
The path of the file to be watched.
"""
if path in self.filesWatched:
self.removePath(path)
del self.filesWatched[path]
[docs] def onFileChanged(self, path):
"""Call installed function when file changed"""
print(f"FileWatcher: file {path} has changed")
f = self.filesWatched.get(path, None)
if f:
if pf.verbosity(2):
print(f"FileWatcher: calling {f.__name__}({path})")
f(path)
#########################################################################
## The GUI ##
#############
[docs]class Gui(QtWidgets.QMainWindow):
"""Implements the pyFormex GUI.
The GUI has a main window with a menubar on top and a statusbar
at the bottom. One or more toolbars may be located at the top, bottom,
left or right side of the main window. The central part is split up
over a display canvas at the top and a Python console at the bottom.
The split size of these two parts can be adjusted. The canvas may
contain one or more OpenGL widgets for 3D rendering. The console
displays messages from the applications and can be used to access
any internal part of pyFormex and interactively execute pyFormex
instructions.
"""
toolbar_area = {'top': QtCore.Qt.ToolBarArea.TopToolBarArea,
'bottom': QtCore.Qt.ToolBarArea.BottomToolBarArea,
'left': QtCore.Qt.ToolBarArea.LeftToolBarArea,
'right': QtCore.Qt.ToolBarArea.RightToolBarArea,
}
def __init__(self, windowname, size=(800, 600), pos=(0, 0), # noqa: C901
splitsize=(450, 150)):
"""Constructs the GUI."""
if pf.debugon(pf.DEBUG.GUI):
print(f'Creating Main Window with size {size} at {pos}')
super().__init__()
self.setWindowTitle(windowname)
self.on_exit = set() # exit functions
self.fullscreen = False
self.maxsize = pf.app.maxSize()
size = qtutils.MinSize(size, self.maxsize)
############## STATUS BAR ################
if pf.debugon(pf.DEBUG.GUI):
print('Creating Status Bar')
self.statusbar = self.statusBar()
self.curproj = self.addStatusbarButtons(
'', actions=[('Project:', lambda: pm.projectmanager(True)),
('None', pm.openProject)])
self.curfile = self.addStatusbarButtons(
'', actions=[('Script:', self.toggleAppScript),
('None', menus.File.openScript)])
self.curdir = self.addStatusbarButtons(
'Cwd:', actions=[('None', guiscript.askDirname)])
self.canPlay = False
self.canEdit = False
################# MENUBAR ###########################
if pf.debugon(pf.DEBUG.GUI):
print('Creating Menu Bar')
self.menu = menu.MenuBar('TopMenu')
self.setMenuBar(self.menu)
################# CENTRAL ###########################
# Create a box for the central widget
self.box = QtWidgets.QWidget()
self.setCentralWidget(self.box)
self.boxlayout = QtWidgets.QVBoxLayout()
self.boxlayout.setContentsMargins(*pf.cfg['gui/boxmargins'])
self.box.setLayout(self.boxlayout)
# Create a splitter
self.splitter = QtWidgets.QSplitter()
self.boxlayout.addWidget(self.splitter)
self.splitter.setOrientation(QtCore.Qt.Orientation.Vertical)
# The central widget is where the rendering viewports will be
# For now, use an empty widget
if pf.debugon(pf.DEBUG.GUI):
print('Creating Central Widget')
self.central = QtWidgets.QWidget()
self.central.autoFillBackground()
self.central.setSizePolicy(
QtWidgets.QSizePolicy.Policy.MinimumExpanding,
QtWidgets.QSizePolicy.Policy.MinimumExpanding)
# self.central.resize(*size)
if pf.options.canvas:
self.viewports = viewport.MultiCanvas(parent=self.central)
self.central.setLayout(self.viewports)
self.splitter.addWidget(self.central)
# Create the console
histfile = pf.cfg['console/histfile']
histmax = pf.cfg['console/histmax']
linenumbers = pf.cfg['console/linenumbers']
self.console = pyconsole.PyConsole(
context=pf.interpreter, parent=self,
historysize=histmax, linenumbers=linenumbers
)
if histfile.exists():
self.console.editline.loadhistory(histfile)
self.splitter.addWidget(self.console)
self.console.setFocus()
self.splitter.setSizes(splitsize)
################# TOOLBAR ###########################
if pf.debugon(pf.DEBUG.GUI):
print('Creating ToolBar')
self.toolbar = self.addToolBar('Top ToolBar')
# Define Toolbar contents
self.actions = toolbar.addActionButtons(self.toolbar)
# timeout button
toolbar.addTimeoutButton(self.toolbar)
if pf.debugon(pf.DEBUG.GUI):
print('Creating Toolbars')
self.camerabar = self.updateToolBar('camerabar', 'Camera ToolBar')
self.modebar = self.updateToolBar('modebar', 'RenderMode ToolBar')
self.viewbar = self.updateToolBar('viewbar', 'Views ToolBar')
self.toolbars = [self.toolbar, self.camerabar, self.modebar,
self.viewbar]
self.enableToolbars(False)
############### TOP menus ################
# pf.PF.createDatabases() # databases needed in some menus
menus.loadConfiguredPlugins(pf.cfg['gui/menus'], self.menu)
############### CAMERA menu and toolbar #############
if self.camerabar:
toolbar.addCameraButtons(self.camerabar)
toolbar.addButton(self.camerabar, "Pick to focus", 'focus',
guiscript.pickFocus)
toolbar.addPerspectiveButton(self.camerabar)
############### RENDERMODE menu and toolbar #############
pmenu = self.menu['viewport']
if pmenu is not None:
mmenu = QtWidgets.QMenu('Render Mode')
modes = ['wireframe', 'smooth', 'smoothwire', 'flat', 'flatwire']
self.modebtns = menu.ActionList(
modes, guifunc.renderMode, menu=mmenu, toolbar=self.modebar)
pmenu.insertMenu(pmenu['background color'], mmenu)
mmenu = QtWidgets.QMenu('Wire Mode')
modes = ['none', 'all', 'border', 'feature']
self.wmodebtns = menu.ActionList(
modes, guifunc.wireMode, menu=mmenu, toolbar=None)
pmenu.insertMenu(pmenu['background color'], mmenu)
# Add the toggle type buttons
if self.modebar and pf.cfg['gui/wirebutton']:
toolbar.addWireButton(self.modebar)
if self.modebar and pf.cfg['gui/transbutton']:
toolbar.addTransparencyButton(self.modebar)
if self.modebar and pf.cfg['gui/lightbutton']:
toolbar.addLightButton(self.modebar)
if self.modebar and pf.cfg['gui/normalsbutton']:
toolbar.addNormalsButton(self.modebar)
# We can not add the shrinkButton here, because
# we can not yet import the geometry menu
if self.modebar:
toolbar.addButton(
self.modebar,
"Popup dialog to interactively change object rendering",
'objects', menus.Viewport.showObjectDialog)
############### VIEWS menu ################
if pf.cfg['gui/viewmenu'] and False:
if pf.cfg['gui/viewmenu'] == 'main':
parent = self.menu
before = 'help'
else:
parent = self.menu['camera']
before = parent.action('---0')
self.viewsMenu = menu.Menu('Views', parent=parent, before=before)
else:
self.viewsMenu = None
# Save front orientation
self.frontview = None
self.setViewButtons(pf.cfg['gui/frontview'])
## TESTING SAVE CURRENT VIEW ##
self.saved_views = {}
self.saved_views_name = pf.NameSequence('View')
if self.viewsMenu:
# TODO: doesn't work !
# name =
next(self.saved_views_name)
self.menu['Camera'].addAction('Save View', self.saveView)
############### MISC settings ################
# Set specified geometry
if pf.debugon(pf.DEBUG.GUI):
print(f'Restore size {size}, pos {pos}')
self.resize(*size)
self.move(*pos)
if pf.debugon(pf.DEBUG.GUI):
print('Set Curdir')
self.setcurdir()
# Drawing lock
self.drawwait = pf.cfg['draw/wait']
self.drawlock = drawlock.DrawLock()
# Runall mode register
self.runallmode = False
# Materials and Lights database
self.materials = canvas_settings.createMaterials()
## for m in self.materials:
## print self.materials[m]
# Modeless child dialogs
self.doc_dialog = None
if pf.debugon(pf.DEBUG.GUI):
print('Done initializing GUI')
# Set up signal/slot connections
self.signals = signals.Signals()
self.signals.FULLSCREEN.connect(self.fullScreen)
self.filewatch = FileWatcher()
# Set up hot keys: hitting the key will emit the corresponding signal
self.hotkey = {
QKEY.Key_F2: self.signals.SAVE,
QKEY.Key_F11: self.signals.FULLSCREEN,
}
# keep a list of the Dialog children
self.dialogs = []
[docs] def dialog(self, caption):
"""Return the dialog with the named caption
Parameters
----------
caption: str
The window caption to find.
Returns
-------
Dialog | None
The dialog with the specified caption, or None if there is no
such dialog.
"""
found = None
delete = []
for d in self.dialogs:
try:
if d.windowTitle() == caption:
found = d
break
except RuntimeError:
# Window has disappeared
delete.append(d)
for d in delete:
self.dialogs.remove(d)
return found
def addStatusbarWidget(self, w):
self.statusbar.addWidget(w)
r = self.statusbar.childrenRect()
self.statusbar.setFixedHeight(r.height()+6)
[docs] def saveConsoleHistory(self):
"""Save the console history"""
if pf.debugon(pf.DEBUG.GUI):
print(f"Save console history to{pf.cfg['console/histfile']}")
self.console.editline.savehistory(
pf.cfg['console/histfile'], pf.cfg['console/histmax'])
[docs] def close_doc_dialog(self):
"""Close the doc_dialog if it is open."""
if self.doc_dialog is not None:
self.doc_dialog.close()
self.doc_dialog = None
[docs] def createView(self, name, angles):
"""Create a new view and add it to the list of predefined views.
This creates a named view with specified angles or, if the name
already exists, changes its angles to the new values.
It adds the view to the views Menu and Toolbar, if these exist and
do not have the name yet.
"""
if name not in self.viewbtns.names():
iconpath = utils.findIcon('userview')
self.viewbtns.add(name, iconpath)
views.setAngles(name, angles)
[docs] def saveView(self, name=None, addtogui=True):
"""Save the current view and optionally create a button for it.
This saves the current viewport ModelView and Projection matrices
under the specified name.
It adds the view to the views Menu and Toolbar, if these exist and
do not have the name yet.
"""
if name is None:
name = next(self.saved_views_name)
self.saved_views[name] = (pf.canvas.camera.modelview, None)
if name not in self.viewbtns.names():
iconpath = utils.findIcon('userview')
self.viewbtns.add(name, iconpath)
[docs] def applyView(self, name):
"""Apply a saved view to the current camera.
"""
m, p = self.saved_views.get(name, (None, None))
if m is not None:
self.viewports.current.camera.setModelview(m)
[docs] def setView(self, view):
"""Change the view of the current GUI viewport, keeping the bbox.
view is the name of one of the defined views.
"""
view = str(view)
if view in self.saved_views:
self.applyView(view)
else:
self.viewports.current.setCamera(angles=view)
self.viewports.current.update()
def updateAppdirs(self):
appmenu.reloadMenu()
def updateToolBars(self):
for t in ['camerabar', 'modebar', 'viewbar']:
self.updateToolBar(t)
def toggleAppScript(self):
if pf.debugon(pf.DEBUG.APPS):
print("Toggle between app and script")
from pyformex import apps
appname = pf.cfg['curfile']
if utils.is_script(appname):
path = pf.Path(appname).parent
appdir = apps.findAppDir(path)
if appdir:
appname = appname.stem
pkgname = appdir.pkg
appname = f"{pkgname}.{appname}"
self.setcurfile(appname)
else:
if pf.warning(
"This script is not in an application directory.\n\n"
f"You should add the directory path '{path}' to the"
" application paths before you can run this file as"
" an application.",
actions=['Not this time', 'Add this directory now']
).startswith('Add'):
menus.Settings.addAppdir(path, dircfg='appdirs')
guiscript.showInfo(f"Added the path {path}")
else:
fn = apps.findAppSource(appname)
if fn.exists():
self.setcurfile(fn)
else:
pf.warning("I can not find the source file for this application.")
def addCoordsTracker(self):
self.coordsbox = widgets.CoordsBox()
self.statusbar.addPermanentWidget(self.coordsbox)
def toggleCoordsTracker(self, onoff=None):
def track(x, y, z):
(X, Y, Z), = pf.canvas.unproject(x, y, z)
# print(f"{(x, y, z)} --> {(X, Y, Z)}")
pf.GUI.coordsbox.setValues([X, Y, Z])
if onoff is None:
onoff = self.coordsbox.isHidden()
if onoff:
func = track
else:
func = None
for vp in self.viewports.all:
vp.trackfunc = func
self.coordsbox.setVisible(onoff)
[docs] def maxCanvasSize(self):
"""Return the maximum canvas size.
The maximum canvas size is the size of the central space in the
main window, occupied by the OpenGL viewports.
"""
return qtutils.Size(pf.GUI.central)
[docs] def setcurproj(self, project=None, savecfg=True):
"""Show and remember the current project name."""
if project:
project = pf.Path(project)
self.curproj.setText(project.name if project else 'None', 1)
if savecfg:
menus.Settings.updateSettings({
'curproj': project if project else '',
'workdir': project.parent if project else pf.Path('.')
}, save=True)
[docs] def setcurfile(self, appname):
"""Set the current application or script.
appname is either an application module name or a script file.
"""
is_app = appname != '' and not utils.is_script(appname)
if is_app:
# application
label = 'App:'
name = appname
from pyformex import apps
try:
app = apps.load(appname)
except (ImportError, ModuleNotFoundError):
app = None
if app is None:
self.canPlay = False
try:
self.canEdit = apps.findAppSource(appname).exists()
except Exception:
self.canEdit = False
else:
self.canPlay = hasattr(app, 'run')
appsource = apps.findAppSource(app)
if appsource:
self.canEdit = apps.findAppSource(app).exists()
else:
print(f"Could not find source of app '{app}'")
self.canEdit = False
else:
# script file
label = 'Script:'
name = pf.Path(appname).name
self.canPlay = self.canEdit = utils.is_script(appname)
pf.prefcfg['curfile'] = appname
# self.curfile.label.setText(label)
self.curfile.setText(label, 0)
self.curfile.setText(name, 1)
self.enableButtons(self.actions, ['Play', 'Info'], self.canPlay)
self.enableButtons(self.actions, ['Edit'], self.canEdit)
self.enableButtons(self.actions, ['ReRun'], is_app and (
self.canEdit or self.canPlay))
self.enableButtons(self.actions, ['Step', 'Continue'], False)
icon = 'ok' if self.canPlay else 'notok'
iconpath = utils.findIcon(icon)
self.curfile.setIcon(QtGui.QIcon(QPixmap(iconpath)), 1)
[docs] def setcurdir(self):
"""Show the current workdir."""
dirname = pf.Path.cwd()
shortname = dirname.name
self.curdir.setText(shortname)
self.curdir.setToolTip(str(dirname))
def setBusy(self, busy=True, force=False):
if busy:
pf.app.setOverrideCursor(QtCore.Qt.WaitCursor)
else:
pf.app.restoreOverrideCursor()
pf.app.processEvents()
[docs] def resetCursor(self):
"""Clear the override cursor stack.
This will reset the application cursor to the initial default.
"""
while pf.app.overrideCursor():
pf.app.restoreOverrideCursor()
pf.app.processEvents()
[docs] def keyPressEvent(self, e):
"""Top level key press event handler.
Events get here if they are not handled by a lower level handler.
Every key press arriving here generates a WAKEUP signal, and if a
dedicated signal for the key was installed in the keypress table,
that signal is emitted too.
Finally, the event is removed.
"""
key = e.key()
if pf.debugon(pf.DEBUG.GUI):
print(f'Key {key} pressed')
self.signals.WAKEUP.emit()
signal = self.hotkey.get(key, None)
if signal is not None:
signal.emit()
e.ignore()
[docs] def XGeometry(self, border=True):
"""Get the main window position and size.
Parameters
----------
border: bool
If True (default), the returned geometry includes the
border frame. If set to False, the border is excluded.
Returns
-------
tuple (x,y,w,h)
A tuple of int with the top left position and the size
of the window geometry.
"""
if border:
geom = self.frameGeometry()
else:
geom = self.geometry()
return geom.getRect()
[docs] def writeSettings(self):
"""Store the GUI settings
This includes the GUI size and position
"""
if pf.debugon(pf.DEBUG.CONFIG):
print('Store current settings')
# store the history and main window size/pos
pf.prefcfg['gui/scripthistory'] = self.scripthistory.files
pf.prefcfg['gui/apphistory'] = self.apphistory.files
if not pf.options.geometry:
# if geometry is specified, we do not store it
pf.prefcfg.update({
'size': (self.width(), self.height()),
'pos': (self.x(), self.y()),
'splitsize': (self.central.height(), self.console.height()),
}, name='gui')
[docs] def findDialog(self, name):
"""Find the Dialog with the specified name.
Returns the list with matching dialogs, possibly empty.
"""
return self.findChildren(widgets.Dialog, str(name))
[docs] def closeDialog(self, name):
"""Close the Dialog with the specified name.
Closest all the Dialogs with the specified caption
owned by the GUI.
"""
for w in self.findDialog(name):
w.close()
# TODO: This should go to a toolbar class
def reloadActionButtons(self):
for b in self.actions:
self.toolbar.removeAction(self.actions[b].defaultAction())
self.actions = toolbar.addActionButtons(self.toolbar)
[docs] def startRun(self):
"""Change the GUI when an app/script starts running.
This method enables/disables the parts of the GUI that should or
should not be available while a script is running
It is called by the application executor.
"""
self.drawlock.allow()
if pf.options.canvas:
pf.canvas.update()
self.enableButtons(self.actions, ['ReRun'], False)
self.enableButtons(self.actions, ['Play', 'Step', 'Continue', 'Stop'], True)
# by default, we run the script in the current GUI viewport
if pf.options.canvas:
pf.canvas = self.viewports.current
pf.app.processEvents()
[docs] def stopRun(self):
"""Change the GUI when an app/script stops running.
This method enables/disables the parts of the GUI that should or
should not be available when no script is being executed.
It is called by the application executor when an application stops.
"""
self.drawlock.release()
pf.canvas.update()
self.enableButtons(self.actions, ['Play', 'ReRun'], True)
self.enableButtons(self.actions, ['Step', 'Continue', 'Stop'], False)
# acknowledge viewport switching
pf.canvas = self.viewports.current
pf.app.processEvents()
[docs] def cleanup(self):
"""Cleanup the GUI (restore default state)."""
if pf.debugon(pf.DEBUG.GUI):
print('GUI cleanup')
self.drawlock.release()
pf.canvas.cancel_selection()
pf.canvas.cancel_draw()
guiscript.clear_canvas()
self.resetCursor()
[docs] def onExit(self, func):
"""Register a function for execution on exit of the GUI.
Parameters
----------
func: callable
A function to be called on exit of the GUI. There is
no guaranteed order of execution of the exit functions.
"""
if not callable(func):
raise ValueError('func should be a callable')
self.on_exit.add(func)
[docs] def closeEvent(self, event):
"""Override the close event handler.
We override the default close event handler for the main
window, to allow the user to cancel the exit, and to save
the latest settings.
"""
#
# DEV: things going wrong during the event handler are hard to debug!
# You can add those things to a function and add the function to a
# menu for testing. At the end of the file helpMenu.py there is an
# example (commented out). Or set a gui/dooze value in the config.
#
self.cleanup()
# TODO: does this make sense?
if pf.options.gui:
pf.script.force_finish()
res = exitDialog()
if res:
self.drawlock.free()
# redirect stdout/stdin back to original
# sys.stdout.flush()
# sys.stderr.flush()
# sys.stdout = sys.__stdout__
# sys.stderr = sys.__stderr__
if pf.debugon(pf.DEBUG.GUI):
print("Executing registered exit functions")
for f in self.on_exit:
if pf.debugon(pf.DEBUG.GUI):
print(f)
f()
self.writeSettings()
self.saveConsoleHistory()
# allow user to see result before shutting down
dooze = pf.cfg['gui/dooze']
if dooze > 0:
print(f"Exiting in {dooze} seconds")
pf.sleep(dooze)
event.accept()
else:
event.ignore()
[docs] def run(self):
"""Go into interactive mode until the user exits"""
try:
# Make the workdir the current dir
workdir = pf.options.workdir
if workdir is None:
workdir = pf.cfg['workdir']
os.chdir(workdir)
if pf.debugon(pf.DEBUG.INFO):
print(f"Setting workdir to {workdir}")
except Exception:
# Save the current dir as workdir
menus.Settings.updateSettings({'workdir': pf.Path.cwd(), '_save_': True})
# correctly display the current workdir
self.setcurdir()
pf.interactive = True
if easter_egg:
if pf.debugon(pf.DEBUG.INFO):
print("Show easter egg")
try:
guiscript.playScript(easter_egg, encoding='egg')
except Exception:
pass
if pf.debugon(pf.DEBUG.INFO):
print("Start main loop")
now = datetime.datetime.now()
if pf.verbosity(3):
print(f"GUI startup time = {now - pf._start_time}")
res = pf.app.exec_()
if pf.debugon(pf.DEBUG.INFO):
print(f"Exit main loop with value {res}")
return res
[docs] def fullScreen(self, onoff=None):
"""Toggle the canvas full screen mode.
Fullscreen mode hides all the components of the main window, except
for the central canvas, maximizes the main window, and removes the
window decorations, thus leaving only the OpenGL canvas on the full
screen. (Currently there is also still a small border remaining.)
This mode is activated by pressing the F5 key. A second F5 press
will revert to normal display mode.
"""
hide = [self.statusbar, self.menu] + self.toolbars
if self.console:
hide.append(self.console)
if onoff is None:
onoff = not self.fullscreen
if onoff:
# goto fullscreen
for w in hide:
w.hide()
self.boxlayout.setContentsMargins(0, 0, 0, 0)
self.showFullScreen()
else:
# go to normal mode
for w in hide:
w.show()
self.boxlayout.setContentsMargins(*pf.cfg['gui/boxmargins'])
self.showNormal()
self.update()
self.fullscreen = onoff
pf.app.processEvents()
[docs]def exitDialog():
"""Show the exit dialog to the user.
Returns True if the shutdown can proceed
"""
from pyformex.gui.guicore import ask
confirm = pf.cfg['gui/exitconfirm']
if confirm == 'never':
# Always shutdown without
return True
if confirm == 'smart' and (pf.PF.filename is None or pf.PF.hits == 0):
# shutdown if no project to save
return True
print(f"Project variable changes: {pf.PF.hits}")
print(f"Project contents: {pf.PF.contents()}")
options = ["Save", "SaveAs", "Ignore", "Cancel"]
ans = ask("There are unsaved changes to your Project. What shall I do?",
options, caption="pyFormex Exit Dialog")
if ans == "Save":
return pm.saveProject()
elif ans == "SaveAs":
return pm.saveAsProject()
elif ans == "Ignore":
return True
elif ans == "Cancel":
return False
[docs]def xwininfo(*, windowid=None, name=None):
"""Get information about an X window.
Returns the information about an X11 window as obtained from
the ``xwininfo`` command, but parsed as a dict. The window can
be specified by its id or by its name. If neither is provided,
the user needs to interactively select a window by clicking the
mouse in that window.
Parameters
----------
windowid: str, optional
A hex string with the window id.
name: str
The window name, usually displayed in the top border decoration.
check_only: bool
If True, only check whether the window exists, but do not return
the info.
Returns
-------
dict
Return all the information obtained from calling
``xwininfo`` for the specified or picked window.
If a window id or name is specified that does not exist,
an empty dict is returned.
Notes
-----
The window id of the pyFormex main window can be obtained from
pf.GUI.winId(). The name of the window is pf.version().
"""
if windowid is not None:
args = f" -id {windowid}"
elif name is not None:
args = f" -name '{name}'"
else:
raise ValueError("Either windowid or name have to be specified")
P = process.run(f"xwininfo {args}")
res = {}
if not P.returncode:
for line in P.stdout.split('\n'):
s = line.split(':')
if len(s) < 2:
s = s[0].strip().split(' ')
if len(s) < 2:
continue
elif len(s) > 2:
if s[0] == 'xwininfo':
s = s[-2:] # remove the xwininfo string
t = s[1].split()
s[1] = t[0] # windowid
name = ' '.join(t[1:]).strip().strip('"')
res['Window name'] = name
if s[0][0] == '-':
s[0] = s[0][1:]
res[s[0].strip()] = s[1].strip()
return res
[docs]def pidofxwin(windowid):
"""Returns the PID of the process that has created the window.
Remark: Not all processes store the PID information in the way
it is retrieved here. In many cases (X over network) the PID can
not be retrieved. However, the intent of this function is just to
find a dangling pyFormex process, and this should probably work on
a normal desktop configuration.
"""
import re
#
# We need a new shell here, otherwise we get a 127 exit.
#
P = process.run(f"xprop -id '{windowid}' _NET_WM_PID", shell=True)
m = re.match(r"_NET_WM_PID\(.*\)\s*=\s*(?P<pid>\d+)", P.stdout)
if m:
pid = m.group('pid')
return int(pid)
return None
[docs]def findOldProcesses(max=16):
"""Find old pyFormex GUI processes still running.
There is a maximum to the number of processes that can be detected.
16 will suffice largely, because there is no sane reason to open that many
pyFormex GUI's on the same screen.
Returns the next available main window name, and a list of
running pyFormex GUI processes, if any.
"""
windowname = pf.version()
if pf.options.gl3:
windowname += '--gl3'
count = 0
running = []
while count < max:
info = xwininfo(name=windowname)
if info:
name = info['Window name']
windowid = info['Window id']
if name == windowname:
pid = pidofxwin(windowid)
else:
pid = None
# pid control needed for invisible windows on ubuntu
if pid:
running.append((windowid, name, pid))
count += 1
windowname = f"{pf.version()} ({count})"
else:
break
else:
break
return windowname, running
[docs]def killProcesses(pids):
"""Kill the processes in the pids list."""
warning = f"""..
Killing processes
-----------------
I will now try to kill the following processes::
{pids}
You can choose the signal to be sent to the processes:
- KILL (9)
- TERM (15)
We advice you to first try the TERM(15) signal, and only if that
does not seem to work, use the KILL(9) signal.
"""
actions = ['Cancel the operation', 'KILL(9)', 'TERM(15)']
answer = guiscript.ask(warning, actions)
if answer == 'TERM(15)':
utils.killProcesses(pids, 15)
elif answer == 'KILL(9)':
utils.killProcesses(pids, 9)
########################
# Main application
########################
[docs]class Application(QtWidgets.QApplication):
"""The interactive Qt application
Sets the default locale to 'C' and rejects thousands separators.
This is the only sensible thing to do for processing numbers in
an international scientific community.
Overrides some QApplication methods for convenience (usually to
allow simple strings as input).
"""
forbidden_styles = ['gtk2'] # causes segmentation fault
def __init__(self, args=sys.argv[:1]):
if pf.debugon(pf.DEBUG.INFO):
print(f"Arguments passed to the QApplication: {args}")
# Make sure numbers are always treated correctly on in/out
# The idiots that think otherwise, perhaps never use files
# as interface between programs.
locale = QtCore.QLocale.c()
locale.setNumberOptions(QtCore.QLocale.NumberOption.RejectGroupSeparator)
QtCore.QLocale.setDefault(locale)
# Initialize the QApplication
super().__init__(args)
if pf.debugon(pf.DEBUG.INFO):
print(f"Arguments left after starting QApplication: {args}")
# Set application attributes"
self.setOrganizationName("pyformex.org")
self.setOrganizationDomain("pyformex.org")
self.setApplicationName("pyFormex")
self.setApplicationVersion(pf.__version__)
# Set appearance
if pf.debugon(pf.DEBUG.GUI):
print("Setting Appearance")
self.setAppearance()
# Quit application on aboutToQuit or lastWindowClosed signals
self.aboutToQuit.connect(self.quit)
self.lastWindowClosed.connect(self.quit)
[docs] def maxSize(self):
"""Return the maximum available screensize"""
return qtutils.Size(self.screens()[0].availableSize())
[docs] def currentStyle(self):
"""Return the application style in use"""
return self.style().metaObject().className()[1:-5].lower()
[docs] def getStyles(self):
"""Return the available styles, removing the faulty ones."""
return [s.lower() for s in QtWidgets.QStyleFactory().keys()
if s not in Application.forbidden_styles]
[docs] def setStyle(self, style):
"""Set the application style.
style is a string, one of those returned by :meth:`getStyles`
"""
styles = self.getStyles()
if style.lower() not in styles:
print(f"Can not set style: {style}")
print(f"Available styles: {styles}")
if 'fusion' in styles:
style = 'fusion'
else:
return
super().setStyle(style)
[docs] def setFont(self, font):
"""Set the main application font.
font is either a QFont or a string resulting from the
QFont.toString() method
"""
if isinstance(font, str):
f = QtGui.QFont()
f.fromString(font)
font = f
super().setFont(font)
[docs] def setFontFamily(self, family):
"""Set the main application font family to the given family."""
font = self.font()
font.setFamily(family)
self.setFont(font)
[docs] def setFontSize(self, size):
"""Set the main application font size to the given point size."""
font = self.font()
font.setPointSize(int(size))
self.setFont(font)
[docs] def setAppearance(self):
"""Set all the GUI appearance elements.
Sets the GUI appearance from the current configuration values
'gui/style', 'gui/font', 'gui/fontfamily', 'gui/fontsize'.
"""
style = pf.cfg['gui/style']
font = pf.cfg['gui/font']
family = pf.cfg['gui/fontfamily']
size = pf.cfg['gui/fontsize']
if style:
self.setStyle(style)
if font or family or size:
if not font:
font = self.font()
if family:
font.setFamily(family)
if size:
font.setPointSize(size)
self.setFont(font)
[docs]def showSplash():
"""Show the splash screen"""
if pf.debugon(pf.DEBUG.GUI):
print("Loading the splash image")
splash = None
splash_path = pf.cfg['gui/splash']
if splash_path.exists():
if pf.debugon(pf.DEBUG.GUI):
print(f"Loading splash {splash_path}")
splashimage = QPixmap(splash_path)
splash = QtWidgets.QSplashScreen(splashimage)
splash.setFont(QtGui.QFont("Helvetica", 20))
splash.showMessage(pf.version(), QtCore.Qt.AlignmentFlag.AlignHCenter |
QtCore.Qt.AlignmentFlag.AlignTop,
QtGui.QColor('red'))
splash.show()
return splash
# TODO: some things could be moved in Application or Gui class
[docs]def createGUI(): # noqa: C901
"""Create the Qt application and GUI.
A (possibly empty) list of command line options should be provided.
Qt wil remove the recognized Qt and X11 options.
"""
pf.app = Application()
pf.X11 = pf.app.platformName() in ('xcb', )
# Set OpenGL format and check if we have DRI
if pf.gui.bindings[-1:] != '6':
qtgl.setOpenGLFormat()
dri = qtgl.hasDRI()
# Check for existing pyFormex processes
if pf.debugon(pf.DEBUG.INFO):
print("Checking for running pyFormex")
if pf.X11:
windowname, running = findOldProcesses()
else:
windowname, running = "UNKOWN", []
if pf.debugon(pf.DEBUG.INFO):
print(f"{windowname}, {running}")
while len(running) > 0:
if len(running) >= 16:
print(f"Too many open pyFormex windows: {len(running)} --- bailing out")
return -1
pids = [i[2] for i in running if i[2] is not None]
warning = """..
pyFormex is already running on this screen
------------------------------------------
A main pyFormex window already exists on your screen.
If you really intended to start another instance of pyFormex, you
can just continue now.
The window might however be a leftover from a previously crashed pyFormex
session, in which case you might not even see the window anymore, nor be able
to shut down that running process. In that case, you would better bail out now
and try to fix the problem by killing the related process(es).
If you think you have already killed those processes, you may check it by
rerunning the tests.
"""
actions = ['Really Continue', 'Rerun the tests', 'Bail out and fix the problem']
if pids:
warning += f"""
I have identified the process(es) by their PID as::
{pids}
If you trust me enough, you can also have me kill this processes for you.
"""
actions[2:2] = ['Kill the running processes']
if dri:
answer = guiscript.ask(warning, actions)
else:
warning += """
I have detected that the Direct Rendering Infrastructure
is not activated on your system. Continuing with a second
instance of pyFormex may crash your XWindow system.
You should seriously consider to bail out now!!!
"""
answer = guiscript.showWarning(warning, actions)
if answer == 'Really Continue':
break # OK, Go ahead
elif answer == 'Rerun the tests':
windowname, running = findOldProcesses() # try again
elif answer == 'Kill the running processes':
killProcesses(pids)
windowname, running = findOldProcesses() # try again
else:
return -1 # I'm out of here!
splash = showSplash()
# create GUI, show it, run it
if pf.debugon(pf.DEBUG.GUI):
print("Creating the GUI")
if splash is not None:
splash.showMessage("Creating the GUI")
if pf.options.geometry:
w, h, x, y = splitXgeometry(pf.options.geometry)
size = (w, h)
pos = (x, y)
splitsize = (3*h//4, h//4)
else:
size = pf.cfg['gui/size']
pos = pf.cfg['gui/pos']
splitsize = pf.cfg['gui/splitsize']
# Create the GUI
pf.GUI = Gui(windowname, size, pos, splitsize)
# update interpreter locals
pf.interpreter.locals.update(pf.script.Globals())
# setup the console
pf.GUI.console.clear()
pf.GUI.console.writecolor(pf.banner())
# Set interaction functions
if pf.cfg['warnings/popup']:
utils.set_warnings(nice=True, popup=True)
pf.GUI.onExit(utils.reset_warnings)
pf.print = guiscript.printc
pf.warning = utils.warning
pf.error = utils.error
# setup the canvas
if pf.options.canvas:
if splash is not None:
splash.showMessage("Creating the canvas")
if pf.debugon(pf.DEBUG.GUI):
print("Setting the canvas")
pf.app.processEvents()
pf.GUI.viewports.changeLayout(1)
pf.GUI.viewports.setCurrent(0)
pf.canvas.setRenderMode(pf.cfg['draw/rendermode'])
guiscript.reset()
# set canvas background
# (does not work before a guiscript.reset, do not know why)
pf.canvas.setBackground(color=pf.cfg['canvas/bgcolor'],
image=pf.cfg['canvas/bgimage'])
pf.canvas.update()
# fix button states
toolbar.perspective_button.update_status()
# setup the status bar
if pf.debugon(pf.DEBUG.GUI):
print("Setup status bar")
if pf.options.canvas:
pf.GUI.addCoordsTracker()
pf.GUI.toggleCoordsTracker(pf.cfg['gui/coordsbox'])
if pf.debugon(pf.DEBUG.GUI):
print(f"Using window name {pf.GUI.windowTitle()}")
# Script/App menu
if splash is not None:
splash.showMessage("Loading script/app menu")
pf.GUI.scriptmenu = appmenu.createAppMenu(
parent=pf.GUI.menu, before='help', mode='script')
pf.GUI.appmenu = appmenu.createAppMenu(
parent=pf.GUI.menu, before='help')
# Last minute menu modifications can go here
# cleanup
if splash is not None:
splash.showMessage("Set status bar")
# pf.GUI.addStatusBarButtons()
if pf.debugon(pf.DEBUG.GUI):
print("Showing the GUI")
if splash is not None:
splash.showMessage("Show the GUI")
pf.GUI.show()
if pf.debugon(pf.DEBUG.GUI):
print(', '.join(
(qtutils.sizeReport(pf.GUI, 'main:'),
qtutils.sizeReport(pf.GUI.box, 'box:'),
qtutils.sizeReport(pf.GUI.central, 'central:'),
qtutils.sizeReport(pf.GUI.console, 'console'),
)
))
# finalize imports
from pyformex.gui import guicore
if pf.cfg['aggregate']:
pf._import_language(guicore)
# show current application/file
if splash is not None:
splash.showMessage("Load current application")
appname = pf.cfg['curfile']
pf.GUI.setcurfile(appname)
if splash is not None:
# remove the splash window
splash.finish(pf.GUI)
if pf.debugon(pf.DEBUG.GUI):
print("Update")
pf.GUI.update()
if pf.cfg['gui/fortune']:
P = process.run(pf.cfg['fortune'])
if P.returncode == 0:
guiscript.showInfo(P.stdout)
# display startup warning
if pf.cfg['gui/startup_warning']:
pf.warning(pf.cfg['gui/startup_warning'])
# Enable the toolbars
pf.GUI.enableToolbars()
# pf.app.setQuitOnLastWindowClosed(False)
if pf.debugon(pf.DEBUG.GUI):
print("ProcessEvents")
pf.app_started = True
pf.app.processEvents()
##### GUI ready ########
# TODO: output should already be redirected!
# or else, buffered (right from the startup)
if pf.options.runall:
from pyformex.apps.RunAll import runAll
runAll(count=pf.options.runall, shuffle=True)
# TODO: should we continue or exit here?
pf.GUI.close()
pf.app.quit()
# load last project
if pf.cfg['openlastproj'] and pf.cfg['curproj']:
fn = pf.Path(pf.cfg['curproj'])
if fn.exists():
pm.set_project(pf.Project(fn))
#
if pf.debugon(pf.DEBUG.GUI):
print("GUI Started")
return 0
#### End