# pylint: disable=attribute-defined-outside-init,protected-access
import math
import os
import sys
import tkinter as tk
import tkinter.colorchooser
import tkinter.filedialog
import tkinter.messagebox
import tkinter.scrolledtext
import traceback
from abc import ABC, abstractmethod
from collections import defaultdict
from tkinter import ttk
from typing import Any, Callable, Literal, TypedDict, TypeVar
import matplotlib as mpl
import matplotlib.cm
import matplotlib.colors
import matplotlib.markers
import matplotlib.patheffects as path_effects
import matplotlib.pyplot as plt
import numpy as np
import spiceypy as spice
from astropy.io import fits
from matplotlib.artist import Artist
from matplotlib.backend_bases import MouseButton, MouseEvent
from matplotlib.backends.backend_tkagg import NavigationToolbar2Tk # type: ignore
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
from matplotlib.text import Text
from . import base, data_loader, progress, utils
from .body import BasicBody, Body, NotFoundError
from .body_xy import MapKwargs
from .observation import Observation
Widget = TypeVar('Widget', bound=tk.Widget)
SetterKey = Literal[
'x0', 'y0', 'r0', 'rotation', 'step', 'plate_scale_arcsec', 'plate_scale_km'
]
PlotKey = Literal[
'image',
'grid',
'limb',
'limb_illuminated',
'terminator',
'ring',
'pole',
'coordinate_of_interest_lonlat',
'coordinate_of_interest_radec',
'other_body_of_interest_marker',
'other_body_of_interest_label',
'marked_coord',
'_',
]
ImageMode = Literal['sum', 'single', 'rgb']
DEFAULT_PLOT_SETTINGS: dict[PlotKey, dict] = {
'grid': dict(zorder=3.1, color='#333', linewidth=1, linestyle='dotted'),
'terminator': dict(zorder=3.2, color='w', linewidth=1, linestyle='dashed'),
'limb': dict(zorder=3.3, color='w', linewidth=0.5, linestyle='solid'),
'limb_illuminated': dict(zorder=3.31, color='w', linewidth=1, linestyle='solid'),
'ring': dict(zorder=3.4, color='w', linewidth=0.5, linestyle='solid'),
'pole': dict(zorder=3.5, color='k', outline_color='w'),
'coordinate_of_interest_lonlat': dict(zorder=3.6, marker='x', color='k', s=36),
'coordinate_of_interest_radec': dict(zorder=3.7, marker='+', color='k', s=36),
'other_body_of_interest_marker': dict(zorder=3.8, marker='+', color='w', s=36),
'other_body_of_interest_label': dict(zorder=3.81, color='grey'),
'marked_coord': dict(zorder=4, color='cyan', linewidth=0.5, linestyle='solid'),
'image': dict(zorder=0.9, cmap='inferno'),
'_': dict(
grid_interval=30,
image_mode='single',
image_idx_single=0,
image_idx_r=0,
image_idx_g=1,
image_idx_b=2,
image_gamma=1,
image_vmin=0,
image_vmax=100,
image_limit_type='absolute',
),
}
LINESTYLES = ('solid', 'dashed', 'dotted', 'dashdot')
MARKERS = ('x', '+', 'o', '.', '*', 'v', '^', '<', '>', ',', 'D', 'd', '|', '_')
GRID_INTERVALS = ('10', '30', '45', '90')
CMAPS = ('gray', 'viridis', 'plasma', 'inferno', 'magma', 'cividis')
LIMIT_TYPES = ('absolute', 'percentile')
MAP_INTERPOLATIONS = ('nearest', 'linear', 'quadratic', 'cubic')
MAP_PROJECTIONS = ('rectangular', 'orthographic', 'azimuthal', 'azimuthal equal area')
DEFAULT_HINT = ''
# Deal with X11 font bug by replacing high codepoint chars with ASCII equivalents.
# This seems to prevent the use of fonts which cause the X_OpenFont error which
# XQuartz was producing when trying to run planetmapper over SSH on mac. This is a bit
# of a hack and produces an uglier UI, but is better than always crashing.
# TODO remove this when the bug is fixed in XQuartz
# https://github.com/ortk95/planetmapper/issues/145
try:
USE_X11_FONT_BUGFIX = bool(os.environ['PLANETMAPPER_USE_X11_FONT_BUGFIX'])
except KeyError:
USE_X11_FONT_BUGFIX = False # pyright: ignore[reportConstantRedefinition]
X11_FONT_BUGRIX_TRANSLATIONS = str.maketrans(
{
'↖': None,
'↑': '^',
'↗': None,
'←': '<',
'→': '>',
'↙': None,
'↓': 'v',
'↘': None,
'↺': '<',
'↻': '>',
}
)
def _run_gui_from_cli(*args: str | None) -> None:
"""Called with `planetmapper` from the command line"""
if USE_X11_FONT_BUGFIX:
print('*** Using X11 font bugfix ***')
gui = GUI()
if args and args[0] is not None:
try:
gui.set_observation(Observation(args[0]))
# pylint: disable-next=broad-exception-caught
except Exception as e:
print(f'Error loading observation: {e}')
sys.exit(1)
gui.run()
class Quit(Exception):
pass
[docs]class GUI:
"""
Class to create and run graphical user interface to fit observations.
This class does not usually need to be run directly, as a GUI can be created
directly from an :class:`planetmapper.Observation` object using
:func:`planetmapper.Observation.run_gui`, or by calling `planetmapper` from the
command line.
"""
MINIMUM_SIZE = (600, 600)
DEFAULT_GEOMETRY = '800x650+15+15'
def __init__(self, allow_open: bool = True) -> None:
self.allow_open = allow_open
self._observation: Observation | None = None
self.step_size = 1
self.click_locations: list[tuple[float, float]] = []
"""
List of click locations marked on the plot in `(x, y)` pixel coordinates.
This list is cleared whenever a new observation is opened.
"""
self.last_click_location: tuple[float, float] | None = None
self.coords_formatted_str: str | None = None
self.coords_machine_str: str | None = None
self.shortcuts: dict[Callable[[], Any], list[str]] = {
self.increase_step: [']'],
self.decrease_step: ['['],
self.move_up: ['<Up>', 'w'],
self.move_down: ['<Down>', 's'],
self.move_right: ['<Right>', 'd'],
self.move_left: ['<Left>', 'a'],
self.rotate_right: ['>', '.'],
self.rotate_left: ['<less>', ','],
self.increase_radius: ['+', '='],
self.decrease_radius: ['-', '_'],
self.save_button: ['<Control-s>'],
self.load_observation: ['<Control-o>'],
self.copy_machine_coord_values: ['<Control-c>'],
self.display_header: ['<Control-h>'],
}
self.shortcuts_to_keep_in_entry = ['<Control-s>', '<Control-o>']
self.setter_callbacks: defaultdict[SetterKey, list[Callable[[float], Any]]] = (
defaultdict(
list,
{
'x0': [lambda f: self.get_observation().set_x0(f)],
'y0': [lambda f: self.get_observation().set_y0(f)],
'r0': [lambda f: self.get_observation().set_r0(f)],
'rotation': [lambda f: self.get_observation().set_rotation(f)],
'step': [self.set_step],
'plate_scale_arcsec': [
lambda f: self.get_observation().set_plate_scale_arcsec(f)
],
'plate_scale_km': [
lambda f: self.get_observation().set_plate_scale_km(f)
],
},
)
)
self.ui_callbacks: defaultdict[SetterKey | None, set[Callable[[], Any]]] = (
defaultdict(set)
)
self.getters: dict[SetterKey, Callable[[], float]] = {
'x0': lambda: self.get_observation().get_x0(),
'y0': lambda: self.get_observation().get_y0(),
'r0': lambda: self.get_observation().get_r0(),
'rotation': lambda: self.get_observation().get_rotation(),
'step': lambda: self.step_size,
'plate_scale_arcsec': lambda: self.get_observation().get_plate_scale_arcsec(),
'plate_scale_km': lambda: self.get_observation().get_plate_scale_km(),
}
self.plot_handles: defaultdict[PlotKey, list[Artist]] = defaultdict(list)
self.plot_settings: defaultdict[PlotKey, dict] = defaultdict(dict)
for k, v in DEFAULT_PLOT_SETTINGS.items():
self.plot_settings[k] = v.copy()
self.disc_finding_routines: dict[
str, list[tuple[Callable[[], None], str, str]]
] = {
'Reset position': [
(
lambda: self.get_observation().centre_disc(),
'Centre disc in image',
'Centre the target\'s planetary disc and make it fill ~90% of the observation',
),
],
'Use WCS data from FITS header': [
(
lambda: self.get_observation().disc_from_wcs(
suppress_warnings=True, validate=False
),
'Use WCS position, rotation & scale',
'Set all disc parameters using approximate WCS information in the observation\'s FITS header',
),
(
lambda: self.get_observation().position_from_wcs(
suppress_warnings=True, validate=False
),
'Use WCS position',
'Set disc position using approximate WCS information in the observation\'s FITS header',
),
(
lambda: self.get_observation().rotation_from_wcs(
suppress_warnings=True, validate=False
),
'Use WCS rotation',
'Set disc rotation using approximate WCS information in the observation\'s FITS header',
),
(
lambda: self.get_observation().plate_scale_from_wcs(
suppress_warnings=True, validate=False
),
'Use WCS plate scale',
'Set plate scale using approximate WCS information in the observation\'s FITS header',
),
],
'Fit observation': [
(
lambda: self.get_observation().fit_disc_position(),
'Fit disc position',
'Set x0 and y0 so that the planet\'s disc is fit to the brightest part of the data (this may take a few seconds)',
),
(
lambda: self.get_observation().fit_disc_radius(),
'Fit disc radius',
'Set r0 by calculating the radius around (x0, y0) where the brightness decrease is the fastest (this may take a few seconds)',
),
],
'Use FITS header metadata': [
(
lambda: self.get_observation().disc_from_header(),
'Use PlanetMapper metadata',
'Set disc parameters using information in the observation\'s FITS header generated by any previous runs of PlanetMapper',
),
],
}
self.kernels: list[str] = [
os.path.join(base.get_kernel_path(), pattern)
for pattern in base._KERNEL_DATA['kernel_patterns']
]
self.delayed_actions: dict[str, tuple[Callable[[], Any], str]] = {}
self.event_time_to_ignore = None
self.gui_built = False
def __repr__(self) -> str:
return 'InteractiveObservation()'
[docs] def run(self) -> None:
"""
Run the GUI.
"""
print('Running user interface...')
try:
self.get_observation()
except Quit:
print('App quit')
return
# Disable keyboard shortcuts
context = {}
for k in mpl.rcParams:
if k.startswith('keymap.'):
context[k] = []
with mpl.rc_context(context):
self.build_gui()
self.bind_keyboard()
self.root.mainloop()
def load_observation(self) -> None:
if self.allow_open:
OpenObservation(gui=self, first_run=self._observation is None)
if self._observation is None:
raise Quit
[docs] def set_observation(self, observation: Observation) -> None:
"""
Set the observation used in the GUI.
For example, to run the GUI with the data in `'europa.fits'`, use: ::
gui = planetmapper.gui.GUI()
gui.set_observation(planetmapper.Observation('europa.fits'))
gui.run()
Args:
observation: Observation to fit.
"""
self._observation = observation
self.image_modes: dict[ImageMode, tuple[Callable[[], np.ndarray], str]] = {
'single': (self.image_single, 'Single wavelength'),
'sum': (self.image_sum, 'Sum all wavelengths'),
'rgb': (self.image_rgb, 'RGB composite'),
}
n_wavl = self.get_observation().data.shape[0]
if n_wavl < 2:
del self.image_modes['sum']
if n_wavl < 3:
del self.image_modes['rgb']
if n_wavl == 1:
self.plot_settings['_']['image_mode'] = 'single'
elif n_wavl == 3 and not self.gui_built:
self.plot_settings['_']['image_mode'] = 'rgb'
else:
self.plot_settings['_']['image_mode'] = 'sum'
self.plot_settings['_'].update(
image_idx_single=0,
image_idx_r=0,
image_idx_g=1,
image_idx_b=2,
)
if self.gui_built:
self.run_all_ui_callbacks()
self.rebuild_plot()
self.root.title(self.get_observation().get_description(multiline=False))
self.reset_help_hint()
self.click_locations = []
self.clear_click_location()
def get_observation(self) -> Observation:
if self._observation is None:
self.load_observation()
assert self._observation is not None
return self._observation
# GUI Building
def build_gui(self) -> None:
self.root = tk.Tk()
self.root.geometry(self.DEFAULT_GEOMETRY)
self.root.minsize(*self.MINIMUM_SIZE)
self.configure_style(self.root)
self.root.title(self.get_observation().get_description(multiline=False))
self.hint_frame = ttk.Frame(self.root)
self.hint_frame.pack(side='bottom', fill='x')
self.controls_frame = ttk.Frame(self.root)
self.controls_frame.pack(side='left', fill='y')
self.plot_frame = ttk.Frame(self.root)
self.plot_frame.pack(side='top', fill='both', expand=True)
self.build_plot()
self.build_help_hint()
self.build_controls()
self.update_plot()
self.root.protocol('WM_DELETE_WINDOW', self.quit)
self.gui_built = True
def quit(self) -> None:
plt.close(self.fig)
self.root.destroy()
self.gui_built = False
def configure_style(self, root: tk.Tk | None) -> None:
if root is None:
root = self.root
self.style = ttk.Style(root)
self.style.theme_use('default')
for element in ['TEntry', 'TCombobox', 'TSpinbox', 'TButton', 'TLabel']:
self.style.configure(
element,
foreground='black',
insertcolor='black',
fieldbackground='white',
selectbackground='#bdf',
selectforeground='black',
)
self.style.map('TScale', troughcolor=[('disabled', '#d9d9d9')])
def build_controls(self) -> None:
self.notebook = ttk.Notebook(self.controls_frame)
self.notebook.pack(fill='both', expand=True)
self.build_main_controls_tab()
self.build_disc_finding_controls_tab()
self.build_plot_settings_controls_tab()
self.build_coords_tab()
def build_main_controls_tab(self) -> None:
frame = ttk.Frame(self.notebook)
frame.pack()
self.notebook.add(frame, text='Controls')
buttons: list[
tuple[
str,
str,
Callable[[], None],
int | tuple[int, int],
int | tuple[int, int],
]
] = [
(
'Open...',
'Open a new observation, change target/date/observer, and adjust kernel settings',
self.load_observation,
0,
0,
),
(
'Save...',
'Save FITS files of the observation and mapped observation with backplane data',
self.save_button,
1,
0,
),
(
'View FITS header',
'View the FITS header for the observation',
self.display_header,
(0, 2) if self.allow_open else 1,
1,
),
]
if not self.allow_open:
del buttons[0]
self.build_main_controls_section(
frame=frame,
label='File',
buttons=buttons,
button_tooltip_base='{hint}',
entry_tooltip='',
numeric_entries=[],
wide_buttons=True,
)
self.build_main_controls_section(
frame=frame,
label='Position',
buttons=[
('↖', 'up and left', self.move_up_left, 0, 0),
('↑', 'up', self.move_up, 1, 0),
('↗', 'up and right', self.move_up_right, 2, 0),
('←', 'left', self.move_left, 0, 1),
('→', 'right', self.move_right, 2, 1),
('↙', 'down and left', self.move_down_left, 0, 2),
('↓', 'down', self.move_down, 1, 2),
('↘', 'down and right', self.move_down_right, 2, 2),
],
button_tooltip_base='Move fitted disc {hint}',
entry_tooltip='Set pixel coordinates of the centre of the disc',
numeric_entries=['x0', 'y0'],
ipadx=20,
)
self.build_main_controls_section(
frame=frame,
label='Rotation',
buttons=[
('↺', 'anticlockwise', self.rotate_left, 0, 0),
('↻', 'clockwise', self.rotate_right, 1, 0),
],
button_tooltip_base='Rotate fitted disc {hint}',
entry_tooltip='Set the rotation of the disc in degrees',
numeric_entries=[('rotation', 'Rotation (°)')],
)
self.build_main_controls_section(
frame=frame,
label='Size',
buttons=[
('-', 'Decrease', self.decrease_radius, 0, 0),
('+', 'Increase', self.increase_radius, 1, 0),
],
button_tooltip_base='{hint} fitted disc radius',
entry_tooltip='Set the equatorial radius of the disc, r0, in pixels',
numeric_entries=[
('r0', 'r0'),
('plate_scale_arcsec', 'arcsec/pixel'),
('plate_scale_km', 'km/pixel'),
],
add_callbacks=['r0', 'plate_scale_arcsec', 'plate_scale_km'],
)
self.build_main_controls_section(
frame=frame,
label='Step size',
buttons=[
('÷', 'Decrease', self.decrease_step, 0, 0),
('×', 'Increase', self.increase_step, 1, 0),
],
button_tooltip_base='{hint} step size when clicking buttons',
entry_tooltip='Set the step size when clicking buttons',
numeric_entries=['step'],
)
def build_main_controls_section(
self,
frame: ttk.Frame,
label: str,
buttons: list[
tuple[
str,
str,
Callable[[], None],
int | tuple[int, int],
int | tuple[int, int],
]
],
button_tooltip_base: str,
entry_tooltip: str,
numeric_entries: list[SetterKey | tuple[SetterKey, str]],
ipadx=30,
ipady=1,
add_callbacks: list[SetterKey] | None = None,
wide_buttons: bool = False,
**kw,
) -> None:
label_frame = ttk.LabelFrame(frame, text=label)
label_frame.pack(fill='x', pady=3, ipadx=1, ipady=1)
if buttons:
button_frame = ttk.Frame(label_frame)
button_frame.pack(
fill='x' if wide_buttons else 'none',
expand=wide_buttons,
padx=5 if wide_buttons else 0,
)
for text, hint, fn, column, row in buttons:
if isinstance(column, tuple):
column, columnspan = column
else:
columnspan = 1
if isinstance(row, tuple):
row, rowspan = row
else:
rowspan = 1
text = self.maybe_replace_string_with_x11_bugfix(text)
self.add_tooltip(
ttk.Button(button_frame, text=text, command=fn, width=1),
button_tooltip_base.format(hint=hint),
fn,
).grid(
column=column,
row=row,
columnspan=columnspan,
rowspan=rowspan,
ipadx=ipadx,
ipady=ipady,
padx=2,
pady=2,
sticky='we',
)
if wide_buttons:
button_frame.grid_columnconfigure(column, weight=1)
if numeric_entries:
entry_frame = self.add_tooltip(ttk.Frame(label_frame), entry_tooltip)
entry_frame.pack(pady=2)
for ne in numeric_entries:
if isinstance(ne, str):
NumericEntry(
self, entry_frame, ne, pady=2, add_callbacks=add_callbacks, **kw
)
else:
NumericEntry(
self,
entry_frame,
ne[0],
ne[1],
pady=2,
add_callbacks=add_callbacks,
**kw,
)
def build_plot_settings_controls_tab(self) -> None:
menu = ttk.Frame(self.notebook)
menu.pack()
self.notebook.add(menu, text='Settings')
# Image
frame = ttk.LabelFrame(menu, text='Observation')
frame.pack(fill='x', pady=5)
frame.grid_columnconfigure(0, weight=1)
self.image_setting = PlotImageSetting(
self,
frame,
'image',
label='Observed image',
hint='the image of your observation',
callbacks=[self.replot_image],
)
self.ui_callbacks[None].add(self.image_setting.update_tool_ui_state)
# Plot features
frame = ttk.LabelFrame(menu, text='Plotted features')
frame.pack(fill='x', pady=5)
frame.grid_columnconfigure(0, weight=1)
PlotLineSetting(self, frame, 'limb', label='Limb', hint='the target\'s limb')
PlotLineSetting(
self,
frame,
'limb_illuminated',
label='Limb (dayside)',
hint='the illuminated part of the target\'s limb',
)
PlotLineSetting(
self,
frame,
'terminator',
label='Terminator',
hint='the line between the dayside and nightside regions on the target',
)
PlotGridSetting(
self,
frame,
'grid',
label='Gridlines',
hint='the longitude/latitude grid on the target',
callbacks=[self.replot_grid],
)
PlotRingsSetting(
self,
frame,
'ring',
label='Rings',
hint='rings around the target (click Edit to define ring radii)',
callbacks=[self.replot_rings],
)
PlotOutlinedTextSetting(
self,
frame,
'pole',
label='Poles',
hint='the target\'s poles',
)
PlotCoordinatesSetting(
self,
frame,
'coordinate_of_interest_lonlat',
label='Lon/Lat POI',
hint='points of interest on the surface of the target (click Edit to define POI)',
callbacks=[self.replot_coordinates_lonlat],
coordinate_list=self.get_observation().coordinates_of_interest_lonlat,
menu_label='\n'.join(
[
'List of Lon/Lat points of interest.',
'Coordinates should be written as comma',
'separated "lon, lat" values, with each',
'coordinate pair on a new line:',
]
),
)
PlotCoordinatesSetting(
self,
frame,
'coordinate_of_interest_radec',
label='RA/Dec POI',
hint='points of interest in the sky (click Edit to define POI)',
callbacks=[self.replot_coordinates_radec],
coordinate_list=self.get_observation().coordinates_of_interest_radec,
menu_label='\n'.join(
[
'List of RA/Dec points of interest.',
'Coordinates should be written as comma',
'separated "ra, dec" values, with each',
'coordinate pair on a new line:',
]
),
)
PlotOtherBodyScatterSetting(
self,
frame,
'other_body_of_interest_marker',
label='Other bodies (e.g. moons)',
hint='other bodies of interest (click Edit to specify other bodies to show, e.g. moons)',
callbacks=[self.replot_other_bodies],
)
PlotOtherBodyTextSetting(
self,
frame,
'other_body_of_interest_label',
label='Other body labels',
hint='labels for other bodies of interest (click Edit to specify other bodies to show, e.g. moons)',
callbacks=[self.replot_other_bodies],
)
# Marked coords
frame = ttk.LabelFrame(menu, text='Marked coords')
frame.pack(fill='x', pady=5)
frame.grid_columnconfigure(0, weight=1)
PlotLineSetting(
self,
frame,
'marked_coord',
label='Click location',
hint='the location on the image clicked when to calculate coordinates (see the Coords tab)',
callbacks=[self.replot_marked_coord],
)
def build_disc_finding_controls_tab(self) -> None:
frame = ttk.Frame(self.notebook)
frame.pack()
self.notebook.add(frame, text='Find disc')
for label, routines in self.disc_finding_routines.items():
label_frame = ttk.LabelFrame(frame, text=label)
label_frame.pack(fill='x', pady=10)
for fn, name, description in routines:
self.add_tooltip(
ttk.Button(
label_frame,
text=name,
command=self.make_disc_finding_fn(fn),
),
description,
).pack(fill='x', pady=2, padx=5)
def make_disc_finding_fn(self, fn: Callable[[], None]) -> Callable[[], None]:
def button_command():
try:
fn()
except ValueError as e:
tkinter.messagebox.showwarning(
title='Error finding disc',
message=str(e),
)
self.run_all_ui_callbacks(update_plot=True)
return button_command
def build_help_hint(self) -> None:
frame = ttk.Frame(self.hint_frame)
frame.pack(fill='x', padx=5, pady=1)
self.help_hint = ttk.Label(frame, text=DEFAULT_HINT)
self.help_hint.pack(side='left')
self.help_hint.bind('<Enter>', lambda e: self.reset_help_hint(hover=True))
self.help_hint.bind('<Leave>', lambda e: self.reset_help_hint())
self.reset_help_hint()
def set_help_hint(self, msg: str, *, color: str = 'black'):
self.help_hint.configure(text=msg, foreground=color)
def reset_help_hint(self, *, hover: bool = False):
path = self.get_observation().path
if path is None:
msg = ''
else:
msg = path if hover else os.path.basename(path)
self.set_help_hint(msg, color='gray50')
def add_tooltip(
self, widget: Widget, msg: str, shortcut_fn: Callable | None = None, **kw
) -> Widget:
if shortcut_fn is not None:
keys = self.shortcuts.get(shortcut_fn, None)
if keys is not None:
key = keys[0]
key = key.replace('<less>', '<').upper()
if key[0] == '<' and key[-1] == '>' and len(key) > 2:
key = key[1:-1]
msg = f'{msg} (keyboard shortcut: {key})'
widget.bind('<Enter>', lambda e: self.set_help_hint(msg, **kw))
widget.bind('<Leave>', lambda e: self.reset_help_hint())
return widget
# Coords
def build_coords_tab(self):
top_level_frame = ttk.Frame(self.notebook)
top_level_frame.pack()
self.notebook.add(top_level_frame, text='Coords')
frame = ttk.Frame(top_level_frame)
frame.pack(padx=5, fill='x')
self.coords_tab_labels: dict[str, ttk.Label] = {}
self.coords_labels: dict[str, list[str | tuple[str, str]]] = {
'Pixel coordinates': ['x', 'y'],
'Celestial coordinates': [
('ra', 'Right ascension'),
('dec', 'Declination'),
],
'Planetographic coordinates': [('lon', 'Longitude'), ('lat', 'Latitude')],
'Illumination angles': ['phase', 'incidence', 'emission', 'azimuth'],
}
for name, part_labels in self.coords_labels.items():
label_frame = ttk.LabelFrame(frame, text=name)
label_frame.pack(fill='x', pady=5)
for col in range(2):
label_frame.grid_columnconfigure(col, weight=1, uniform='a')
for row, kl in enumerate(part_labels):
if isinstance(kl, tuple):
key, label = kl
else:
key = kl
label = kl.capitalize()
label = label + ':'
l1 = ttk.Label(label_frame, text=label)
l1.grid(row=row, column=0, sticky='e', pady=2, padx=2)
l2 = ttk.Label(label_frame, text='')
l2.grid(row=row, column=1, sticky='w', pady=2, padx=2)
self.coords_tab_labels[key] = l2
button_frame = ttk.Frame(frame)
button_frame.pack(fill='x', pady=5)
self.coords_copy_formatted_button = self.add_tooltip(
ttk.Button(
button_frame,
text='Copy formatted values',
command=self.copy_formatted_coord_values,
state='disable',
),
'Copy formatted coordinate values',
self.copy_formatted_coord_values,
)
self.coords_copy_formatted_button.pack(fill='x', pady=2, padx=2)
self.coords_copy_machine_button = self.add_tooltip(
ttk.Button(
button_frame,
text='Copy machine readable values',
command=self.copy_machine_coord_values,
state='disable',
),
'Copy machine readable coordinate values, compatible with Python, JSON, etc.',
self.copy_machine_coord_values,
)
self.coords_copy_machine_button.pack(fill='x', pady=2, padx=2)
message = [
'',
'Click on the plot to get coordinates',
'Right click on the plot to clear',
'',
'Note that most of these values change',
'when you adjust the disc position',
'',
]
ttk.Label(top_level_frame, text='\n'.join(message), justify='center').pack(
fill='x', padx=5
)
def get_click_coords(self) -> dict[str, float]:
if self.last_click_location is None:
return {}
out: dict[str, float] = {}
observation = self.get_observation()
x, y = self.last_click_location
out['x'] = x
out['y'] = y
out['ra'], out['dec'] = observation.xy2radec(x, y)
try:
targvec = observation._xy2targvec(x, y)
out['lon'], out['lat'] = observation.targvec2lonlat(targvec)
(
phase,
incdnc,
emissn,
) = observation._illumination_angles_from_targvec_radians(targvec)
az = observation._azimuth_angle_from_gie_radians(phase, incdnc, emissn)
(
out['phase'],
out['incidence'],
out['emission'],
out['azimuth'],
) = np.rad2deg((phase, incdnc, emissn, az))
except NotFoundError:
pass
return out
def update_coords(self, print_coords: bool = False) -> None:
if self.last_click_location is None:
for k, label in self.coords_tab_labels.items():
label.configure(text='')
return
coords = self.get_click_coords()
coords_strs = self.get_click_coords_formatted_strings(coords)
if print_coords:
# Print with trailing comma so can be copied straight into a list
print(self.make_click_json_string(coords) + ',')
self.coords_machine_str = self.make_click_json_string(
coords, fmt='', fmt_radec=''
)
self.coords_formatted_str = self.make_click_formatted_string(coords_strs)
for k, label in self.coords_tab_labels.items():
label.configure(text=coords_strs.get(k, ''))
def get_click_coords_formatted_strings(
self, coords: dict[str, float], fmt: str = '.2f', dms_fmt: str = '.3f'
) -> dict[str, str]:
out: dict[str, str] = {}
x, y = coords['x'], coords['y']
observation = self.get_observation()
out['x'] = f'{x:{fmt}}'
out['y'] = f'{y:{fmt}}'
ra, dec = coords['ra'], coords['dec']
out['ra'] = utils.decimal_degrees_to_dms_str(ra, dms_fmt)
out['dec'] = utils.decimal_degrees_to_dms_str(dec, dms_fmt)
try:
# Use targvec for a bit more speed here
lon, lat = coords['lon'], coords['lat']
ew = observation.positive_longitude_direction
ns = 'N' if lat >= 0 else 'S'
out['lon'] = f'{lon:{fmt}}°{ew}'
out['lat'] = f'{abs(lat):{fmt}}°{ns}'
out['phase'] = f'{coords["phase"]:{fmt}}°'
out['incidence'] = f'{coords["incidence"]:{fmt}}°'
out['emission'] = f'{coords["emission"]:{fmt}}°'
out['azimuth'] = f'{coords["azimuth"]:{fmt}}°'
except KeyError:
pass
return out
def make_click_formatted_string(self, coords_strs: dict[str, str]) -> str:
msg = []
for name, part_labels in self.coords_labels.items():
msg.append(name)
for row, kl in enumerate(part_labels):
if isinstance(kl, tuple):
key, label = kl
else:
key = kl
label = kl.capitalize()
value = coords_strs.get(key, '')
msg.append(f' - {label}: {value}')
return '\n'.join(msg)
def make_click_json_string(
self, coords: dict[str, float], fmt: str = '.2f', fmt_radec: str = '.6f'
) -> str:
x, y = coords['x'], coords['y']
ra, dec = coords['ra'], coords['dec']
parts = [
f'"xy": [{x:{fmt}}, {y:{fmt}}]',
f'"radec": [{ra:{fmt_radec}}, {dec:{fmt_radec}}]',
]
try:
# Use targvec for a bit more speed here
lon, lat = coords['lon'], coords['lat']
parts.extend(
[
f'"lonlat": [{lon:{fmt}}, {lat:{fmt}}]',
f'"phase": {coords["phase"]:{fmt}}',
f'"incidence": {coords["incidence"]:{fmt}}',
f'"emission": {coords["emission"]:{fmt}}',
f'"azimuth": {coords["azimuth"]:{fmt}}',
]
)
except KeyError:
pass # Not on disc
return '{' + ', '.join(parts) + '}'
def figure_click_callback(self, event: MouseEvent) -> None:
if not event.inaxes or event.dblclick:
return
try:
# Disable when panning/zooming
if self.toolbar.mode._navigate_mode is not None:
return
# pylint: disable-next=bare-except
except:
pass
if event.button == MouseButton.RIGHT:
self.clear_click_location()
if event.button == MouseButton.LEFT:
x, y = event.xdata, event.ydata
if x is None or y is None:
return
self.set_click_location(x, y)
self.replot_marked_coord()
self.update_plot(print_coords=True)
def set_click_location(self, x: float, y: float) -> None:
self.click_locations.append((x, y))
self.last_click_location = (x, y)
for button in [
self.coords_copy_formatted_button,
self.coords_copy_machine_button,
]:
button['state'] = 'normal'
def clear_click_location(self) -> None:
self.last_click_location = None
self.coords_formatted_str = None
self.coords_machine_str = None
try:
for button in [
self.coords_copy_formatted_button,
self.coords_copy_machine_button,
]:
button['state'] = 'disable'
except AttributeError:
pass
def copy_formatted_coord_values(self) -> None:
self.copy_to_clipboard(self.coords_formatted_str)
def copy_machine_coord_values(self) -> None:
self.copy_to_clipboard(self.coords_machine_str)
def copy_to_clipboard(self, s: str | None) -> None:
if s is None:
s = ''
self.root.clipboard_clear()
self.root.clipboard_append(s)
# Plotting
def update_plot(self, print_coords: bool = False) -> None:
self.get_observation().update_transform()
self.canvas.draw()
self.update_coords(print_coords=print_coords)
def update_only_image(self) -> None:
self.replot_image()
self.canvas.draw()
def build_plot(self) -> None:
# Use Figure rather than plt.figure to avoid segmentation fault when running
# from tkinter GUI (issue #258)
self.fig = Figure()
self.ax = self.fig.add_axes([0.06, 0.03, 0.93, 0.96])
self.transform = (
self.get_observation().matplotlib_radec2xy_transform() + self.ax.transData
)
self.replot_all()
self.format_plot()
self.canvas = FigureCanvasTkAgg(self.fig, master=self.plot_frame)
self.canvas.get_tk_widget().pack(side='top', fill='both', expand=True)
toolbar_frame = tk.Frame(self.plot_frame)
toolbar_frame.pack(side='bottom', fill='x')
tk.Label(toolbar_frame, text='\N{NO-BREAK SPACE}').pack(side='left')
self.toolbar = CustomNavigationToolbar(
self.canvas,
toolbar_frame,
pack_toolbar=False,
gui=self,
)
self.toolbar.pack(side='bottom', fill='x')
self.fig.canvas.callbacks.connect(
'button_press_event', self.figure_click_callback
)
self.add_tooltip(
self.canvas.get_tk_widget(),
'Customise the displayed data, colour scales and plotted features in the "Settings" tab',
)
def rebuild_plot(self) -> None:
self.transform = (
self.get_observation().matplotlib_radec2xy_transform() + self.ax.transData
)
self.replot_all()
self.format_plot()
self.update_plot()
def replot_all(self) -> None:
self.replot_image()
self.replot_grid()
self.replot_terminator()
self.replot_limb()
self.replot_rings()
self.replot_poles()
self.replot_coordinates_lonlat()
self.replot_coordinates_radec()
self.replot_other_bodies()
def format_plot(self):
self.fig.set_dpi(100)
self.ax.set_xlim(-0.5, self.get_observation()._nx - 0.5)
self.ax.set_ylim(-0.5, self.get_observation()._ny - 0.5)
self.ax.xaxis.set_tick_params(labelsize='x-small')
self.ax.yaxis.set_tick_params(labelsize='x-small')
self.ax.set_facecolor('0.1')
self.ax.set_axisbelow(True)
def replot_image(self):
self.remove_artists('image')
mode = self.plot_settings['_'].setdefault('image_mode', 'single')
vmin = self.plot_settings['_'].setdefault('image_vmin', 0)
vmax = self.plot_settings['_'].setdefault('image_vmax', 1)
limit_type = self.plot_settings['_'].setdefault('image_limit_type', 'absolute')
with utils.ignore_warnings('All-NaN slice encountered'):
image = self.image_modes[mode][0]()
if limit_type == 'percentile':
vmin = np.nanpercentile(image, vmin)
vmax = np.nanpercentile(image, vmax)
self.plot_handles['image'].append(
self.ax.imshow(
image,
origin='lower',
vmin=vmin,
vmax=vmax,
**self.plot_settings['image'],
)
)
def replot_limb(self):
self.remove_artists('limb')
self.remove_artists('limb_illuminated')
self.plot_handles['limb'].extend(
self.ax.plot(
*self.get_observation().limb_radec(),
transform=self.transform,
**self.plot_settings['limb'],
)
)
(
ra_day,
dec_day,
ra_night,
dec_night,
) = self.get_observation().limb_radec_by_illumination()
self.plot_handles['limb_illuminated'].extend(
self.ax.plot(
ra_day,
dec_day,
transform=self.transform,
**self.plot_settings['limb_illuminated'],
)
)
def replot_terminator(self):
self.remove_artists('terminator')
self.plot_handles['terminator'].extend(
self.ax.plot(
*self.get_observation().terminator_radec(),
transform=self.transform,
**self.plot_settings['terminator'],
)
)
def replot_poles(self):
self.remove_artists('pole')
for lon, lat, s in self.get_observation().get_poles_to_plot():
ra, dec = self.get_observation().lonlat2radec(lon, lat)
self.plot_handles['pole'].append(
self.ax.add_artist(
OutlinedText(
ra,
dec,
s,
ha='center',
va='center',
weight='bold',
size='small',
transform=self.transform,
clip_on=True,
**self.plot_settings['pole'],
)
)
)
def replot_grid(self) -> None:
self.remove_artists('grid')
interval = self.plot_settings['_'].setdefault('grid_interval', 30)
for ra, dec in self.get_observation().visible_lonlat_grid_radec(interval):
self.plot_handles['grid'].extend(
self.ax.plot(
ra,
dec,
transform=self.transform,
**self.plot_settings['grid'],
)
)
def replot_coordinates_lonlat(self) -> None:
self.remove_artists('coordinate_of_interest_lonlat')
for lon, lat in self.get_observation().coordinates_of_interest_lonlat:
if self.get_observation().test_if_lonlat_visible(lon, lat):
ra, dec = self.get_observation().lonlat2radec(lon, lat)
self.plot_handles['coordinate_of_interest_lonlat'].append(
self.ax.scatter(
ra,
dec,
transform=self.transform,
**self.plot_settings['coordinate_of_interest_lonlat'],
)
)
def replot_coordinates_radec(self) -> None:
self.remove_artists('coordinate_of_interest_radec')
for ra, dec in self.get_observation().coordinates_of_interest_radec:
self.plot_handles['coordinate_of_interest_radec'].append(
self.ax.scatter(
ra,
dec,
transform=self.transform,
**self.plot_settings['coordinate_of_interest_radec'],
)
)
def replot_rings(self) -> None:
self.remove_artists('ring')
for radius in self.get_observation().ring_radii:
ra, dec = self.get_observation().ring_radec(radius)
self.plot_handles['ring'].extend(
self.ax.plot(
ra,
dec,
transform=self.transform,
**self.plot_settings['ring'],
)
)
def replot_other_bodies(self) -> None:
self.remove_artists('other_body_of_interest_label')
self.remove_artists('other_body_of_interest_marker')
for body in self.get_observation().other_bodies_of_interest:
ra = body.target_ra
dec = body.target_dec
label = body.target
hidden = not self.get_observation().test_if_other_body_visible(body)
if hidden:
label = f'({label})'
self.plot_handles['other_body_of_interest_label'].append(
self.ax.text(
ra,
dec,
label + '\n',
size='small',
ha='center',
va='center',
transform=self.transform,
clip_on=True,
**self.plot_settings['other_body_of_interest_label'],
)
)
self.plot_handles['other_body_of_interest_marker'].append(
self.ax.scatter(
ra,
dec,
transform=self.transform,
alpha=0.5 if hidden else 1,
**self.plot_settings['other_body_of_interest_marker'],
)
)
def replot_marked_coord(self) -> None:
self.remove_artists('marked_coord')
if self.last_click_location is not None:
x, y = self.last_click_location
self.plot_handles['marked_coord'].append(
self.ax.axvline(x, **self.plot_settings['marked_coord'])
)
self.plot_handles['marked_coord'].append(
self.ax.axhline(y, **self.plot_settings['marked_coord'])
)
def remove_artists(self, key: PlotKey) -> None:
while self.plot_handles[key]:
self.plot_handles[key].pop().remove()
# Delayed actions
def add_delayed_action(self, name: str, ms: int, func: Callable[[], Any]) -> None:
self.cancel_delayed_action(name)
self.delayed_actions[name] = (
func,
self.root.after(ms, lambda: self.run_delayed_action(name)),
)
def cancel_delayed_action(self, name: str) -> None:
action = self.delayed_actions.pop(name, None)
if action:
self.root.after_cancel(action[1])
def run_delayed_action(self, name: str) -> None:
func, _ = self.delayed_actions.pop(name)
func()
# Image
def image_sum(self) -> np.ndarray:
return 100 * utils.normalise(
np.nansum(self.get_observation().data, axis=0)
) ** self.plot_settings['_'].setdefault('image_gamma', 1)
def image_single(self) -> np.ndarray:
return 100 * utils.normalise(
self.get_observation().data[
self.plot_settings['_'].setdefault('image_idx_single', 0)
]
) ** self.plot_settings['_'].setdefault('image_gamma', 1)
def image_rgb(self) -> np.ndarray:
r = self.get_observation().data[
self.plot_settings['_'].setdefault('image_idx_r', 0)
]
g = self.get_observation().data[
self.plot_settings['_'].setdefault('image_idx_g', 0)
]
b = self.get_observation().data[
self.plot_settings['_'].setdefault('image_idx_b', 0)
]
return utils.normalise(np.stack((r, g, b), axis=2)) ** self.plot_settings[
'_'
].setdefault('image_gamma', 1)
# Keybindings
def bind_keyboard(self) -> None:
for fn, events in self.shortcuts.items():
handler = lambda e, f=fn: self.process_keypress(e, f)
for event in events:
self.root.bind(event, handler)
def process_keypress(self, event, fn) -> None:
if event.time != self.event_time_to_ignore:
fn()
def ignore_keypress(self, event) -> None:
self.event_time_to_ignore = event.time
# API
def run_all_ui_callbacks(self, update_plot: bool = True):
all_callbacks: set[Callable[[], None]] = set()
# Use a set so we don't call same callback multiple times
for k, callbacks in self.ui_callbacks.items():
all_callbacks.update(callbacks)
for fn in all_callbacks:
fn()
if update_plot:
self.update_plot()
def set_value(self, key: SetterKey, value: float, update_plot: bool = True) -> None:
for fn in self.setter_callbacks[key]:
fn(value)
for fn in self.ui_callbacks[key]:
fn()
if update_plot:
self.update_plot()
def set_step(self, step: float) -> None:
if not step > 0:
raise ValueError('step must be greater than zero')
self.step_size = step
# Buttons
def increase_step(self) -> None:
self.set_value('step', self.step_size * 10, update_plot=False)
def decrease_step(self) -> None:
self.set_value('step', self.step_size / 10, update_plot=False)
def move_up(self) -> None:
self.set_value('y0', self.get_observation().get_y0() + self.step_size)
def move_up_right(self) -> None:
self.set_value(
'y0', self.get_observation().get_y0() + self.step_size, update_plot=False
)
self.set_value('x0', self.get_observation().get_x0() + self.step_size)
def move_right(self) -> None:
self.set_value('x0', self.get_observation().get_x0() + self.step_size)
def move_down_right(self) -> None:
self.set_value(
'y0', self.get_observation().get_y0() - self.step_size, update_plot=False
)
self.set_value('x0', self.get_observation().get_x0() + self.step_size)
def move_down(self) -> None:
self.set_value('y0', self.get_observation().get_y0() - self.step_size)
def move_down_left(self) -> None:
self.set_value(
'y0', self.get_observation().get_y0() - self.step_size, update_plot=False
)
self.set_value('x0', self.get_observation().get_x0() - self.step_size)
def move_left(self) -> None:
self.set_value('x0', self.get_observation().get_x0() - self.step_size)
def move_up_left(self) -> None:
self.set_value(
'y0', self.get_observation().get_y0() + self.step_size, update_plot=False
)
self.set_value('x0', self.get_observation().get_x0() - self.step_size)
def rotate_left(self) -> None:
self.set_value(
'rotation', self.get_observation().get_rotation() - self.step_size
)
def rotate_right(self) -> None:
self.set_value(
'rotation', self.get_observation().get_rotation() + self.step_size
)
def increase_radius(self) -> None:
self.set_value('r0', self.get_observation().get_r0() + self.step_size)
def decrease_radius(self) -> None:
try:
self.set_value('r0', self.get_observation().get_r0() - self.step_size)
except ValueError:
# hide value error message when trying to go r0<0
pass
# File IO
def save_button(self) -> None:
SaveObservation(self)
def maybe_replace_string_with_x11_bugfix(self, s: str) -> str:
# X11 font bug https://github.com/ortk95/planetmapper/issues/145
if USE_X11_FONT_BUGFIX:
s = s.translate(X11_FONT_BUGRIX_TRANSLATIONS)
return s
def display_header(self) -> None:
HeaderDisplay(self)
class Popup:
def get_int(
self,
string_variable: tk.StringVar | str,
name: str,
positive: bool = True,
minimum: int | None = None,
maximum: int | None = None,
) -> int:
if isinstance(string_variable, tk.StringVar):
s = string_variable.get()
else:
s = string_variable
try:
value = int(s)
except ValueError:
tkinter.messagebox.showwarning(
title=f'Error parsing {name}',
message=f'Could not convert {name} {s!r} to int',
)
raise
if not np.isfinite(value):
tkinter.messagebox.showwarning(
title=f'Error parsing {name}',
message=f'{name.capitalize()} must be finite',
)
raise ValueError
if positive and not value > 0:
tkinter.messagebox.showwarning(
title=f'Error parsing {name}',
message=f'{name.capitalize()} must be greater than zero',
)
raise ValueError
if minimum is not None and value < minimum:
tkinter.messagebox.showwarning(
title=f'Error parsing {name}',
message=f'{name.capitalize()} must not be less than {minimum}',
)
raise ValueError
if maximum is not None and value > maximum:
tkinter.messagebox.showwarning(
title=f'Error parsing {name}',
message=f'{name.capitalize()} must not be greater than {maximum}',
)
raise ValueError
return value
def get_float(
self,
string_variable: tk.StringVar | str,
name: str,
positive: bool = True,
finite: bool = True,
) -> float:
if isinstance(string_variable, tk.StringVar):
s = string_variable.get()
else:
s = string_variable
try:
value = float(s)
except ValueError:
tkinter.messagebox.showwarning(
title=f'Error parsing {name}',
message=f'Could not convert {name} {s!r} to float',
)
raise
if finite and not math.isfinite(value):
tkinter.messagebox.showwarning(
title=f'Error parsing {name}',
message=f'{name.capitalize()} must be finite',
)
raise ValueError
if positive and not value > 0:
tkinter.messagebox.showwarning(
title=f'Error parsing {name}',
message=f'{name.capitalize()} must be greater than zero',
)
raise ValueError
return value
# File IO popups
class OpenObservation(Popup):
def __init__(self, gui: GUI, first_run: bool) -> None:
self.gui = gui
self.first_run = first_run
try:
self.gui.root
except AttributeError:
self.first_run = True
self.make_widget()
self.make_menu()
if self.first_run:
self.window.mainloop()
def make_widget(self) -> None:
if self.first_run:
self.window = tk.Tk()
self.window.title('PlanetMapper')
self.gui.configure_style(self.window)
geometry = self.gui.DEFAULT_GEOMETRY
else:
self.window = tk.Toplevel(self.gui.root)
self.window.title('Observation settings')
self.window.grab_set()
self.window.transient(self.gui.root)
geometry = self.gui.root.geometry()
x, y = (int(s) for s in geometry.split('+')[1:])
self.window.geometry(
'{sz}+{x:.0f}+{y:.0f}'.format(
sz='600x400',
x=x + 50,
y=y + 50,
)
)
frame = ttk.Frame(self.window)
frame.pack(side='bottom', fill='x')
frame = ttk.Frame(frame)
frame.pack(padx=10, pady=10)
for idx, (text, command) in enumerate(
[
('OK', self.click_ok),
('Cancel', self.click_cancel),
('Apply', self.click_apply),
]
):
ttk.Button(frame, text=text, width=7, command=command).grid(
row=0,
column=idx,
padx=2,
)
if not self.first_run:
self.window.bind('<Escape>', self.close_window)
window_frame = ttk.Frame(self.window)
window_frame.pack(expand=True, fill='both')
self.menu_frame = ttk.Frame(window_frame)
self.menu_frame.pack(side='top', padx=10, pady=10, fill='x')
self.heading_frame = ttk.Frame(self.menu_frame)
self.heading_frame.pack(fill='x')
self.grid_frame = ttk.Frame(self.menu_frame)
self.grid_frame.pack(fill='x')
self.window.protocol('WM_DELETE_WINDOW', self.close_window)
def make_menu(self):
kwargs = {}
self.stringvars: dict[str, tk.StringVar] = {}
observation = self.gui._observation
if observation is not None:
kwargs['path'] = observation.path
kwargs['target'] = observation.target
kwargs['utc'] = observation.utc
kwargs['observer'] = observation.observer
self.stringvars['path'] = tk.StringVar(value=str(kwargs.get('path', '')))
self.stringvars['target'] = tk.StringVar(value=str(kwargs.get('target', '')))
self.stringvars['utc'] = tk.StringVar(value=str(kwargs.get('utc', '')))
self.stringvars['observer'] = tk.StringVar(
value=str(kwargs.get('observer', 'EARTH'))
)
self.stringvars['path'].trace_add('write', self.path_changed)
heading = '\n'.join(
['Select a FITS or image (e.g. PNG, JPEG) file to navigate and map']
)
ttk.Label(self.heading_frame, text=heading + '\n').pack()
self.grid: list[tuple[tk.Widget, ...]] = [
(
ttk.Label(self.grid_frame, text='Path: '),
ttk.Entry(
self.grid_frame,
textvariable=self.stringvars['path'],
# state='disabled',
),
ttk.Button(self.grid_frame, text='Open', command=self.get_path),
),
(
ttk.Label(self.grid_frame, text='Target: '),
ttk.Entry(self.grid_frame, textvariable=self.stringvars['target']),
),
(
ttk.Label(self.grid_frame, text='Date (UTC): '),
ttk.Entry(self.grid_frame, textvariable=self.stringvars['utc']),
),
(
ttk.Label(self.grid_frame, text='Observer: '),
ttk.Entry(self.grid_frame, textvariable=self.stringvars['observer']),
),
]
self.add_to_menu_grid(self.grid)
self.grid_frame.grid_columnconfigure(1, weight=1)
value = '\n'.join(self.gui.kernels)
label = '\n'.join(
[
'List of paths of SPICE kernels to load, with each path listed on a new line.',
'User "~" and glob (e.g. "*", "**") patterns will be automatically expanded:',
]
)
ttk.Label(self.menu_frame, text='\n' + label).pack(fill='x')
self.kernel_txt = tkinter.scrolledtext.ScrolledText(self.menu_frame)
self.kernel_txt.pack(fill='both')
self.kernel_txt.insert('1.0', value)
def path_changed(self, *_) -> None:
path = self.stringvars['path'].get()
kwargs = {}
if any(path.endswith(ext) for ext in Observation.FITS_FILE_EXTENSIONS):
try:
with fits.open(path) as hdul:
# pylint: disable-next=no-member
header = hdul[0].header # type: ignore
Observation._add_kw_from_header(kwargs, header)
except FileNotFoundError:
pass
for k, v in kwargs.items():
try:
if v:
self.stringvars[k].set(str(v))
except KeyError:
pass
def get_path(self):
path = tkinter.filedialog.askopenfilename(
title='Choose observation',
parent=self.window,
)
if path:
self.stringvars['path'].set(str(path))
def click_ok(self) -> None:
if self.apply_changes():
self.close_window()
def click_apply(self) -> None:
self.apply_changes()
def apply_changes(self) -> bool:
ObservationKwargs = TypedDict(
'ObservationKwargs',
{'path': str, 'target': str, 'utc': str | float, 'observer': str},
)
observation_kwargs: ObservationKwargs = {
k: v.get() for k, v in self.stringvars.items()
} # type: ignore
for k, v in observation_kwargs.items():
if isinstance(v, str) and len(v.strip()) == 0:
tkinter.messagebox.showwarning(
title=f'Error parsing {k}', message=f'{k!r} must not be empty'
)
return False
string = self.kernel_txt.get('1.0', 'end')
kernels = [k.strip() for k in string.splitlines()]
base.load_kernels(*kernels, clear_before=True)
kernel_help = '\n' + base._SPICE_ERROR_HELP_TEXT
sb = base.SpiceBase(auto_load_kernels=False)
try:
target = sb.standardise_body_name(observation_kwargs['target'])
# pylint: disable-next=bare-except
except:
tkinter.messagebox.showwarning(
title='Error parsing target',
message='Target name {!r} not recognised\n{}'.format(
observation_kwargs['target'], kernel_help
),
)
return False
try:
observer = sb.standardise_body_name(observation_kwargs['observer'])
# pylint: disable-next=bare-except
except:
tkinter.messagebox.showwarning(
title='Error parsing observer',
message='Observer name {!r} not recognised\n{}'.format(
observation_kwargs['observer'], kernel_help
),
)
return False
if target == observer:
tkinter.messagebox.showwarning(
title='Target and observer identical',
message='Target and observer must correspond to different bodies',
)
return False
try:
observation_kwargs['utc'] = float(
observation_kwargs['utc']
) # type: ignore
except ValueError:
try:
spice.utc2et(observation_kwargs['utc']) # type: ignore
# pylint: disable-next=bare-except
except:
tkinter.messagebox.showwarning(
title='Error parsing utc',
message='Could not parse {!r}\n{}'.format(
observation_kwargs['utc'], kernel_help
),
)
return False
try:
observation = Observation(**observation_kwargs, auto_load_kernels=False)
# pylint: disable-next=broad-except
except Exception as e:
traceback.print_exc()
tkinter.messagebox.showwarning(
title='Error processing inputs',
message=f'Error: {e}' + '\n\nSee terminal for more details',
)
return False
self.gui.set_observation(observation)
self.gui.kernels = kernels
return True
def click_cancel(self) -> None:
self.close_window()
def close_window(self, *_) -> None:
self.window.destroy()
base.load_kernels(*self.gui.kernels, clear_before=True)
def add_to_menu_grid(
self, grid: list[tuple[tk.Widget, ...]], frame: ttk.Frame | None = None
) -> None:
if frame is None:
frame = self.grid_frame
ncols = max(len(row) for row in grid)
for grid_row in grid:
row = frame.grid_size()[1]
label = grid_row[0]
widgets = grid_row[1:]
label.grid(row=row, column=0, sticky='w', pady=5)
for idx, widget in enumerate(widgets):
if idx == len(widgets) - 1:
colspan = ncols - len(widgets)
else:
colspan = 1
widget.grid(row=row, column=1 + idx, sticky='ew', columnspan=colspan)
class SaveObservation(Popup):
def __init__(self, gui: GUI) -> None:
self.gui = gui
self.make_widget()
self.make_menu()
self.save_nav_toggle()
self.save_map_toggle()
def make_widget(self) -> None:
self.window = tk.Toplevel(self.gui.root)
self.window.title('Save observation')
self.window.grab_set()
self.window.transient(self.gui.root)
x, y = (int(s) for s in self.gui.root.geometry().split('+')[1:])
self.window.geometry(
'{sz}+{x:.0f}+{y:.0f}'.format(
sz='600x400',
x=x + 50,
y=y + 50,
)
)
frame = ttk.Frame(self.window)
frame.pack(side='bottom', fill='x')
frame = ttk.Frame(frame)
frame.pack(padx=10, pady=10)
self.save_button = ttk.Button(
frame, text='Save', width=10, command=self.click_save
)
self.save_button.grid(row=0, column=0, padx=2)
ttk.Button(frame, text='Cancel', width=10, command=self.click_cancel).grid(
row=0, column=1, padx=2
)
self.window.bind('<Escape>', self.close_window)
window_frame = ttk.Frame(self.window)
window_frame.pack(expand=True, fill='both')
self.menu_frame = ttk.Frame(window_frame)
self.menu_frame.pack(side='top', padx=10, pady=10, fill='x')
self.heading_frame = ttk.Frame(self.menu_frame)
self.heading_frame.pack(fill='x')
self.grid_frame = ttk.Frame(self.menu_frame)
self.grid_frame.pack(fill='x')
self.saving_progress_window: SavingProgress | None = None
def add_to_menu_grid(
self, grid: list[tuple[tk.Widget, ...]], frame: ttk.Frame | None = None
) -> None:
if frame is None:
frame = self.grid_frame
ncols = max(len(row) for row in grid)
for grid_row in grid:
row = frame.grid_size()[1]
label = grid_row[0]
widgets = grid_row[1:]
label.grid(row=row, column=0, sticky='w', pady=5)
for idx, widget in enumerate(widgets):
if idx == len(widgets) - 1:
colspan = ncols - len(widgets)
else:
colspan = 1
widget.grid(row=row, column=1 + idx, sticky='ew', columnspan=colspan)
def make_menu(self):
path = self.gui.get_observation().path
if path is not None:
root, _ = os.path.splitext(path)
if root.endswith('.fits'):
# deal with e.g. .fits.gz files
root, _ = os.path.splitext(root)
path_nav = os.path.abspath(root + '_nav.fits')
path_map = os.path.abspath(root + '_map.fits')
else:
path_nav = os.path.abspath(
self.gui.get_observation().make_filename(suffix='_nav')
)
path_map = os.path.abspath(
self.gui.get_observation().make_filename(suffix='_map')
)
self.save_nav = tk.IntVar(value=1)
self.save_map = tk.IntVar(value=1)
self.path_nav = tk.StringVar(value=path_nav)
self.path_map = tk.StringVar(value=path_map)
self.map_projection = tk.StringVar(value='rectangular')
self.map_degree_interval = tk.StringVar(value=str(1))
self.map_lon = tk.StringVar(value=str(0))
self.map_lat = tk.StringVar(value=str(0))
self.map_output_size = tk.StringVar(value=str(100))
self.map_interpolation = tk.StringVar(value='linear')
self.keep_open = tk.IntVar(value=0)
self.save_nav.trace_add('write', self.save_nav_toggle)
self.save_map.trace_add('write', self.save_map_toggle)
self.map_projection.trace_add('write', self.save_map_toggle)
self.nav_widgets: list[tk.Widget] = []
self.map_widgets: list[tk.Widget] = []
self.map_rect_widgets: list[tk.Widget] = []
self.map_ortho_widgets: list[tk.Widget] = []
self.grid_frame.grid_columnconfigure(1, weight=1)
LabelKwargs = TypedDict('LabelKwargs', {'sticky': str, 'pady': int})
label_kwargs = LabelKwargs(sticky='w', pady=5)
# Navigated
ttk.Checkbutton(
self.grid_frame, text='Save navigated observation', variable=self.save_nav
).grid(row=0, column=1, columnspan=2, sticky='ew')
ttk.Label(self.grid_frame, text='Path: ').grid(row=1, column=0, **label_kwargs)
w = ttk.Entry(self.grid_frame, textvariable=self.path_nav)
w.grid(row=1, column=1, sticky='ew')
self.nav_widgets.append(w)
w = ttk.Button(self.grid_frame, text='...', width=3, command=self.get_path_nav)
w.grid(row=1, column=2)
self.nav_widgets.append(w)
ttk.Label(self.grid_frame, text=' ').grid(row=2, column=0, **label_kwargs)
# Mapped
ttk.Checkbutton(
self.grid_frame, text='Save mapped observation', variable=self.save_map
).grid(row=3, column=1, columnspan=2, sticky='ew')
ttk.Label(self.grid_frame, text='Path: ').grid(row=4, column=0, **label_kwargs)
w = ttk.Entry(self.grid_frame, textvariable=self.path_map)
w.grid(row=4, column=1, sticky='ew')
self.map_widgets.append(w)
w = ttk.Button(self.grid_frame, text='...', width=3, command=self.get_path_map)
w.grid(row=4, column=2, sticky='w')
self.map_widgets.append(w)
self.map_option_grid = ttk.Frame(self.grid_frame)
self.map_option_grid.grid(row=5, column=0, columnspan=3, sticky='nsew')
for col in [1, 3, 5]:
self.map_option_grid.grid_columnconfigure(col, weight=1)
label_kwargs = LabelKwargs(sticky='w', pady=2)
ttk.Label(self.map_option_grid, text='Interpolation: ').grid(
row=0, column=0, **label_kwargs
)
w = ttk.Combobox(
self.map_option_grid,
textvariable=self.map_interpolation,
width=15,
values=MAP_INTERPOLATIONS,
state='readonly',
)
w.grid(row=0, column=1, columnspan=5, sticky='w')
self.map_widgets.append(w)
ttk.Label(self.map_option_grid, text='Projection: ').grid(
row=1, column=0, **label_kwargs
)
w = ttk.Combobox(
self.map_option_grid,
textvariable=self.map_projection,
width=15,
values=MAP_PROJECTIONS,
state='readonly',
)
w.grid(row=1, column=1, columnspan=5, sticky='w')
self.map_widgets.append(w)
# Projection options
width = 10
ttk.Label(self.map_option_grid, text='Degree interval: ').grid(
row=2, column=0, **label_kwargs
)
w = ttk.Entry(
self.map_option_grid, textvariable=self.map_degree_interval, width=width
)
w.grid(row=2, column=1, sticky='w')
self.map_rect_widgets.append(w)
ttk.Label(self.map_option_grid, text='Output size: ').grid(
row=3, column=0, **label_kwargs
)
w = ttk.Entry(
self.map_option_grid, textvariable=self.map_output_size, width=width
)
w.grid(row=3, column=1, sticky='w')
self.map_ortho_widgets.append(w)
ttk.Label(self.map_option_grid, text='Longitude: ').grid(
row=3, column=2, **label_kwargs
)
w = ttk.Entry(self.map_option_grid, textvariable=self.map_lon, width=width)
w.grid(row=3, column=3, sticky='w')
self.map_ortho_widgets.append(w)
ttk.Label(self.map_option_grid, text='Latitude: ').grid(
row=3, column=4, **label_kwargs
)
w = ttk.Entry(self.map_option_grid, textvariable=self.map_lat, width=width)
w.grid(row=3, column=5, sticky='w')
self.map_ortho_widgets.append(w)
message = '\n'.join(
[
'',
'Click SAVE below to save the requested files',
'For larger files, backplane generation, mapping, and saving can take ~1 minute',
'',
]
)
ttk.Label(self.menu_frame, text='\n' + message, justify='center').pack()
ttk.Checkbutton(
self.menu_frame,
text='Keep this popup open after saving files',
variable=self.keep_open,
).pack()
def get_path_nav(self) -> None:
self.get_path(self.path_nav)
def get_path_map(self) -> None:
self.get_path(self.path_map)
def get_path(self, stringvar: tk.StringVar) -> None:
path = tkinter.filedialog.asksaveasfilename(
parent=self.window,
confirmoverwrite=True,
initialfile=stringvar.get(),
)
if len(path.strip()) > 0:
stringvar.set(path)
def save_nav_toggle(self, *_) -> None:
self.toggle(bool(self.save_nav.get()), self.nav_widgets)
def save_map_toggle(self, *_) -> None:
map_enabled = bool(self.save_map.get())
projection_type = self.map_projection.get()
self.toggle(map_enabled, self.map_widgets)
self.toggle(
map_enabled and projection_type in {'rectangular'}, self.map_rect_widgets
)
self.toggle(
map_enabled
and projection_type
in {'orthographic', 'azimuthal', 'azimuthal equal area'},
self.map_ortho_widgets,
)
def toggle(self, enabled: bool, widgets: list[tk.Widget]) -> None:
for widget in widgets:
if enabled:
if isinstance(widget, ttk.Combobox):
widget['state'] = ['readonly']
else:
widget['state'] = 'normal'
else:
widget['state'] = 'disable'
if any(iv.get() for iv in [self.save_nav, self.save_map]):
self.save_button['state'] = 'normal'
else:
self.save_button['state'] = 'disable'
def click_save(self) -> None:
self.try_run_save()
def click_cancel(self) -> None:
self.close_window()
def close_window(self, *_) -> None:
self.window.destroy()
def try_run_save(self) -> None:
save_nav = bool(self.save_nav.get())
save_map = bool(self.save_map.get())
map_kw: MapKwargs = {}
path_map = self.path_map.get().strip()
path_nav = self.path_nav.get().strip()
keep_open = bool(self.keep_open.get())
interpolation = 'linear'
if (save_nav and len(path_nav) == 0) or (save_map and len(path_map) == 0):
tkinter.messagebox.showwarning(
title='Error saving file',
message='File paths must not be empty',
)
return
try:
if save_map:
interpolation = self.map_interpolation.get()
map_kw['projection'] = self.map_projection.get()
if map_kw['projection'] in {'rectangular'}:
map_kw['degree_interval'] = self.get_float(
self.map_degree_interval,
name='degree interval',
positive=True,
finite=True,
)
if map_kw['projection'] in {'orthographic', 'azimuthal'}:
map_kw['size'] = self.get_int(
self.map_output_size,
name='output size',
positive=True,
)
map_kw['lon'] = self.get_float(
self.map_lon,
name='longitude',
positive=False,
finite=True,
)
map_kw['lat'] = self.get_float(
self.map_lat,
name='latitude',
positive=False,
finite=True,
)
except ValueError:
return
# If we get to this point, everything should (hopefully) be working
saving_process = SavingProgress(
self,
save_nav=save_nav,
path_nav=path_nav,
save_map=save_map,
path_map=path_map,
interpolation=interpolation,
map_kw=map_kw,
keep_open=keep_open,
)
try:
saving_process.run_save()
# pylint: disable-next=broad-except
except Exception as e:
traceback.print_exc()
tkinter.messagebox.showwarning(
title='Error saving files',
message=f'Error: {e}' + '\n\nSee terminal for more details',
)
return
finally:
self.gui.get_observation()._remove_progress_hook()
self.gui.set_help_hint(
'File{s} saved successfully'.format(s='s' if save_nav and save_map else ''),
color='green',
)
class SavingProgress(Popup):
def __init__(
self,
parent: SaveObservation,
save_nav: bool,
path_nav: str,
save_map: bool,
path_map: str,
interpolation: str,
map_kw: MapKwargs,
keep_open: bool,
):
self.parent = parent
self.parent.saving_progress_window = self
self.save_nav = save_nav
self.path_nav = path_nav
self.save_map = save_map
self.path_map = path_map
self.interpolation = interpolation
self.map_kw = map_kw
self.keep_open = keep_open
self.make_window()
self.make_required_widgets()
def make_window(self) -> None:
self.window = tk.Toplevel(self.parent.window)
self.window.transient(self.parent.window)
self.window.grab_set()
self.window.title('Saving files...')
x, y = (int(s) for s in self.parent.window.geometry().split('+')[1:])
self.window.geometry(
'{sz}+{x:.0f}+{y:.0f}'.format(sz='500x175', x=x + 50, y=y + 50)
)
self.frame = ttk.Frame(self.window)
self.frame.pack(expand=True, fill='both')
def make_required_widgets(self) -> None:
if self.save_nav:
self.nav_widgets = self.make_widgets('Saving navigated observation...')
if self.save_map:
self.map_widgets = self.make_widgets('Saving mapped observation...')
button_frame = ttk.Frame(self.frame)
button_frame.pack(padx=10, pady=10, fill='x')
self.close_button = ttk.Button(
button_frame,
command=self.click_close,
text='Close',
width=10,
)
def make_widgets(self, label: str) -> dict[str, tk.Widget]:
frame = ttk.Frame(self.frame)
frame.pack(padx=10, pady=10, fill='x')
widgets = {}
text_frame = ttk.Frame(frame)
text_frame.pack(fill='x')
widgets['label'] = ttk.Label(text_frame, text=label, justify='left')
widgets['label'].pack(side='left')
widgets['message'] = ttk.Label(text_frame, text='', justify='right')
widgets['message'].pack(side='right')
widgets['bar'] = ttk.Progressbar(frame)
widgets['bar'].pack(fill='x')
return widgets
def run_save(self) -> None:
SaveKwargs = TypedDict(
'SaveKwargs', {'show_progress': bool, 'print_info': bool}
)
save_kwargs = SaveKwargs(show_progress=False, print_info=True)
observation = self.parent.gui.get_observation()
if self.save_nav:
observation._set_progress_hook(SaveNavProgressHookGUI(**self.nav_widgets))
observation.save_observation(self.path_nav, **save_kwargs)
observation._remove_progress_hook()
if self.save_map:
n_wavelengths = len(self.parent.gui.get_observation().data)
observation._set_progress_hook(
SaveMapProgressHookGUI(n_wavelengths, **self.map_widgets)
)
observation.save_mapped_observation(
self.path_map,
interpolation=self.interpolation, # type: ignore
**self.map_kw,
**save_kwargs,
)
observation._remove_progress_hook()
self.close_button.pack()
self.window.title('Saving files complete')
def click_close(self) -> None:
self.destroy()
if not self.keep_open:
self.parent.close_window()
def destroy(self) -> None:
self.window.destroy()
self.parent.gui.get_observation()._remove_progress_hook()
self.parent.saving_progress_window = None
class HeaderDisplay(Popup):
def __init__(self, gui: GUI) -> None:
self.gui = gui
self.make_widget()
def make_widget(self) -> None:
self.window = tk.Toplevel(self.gui.root)
self.window.title('FITS Header')
self.window.grab_set()
self.window.transient(self.gui.root)
geometry = self.gui.root.geometry()
x, y = (int(s) for s in geometry.split('+')[1:])
self.window.geometry(
'{sz}+{x:.0f}+{y:.0f}'.format(
sz='650x800',
x=x + 50,
y=y + 50,
)
)
self.window_frame = ttk.Frame(self.window)
self.window_frame.pack(expand=True, fill='both')
self.content_frame = ttk.Frame(self.window_frame)
self.content_frame.pack(expand=True, fill='both')
self.window.protocol('WM_DELETE_WINDOW', self.close_window)
self.window.bind('<Escape>', self.close_window)
self.add_header_widget()
def add_header_widget(self) -> None:
self.header_txt = tkinter.scrolledtext.ScrolledText(self.content_frame)
self.header_txt.pack(expand=True, fill='both')
self.header_txt.insert('1.0', self.get_header_string())
self.header_txt.configure(state='disabled')
def get_header_string(self) -> str:
header = self.gui.get_observation().header
return header.tostring(sep='\n')
def click_close(self) -> None:
self.close_window()
def close_window(self, *_) -> None:
self.window.destroy()
# Progress hooks
class SaveProgressHookGUI(progress._SaveProgressHook):
def __init__(
self,
label: ttk.Label,
bar: ttk.Progressbar,
message: ttk.Label,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.label = label
self.bar = bar
self.message = message
def update_bar(self, progress_change: float) -> None:
if self.progress_parts.get(self.default_key, 0) >= 1:
self.bar['value'] = 100
self.message.configure(text='Complete')
else:
self.bar['value'] = self.overall_progress * 100
self.message.configure(text=format(self.overall_progress, '.0%'))
self.bar.update_idletasks()
class SaveNavProgressHookGUI(progress._SaveNavProgressHook, SaveProgressHookGUI):
pass
class SaveMapProgressHookGUI(progress._SaveMapProgressHook, SaveProgressHookGUI):
pass
# Artist settings popups
class ArtistSetting(Popup, ABC):
def __init__(
self,
gui: GUI,
parent: tk.Widget,
key: PlotKey,
label: str | None = None,
hint: str | None = None,
callbacks: list[Callable[[], None]] | None = None,
row: int | None = None,
):
self.parent = parent
self.key: PlotKey = key
self.gui = gui
self._enable_callback = True
if label is None:
label = key
self.label = label
self.callbacks = callbacks
if row is None:
row = parent.grid_size()[1]
self.enabled = tk.IntVar()
self.enabled.set(self.gui.plot_settings[self.key].get('visible', True))
self.enabled.trace_add('write', self.checkbutton_toggle)
self.checkbutton = ttk.Checkbutton(
parent, text=self.label, variable=self.enabled
)
self.checkbutton.grid(column=0, row=row, sticky='nsew')
self.button = ttk.Button(
parent, text='Edit', width=4, command=self.button_click
)
self.button.grid(column=1, row=row, sticky='e')
if hint:
self.gui.add_tooltip(self.checkbutton, 'Show ' + hint)
self.gui.add_tooltip(self.button, 'Format ' + hint)
def checkbutton_toggle(self, *_) -> None:
enabled = bool(self.enabled.get())
if enabled:
self.button['state'] = 'normal'
else:
self.button['state'] = 'disable'
for artist in self.gui.plot_handles[self.key]:
artist.set_visible(enabled)
self.gui.plot_settings[self.key]['visible'] = enabled
self.gui.update_plot()
def button_click(self) -> None:
self.make_window()
def make_window(self) -> None:
self.window = tk.Toplevel(self.gui.root)
self.window.title(self.label)
self.window.grab_set()
self.window.transient(self.gui.root)
x, y = (int(s) for s in self.gui.root.geometry().split('+')[1:])
self.window.geometry(
'{sz}+{x:.0f}+{y:.0f}'.format(
sz=self.get_window_size(),
x=x + 50,
y=y + 50,
)
)
frame = ttk.Frame(self.window)
frame.pack(side='bottom', fill='x')
frame = ttk.Frame(frame)
frame.pack(padx=10, pady=10)
for idx, (text, command) in enumerate(
[
('OK', self.click_ok),
('Cancel', self.click_cancel),
('Apply', self.click_apply),
]
):
ttk.Button(frame, text=text, width=7, command=command).grid(
row=0,
column=idx,
padx=2,
)
self.window.bind('<Escape>', self.close_window)
window_frame = ttk.Frame(self.window)
window_frame.pack(expand=True, fill='both')
self.menu_frame = ttk.Frame(window_frame)
self.menu_frame.pack(side='top', padx=10, pady=10)
self.grid_frame = ttk.Frame(self.menu_frame)
self.grid_frame.pack()
self.make_menu()
@abstractmethod
def make_menu(self) -> None: ...
@abstractmethod
def apply_settings(self) -> bool: ...
def run_callbacks(self) -> None:
if self.callbacks is None:
# Update artists in place
settings = self.gui.plot_settings[self.key]
if settings:
plt.setp(self.gui.plot_handles[self.key], **settings)
else:
# Replot artists
for callback in self.callbacks:
callback()
self.gui.update_plot()
def close_window(self, *_) -> None:
self.window.destroy()
def click_ok(self) -> None:
if self.apply_settings():
self.run_callbacks()
self.close_window()
def click_apply(self) -> None:
if self.apply_settings():
self.run_callbacks()
def click_cancel(self) -> None:
self.close_window()
def add_to_menu_grid(
self, grid: list[tuple[tk.Widget, tk.Widget]], frame: ttk.Frame | None = None
) -> None:
if frame is None:
frame = self.grid_frame
for label, widget in grid:
row = frame.grid_size()[1]
label.grid(row=row, column=0, sticky='w', pady=5)
widget.grid(row=row, column=1, sticky='w')
def get_window_size(self) -> str:
return '350x350'
class PlotImageSetting(ArtistSetting):
REPLOT_DELAY_MS = 100
def __init__(
self,
gui: GUI,
parent: tk.Widget,
key: PlotKey,
label: str | None = None,
hint: str | None = None,
callbacks: list[Callable[[], None]] | None = None,
row: int | None = None,
):
super().__init__(gui, parent, key, label, hint, callbacks, row)
row = parent.grid_size()[1]
self.tools_frame = ttk.Frame(parent)
self.tools_frame.grid(
row=row, column=0, columnspan=2, padx=5, pady=5, sticky='nsew'
)
general_settings = self.gui.plot_settings['_']
self.single_wavelength_enabled = tk.IntVar(
value=int(general_settings.setdefault('image_mode', 'single') == 'single')
)
self.wavelength_variable = tk.IntVar(
value=int(general_settings.setdefault('image_idx_single', 0))
)
self.single_wavelength_checkbutton = ttk.Checkbutton(
self.tools_frame,
text='Single wavelength',
variable=self.single_wavelength_enabled,
)
self.single_wavelength_checkbutton.pack()
self.wavelength_slider = ttk.Scale(
self.tools_frame,
variable=self.wavelength_variable,
from_=0,
to=self.gui.get_observation().data.shape[0],
)
self.wavelength_slider.pack(fill='x')
self.enabled.trace_add('write', self.update_tool_ui_state)
self.single_wavelength_enabled.trace_add(
'write', self.on_single_wavelength_checkbutton_change
)
self.wavelength_variable.trace_add('write', self.on_wavelength_slider_change)
self.gui.add_tooltip(
self.single_wavelength_checkbutton,
'Toggle displaying single wavelength or average of all wavelengths (click Edit for more options)',
)
self.gui.add_tooltip(
self.wavelength_slider,
'Change displayed wavelength (click Edit for more options)',
)
self.in_tool_updating_state = False
self.update_tool_ui_state()
def on_single_wavelength_checkbutton_change(self, *_) -> None:
if self.in_tool_updating_state or self.gui.get_observation().data.shape[0] < 2:
return
is_single = bool(self.single_wavelength_enabled.get())
self.gui.plot_settings['_']['image_mode'] = 'single' if is_single else 'sum'
self.schedule_replot(skip_full_delay=True)
def on_wavelength_slider_change(self, *_) -> None:
if self.in_tool_updating_state or self.gui.get_observation().data.shape[0] < 2:
return
wavelength_idx = self.wavelength_variable.get()
if not self.single_wavelength_enabled.get():
# tick the checkbox if user changes the slider value
if wavelength_idx != self.gui.plot_settings['_']['image_idx_single']:
self.single_wavelength_enabled.set(1)
self.gui.plot_settings['_']['image_idx_single'] = wavelength_idx
self.schedule_replot()
def schedule_replot(self, skip_full_delay: bool = False) -> None:
self.gui.add_delayed_action(
'update_only_image',
1 if skip_full_delay else self.REPLOT_DELAY_MS,
self.gui.update_only_image,
)
def update_tool_ui_state(self, *_) -> None:
self.in_tool_updating_state = True
try:
general_settings = self.gui.plot_settings['_']
n_wavelengths = self.gui.get_observation().data.shape[0]
enable = self.enabled.get() and n_wavelengths > 1
is_single_wavelength = bool(
general_settings.setdefault('image_mode', 'single') == 'single'
)
wavelength_idx = int(general_settings.setdefault('image_idx_single', 0))
self.single_wavelength_enabled.set(int(is_single_wavelength))
self.wavelength_variable.set(wavelength_idx)
self.single_wavelength_checkbutton.configure(
state='normal' if enable else 'disable',
)
self.wavelength_slider.configure(
to=n_wavelengths - 1,
state='normal' if enable else 'disable',
)
finally:
self.in_tool_updating_state = False
def make_menu(self) -> None:
settings = self.gui.plot_settings[self.key]
general_settings = self.gui.plot_settings['_']
self.cmap = tk.StringVar(value=settings.setdefault('cmap', 'gray'))
self.image_vmin = tk.StringVar(
value=str(general_settings.setdefault('image_vmin', 0))
)
self.image_vmax = tk.StringVar(
value=str(general_settings.setdefault('image_vmax', 100))
)
self.image_limit_type = tk.StringVar(
value=str(general_settings.setdefault('image_limit_type', 'absolute'))
)
self.image_mode = tk.StringVar(
value=general_settings.setdefault('image_mode', 'single')
)
self.image_idx_single = tk.StringVar(
value=str(general_settings.setdefault('image_idx_single', 0))
)
self.image_idx_r = tk.StringVar(
value=str(general_settings.setdefault('image_idx_r', 0))
)
self.image_idx_g = tk.StringVar(
value=str(general_settings.setdefault('image_idx_g', 0))
)
self.image_idx_b = tk.StringVar(
value=str(general_settings.setdefault('image_idx_b', 0))
)
self.image_gamma = tk.StringVar(
value=str(general_settings.setdefault('image_gamma', 1))
)
# Image mode selection
frame_top = ttk.Frame(self.grid_frame)
frame_top.pack()
ttk.Label(frame_top, text='Image mode:').pack()
for mode, (fn, desc) in self.gui.image_modes.items():
ttk.Radiobutton(
frame_top,
text=desc,
value=mode,
variable=self.image_mode,
).pack(fill='x')
ttk.Label(frame_top, text='').pack() # spacer
# Settings
frame = ttk.Frame(self.grid_frame)
frame.pack()
idx_max = self.gui.get_observation().data.shape[0] - 1
class IndexInput(ttk.Spinbox):
def __init__(self, textvariable: tk.StringVar):
super().__init__(
frame,
textvariable=textvariable,
from_=0,
to=idx_max,
increment=1,
width=10,
)
self.grid: list[
tuple[tk.Widget, tk.Widget, set[ImageMode | Literal['_readonly']]]
] = [
(
ttk.Label(frame, text='Wavelength index (single): '),
IndexInput(self.image_idx_single),
{'single'},
),
(
ttk.Label(frame, text='Wavelength index (red): '),
IndexInput(self.image_idx_r),
{'rgb'},
),
(
ttk.Label(frame, text='Wavelength index (green): '),
IndexInput(self.image_idx_g),
{'rgb'},
),
(
ttk.Label(frame, text='Wavelength index (blue): '),
IndexInput(self.image_idx_b),
{'rgb'},
),
(
ttk.Label(frame, text='Matplotlib colormap: '),
ttk.Combobox(
frame,
textvariable=self.cmap,
values=CMAPS,
width=10,
),
{'single', 'sum'},
),
(
ttk.Label(frame, text='gamma: '),
ttk.Spinbox(
frame,
textvariable=self.image_gamma,
from_=0,
to=100,
increment=0.2,
width=10,
),
{'single', 'sum', 'rgb'},
),
(
ttk.Label(frame, text='vmin: '),
ttk.Spinbox(
frame,
textvariable=self.image_vmin,
from_=0,
to=100,
increment=5,
width=10,
),
{'single', 'sum'},
),
(
ttk.Label(frame, text='vmax: '),
ttk.Spinbox(
frame,
textvariable=self.image_vmax,
from_=0,
to=100,
increment=5,
width=10,
),
{'single', 'sum'},
),
(
ttk.Label(frame, text='vmin/vmax type: '),
ttk.Combobox(
frame,
textvariable=self.image_limit_type,
values=LIMIT_TYPES,
width=10,
state='readonly',
),
{'single', 'sum', '_readonly'},
),
]
self.add_to_menu_grid([(a, b) for a, b, c in self.grid], frame=frame)
msg = '\n'.join(
[
'',
'Images are scaled to vary from 0 to 100,',
'so set vmin=0 and vmax=100 to show the',
'entire dynamic range.',
'',
'Set vmin/vmax type to "percentile" to',
'calculate the limits as percentiles of the',
'data in the image. This can be useful if',
'your data has extreme outliers (e.g. try',
'vmin=1, vmax=99 & type=percentile).',
]
)
ttk.Label(self.grid_frame, text=msg).pack()
self.image_mode.trace_add('write', self.change_image_mode_radio)
self.change_image_mode_radio() # run initial setup
def change_image_mode_radio(self, *_) -> None:
mode = self.image_mode.get()
for l, widget, modes in self.grid:
if mode in modes:
widget['state'] = 'readonly' if '_readonly' in modes else 'normal'
else:
widget['state'] = 'disable'
def get_idx(self, stirng_variable: tk.StringVar, name: str) -> int:
sz = self.gui.get_observation().data.shape[0]
value = self.get_int(
stirng_variable, name=name, positive=False, minimum=-sz, maximum=sz - 1
)
if value < 0:
value = sz - value
return value
def apply_settings(self) -> bool:
settings = {}
general_settings = {}
try:
image_mode = self.image_mode.get()
general_settings['image_mode'] = image_mode
if image_mode == 'single':
general_settings['image_idx_single'] = self.get_idx(
self.image_idx_single, 'wavelength index (single)'
)
if image_mode == 'rgb':
general_settings['image_idx_r'] = self.get_idx(
self.image_idx_r, 'wavelength index (red)'
)
general_settings['image_idx_g'] = self.get_idx(
self.image_idx_g, 'wavelength index (green)'
)
general_settings['image_idx_b'] = self.get_idx(
self.image_idx_b, 'wavelength index (blue)'
)
general_settings['image_gamma'] = self.get_float(
self.image_gamma, 'gamma', positive=False
)
if image_mode in {'single', 'sum'}:
general_settings['image_limit_type'] = self.image_limit_type.get()
general_settings['image_vmin'] = self.get_float(
self.image_vmin, 'vmin', positive=False
)
general_settings['image_vmax'] = self.get_float(
self.image_vmax, 'vmax', positive=False
)
if general_settings['image_vmin'] >= general_settings['image_vmax']:
tkinter.messagebox.showwarning(
title='Error parsing limits',
message='vmin must be less than vmax',
)
return False
except ValueError:
return False
if image_mode in {'single', 'sum'}:
try:
cmap = self.cmap.get()
matplotlib.cm.get_cmap(cmap)
except ValueError:
tkinter.messagebox.showwarning(
title='Error parsing colormap',
message=f'Unrecognised matplotlib colormap {self.cmap.get()!r}',
)
return False
settings['cmap'] = cmap
self.gui.plot_settings[self.key].update(settings)
self.gui.plot_settings['_'].update(general_settings)
self.update_tool_ui_state()
return True
def get_window_size(self) -> str:
return '350x600'
class PlotLineSetting(ArtistSetting):
def make_menu(self) -> None:
settings = self.gui.plot_settings[self.key]
self.linewidth = tk.StringVar(value=str(settings.get('linewidth', '1.0')))
self.linestyle = tk.StringVar(value=str(settings.get('linestyle', 'solid')))
self.color = tk.StringVar(value=str(settings.get('color', 'red')))
self.add_to_menu_grid(
[
(
ttk.Label(self.grid_frame, text='Linewidth: '),
ttk.Spinbox(
self.grid_frame,
textvariable=self.linewidth,
from_=0.1,
to=10,
increment=0.1,
width=10,
),
),
(
ttk.Label(self.grid_frame, text='Linestyle: '),
ttk.Combobox(
self.grid_frame,
textvariable=self.linestyle,
values=LINESTYLES,
state='readonly',
width=10,
),
),
(
ttk.Label(self.grid_frame, text='Colour: '),
ColourButton(self.grid_frame, width=10, textvariable=self.color),
),
]
)
def apply_settings(self) -> bool:
settings = self.gui.plot_settings[self.key]
try:
linewidth = self.get_float(self.linewidth, 'linewidth')
except ValueError:
return False
settings['linewidth'] = linewidth
settings['linestyle'] = self.linestyle.get()
settings['color'] = self.color.get()
return True
class PlotGridSetting(PlotLineSetting):
def make_menu(self) -> None:
super().make_menu()
self.grid_interval = tk.StringVar(
value=str(self.gui.plot_settings['_'].setdefault('grid_interval', 30))
)
self.add_to_menu_grid(
[
(
ttk.Label(self.grid_frame, text='Grid interval (°): '),
ttk.Combobox(
self.grid_frame,
textvariable=self.grid_interval,
values=GRID_INTERVALS,
width=10,
),
),
]
)
def apply_settings(self) -> bool:
try:
grid_interval = self.get_float(self.grid_interval, 'grid interval')
except ValueError:
return False
self.gui.plot_settings['_']['grid_interval'] = grid_interval
return super().apply_settings()
class PlotRingsSetting(PlotLineSetting):
def make_menu(self) -> None:
super().make_menu()
radii_selected = self.gui.get_observation().ring_radii.copy()
radii_options = data_loader.get_ring_radii().setdefault(
self.gui.get_observation().target, {}
)
ttk.Label(self.menu_frame, text='').pack(fill='x') # Add a spacer
self.checkbox_dict: dict[tuple[float, ...], tk.IntVar] = {}
for name, radii in sorted(radii_options.items(), key=lambda x: x[1]):
key = tuple(radii)
if key in self.checkbox_dict:
# Skip repeated rings (for Neptune where multiple rings have same radii
# on fact sheet)
continue
iv = tk.IntVar()
self.checkbox_dict[key] = iv
label = '{n} ({r})'.format(
n=name, r=', '.join(format(r, 'g') + 'km' for r in radii)
)
ttk.Checkbutton(self.menu_frame, text=label, variable=iv).pack(fill='x')
iv.set(all(r in radii_selected for r in radii))
for radii, iv in self.checkbox_dict.items():
# Radii will be indicated by checkbox, so remove them from the text list
# Do after creating all checkboxes so that overlapping rings (e.g. Saturn's
# B & C rings) don't break logic.
if iv.get():
radii_selected -= set(radii)
value = '\n'.join(str(r) for r in sorted(radii_selected))
label = '\n'.join(
[
'Manually list{s} ring radii in km from the'.format(
s=' more' if self.checkbox_dict else ''
),
'target\'s centre below. Each radius should',
'be listed on a new line:',
]
)
ttk.Label(self.menu_frame, text='\n' + label).pack(fill='x')
self.txt = tkinter.scrolledtext.ScrolledText(self.menu_frame)
self.txt.pack(fill='both')
self.txt.insert('1.0', value)
def apply_settings(self) -> bool:
rings: list[float] = []
try:
string = self.txt.get('1.0', 'end')
for value in string.splitlines():
value = value.strip()
if value:
rings.append(self.get_float(value, 'ring radius'))
except ValueError:
return False
for radii, iv in self.checkbox_dict.items():
if iv.get():
rings.extend(radii)
self.gui.get_observation().ring_radii.clear()
self.gui.get_observation().ring_radii.update(rings)
return super().apply_settings()
def get_window_size(self) -> str:
return '350x600'
class PlotScatterSetting(ArtistSetting):
def make_menu(self) -> None:
settings = self.gui.plot_settings[self.key]
self.marker = tk.StringVar(value=str(settings.setdefault('marker', 'o')))
self.size = tk.StringVar(value=str(settings.setdefault('s', '36')))
self.color = tk.StringVar(value=str(settings.setdefault('color', 'red')))
self.add_to_menu_grid(
[
(
ttk.Label(self.grid_frame, text='Marker: '),
ttk.Combobox(
self.grid_frame,
textvariable=self.marker,
values=MARKERS,
width=10,
),
),
(
ttk.Label(self.grid_frame, text='Size: '),
ttk.Spinbox(
self.grid_frame,
textvariable=self.size,
from_=1,
to=100,
increment=1,
width=10,
),
),
(
ttk.Label(self.grid_frame, text='Colour: '),
ColourButton(self.grid_frame, width=10, textvariable=self.color),
),
]
)
def apply_settings(self) -> bool:
settings = self.gui.plot_settings[self.key]
try:
marker = self.marker.get()
matplotlib.markers.MarkerStyle(marker)
except ValueError:
tkinter.messagebox.showwarning(
title='Error parsing marker',
message=f'Unrecognised matplotlib marker {self.marker.get()!r}',
)
return False
try:
size = self.get_float(self.size, 'size')
except ValueError:
return False
settings['marker'] = marker
settings['s'] = size
settings['color'] = self.color.get()
return True
class PlotCoordinatesSetting(PlotScatterSetting):
def __init__(
self,
gui: GUI,
parent: tk.Widget,
key: PlotKey,
coordinate_list: list[tuple[float, float]],
menu_label: str,
label: str | None = None,
hint: str | None = None,
callbacks: list[Callable[[], None]] | None = None,
**kwargs,
):
self.coordinate_list = coordinate_list
self.menu_label = menu_label
super().__init__(gui, parent, key, label, hint, callbacks, **kwargs)
def make_menu(self) -> None:
super().make_menu()
value = '\n'.join(f'{a}, {b}' for a, b in self.coordinate_list)
ttk.Label(self.menu_frame, text='\n' + self.menu_label).pack(fill='x')
self.txt = tkinter.scrolledtext.ScrolledText(self.menu_frame)
self.txt.pack(fill='both')
self.txt.insert('1.0', value)
def apply_settings(self) -> bool:
values: list[tuple[float, float]] = []
try:
string = self.txt.get('1.0', 'end')
for line in string.splitlines():
line = line.strip().lstrip('(').rstrip(')')
if line:
coordinates = line.split(',')
if len(coordinates) != 2:
tkinter.messagebox.showwarning(
title='Error parsing coordinates',
message=f'Line {line!r} must have exactly two values separated by a comma',
)
return False
values.append(
tuple(
self.get_float(c, 'coordinate', positive=False)
for c in coordinates
) # type: ignore
)
except ValueError:
return False
self.coordinate_list.clear()
self.coordinate_list[:] = values
return super().apply_settings()
def get_window_size(self) -> str:
return '350x600'
class PlotTextSetting(ArtistSetting):
def make_menu(self) -> None:
settings = self.gui.plot_settings[self.key]
self.color = tk.StringVar(value=str(settings.get('color', 'red')))
self.add_to_menu_grid(
[
(
ttk.Label(self.grid_frame, text='Colour: '),
ColourButton(self.grid_frame, width=10, textvariable=self.color),
),
]
)
def apply_settings(self) -> bool:
settings = self.gui.plot_settings[self.key]
settings['color'] = self.color.get()
return True
class PlotOutlinedTextSetting(ArtistSetting):
def make_menu(self) -> None:
settings = self.gui.plot_settings[self.key]
self.color = tk.StringVar(value=str(settings.setdefault('color', 'red')))
self.outline_color = tk.StringVar(
value=str(settings.setdefault('outline_color', 'red'))
)
self.add_to_menu_grid(
[
(
ttk.Label(self.grid_frame, text='Colour: '),
ColourButton(self.grid_frame, width=10, textvariable=self.color),
),
(
ttk.Label(self.grid_frame, text='Outline: '),
ColourButton(
self.grid_frame, width=10, textvariable=self.outline_color
),
),
]
)
def apply_settings(self) -> bool:
settings = self.gui.plot_settings[self.key]
settings['color'] = self.color.get()
settings['outline_color'] = self.outline_color.get()
return True
class GenericOtherBodySetting(ArtistSetting):
def add_other_body_menu_setting(self):
label = '\n'.join(
[
'List other bodies of interest to',
'mark (e.g. moons). Body names should',
'be recognisable by SPICE (e.g "Europa"',
'or "502") with each body listed on a',
'new line:',
]
)
ttk.Label(self.menu_frame, text='\n' + label).pack(fill='x')
self.txt = tkinter.scrolledtext.ScrolledText(self.menu_frame)
self.txt.pack(fill='both')
self.button_frame = ttk.Frame(self.menu_frame)
self.button_frame.pack(fill='x', pady=3)
self.add_satellites_button = ttk.Button(
self.button_frame,
text='Add all visible satellites in target system',
command=self.add_satellites,
)
self.add_satellites_button.pack()
self.populate_text_box()
def populate_text_box(
self, bodies: list[Body | BasicBody] | None = None, append: bool = False
) -> None:
if bodies is None:
bodies = self.gui.get_observation().other_bodies_of_interest
bodies = sorted((b for b in bodies), key=lambda b: b.target_body_id)
if append:
lines = [l for l in self.txt.get('1.0', 'end').splitlines() if l.strip()]
else:
lines = []
for b in bodies:
line = b.target
if line not in lines:
lines.append(line)
value = '\n'.join(lines)
self.txt.delete('1.0', 'end')
self.txt.insert('1.0', value)
def add_satellites(self) -> None:
bodies = self.gui.get_observation()._get_all_satellite_bodies(
skip_insufficient_data=True, only_visible=True
)
self.populate_text_box(bodies, append=True)
def apply_other_body_setting(self) -> bool:
bodies: list[Body | BasicBody] = []
string = self.txt.get('1.0', 'end')
lines = [l for line in string.splitlines() if (l := line.strip())]
for line in set(lines):
try:
bodies.append(self.gui.get_observation().create_other_body(line))
except NotFoundError:
tkinter.messagebox.showwarning(
title='Error parsing target name',
message=f'Target {line!r} is not recognised by SPICE',
)
return False
self.gui.get_observation().other_bodies_of_interest.clear()
self.gui.get_observation().other_bodies_of_interest[:] = bodies
return True
def get_window_size(self) -> str:
return '350x600'
class PlotOtherBodyScatterSetting(PlotScatterSetting, GenericOtherBodySetting):
def make_menu(self) -> None:
super().make_menu()
self.add_other_body_menu_setting()
def apply_settings(self) -> bool:
return self.apply_other_body_setting() and super().apply_settings()
class PlotOtherBodyTextSetting(PlotTextSetting, GenericOtherBodySetting):
def make_menu(self) -> None:
super().make_menu()
self.add_other_body_menu_setting()
def apply_settings(self) -> bool:
return self.apply_other_body_setting() and super().apply_settings()
# Generic artist settting elements
class ColourButton(ttk.Button):
def __init__(
self,
parent: tk.Widget,
*args,
textvariable: tk.StringVar,
**kwargs,
) -> None:
self.parent = parent
self.textvariable = textvariable
self.textvariable.set(matplotlib.colors.to_hex(textvariable.get()))
super().__init__(
parent, *args, command=self.command, text=self.textvariable.get(), **kwargs
)
def command(self):
_, color = tkinter.colorchooser.askcolor(
initialcolor=self.textvariable.get(),
parent=self.parent,
title='Pick colour',
)
if color is not None:
self.textvariable.set(color)
self.configure(text=color)
class NumericEntry:
def __init__(
self,
gui: GUI,
parent: tk.Widget,
key: SetterKey,
label: str | None = None,
row: int | None = None,
add_callbacks: list[SetterKey] | None = None,
**kw,
):
self.parent = parent
self.key: SetterKey = key
self.gui = gui
self._enable_callback = True
if label is None:
label = key
self.label = ttk.Label(parent, text=label + ': ')
self.sv = tk.StringVar()
self.sv.trace_add('write', self.text_input)
self.entry = ttk.Entry(parent, width=10, textvariable=self.sv)
self.gui.ui_callbacks[self.key].add(self.update_text)
if add_callbacks:
for k in add_callbacks:
self.gui.ui_callbacks[k].add(self.update_text)
self.update_text()
if row is None:
row = parent.grid_size()[1]
self.label.grid(row=row, column=0, sticky='e', **kw)
self.entry.grid(row=row, column=1, sticky='w', **kw)
self.disable_keybindings()
def disable_keybindings(self):
for bindings in self.gui.shortcuts.values():
for binding in bindings:
if binding not in self.gui.shortcuts_to_keep_in_entry:
self.entry.bind(binding, self.gui.ignore_keypress)
def update_text(self) -> None:
if not self._enable_callback:
return
self._enable_callback = False
value = self.gui.getters[self.key]()
self.sv.set(self.format_value(value))
self.entry.configure(foreground='black')
self._enable_callback = True
def format_value(self, value: float) -> str:
return format(value, '.8g')
def text_input(self, *_) -> None:
if not self._enable_callback:
return
self._enable_callback = False
value = self.sv.get()
try:
self.gui.set_value(self.key, float(value))
self.entry.configure(foreground='black')
except (ValueError, ZeroDivisionError):
self.entry.configure(foreground='red')
self._enable_callback = True
# Plotting stuff
class OutlinedText(Text):
def __init__(
self,
x: float,
y: float,
text: str,
*args,
outline_color: str = 'none',
**kwargs,
):
super().__init__(x, y, text, *args, **kwargs)
self.set_outline_color(outline_color)
def set_outline_color(self, c):
self.set_path_effects(
[
path_effects.Stroke(linewidth=3, foreground=c),
path_effects.Normal(),
] # type: ignore
)
class CustomNavigationToolbar(NavigationToolbar2Tk):
def __init__(self, canvas, window, *, pack_toolbar: bool = True, gui: GUI) -> None:
# Default tooltips don't work with tk (on my laptop with dark mode at least)
# so disable them here by setting to None, then use our custom tooltips instead.
# This list also removes the Subplots button which we don't want.
self.toolitems = (
('Home', None, 'home', 'home'),
('Back', None, 'back', 'back'),
('Forward', None, 'forward', 'forward'),
(None, None, None, None),
('Pan', None, 'move', 'pan'),
('Zoom', None, 'zoom_to_rect', 'zoom'),
(None, None, None, None),
('Save', None, 'filesave', 'save_figure'),
)
super().__init__(canvas, window, pack_toolbar=pack_toolbar)
try:
self._message_label.configure(foreground='#888888')
# pylint: disable-next=bare-except
except:
pass
try:
for name, button in self._buttons.items():
# Get default tooltips from super() and use them
for text, tooltip_text, image_file, callback in super().toolitems:
if text == name:
hint = tooltip_text.replace('\n', ', ')
gui.add_tooltip(button, hint)
break
# pylint: disable-next=bare-except
except:
pass