diff --git a/packaging/timbergraph.1 b/packaging/timbergraph.1 index 64382f3..996a589 100644 --- a/packaging/timbergraph.1 +++ b/packaging/timbergraph.1 @@ -166,10 +166,38 @@ where the name resolves to a different document or to none. .B \-\-list Say what the input holds and draw nothing. .TP -.BI \-\-png " FILE" ", " \-\-svg " FILE" ", " \-\-window -Draw somewhere other than the terminal. +.BI \-\-png " FILE" ", " \-\-svg " FILE" ", " \-\-window ", " \-\-ascii +WHERE to draw, and by DEFAULT that is a WINDOW \(em characters cannot +carry the detail, five series an order of magnitude apart collapsing into +two rows of them with one disappearing altogether. It needs three things +and none implies another: a display +.RB ( $DISPLAY +or +.BR $WAYLAND_DISPLAY ), +a +.BR gnuplot (1) +built with an interactive terminal +.RB ( gnuplot\-nox +has none), and a TTY to be watching it \(em so a redirected or scripted +run keeps its answer as text rather than losing it to a window nobody +sees. Missing any of them, the default is ASCII. +.IP +.B \-\-window +and +.B \-\-ascii +say which outright, and .B \-\-window -needs a display and gnuplot's qt or x11 terminal. +overrides the TTY test, a window not being stdout. A +.B \-\-window +that cannot be drawn is REFUSED rather than downgraded, because the point +of naming it is to get one. +.BI \-\-png " FILE" +and +.BI \-\-svg " FILE" +write a file. One of the four at a time: two name different places to +draw. ⚠ Identical in +.BR timbersh (1), +which calls this same rule rather than keeping its own. .TP .BI \-\-width " N" ", " \-\-height " N" The plot's size \(em CHARACTERS on the terminal, where it defaults to the diff --git a/packaging/timbersh.1 b/packaging/timbersh.1 index d93b983..62698cc 100644 --- a/packaging/timbersh.1 +++ b/packaging/timbersh.1 @@ -455,6 +455,36 @@ and .BR timbergraph (1) is the same drawing outside the shell. .PP +WHERE IT DRAWS: a WINDOW where one can be drawn, and characters on the +terminal where it cannot \(em so the same statement gives a picture at a +desk and still answers over ssh. A window wants two things, and neither +implies the other: a display +.RB ( $DISPLAY +or +.BR $WAYLAND_DISPLAY ) +and a +.BR gnuplot (1) +built with an interactive terminal, which +.B gnuplot\-nox +is not \(em and, for the DEFAULT only, a terminal to be watching it: a +redirected run keeps its answer as text rather than losing it to a +window nobody asked for. Naming +.B window +overrides that, a window not being stdout. +.B window +and +.B ascii +say which outright, and a +.B window +that cannot be drawn is REFUSED rather than quietly downgraded \(em the +point of naming it is to get one. +.BI "png " \(fmFILE\(fm +and +.BI "svg " \(fmFILE\(fm +write a file instead, which timbersh does not open for you. One of the +four at a time: two name different places to draw, and a statement whose +destination is picked for it means something other than what it says. +.PP .B view opens a store as a tape rather than as an answer, IN PROCESS: the loop it exists for is search, jump, search again, and a separate program would diff --git a/tests/timbergraph/test-timbergraph b/tests/timbergraph/test-timbergraph index ae0111e..f74b1ca 100755 --- a/tests/timbergraph/test-timbergraph +++ b/tests/timbergraph/test-timbergraph @@ -48,6 +48,62 @@ def raises(name, fn, contains): FAILED.append(f"{name}: no refusal, and {contains!r} was expected") +def test_where_it_draws_is_one_rule_for_both_tools(): + """`choose_terminal` is the whole of it, and timbersh calls this same + function — two copies would drift and the same words would then mean + different pictures depending which you typed them in. + + The default is a window because ASCII cannot carry the detail. It + wants a display, an interactive gnuplot terminal AND a tty, and the + tty binds the default only: a redirected run keeps its answer as + text, while a NAMED window is what was asked for and a window is not + stdout. + """ + at_a_desk = dict(term="qt", tty=True) + redirected = dict(term="qt", tty=False) + no_window = dict(term=None, tty=True) + check("default at a desk", "qt", g.choose_terminal(**at_a_desk)) + check("default redirected", "dumb", g.choose_terminal(**redirected)) + check("no interactive terminal", "dumb", g.choose_terminal(**no_window)) + check("named window survives a pipe", "qt", + g.choose_terminal(window=True, **redirected)) + check("named ascii at a desk", "dumb", + g.choose_terminal(want_ascii=True, **at_a_desk)) + check("a file needs no window", "pngcairo", + g.choose_terminal(png="f.png", **no_window)) + check("svg likewise", "svg", g.choose_terminal(svg="f.svg", **no_window)) + + +def test_a_window_that_cannot_be_drawn_is_refused_not_downgraded(): + """Naming it is asking for it, so ASCII instead would answer a + question nobody asked. The message has to name BOTH things that + could be missing — a display and a gnuplot built for one — because + either alone looks the same from here.""" + raises("no terminal", lambda: g.choose_terminal(window=True, term=None, + tty=True), + "no interactive gnuplot terminal") + raises("names gnuplot-nox", lambda: g.choose_terminal(window=True, + term=None, tty=True), + "gnuplot-nox") + + +def test_two_places_to_draw_is_refused_rather_than_one_winning(): + """`--window --png f` used to write the file and no window; `--png` + beside `--svg` quietly preferred the png. A statement whose + destination is picked for it means something other than what it + says, so both are named and neither wins.""" + raises("window and ascii", + lambda: g.choose_terminal(window=True, want_ascii=True, term="qt"), + "different places to draw") + raises("png and svg", + lambda: g.choose_terminal(png="a.png", svg="b.svg", term="qt"), + "different places to draw") + raises("names the flags it was given", + lambda: g.choose_terminal(window=True, png="a.png", term="qt", + spell="--"), + "`--window` and `--png`") + + def test_a_line_says_what_it_holds(): s = g.parse_line( '2026-09-06T13:37:00.000Z 60s m a=1 msg="two words" count=3 sum=1.5 @40+9' diff --git a/tests/timbersh/fake-gnuplot b/tests/timbersh/fake-gnuplot new file mode 100755 index 0000000..68efdeb --- /dev/null +++ b/tests/timbersh/fake-gnuplot @@ -0,0 +1,25 @@ +#!/bin/sh +# A gnuplot that exists but draws nothing, so a test can decide what +# this machine can do rather than inherit it. +# +# $FAKE_TERMS is the terminal list `set term` reports (space separated). +# It prints which of the two it was asked for: -p — which timbergraph +# passes only for an interactive terminal — is a WINDOW, anything else is +# text on stdout. So a test asserts what WAS chosen rather than the +# absence of the other, which would also pass if nothing ran at all. +case "$1" in + -e) [ "$2" = "set term" ] && { + echo "" >&2 + echo "Available terminal types:" >&2 + for t in ${FAKE_TERMS:-dumb}; do echo " $t a fake" >&2; done + } + # ⚠ Then READ STDIN to EOF, because that is what gnuplot does: -e + # runs the command and carries on reading. A fake that exited here + # would probe instantly on a terminal, where the real one waits for + # input — and the probe running without stdin redirected is exactly + # the defect this fidelity exists to catch. + cat >/dev/null + exit 0 ;; + -p) cat >/dev/null; echo "FAKE-GNUPLOT-WINDOW"; exit 0 ;; + *) cat >/dev/null; echo "FAKE-GNUPLOT-TEXT"; exit 0 ;; +esac diff --git a/tests/timbersh/test-timbersh b/tests/timbersh/test-timbersh index b8a05bd..d722579 100755 --- a/tests/timbersh/test-timbersh +++ b/tests/timbersh/test-timbersh @@ -29,7 +29,14 @@ ID_B = "bbbbbbbb-1111-2222-3333-444444444444" class Session: """One timbersh run against a scripted server.""" - def __init__(self, scenario, hosts=None, extra=(), resolver=False): + def __init__(self, scenario, hosts=None, extra=(), resolver=False, + env=None): + # `env` overrides the inherited environment per key; None as a + # value UNSETS one. Which is what lets a test say whether a + # display exists — `graph` draws a window where one can be drawn, + # so a suite that inherited the developer's DISPLAY would assert + # different things on a desk and in CI. + self.env = env or {} self.dir = tempfile.mkdtemp(prefix="timbersh-test-") json.dump(scenario, open(os.path.join(self.dir, "scenario.json"), "w")) self.hosts = hosts @@ -90,7 +97,9 @@ class Session: try: p = subprocess.run(cmd, input="\n".join(statements) + "\n\\q\n", capture_output=True, text=True, timeout=60, - env={**os.environ, "FAKE_DIR": self.dir}) + env={k: v for k, v in + {**os.environ, "FAKE_DIR": self.dir, + **self.env}.items() if v is not None}) except subprocess.TimeoutExpired as e: # `text=True` does not reach here: what was captured before the # timeout comes back as bytes. @@ -166,7 +175,9 @@ class Pty(Session): "--histfile", os.path.join(self.dir, "history"), "--rc", os.path.join(self.dir, "nonexistent-rc")] cmd += self.where() + self.extra - env = {**os.environ, "FAKE_DIR": self.dir, "TERM": "xterm"} + env = {k: v for k, v in + {**os.environ, "FAKE_DIR": self.dir, "TERM": "xterm", + **self.env}.items() if v is not None} self.pid, self.fd = pty.fork() if self.pid == 0: os.execve(cmd[0], cmd, env) @@ -457,6 +468,139 @@ def a_graph_makes_the_fleet_a_dimension_rather_than_merging_it(): s.cleanup() +def with_gnuplot(terms): + """A PATH whose `gnuplot` is the fake, reporting `terms` as the + terminals it has. Which is the only way to test the choice: a real + window needs a real display, so CI would assert the opposite of a + desk and neither would be testing the decision. + + ⚠ A DIRECTORY of one binary, prepended — not the whole of tests/, + which holds `fake-timberfs` and would shadow the real one. + """ + d = tempfile.mkdtemp(prefix="timbersh-gnuplot-") + shutil.copy(os.path.join(HERE, "fake-gnuplot"), + os.path.join(d, "gnuplot")) + return {"PATH": d + os.pathsep + os.environ["PATH"], + "FAKE_TERMS": terms} + + +@test +def a_graph_draws_a_window_by_default_where_one_can_be_drawn(): + """The default is a window where one can be drawn, and that decision + is what this pins: a display, a gnuplot reporting an interactive + terminal, and somebody watching a terminal. The fake prints its + marker only when asked for one with `-p`, so this fails if `dumb` + was chosen. + + ⚠ On a PTY, because the default is gated on stdout being one — over + a pipe the answer is the text and a window would steal it.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + s = Pty({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env={**with_gnuplot("qt dumb"), "DISPLAY": ":99"}).start() + s.send("graph heap from [class=tally];\n") + s.expect("FAKE-GNUPLOT-WINDOW") + s.finish().cleanup() + + +@test +def a_redirected_run_keeps_its_answer_rather_than_losing_it_to_a_window(): + """The suite itself is the case: stdout is a pipe. A DEFAULT window + there would turn `timbersh -q < script > out.txt` at a desk into an + empty file, so the default is a window only where somebody is + watching one. Naming `window` still gets one -- a window is not + stdout, and asking for it with the output redirected is coherent.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + env = {**with_gnuplot("qt dumb"), "DISPLAY": ":99"} + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env=env).run("graph heap from [class=tally];") + assert "FAKE-GNUPLOT-TEXT" in s.out, s.out + s.cleanup() + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env=env).run("graph heap from [class=tally] window;") + assert "FAKE-GNUPLOT-WINDOW" in s.out, s.out + s.cleanup() + + +@test +def a_gnuplot_with_no_interactive_terminal_draws_ascii_though_a_display_exists(): + """`gnuplot-nox` is the case a DISPLAY check alone gets wrong: the + display is there and the terminal is not, so a window cannot be + drawn and asking for one is an error rather than a picture.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + env = {**with_gnuplot("dumb svg pngcairo"), "DISPLAY": ":99"} + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env=env).run("graph heap from [class=tally];") + assert "FAKE-GNUPLOT-TEXT" in s.out, s.out + s.cleanup() + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env=env).run("graph heap from [class=tally] window;") + assert "no interactive gnuplot terminal" in s.out, s.out + s.cleanup() + + +@test +def a_graph_draws_ascii_where_no_window_can_be_drawn(): + """The default is a WINDOW where one can be drawn and ASCII where it + cannot, so a session over ssh still answers. With no display there is + nothing to put a window on, whatever gnuplot can do.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env={"DISPLAY": None, "WAYLAND_DISPLAY": None}).run( + "graph heap from [class=tally];") + # The dumb terminal draws with characters; a window writes nothing to + # stdout at all. + assert "heap" in s.out, s.out + assert "no interactive gnuplot terminal" not in s.out, s.out + s.cleanup() + + +@test +def a_named_window_that_cannot_be_drawn_is_refused_not_downgraded(): + """Saying `window` is asking for one. Falling back to ASCII there + would answer a question nobody asked and hide the reason — the two + things needed are a display and a gnuplot built with an interactive + terminal, and the message has to name both.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env={"DISPLAY": None, "WAYLAND_DISPLAY": None}).run( + "graph heap from [class=tally] window;") + assert "no interactive gnuplot terminal" in s.out, s.out + assert "gnuplot-nox" in s.out, s.out + s.cleanup() + + +@test +def two_places_to_draw_is_refused_rather_than_one_of_them_winning(): + """`window png 'f'` named a file and a window; the file won and the + window was dropped in silence. Two destinations is a statement that + means something other than what it says, so it is refused and both + are named.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + for stmt, want in (("window ascii", ("window", "ascii")), + ("window png 'f.png'", ("window", "png")), + ("png 'f.png' svg 'f.svg'", ("png", "svg"))): + s = Session({"": {"stores": [store("t", ID_A)], + "loglines": gauge}}).run( + f"graph heap from [class=tally] {stmt};") + assert "different places to draw" in s.out, (stmt, s.out) + for w in want: + assert f"`{w}`" in s.out, (stmt, w, s.out) + s.cleanup() + + +@test +def ascii_is_sayable_where_a_window_would_otherwise_be_drawn(): + """The inverse of the default: at a desk, one graph wanted as text + to paste somewhere. It must not depend on unsetting a variable.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env={"DISPLAY": ":99"}).run( + "graph heap from [class=tally] ascii;") + assert "heap" in s.out, s.out + assert "different places" not in s.out, s.out + s.cleanup() + + @test def a_graph_refuses_a_host_label_it_would_have_to_write_over(): """The shell adds `host` itself, so a metric that already carries one diff --git a/tools/timbergraph.py b/tools/timbergraph.py index d367b5d..8f3a36c 100644 --- a/tools/timbergraph.py +++ b/tools/timbergraph.py @@ -813,6 +813,107 @@ def gp_quote(text): return "'" + str(text).replace("'", "''") + "'" +#: Interactive gnuplot terminals, best first. `aqua` is macOS's. +WINDOW_TERMINALS = ("qt", "wxt", "x11", "aqua") + +_window_term = "unprobed" + + +def window_terminal(): + """The interactive gnuplot terminal available here, or None. + + Two facts are needed and neither implies the other: a DISPLAY to put + a window on, and a gnuplot built with an interactive terminal — + `gnuplot-nox` has none, so it draws files and ASCII and warns on + `set terminal qt`. + + ⚠ Read from the TERMINAL LIST, not from gnuplot's exit code: `set + terminal nosuchterm` exits 0 and only warns on stderr, so a probe + that trusted the status would call every terminal available. The + list costs one gnuplot at ~10 ms and is cached, this being a fact + about the machine rather than about a plot. + """ + global _window_term + if _window_term != "unprobed": + return _window_term + _window_term = None + if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"): + exe = shutil.which("gnuplot") + if exe: + try: + # ⚠ stdin=DEVNULL, and it is the whole of this working. + # `gnuplot -e CMD` runs CMD and then goes on READING + # STDIN, and `capture_output` does not touch stdin — so + # inside an interactive shell the probe inherited the + # user's terminal, sat there until the timeout, and the + # fallback below reported "no interactive terminal" on a + # machine with gnuplot-qt installed and a display. It + # also ate the keystrokes typed at it meanwhile. Every + # test piped stdin, so none of them could see it. + out = subprocess.run([exe, "-e", "set term"], text=True, + capture_output=True, check=False, + stdin=subprocess.DEVNULL, timeout=10) + have = {ln.split()[0] for ln in + (out.stderr + out.stdout).splitlines() if ln.split()} + _window_term = next( + (w for w in WINDOW_TERMINALS if w in have), None) + except (OSError, subprocess.SubprocessError): + # Fall back to ASCII, which is the safe direction — but + # narrowly, so a bug here is a traceback rather than a + # silent "this machine cannot draw". + _window_term = None + return _window_term + + +def choose_terminal(png=None, svg=None, window=False, want_ascii=False, + tty=None, term="probe", spell=""): + """WHERE to draw, from what was asked and what is possible. + + One rule, called by the CLI and by timbersh's `graph`, because two + copies of it would drift and the drift would be silent: the same + words would mean different pictures depending which you typed it in. + + The default is a WINDOW, because ASCII cannot carry the detail — + five series an order of magnitude apart collapse into two rows of + characters, and one of them vanishes. It needs three things, none of + which implies another: a display, a gnuplot built with an + interactive terminal, and a TTY to be watching it. That last one + bounds the DEFAULT only: a redirected run keeps its answer as text + rather than losing it to a window nobody sees, while a NAMED window + is what was asked for and a window is not stdout. + + A named window that cannot be drawn is refused rather than + downgraded — the point of saying it is to get one. + """ + named = [n for n, on in (("window", window), ("ascii", want_ascii), + ("png", bool(png)), ("svg", bool(svg))) if on] + if len(named) > 1: + raise Bad( + "`" + "` and `".join(spell + n for n in named) + "` name " + "different places to draw — give one. Omit them all for a " + "window where one can be drawn and ASCII where it cannot") + if png: + return "pngcairo" + if svg: + return "svg" + if want_ascii: + return "dumb" + if term == "probe": + term = window_terminal() + if window: + if not term: + raise Bad( + "no interactive gnuplot terminal here — a window needs a " + "display (DISPLAY or WAYLAND_DISPLAY) and a gnuplot built " + "with one (`apt install gnuplot-qt`; gnuplot-nox has none). " + f"Drop `{spell}window` for ASCII, or " + f"`{spell}png`/`{spell}svg` for a file") + return term + if tty is None: + tty = sys.stdout.isatty() + return term if (term and tty) else "dumb" + + def draw(text, terminal): """Run gnuplot, or say what to install. ⚠ A SOFT dependency: the tools carry no third-party imports and nothing here should be the @@ -825,7 +926,7 @@ def draw(text, terminal): "window needs gnuplot-qt or gnuplot-x11)" ) proc = subprocess.run( - [exe, "-p" if terminal in ("qt", "x11", "wxt") else "-"], + [exe, "-p" if terminal in WINDOW_TERMINALS else "-"], input=text, text=True, capture_output=True, check=False, ) if proc.returncode != 0: @@ -927,7 +1028,11 @@ def main(argv=None): ap.add_argument("--png", metavar="FILE") ap.add_argument("--svg", metavar="FILE") ap.add_argument("--window", action="store_true", - help="an interactive window; needs a display and gnuplot-qt") + help="force an interactive window; needs a display and " + "gnuplot-qt (the default already draws one where it " + "can)") + ap.add_argument("--ascii", action="store_true", + help="force characters on the terminal instead of a window") ap.add_argument("--width", type=int, help="characters on the terminal, pixels in an image") ap.add_argument("--height", type=int) @@ -1010,13 +1115,13 @@ def main(argv=None): and s.labels.get("metric") in metrics] dest = args.png or args.svg - terminal = ("pngcairo" if args.png else "svg" if args.svg - else "qt" if args.window else "dumb") + terminal = choose_terminal(png=args.png, svg=args.svg, window=args.window, + want_ascii=args.ascii, spell="--") if terminal == "dumb": size = (args.width or terminal_size()[0], args.height or terminal_size()[1]) else: - size = (args.width or (900 if args.window else 1000), + size = (args.width or (900 if terminal in WINDOW_TERMINALS else 1000), args.height or 500) if args.against: text_out = scatter(lines, title, xlabel, ylabel, terminal, size, dest) diff --git a/tools/timbersh b/tools/timbersh index a7c6358..6fd6b5d 100755 --- a/tools/timbersh +++ b/tools/timbersh @@ -197,7 +197,8 @@ HELP = """ select from [where ...] [limit N [chunks]] [into view]; tail from [where ...]; graph [, ...] from [by LABEL] [where ...] - [quantile Q] [rate] [using ] [png 'FILE'|svg 'FILE']; + [quantile Q] [rate] [using ] + [window|ascii|png 'FILE'|svg 'FILE']; graph against from [...]; graph from extracting where ... ; @@ -234,8 +235,18 @@ HELP = """ takes for one, so `by host` keeps them apart and leaving it out sums them on purpose. `quantile 0.95` of a cumulative `le` ladder -- summing one multiplies the count, so it is refused; `rate` divides by - the bucket width; `png 'f'`/`svg 'f'` draw somewhere other than the - terminal. Needs gnuplot. + the bucket width. Needs gnuplot. + + WHERE IT DRAWS, and by default that is a WINDOW where one can be + drawn and ASCII where it cannot -- so the same statement gives a + picture at a desk and still answers over ssh. A window needs a + display and a gnuplot built with an interactive terminal, which + `gnuplot-nox` is not -- and, for the DEFAULT only, a terminal to be + watching it, so a redirected run keeps its answer as text instead of + losing it to a window. `window` and `ascii` say which outright, and a + `window` that cannot be drawn is refused rather than downgraded; + `png 'f'`/`svg 'f'` write a file instead -- timbersh does not open + it. One of the four at a time. records | loglines | stores | chunks a view name, or a predicate literal like [type=console]. @@ -779,7 +790,8 @@ def show_window(session): #: What ends a `where` in a `graph`. A select's clauses are `limit` and #: `into`; a graph has its own, and without them here `quantile` after a #: `where` was read as a condition and refused. -GRAPH_CLAUSES = ("by", "quantile", "rate", "png", "svg", "using", "extracting") +GRAPH_CLAUSES = ("by", "quantile", "rate", "png", "svg", "using", "extracting", + "window", "ascii") def parse_where(toks, i, stop=()): @@ -974,7 +986,8 @@ class Complete: return AFTER_TIME low = [t.lower() for t in rest] once = [w for w in ("where", "quantile", "rate", "png", "svg", - "using", "extracting") if w not in low] + "using", "extracting", "window", "ascii") + if w not in low] return ["by"] + once + (["and"] if "where" in low else []) if slot == "": if prev in ("chunk", "offset"): @@ -1722,8 +1735,8 @@ class Shell: raise ValueError(f"`{w}` what?") if w == "by": by.append(toks[i + 1][1]); i += 2 - elif w == "rate": - opts["rate"] = True; i += 1 + elif w in ("rate", "window", "ascii"): + opts[w] = True; i += 1 elif w == "quantile": opts["quantile"] = float(toks[i + 1][1]); i += 2 elif w in ("png", "svg", "using", "extracting"): @@ -1732,7 +1745,26 @@ class Shell: raise ValueError( f"`{toks[i][1]}`? after the source come `by LABEL`, " f"`where ...`, `quantile Q`, `rate`, `using PATH`, " - f"`extracting NAME` and `png FILE`/`svg FILE`") + f"`extracting NAME`, `png FILE`/`svg FILE`, and " + f"`window`/`ascii`") + + # ⚠ WHERE to draw, decided BEFORE the read and by timbergraph's + # own rule rather than a second copy of it — the same words must + # mean the same picture whichever of the two you typed them in. + # Before the read, because a statement naming two places is + # refused and there is no reason to spend a fleet query on it + # first. + # + # ⚠ `Bad` is not a ValueError, and only ValueError reaches the + # loop's handler — so a refusal left untranslated arrives as a + # traceback out of the shell rather than as a message. + try: + terminal = timbergraph.choose_terminal( + png=opts.get("png"), svg=opts.get("svg"), + window=opts.get("window", False), + want_ascii=opts.get("ascii", False)) + except timbergraph.Bad as e: + raise ValueError(str(e)) from None extracting = opts.get("extracting") if extracting: @@ -1817,8 +1849,9 @@ class Shell: and m.labels.get("metric") in metrics] dest = opts.get("png") or opts.get("svg") - terminal = "pngcairo" if "png" in opts else "svg" if "svg" in opts else "dumb" - size = ((1000, 500) if dest else timbergraph.terminal_size()) + size = ((1000, 500) if dest + else (900, 500) if terminal in timbergraph.WINDOW_TERMINALS + else timbergraph.terminal_size()) if scatter_x: text = timbergraph.scatter(lines, title, xlabel, ylabel, terminal, size, dest)