specterm1d 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
specterm1d/__init__.py ADDED
File without changes
specterm1d/cli.py ADDED
@@ -0,0 +1,155 @@
1
+ """Command-line entry point."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import io
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import specterm1d.commands
10
+ import specterm1d.term # noqa: F401 - registers renderer factories
11
+ from specterm1d.io import registry
12
+ from specterm1d.logfile import SplotLog
13
+ from specterm1d.plot import SpectrumPlot
14
+ from specterm1d.session import Session
15
+ from specterm1d.term import caps as caps_mod
16
+
17
+ RENDERERS = ("kitty", "iterm2", "sixel", "gui", "halfblock")
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="specterm1d",
23
+ description="Terminal 1D spectrum viewer with IRAF splot keybindings",
24
+ )
25
+ parser.add_argument("files", nargs="*", type=Path, help="FITS spectra to view")
26
+ parser.add_argument("--renderer", choices=RENDERERS,
27
+ help="force a renderer instead of probing the terminal")
28
+ parser.add_argument("--gui", action="store_true",
29
+ help="shortcut for --renderer gui (a matplotlib window)")
30
+ parser.add_argument("--format", help="force a loader instead of sniffing")
31
+ parser.add_argument("--units", help="initial dispersion units, e.g. nm, um, GHz")
32
+ parser.add_argument(
33
+ "--mouse", action=argparse.BooleanOptionalAction, default=None,
34
+ help="toggle mouse positioning (default: on for inline graphics)",
35
+ )
36
+ parser.add_argument("--log", default="splot.log", help="measurement log file")
37
+ parser.add_argument("--debug", action="store_true",
38
+ help="show full tracebacks instead of one-line errors")
39
+ parser.add_argument("--cursor", type=Path,
40
+ help="run a cursor script instead of reading the keyboard")
41
+ parser.add_argument("--dump", type=Path,
42
+ help="render one frame to a PNG and exit (no tty needed)")
43
+ parser.add_argument("--dump-size", default="1200x700",
44
+ help="pixel size for --dump, as WxH")
45
+ return parser
46
+
47
+
48
+ def resolve_renderer_choice(args) -> str | None:
49
+ """--gui is a shortcut; an explicit --renderer always wins."""
50
+ if args.renderer:
51
+ return args.renderer
52
+ return "gui" if args.gui else None
53
+
54
+
55
+ def attach_or_fall_back(renderer, plot, caps, out):
56
+ """Open the renderer's window, or warn once and use halfblock.
57
+
58
+ A viewer that refuses to start over a missing window is worse than one
59
+ that draws coarsely, so this is never fatal - even for an explicit
60
+ --renderer gui. Terminal backends have no attach() and pass straight
61
+ through.
62
+ """
63
+ from specterm1d.term.gui import GuiUnavailable
64
+ from specterm1d.term.halfblock import HalfblockRenderer
65
+
66
+ attach = getattr(renderer, "attach", None)
67
+ if attach is None:
68
+ return renderer
69
+ try:
70
+ attach(plot)
71
+ except GuiUnavailable as exc:
72
+ print(f"graphics window unavailable ({exc}); using halfblock",
73
+ file=sys.stderr)
74
+ return HalfblockRenderer(out=out, truecolor=caps.truecolor)
75
+ return renderer
76
+
77
+
78
+ def main(argv: list[str] | None = None) -> int:
79
+ args = build_parser().parse_args(argv)
80
+
81
+ if not args.files:
82
+ build_parser().print_help()
83
+ return 2
84
+
85
+ try:
86
+ collection = registry.load(args.files[0], format=args.format)
87
+ except registry.LoaderError as exc:
88
+ print(str(exc), file=sys.stderr)
89
+ return 1
90
+
91
+ # Headless: --dump and --cursor must work with no tty at all, so this
92
+ # branches before the tty check.
93
+ if args.dump or args.cursor:
94
+ from specterm1d.cursorscript import parse_script, run_script
95
+ from specterm1d.term.halfblock import HalfblockRenderer
96
+
97
+ width, height = (int(v) for v in args.dump_size.lower().split("x"))
98
+ caps = caps_mod.TerminalCaps(
99
+ kitty=False, iterm2=False, sixel=False, truecolor=True,
100
+ rows=height // 2, cols=width, pixel_width=width,
101
+ pixel_height=height, is_tty=False,
102
+ )
103
+ session = Session(collection, HalfblockRenderer(out=io.StringIO()),
104
+ SpectrumPlot(width, height), out=io.StringIO(),
105
+ caps=caps)
106
+ session.debug = args.debug
107
+ session.log = SplotLog(args.log)
108
+ if args.units:
109
+ import astropy.units as u
110
+ session.view.set_axis(unit=u.Unit(args.units))
111
+ if args.cursor:
112
+ run_script(session, parse_script(args.cursor.read_text()))
113
+ if args.dump:
114
+ session.dump_png(args.dump, size=(width, height))
115
+ return 0
116
+
117
+ caps = caps_mod.detect(is_tty=sys.stdout.isatty())
118
+ if not caps.is_tty:
119
+ print("specterm1d needs a terminal; stdout is not a tty.", file=sys.stderr)
120
+ return 1
121
+
122
+ renderer = caps_mod.choose_renderer(caps, override=resolve_renderer_choice(args),
123
+ out=sys.stdout)
124
+ width, height = renderer.target_pixels(caps.rows - 2, caps.cols)
125
+ plot = SpectrumPlot(width, height)
126
+ renderer = attach_or_fall_back(renderer, plot, caps, out=sys.stdout)
127
+ session = Session(collection, renderer, plot, out=sys.stdout, caps=caps)
128
+ session.debug = args.debug
129
+
130
+ if args.log:
131
+ session.log = SplotLog(args.log)
132
+
133
+ if args.units:
134
+ import astropy.units as u
135
+ session.view.set_axis(unit=u.Unit(args.units))
136
+
137
+ mouse_enabled = args.mouse
138
+ if mouse_enabled is None:
139
+ mouse_enabled = bool(getattr(renderer, "inline_graphics", False))
140
+ if mouse_enabled:
141
+ session.set_mouse(True)
142
+
143
+ try:
144
+ session.run()
145
+ except Exception as exc:
146
+ session.teardown()
147
+ if args.debug:
148
+ raise
149
+ print(f"error: {exc}", file=sys.stderr)
150
+ return 1
151
+ return 0
152
+
153
+
154
+ if __name__ == "__main__":
155
+ raise SystemExit(main())
@@ -0,0 +1,9 @@
1
+ """Importing this package registers every command handler."""
2
+ from specterm1d.commands import (
3
+ colon,
4
+ display,
5
+ measure,
6
+ transform,
7
+ )
8
+
9
+ __all__ = ["colon", "display", "measure", "transform"]
@@ -0,0 +1,174 @@
1
+ """Colon commands.
2
+
3
+ splot's set is reproduced except :dispaxis and :nsum, which are 2D-image
4
+ concerns - this tool ingests 1D. New commands cover the toggles splot had no
5
+ concept of: overlays, the sigma band, the mask, and the variant.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import astropy.units as u
10
+
11
+ from specterm1d.keymap import command
12
+
13
+ _TRUE = {"yes", "y", "true", "on", "1", "+"}
14
+ _FALSE = {"no", "n", "false", "off", "0", "-"}
15
+
16
+
17
+ def parse_colon(text: str) -> tuple[str, list[str]]:
18
+ stripped = text.strip()
19
+ if not stripped:
20
+ return ("", [])
21
+ if stripped.startswith("#"):
22
+ return ("#", [stripped[1:].strip()])
23
+ head, _, tail = stripped.partition(" ")
24
+ return (head, tail.split() if tail.strip() else [])
25
+
26
+
27
+ def _boolean(args: list[str], current: bool) -> bool:
28
+ if not args:
29
+ return not current
30
+ value = args[0].lower()
31
+ if value in _TRUE:
32
+ return True
33
+ if value in _FALSE:
34
+ return False
35
+ return not current
36
+
37
+
38
+ def _view_toggle(field: str, label: str):
39
+ def handler(session, args):
40
+ current = getattr(session.view, field)
41
+ new = _boolean(args, current)
42
+ setattr(session.view, field, new)
43
+ if field in ("zero_base",):
44
+ session.view.rescale_y()
45
+ session.message(f"{label} {'on' if new else 'off'}")
46
+ return handler
47
+
48
+
49
+ def _overlay_toggle(name: str):
50
+ def handler(session, args):
51
+ if name in session.view.overlays:
52
+ session.view.overlays.discard(name)
53
+ session.message(f"{name} overlay off")
54
+ else:
55
+ session.view.overlays.add(name)
56
+ available = session.view.current_spec().overlays
57
+ if name not in available:
58
+ session.message(f"{name} overlay on (not present in this file)")
59
+ else:
60
+ session.message(f"{name} overlay on")
61
+ return handler
62
+
63
+
64
+ def _cmd_log(session, args):
65
+ session.log.enable()
66
+ session.message(f"logging to {session.log.path}")
67
+
68
+
69
+ def _cmd_nolog(session, args):
70
+ session.log.disable()
71
+ session.message("logging disabled")
72
+
73
+
74
+ def _cmd_show(session, args):
75
+ session.showing_log = not session.showing_log
76
+ session.page_index = 0
77
+ if not session.showing_log:
78
+ session._close_text_page()
79
+
80
+
81
+ def _cmd_units(session, args):
82
+ if not args:
83
+ session.message(f"units {session.view.axis.unit.to_string()}")
84
+ return
85
+ try:
86
+ unit = u.Unit(args[0])
87
+ except ValueError:
88
+ session.message(f"unknown unit: {args[0]}")
89
+ return
90
+ try:
91
+ session.view.set_axis(mode="wave", unit=unit)
92
+ except u.UnitConversionError:
93
+ session.message(f"cannot express the dispersion in {args[0]}")
94
+ return
95
+ session.view.rescale_y()
96
+ session.message(f"units {unit.to_string()}")
97
+
98
+
99
+ def _cmd_comment(session, args):
100
+ session.log.comment(args[0] if args else "")
101
+ session.message("comment added to the log")
102
+
103
+
104
+ def _cmd_variant(session, args):
105
+ keys = session.view.entry.variant_keys()
106
+ if not args:
107
+ session.message("variants: " + " ".join(keys))
108
+ return
109
+ if args[0] not in keys:
110
+ session.message(f"no variant {args[0]!r}; have: {' '.join(keys)}")
111
+ return
112
+ session.view.variant = args[0]
113
+ session.view.rescale_y()
114
+ session.message(f"variant {args[0]}")
115
+
116
+
117
+ def _cmd_renderer(session, args):
118
+ session.message(f"renderer {session.renderer.name} "
119
+ "(change with --renderer at startup)")
120
+
121
+
122
+ def _cmd_mouse(session, args):
123
+ session.set_mouse(_boolean(args, session.mouse_enabled))
124
+
125
+
126
+ def _cmd_not_applicable(name: str):
127
+ def handler(session, args):
128
+ session.message(f":{name} applies to 2D images; specterm1d ingests 1D")
129
+ return handler
130
+
131
+
132
+ COLON_COMMANDS = {
133
+ "log": _cmd_log,
134
+ "nolog": _cmd_nolog,
135
+ "show": _cmd_show,
136
+ "units": _cmd_units,
137
+ "#": _cmd_comment,
138
+ "variant": _cmd_variant,
139
+ "renderer": _cmd_renderer,
140
+ "mouse": _cmd_mouse,
141
+ "zero": _view_toggle("zero_base", "zero base"),
142
+ "hist": _view_toggle("histogram", "histogram"),
143
+ "flip": _view_toggle("flip", "flipped coordinates"),
144
+ "wreset": _view_toggle("window_reset", "window reset"),
145
+ "overplot": _view_toggle("overplot_next", "overplot"),
146
+ "sigma": _view_toggle("show_sigma", "sigma band"),
147
+ "mask": _view_toggle("show_mask", "mask highlight"),
148
+ "sky": _overlay_toggle("sky"),
149
+ "telluric": _overlay_toggle("telluric"),
150
+ "model": _overlay_toggle("obj_model"),
151
+ "dispaxis": _cmd_not_applicable("dispaxis"),
152
+ "nsum": _cmd_not_applicable("nsum"),
153
+ }
154
+ # splot accepts these but they are no-ops here: we always draw our own frame.
155
+ COLON_COMMANDS["auto"] = _view_toggle("window_reset", "autodraw")
156
+ COLON_COMMANDS["nosysid"] = lambda s, a: s.message("nosysid has no effect here")
157
+
158
+
159
+ @command("colon.prompt")
160
+ def colon_prompt(session):
161
+ def submitted(sess, text):
162
+ if text is None:
163
+ sess.message("cancelled")
164
+ return
165
+ name, args = parse_colon(text)
166
+ if not name:
167
+ return
168
+ handler = COLON_COMMANDS.get(name)
169
+ if handler is None:
170
+ sess.message(f"unknown colon command: {name}")
171
+ return
172
+ handler(sess, args)
173
+
174
+ session.await_line(":", submitted)
@@ -0,0 +1,347 @@
1
+ """Display and navigation commands - splot's viewing keys."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+ from specterm1d.keymap import STATUS_HINTS, command
7
+
8
+ SHIFT_FRACTION = 0.25
9
+ ZOOM_FACTOR = 2.0
10
+
11
+
12
+ def _spec_and_pixel(session, x: float):
13
+ spec = session.view.display_spec()
14
+ pixel = int(np.clip(np.searchsorted(spec.wave, x), 0, spec.npix - 1))
15
+ return spec, pixel
16
+
17
+
18
+ @command("display.report")
19
+ def report(session):
20
+ """<space>: cursor position and the nearest pixel's value."""
21
+ x = session.view.cursor_x
22
+ if x is None:
23
+ session.message("no cursor")
24
+ return
25
+ spec, pixel = _spec_and_pixel(session, x)
26
+ session.message(
27
+ f"x={x:.6g} y={spec.flux[pixel]:.6g} pix={pixel} "
28
+ f"wave={spec.wave[pixel]:.6g}"
29
+ )
30
+
31
+
32
+ @command("display.expand")
33
+ def expand(session):
34
+ """a: expand between two cursors; the same point twice autoscales all."""
35
+ def done(sess, positions):
36
+ xs = [x for x, _ in positions]
37
+ lo, hi = min(xs), max(xs)
38
+ if np.isclose(lo, hi):
39
+ sess.view.reset_limits()
40
+ sess.message("autoscaled the whole spectrum")
41
+ return
42
+ sess.view.xlim = (float(lo), float(hi))
43
+ sess.view.rescale_y()
44
+ sess.message(f"expanded to {lo:.6g} - {hi:.6g}")
45
+
46
+ session.await_cursor(2, "mark two positions to expand between", done)
47
+
48
+
49
+ @command("display.zero_base")
50
+ def zero_base(session):
51
+ session.view.zero_base = not session.view.zero_base
52
+ session.view.rescale_y()
53
+ session.message(f"zero base {'on' if session.view.zero_base else 'off'}")
54
+
55
+
56
+ @command("display.clear")
57
+ def clear(session):
58
+ session.view.markers.clear()
59
+ session.view.fits.clear()
60
+ session.overlay_specs.clear()
61
+ session.view.reset_limits()
62
+ session.message("cleared windowing")
63
+
64
+
65
+ @command("display.redraw")
66
+ def redraw(session):
67
+ session.message("redrawn")
68
+
69
+
70
+ @command("display.zoom")
71
+ def zoom(session):
72
+ lo, hi = session.view.xlim
73
+ centre = session.view.cursor_x if session.view.cursor_x is not None \
74
+ else (lo + hi) / 2
75
+ half = (hi - lo) / (2 * ZOOM_FACTOR)
76
+ session.view.xlim = (float(centre - half), float(centre + half))
77
+ session.view.rescale_y()
78
+ session.message(f"zoomed to {session.view.xlim[0]:.6g} - "
79
+ f"{session.view.xlim[1]:.6g}")
80
+
81
+
82
+ def _shift(session, sign: float):
83
+ lo, hi = session.view.xlim
84
+ step = (hi - lo) * SHIFT_FRACTION * sign
85
+ session.view.xlim = (float(lo + step), float(hi + step))
86
+ session.view.rescale_y()
87
+
88
+
89
+ @command("display.shift_left")
90
+ def shift_left(session):
91
+ _shift(session, -1.0)
92
+ session.message("shifted left")
93
+
94
+
95
+ @command("display.shift_right")
96
+ def shift_right(session):
97
+ _shift(session, +1.0)
98
+ session.message("shifted right")
99
+
100
+
101
+ def _goto(session, index: int):
102
+ session.view.index = index
103
+ session.view.variant = None
104
+ session.view.markers.clear()
105
+ session.view.fits.clear()
106
+ if session.view.window_reset:
107
+ session.view.reset_limits()
108
+ session.view.cursor_x = float(np.mean(session.view.xlim))
109
+ session.view.follow_flux()
110
+ session.message(f"{session.view.entry.label} "
111
+ f"({index + 1}/{len(session.collection)})")
112
+
113
+
114
+ @command("display.prev")
115
+ def previous(session):
116
+ if session.view.index == 0:
117
+ session.message("already at the first spectrum")
118
+ return
119
+ _goto(session, session.view.index - 1)
120
+
121
+
122
+ @command("display.next")
123
+ def next_entry(session):
124
+ if session.view.index >= len(session.collection) - 1:
125
+ session.message("already at the last spectrum")
126
+ return
127
+ _goto(session, session.view.index + 1)
128
+
129
+
130
+ @command("display.goto")
131
+ def goto(session):
132
+ def done(sess, text):
133
+ if not text:
134
+ sess.message("cancelled")
135
+ return
136
+ key = int(text) - 1 if text.strip().lstrip("-").isdigit() else text.strip()
137
+ try:
138
+ sess_index = sess.collection.find(key)
139
+ except KeyError:
140
+ sess.message(f"no spectrum matching {text!r}")
141
+ return
142
+ _goto(sess, sess_index)
143
+
144
+ session.await_line(f"spectrum (1-{len(session.collection)} or name): ", done)
145
+
146
+
147
+ @command("display.variant")
148
+ def variant(session):
149
+ keys = session.view.entry.variant_keys()
150
+ if len(keys) < 2:
151
+ session.message(f"only one variant available: {keys[0]}")
152
+ return
153
+ current = session.view.variant or session.view.entry.default
154
+ nxt = keys[(keys.index(current) + 1) % len(keys)]
155
+ session.view.variant = nxt
156
+ if session.view.window_reset:
157
+ session.view.rescale_y()
158
+ session.message(f"variant {nxt}")
159
+
160
+
161
+ @command("display.pixel_coords")
162
+ def pixel_coords(session):
163
+ mode = "wave" if session.view.axis.mode == "pixel" else "pixel"
164
+ session.view.set_axis(mode=mode)
165
+ session.view.rescale_y()
166
+ session.message(f"{mode} coordinates")
167
+
168
+
169
+ @command("display.velocity")
170
+ def velocity(session):
171
+ if session.view.axis.mode == "velocity":
172
+ session.view.set_axis(mode="wave")
173
+ session.message("wavelength scale")
174
+ return
175
+ x = session.view.cursor_x
176
+ spec = session.view.current_spec()
177
+ origin = float(session.view.axis.to_wave(spec, np.array([x]))[0])
178
+ session.view.set_axis(mode="velocity", velocity_origin=origin)
179
+ session.message(f"velocity scale about {origin:.6g}")
180
+
181
+
182
+ @command("display.overplot")
183
+ def overplot(session):
184
+ session.view.overplot_next = True
185
+ session.message("the next spectrum will overplot")
186
+
187
+
188
+ @command("display.get")
189
+ def get_spectrum(session):
190
+ def done(sess, text):
191
+ if not text:
192
+ sess.message("cancelled")
193
+ return
194
+ sess.load_path(text.strip())
195
+
196
+ session.await_line("file: ", done)
197
+
198
+
199
+ @command("display.quit")
200
+ def quit_command(session):
201
+ if session.file_index + 1 < len(session.files):
202
+ session.file_index += 1
203
+ session.load_path(session.files[session.file_index])
204
+ return True
205
+ return False
206
+
207
+
208
+ @command("display.interrupt")
209
+ def interrupt(session):
210
+ return False
211
+
212
+
213
+ @command("help.page")
214
+ def help_page(session):
215
+ session.showing_help = not session.showing_help
216
+ session.page_index = 0
217
+ if not session.showing_help:
218
+ session._close_text_page()
219
+
220
+
221
+ @command("help.cycle")
222
+ def help_cycle(session):
223
+ session.hint_index = (session.hint_index + 1) % len(STATUS_HINTS)
224
+ session.message(STATUS_HINTS[session.hint_index])
225
+
226
+
227
+ # ---- w: the gtools window submode -----------------------------------
228
+ #
229
+ # Transcribed from IRAF pkg/xtools/gtools/gtwindow.x and lib/scr/gtools.key,
230
+ # so every key keeps the meaning a splot user already has in their fingers.
231
+ # Shifts move 0.75 of the window, zooms are cursor +/- d/4 (a factor of two),
232
+ # and 'p' pans to cursor +/- d, which doubles the window - all as gt_window1
233
+ # does it. 'e' takes two cursors and, following gt_window2, applies each axis
234
+ # only if the two marks differ by more than 0.001 of that axis's span.
235
+
236
+ SHIFT_WINDOW = 0.75
237
+ EXPAND_TOLERANCE = 0.001
238
+
239
+
240
+ def _window_action(session, char):
241
+ view = session.view
242
+ x1, x2 = view.xlim
243
+ y1, y2 = view.ylim
244
+ dx, dy = x2 - x1, y2 - y1
245
+ cx = view.cursor_x if view.cursor_x is not None else (x1 + x2) / 2
246
+ cy = view.cursor_y if view.cursor_y is not None else (y1 + y2) / 2
247
+
248
+ if char == "?":
249
+ session.showing_help = True
250
+ session.message("window keys")
251
+ elif char == "a":
252
+ view.reset_limits()
253
+ session.message("autoscaled x and y")
254
+ elif char == "b":
255
+ view.ylim = (float(cy), float(y2))
256
+ session.message(f"bottom edge {cy:.6g}")
257
+ elif char == "c":
258
+ view.xlim = (float(cx - dx / 2), float(cx + dx / 2))
259
+ view.ylim = (float(cy - dy / 2), float(cy + dy / 2))
260
+ session.message("centred at cursor")
261
+ elif char == "d":
262
+ view.ylim = (float(y1 - SHIFT_WINDOW * dy), float(y2 - SHIFT_WINDOW * dy))
263
+ session.message("shifted down")
264
+ elif char == "f":
265
+ view.flip = not view.flip
266
+ session.message(f"x axis flip {'on' if view.flip else 'off'}")
267
+ elif char == "g":
268
+ view.flip_y = not view.flip_y
269
+ session.message(f"y axis flip {'on' if view.flip_y else 'off'}")
270
+ elif char == "j":
271
+ view.xlim = (float(cx), float(x2))
272
+ session.message(f"left edge {cx:.6g}")
273
+ elif char == "k":
274
+ view.xlim = (float(x1), float(cx))
275
+ session.message(f"right edge {cx:.6g}")
276
+ elif char == "l":
277
+ view.xlim = (float(x1 - SHIFT_WINDOW * dx), float(x2 - SHIFT_WINDOW * dx))
278
+ session.message("shifted left")
279
+ elif char == "m":
280
+ spec = view.display_spec()
281
+ good = spec.wave[spec.good]
282
+ if good.size:
283
+ view.xlim = (float(good.min()), float(good.max()))
284
+ session.message("autoscaled x")
285
+ elif char == "n":
286
+ view.rescale_y()
287
+ session.message("autoscaled y")
288
+ elif char == "p":
289
+ view.xlim = (float(cx - dx), float(cx + dx))
290
+ view.ylim = (float(cy - dy), float(cy + dy))
291
+ session.message("panned about cursor")
292
+ elif char == "r":
293
+ view.xlim = (float(x1 + SHIFT_WINDOW * dx), float(x2 + SHIFT_WINDOW * dx))
294
+ session.message("shifted right")
295
+ elif char == "t":
296
+ view.ylim = (float(y1), float(cy))
297
+ session.message(f"top edge {cy:.6g}")
298
+ elif char == "u":
299
+ view.ylim = (float(y1 + SHIFT_WINDOW * dy), float(y2 + SHIFT_WINDOW * dy))
300
+ session.message("shifted up")
301
+ elif char == "x":
302
+ view.xlim = (float(cx - dx / 4), float(cx + dx / 4))
303
+ session.message("zoomed x")
304
+ elif char == "y":
305
+ view.ylim = (float(cy - dy / 4), float(cy + dy / 4))
306
+ session.message("zoomed y")
307
+ elif char == "z":
308
+ view.xlim = (float(cx - dx / 4), float(cx + dx / 4))
309
+ view.ylim = (float(cy - dy / 4), float(cy + dy / 4))
310
+ session.message("zoomed x and y")
311
+ else:
312
+ session.message(f"window: {char!r} is not a window key")
313
+
314
+
315
+ def _window_expand(session):
316
+ """gtools 'e': mark two corners; each axis moves only if the marks differ."""
317
+ x1, x2 = session.view.xlim
318
+ y1, y2 = session.view.ylim
319
+ dx, dy = x2 - x1, y2 - y1
320
+
321
+ def done(sess, positions):
322
+ (mx1, my1), (mx2, my2) = positions
323
+ moved = []
324
+ if abs(mx2 - mx1) > EXPAND_TOLERANCE * abs(dx):
325
+ sess.view.xlim = (float(min(mx1, mx2)), float(max(mx1, mx2)))
326
+ moved.append("x")
327
+ if abs(my2 - my1) > EXPAND_TOLERANCE * abs(dy):
328
+ sess.view.ylim = (float(min(my1, my2)), float(max(my1, my2)))
329
+ moved.append("y")
330
+ sess.message(f"expanded {' and '.join(moved)}" if moved
331
+ else "marks too close; window unchanged")
332
+
333
+ session.await_cursor(2, "mark two corners of the new window", done)
334
+
335
+
336
+ @command("display.window")
337
+ def window(session):
338
+ def chosen(sess, char):
339
+ if char == "e":
340
+ _window_expand(sess)
341
+ elif char == "I":
342
+ sess.message("cancelled")
343
+ else:
344
+ _window_action(sess, char)
345
+
346
+ session.await_key(
347
+ "window: a b c d e f g j k l m n p r t u x y z, ? for help", chosen, {})