"""Main tkinter window: a notebook of module tabs.
The fallback interface, used when no Qt binding is installed. See :mod:`riverarchitect.gui`
for the backend choice and :mod:`riverarchitect.gui.qt` for the default front end.
Carries over the structure of the original ``parent_gui.RaGui``, with three deliberate
differences:
* selecting a tab no longer calls :func:`os.chdir`. The original changed the process working
directory per tab, which coupled unrelated modules through global state and made behaviour
depend on click order. Paths are now explicit;
* the *window* owns the menu bar and builds it once. The original rebuilt every menu on each
tab change, which leaked a ``tk.Menu`` per click;
* selecting a tab no longer resizes and re-centres the window, which fought whatever size
the user had chosen.
"""
import glob
import logging
import os
import sys
import tkinter as tk
from tkinter import ttk
from tkinter.filedialog import askdirectory
from tkinter.messagebox import askokcancel, showinfo, showwarning
from .. import __version__, config, guide
from .getstarted_tab import GetStartedGui
from .lifespan_tab import LifespanGui
from .mapping_tab import MappingGui
from .projectmaker_tab import ProjectMakerGui
from .maxlifespan_tab import MaxLifespanGui
from .recruitment_tab import RecruitmentGui
from .riverbuilder_tab import RiverBuilderGui
from .sharc_tab import SharcGui
from .stranding_tab import StrandingGui
from .terraforming_tab import TerraformingGui
from .volume_tab import VolumeGui
__all__ = ["RiverArchitectGui", "main", "TAB_GROUPS"]
#: Top-level tabs and their sub-tabs, matching the Qt front end and the ArcGIS version.
TAB_GROUPS = (
("Get Started", (GetStartedGui,)),
("Lifespan", (LifespanGui, MaxLifespanGui)),
("Morphology", (TerraformingGui, RiverBuilderGui, VolumeGui)),
("Ecohydraulics", (SharcGui, StrandingGui, RecruitmentGui)),
("Project Maker", (ProjectMakerGui,)),
("Maps", (MappingGui,)),
)
[docs]
class RiverArchitectGui(tk.Frame):
"""Top-level window holding one tab per module."""
def __init__(self, master=None):
super().__init__(master)
self.logger = logging.getLogger("riverarchitect")
self.pack(expand=True, fill=tk.BOTH)
top = self.winfo_toplevel()
top.title("River Architect %s" % __version__)
top.geometry("960x720")
self.notebook = ttk.Notebook(self)
self.notebook.pack(expand=True, fill=tk.BOTH)
self.tabs = {}
self.module_tabs = []
self.groups = {}
for group, factories in TAB_GROUPS:
if len(factories) == 1:
# A group of one is just a tab; nesting it would only add a click.
self.notebook.add(self._make_tab(self.notebook, factories[0]), text=group)
continue
inner = ttk.Notebook(self.notebook)
for factory in factories:
inner.add(self._make_tab(inner, factory), text=factory.title)
self.groups[group] = inner
self.notebook.add(inner, text=group)
self.unit = tk.StringVar(value="us")
self._build_menus()
self.status = tk.Label(self, anchor=tk.W, relief=tk.SUNKEN, fg="dim gray")
self.status.pack(side=tk.BOTTOM, fill=tk.X)
self._update_status()
def _make_tab(self, parent, factory):
"""Build a module tab, or a message standing in for one that could not load."""
try:
tab = factory(parent)
except Exception as exc: # a broken module must not take the whole GUI down
self.logger.error("Could not load the %s tab: %s", factory.title, exc)
tab = self._placeholder(parent, factory.title, exc)
else:
self.module_tabs.append(tab)
self.tabs[factory.title] = tab
return tab
def _placeholder(self, parent, label, exc):
frame = tk.Frame(parent)
tk.Label(frame, text="The %s tab could not be loaded." % label,
font=("TkDefaultFont", 11, "bold")).pack(padx=20, pady=(30, 8))
tk.Label(frame, text=str(exc), fg="firebrick", wraplength=600,
justify=tk.LEFT).pack(padx=20)
return frame
# --------------------------------------------------------------------- menus
def _build_menus(self):
"""Build the menu bar once. It belongs to the window, not to a tab."""
top = self.winfo_toplevel()
menu_bar = tk.Menu(self)
top.config(menu=menu_bar)
project_menu = tk.Menu(menu_bar, tearoff=0)
menu_bar.add_cascade(label="Project", menu=project_menu)
project_menu.add_command(label="Set project directory ...",
command=self.choose_project_home)
project_menu.add_separator()
project_menu.add_command(label="Quit", command=self.quit_program)
unit_menu = tk.Menu(menu_bar, tearoff=0)
menu_bar.add_cascade(label="Units", menu=unit_menu)
unit_menu.add_radiobutton(label="U.S. customary", value="us", variable=self.unit,
command=lambda: self.set_unit("us"))
unit_menu.add_radiobutton(label="SI (metric)", value="si", variable=self.unit,
command=lambda: self.set_unit("si"))
tools_menu = tk.Menu(menu_bar, tearoff=0)
menu_bar.add_cascade(label="Tools", menu=tools_menu)
tools_menu.add_command(label="Reconcile NoData in a condition ...",
command=self.run_reconcile_nodata)
help_menu = tk.Menu(menu_bar, tearoff=0)
menu_bar.add_cascade(label="Help", menu=help_menu)
help_menu.add_command(label="Documentation", command=self.open_documentation)
help_menu.add_command(label=guide.TITLE, command=self.open_live_guide)
help_menu.add_separator()
help_menu.add_command(label="About", command=self.show_about)
# ----------------------------------------------------------------- navigation
[docs]
def select_tab(self, group, tab=None):
"""Bring a module tab to the front. Used by the Live Guide.
Args:
group (str): the top-level tab, as :data:`TAB_GROUPS` names it.
tab (str): the module tab within it. Defaults to the group itself.
Returns:
bool: True when the tab was found and selected.
"""
tab = tab or group
for index in range(len(self.notebook.tabs())):
if self.notebook.tab(index, "text") != group:
continue
self.notebook.select(index)
inner = self.groups.get(group)
if inner is None:
return True
for inner_index in range(len(inner.tabs())):
if inner.tab(inner_index, "text") == tab:
inner.select(inner_index)
return True
return tab == group
return False
# -------------------------------------------------------------------- actions
[docs]
def set_unit(self, unit):
"""Switch the unit system for every tab."""
self.unit.set(unit)
for tab in self.module_tabs:
tab.set_unit(unit)
[docs]
def choose_project_home(self):
directory = askdirectory(title="Select the River Architect project directory",
initialdir=config.project_home())
if not directory:
return
config.set_project_home(directory)
for tab in self.module_tabs:
tab.on_project_home_change()
self._update_status()
conditions = self.module_tabs[0].condition_list if self.module_tabs else []
showinfo("Project directory",
"Project directory set to:\n%s\n\n%d condition(s) found."
% (directory, len(conditions)))
[docs]
def run_reconcile_nodata(self):
"""Run the NoData reconciliation tool on a chosen condition folder."""
from ..tools import reconcile_nodata
directory = askdirectory(title="Select a condition folder to reconcile",
initialdir=config.dir_conditions())
if not directory:
return
rasters = sorted(glob.glob(os.path.join(directory, "*.tif")))
if not rasters:
showwarning("Reconcile NoData", "No GeoTIFFs found in\n%s" % directory)
return
if not askokcancel("Reconcile NoData",
"Rewrite %d raster(s) in\n%s\nto NoData = %s?\n\n"
"The NoData mask is preserved exactly; only the sentinel changes."
% (len(rasters), directory, config.NODATA)):
return
changed = failed = 0
for path in rasters:
try:
if reconcile_nodata.reconcile(path):
changed += 1
except Exception as exc: # pragma: no cover - surfaced to the user
failed += 1
self.logger.error("Could not reconcile %s: %s", path, exc)
message = "%d raster(s) updated." % changed
if failed:
message += "\n%d could not be read; see the log." % failed
showinfo("Reconcile NoData", message)
[docs]
@staticmethod
def open_documentation():
import webbrowser
webbrowser.open(guide.DOCS_URL)
[docs]
def open_live_guide(self):
"""Open the step-by-step walkthrough of the sample-data example."""
from .guide_window import open_guide
open_guide(self.winfo_toplevel(), self)
[docs]
def show_about(self):
showinfo("About River Architect",
"River Architect %s\n\n"
"Open-source analysis and design of fluvial ecosystems.\n"
"https://riverarchitect.readthedocs.io/\n\n"
"Interface: tkinter (install PySide6 for the Qt interface)\n"
"Project directory:\n%s" % (__version__, config.project_home()))
[docs]
def quit_program(self):
if askokcancel("Close", "Do you really want to quit?"):
self.winfo_toplevel().destroy()
def _update_status(self):
self.status.config(text=" Project: %s" % config.project_home())
[docs]
def main(argv=None):
"""Start the tkinter interface.
Normally reached through :func:`riverarchitect.gui.main`, which picks a front end.
"""
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
datefmt="%H:%M:%S")
argv = sys.argv[1:] if argv is None else list(argv)
if argv and not argv[0].startswith("-"):
config.set_project_home(argv[0])
try:
root = tk.Tk()
icon = tk.PhotoImage(file=config.icon_path())
root.iconphoto(True, icon)
root._riverarchitect_icon = icon # retain the image
except tk.TclError as exc:
print("ERROR: no display available for the graphical interface (%s).\n"
"River Architect's modules can be used directly from Python:\n"
" from riverarchitect.volume_assessment import VolumeAssessment" % exc,
file=sys.stderr)
return 1
RiverArchitectGui(root)
root.mainloop()
return 0
if __name__ == "__main__":
sys.exit(main())