cf-tool-cli 1.0.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.
@@ -0,0 +1,226 @@
1
+ """Tab completion: what each argument can be, from the folders around and the
2
+ contest list cf keeps. Never every number: only contests and problems that
3
+ have folders here (or are on now), and code files rather than every file.
4
+ Completers run on every Tab, so they stay quick and never raise or print."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Iterable, Union
13
+
14
+ import click
15
+ from click.shell_completion import CompletionItem, get_completion_class
16
+
17
+ from cf_tool import languages, paths, workspace
18
+ from cf_tool.errors import CFError
19
+
20
+ Candidate = Union[str, tuple[str, str]] # a value, or (value, what it is)
21
+ RECENT_CONTESTS = 8 # contests on or just over offered to cf get
22
+
23
+
24
+ def words(incomplete: str, candidates: Iterable[Candidate]) -> list[CompletionItem]:
25
+ """Completions: the candidates that start with what was typed so far,
26
+ each once, with what it is when known (zsh and fish show that)."""
27
+ typed = incomplete.lower()
28
+ items: dict[str, CompletionItem] = {}
29
+ for candidate in candidates:
30
+ value, about = candidate if isinstance(candidate, tuple) else (candidate, "")
31
+ if value.lower().startswith(typed) and value not in items:
32
+ items[value] = CompletionItem(value, help=about or None)
33
+ return list(items.values())
34
+
35
+
36
+ def _subfolders(folder: Path) -> list[Path]:
37
+ try:
38
+ return sorted(p for p in folder.iterdir() if p.is_dir() and not p.name.startswith("."))
39
+ except OSError:
40
+ return []
41
+
42
+
43
+ def _problems_of(contest: Path) -> list[tuple[str, str]]:
44
+ """A contest folder's problems: (folder name, problem name), a -> A. Watermelon."""
45
+ found = []
46
+ for child in _subfolders(contest):
47
+ if (child / workspace.METADATA).is_file():
48
+ info = workspace.load_info(child)
49
+ found.append((child.name, info.name if info else ""))
50
+ return found
51
+
52
+
53
+ def _contests_around(cwd: Path) -> dict[str, list[tuple[str, str]]]:
54
+ """Contests with folders around, by id, with their problems: the one cwd
55
+ is in, and those in cwd and in the root folder (2050/ holding 2050/a/)."""
56
+ folders = []
57
+ current = workspace.contest_folder(cwd)
58
+ if current is not None:
59
+ folders.append(current)
60
+ for base in dict.fromkeys([cwd, _root()]):
61
+ if base is not None:
62
+ folders += [f for f in _subfolders(base) if f.name.isdigit()]
63
+ contests: dict[str, list[tuple[str, str]]] = {}
64
+ for folder in folders:
65
+ problems = _problems_of(folder)
66
+ if not problems:
67
+ continue
68
+ info = workspace.load_info(folder / problems[0][0])
69
+ target = info.target if info else None
70
+ contest = target.contest if target and target.kind != "acmsguru" else folder.name
71
+ if contest.isdigit():
72
+ contests.setdefault(contest, problems)
73
+ return contests
74
+
75
+
76
+ def _root() -> Path | None:
77
+ from cf_tool.settings import Settings
78
+
79
+ try:
80
+ return Settings.load().root_folder()
81
+ except (CFError, OSError):
82
+ return None
83
+
84
+
85
+ def _listed_contests() -> list[dict]:
86
+ """The contest list cf keeps (from cf contests and cf get); [] without one."""
87
+ try:
88
+ data = json.loads((paths.STATE_DIR / "contests.json").read_text(encoding="utf-8"))
89
+ return [c for c in data.get("result", []) if isinstance(c, dict) and "id" in c]
90
+ except (OSError, ValueError, AttributeError):
91
+ return []
92
+
93
+
94
+ def _named_by_files(cwd: Path) -> list[str]:
95
+ """The problems this folder's code files are named for (2050A.cpp -> 2050A)."""
96
+ found = []
97
+ for name in _code_files(cwd):
98
+ number, letter = workspace.named_target(name, cwd)
99
+ if number and letter:
100
+ found.append(f"{number}{letter}")
101
+ return found
102
+
103
+
104
+ def _code_files(cwd: Path) -> list[str]:
105
+ """The code files in cwd, helpers left out."""
106
+ known = languages.code_extensions()
107
+ try:
108
+ entries = sorted(cwd.iterdir())
109
+ except OSError:
110
+ return []
111
+ return [
112
+ e.name
113
+ for e in entries
114
+ if e.suffix.lower() in known and e.is_file() and not workspace.is_helper(e.name)
115
+ ]
116
+
117
+
118
+ def _earlier(ctx) -> tuple[str, ...]:
119
+ """The arguments typed before the one being completed."""
120
+ for name in ("specs", "args"):
121
+ value = ctx.params.get(name)
122
+ if value:
123
+ return tuple(value)
124
+ return ()
125
+
126
+
127
+ def _problems(ctx, incomplete: str) -> list[Candidate]:
128
+ """What a problem argument can name here:
129
+ - after a contest's id (cf submit 2050 _): that contest's problems
130
+ - the problems of the contest cwd is in, by letter
131
+ - contests with folders around; once one's id is typed, its problems too
132
+ - problems that code files here are named for (2050A.cpp)"""
133
+ try:
134
+ cwd = Path.cwd()
135
+ contests = _contests_around(cwd)
136
+ named = next((a for a in reversed(_earlier(ctx)) if a in contests), None)
137
+ if named is not None:
138
+ return list(contests[named])
139
+ candidates: list[Candidate] = []
140
+ current = workspace.contest_folder(cwd)
141
+ if current is not None:
142
+ candidates += _problems_of(current)
143
+ names = {str(c["id"]): str(c.get("name", "")) for c in _listed_contests()}
144
+ for contest, problems in contests.items():
145
+ count = f"{len(problems)} problem{'s' if len(problems) > 1 else ''} here"
146
+ candidates.append((contest, names.get(contest) or count))
147
+ if incomplete == contest:
148
+ candidates += [(f"{contest}{letter}", name) for letter, name in problems]
149
+ return candidates + _named_by_files(cwd)
150
+ except (CFError, OSError):
151
+ return [] # NOTE: a broken folder only costs the suggestions
152
+
153
+
154
+ def _files(incomplete: str) -> list[CompletionItem]:
155
+ """The code files here; for a path being typed (../, ~/), the shell's own
156
+ file completion."""
157
+ if incomplete.startswith((".", "~", "/")) or os.sep in incomplete or "/" in incomplete:
158
+ return [CompletionItem(incomplete, type="file")]
159
+ return words(incomplete, _code_files(Path.cwd()))
160
+
161
+
162
+ def complete_languages(ctx, param, incomplete: str) -> list[CompletionItem]:
163
+ """The languages cf-tool knows, by their extension (cpp, py, ...)."""
164
+ from cf_tool.settings import Settings
165
+
166
+ try:
167
+ extra = list(Settings.load().languages)
168
+ except (CFError, OSError):
169
+ extra = []
170
+ return words(incomplete, [*languages.LANGUAGES, *extra])
171
+
172
+
173
+ def complete_problems(ctx, param, incomplete: str) -> list[CompletionItem]:
174
+ return words(incomplete, _problems(ctx, incomplete))
175
+
176
+
177
+ def complete_contests(ctx, param, incomplete: str) -> list[CompletionItem]:
178
+ """For cf get and cf race: the contests on now or about to start, those
179
+ just over (from the contest list cf keeps), and the ones around."""
180
+ now = time.time()
181
+ listed = sorted(_listed_contests(), key=lambda c: -int(c.get("startTimeSeconds") or 0))
182
+ soon = [
183
+ c
184
+ for c in listed
185
+ if c.get("phase") == "BEFORE" and 0 <= (c.get("startTimeSeconds") or 0) - now < 86400
186
+ ]
187
+ started = [c for c in listed if c.get("phase") != "BEFORE"][:RECENT_CONTESTS]
188
+ recent = [(str(c["id"]), str(c.get("name", ""))) for c in [*soon, *started]]
189
+ return words(incomplete, [*_problems(ctx, incomplete), *recent])
190
+
191
+
192
+ def complete_problems_and_files(ctx, param, incomplete: str) -> list[CompletionItem]:
193
+ """For cf submit: the problems around and the code files here."""
194
+ return [*words(incomplete, _problems(ctx, incomplete)), *_files(incomplete)]
195
+
196
+
197
+ def complete_tests(ctx, param, incomplete: str) -> list[CompletionItem]:
198
+ """For cf test: the tests of the problem here, the problems around, and
199
+ the code files here."""
200
+ try:
201
+ folder = workspace.find_problem_folder(Path.cwd())
202
+ tests = [t.name for t in workspace.tests_of(folder)] if folder else []
203
+ except OSError:
204
+ tests = []
205
+ return [*words(incomplete, [*tests, *_problems(ctx, incomplete)]), *_files(incomplete)]
206
+
207
+
208
+ @click.command()
209
+ @click.argument("shell", required=False, type=click.Choice(["bash", "zsh", "fish"]))
210
+ def completion(shell: str | None) -> None:
211
+ """Print the Tab-completion script for bash, zsh or fish.
212
+
213
+ \b
214
+ zsh: eval "$(cf completion zsh)" in ~/.zshrc, after compinit
215
+ bash: eval "$(cf completion bash)" in ~/.bashrc (bash 4.4+)
216
+ fish: cf completion fish | source in ~/.config/fish/config.fish
217
+ """
218
+ from cf_tool.cli import main
219
+
220
+ shell = shell or Path(os.environ.get("SHELL", "")).name
221
+ script = get_completion_class(shell)
222
+ if script is None:
223
+ raise click.UsageError(
224
+ f'Cannot tell your shell ("{shell}"): run cf completion zsh, bash or fish'
225
+ )
226
+ click.echo(script(main, {}, "cf", "_CF_COMPLETE").source())
@@ -0,0 +1,386 @@
1
+ """cf config's menu: every setting in one list, grouped by language. Each
2
+ setting opens a list whose first choice is the recommended one (already
3
+ selected, so Enter keeps it); Esc goes back. Everything is saved in
4
+ config.toml, which stays open to editing too (cf config --edit)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import dataclasses
10
+ import re
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from cf_tool import languages, paths, ui
17
+ from cf_tool.cli.common import client, reset, settings, short
18
+ from cf_tool.errors import CFError
19
+ from cf_tool.settings import Settings
20
+
21
+ DEFAULT = "" # the menu value for the recommended choice: the setting goes away
22
+ OTHER = "\0other" # the menu value for "something not listed"
23
+ RECOMMENDED = "recommended"
24
+
25
+ # the languages the menus offer; C is left to its files (.c still works),
26
+ # since hardly anybody picks it over C++
27
+ LISTED = [key for key in languages.LANGUAGES if key != "c"]
28
+
29
+
30
+ def _offered() -> dict[str, str]:
31
+ """Codeforces' languages: the submit form's when logged in, else the list read last."""
32
+ offered, _ = client().languages()
33
+ return offered
34
+
35
+
36
+ def _tools(key: str) -> list[languages.Tool]:
37
+ language = languages.LANGUAGES.get(key)
38
+ return language.detect(languages.Machine()) if language else []
39
+
40
+
41
+ def _commands(key: str, recommended: bool = False) -> tuple[str, str] | None:
42
+ """(build, run) as cf-tool runs them now, or as it would without the
43
+ settings that change them (recommended); None without a compiler."""
44
+ current = settings()
45
+ if recommended:
46
+ current = copy.copy(current)
47
+ conf = current.language_settings(key)
48
+ plain = dataclasses.replace(conf, build=None, run=None, flags=())
49
+ current.languages = {**current.languages, key: plain}
50
+ try:
51
+ setup = languages.setup(key, current)
52
+ except CFError:
53
+ return None
54
+ return " ".join(setup.build or ()), " ".join(setup.run)
55
+
56
+
57
+ def _shown(command: str) -> str:
58
+ """A command for reading: home as ~."""
59
+ return command.replace(str(Path.home()), "~") if command else "(nothing to build)"
60
+
61
+
62
+ # ┌──────────────────────────────────────────────────────────┐
63
+ # │ What each row shows │
64
+ # └──────────────────────────────────────────────────────────┘
65
+
66
+
67
+ def _compiler_row(key: str) -> str:
68
+ conf = settings().language_settings(key)
69
+ label = "Interpreter" if key == "py" else "Compiler"
70
+ if conf.compiler:
71
+ return ui.row(label, conf.compiler)
72
+ tools = _tools(key)
73
+ if not tools:
74
+ return ui.row(label, f"none found: {languages.LANGUAGES[key].install_hint()}")
75
+ return ui.row(label, tools[0].label, RECOMMENDED)
76
+
77
+
78
+ def _submit_row(key: str, offered: dict[str, str]) -> str:
79
+ conf = settings().language_settings(key)
80
+ chosen = languages.choose_submit(key, conf.submit, offered)
81
+ if chosen is None:
82
+ value = f"{conf.submit}, not offered any more" if conf.submit else "nothing offered"
83
+ return ui.row("Submitted as", value)
84
+ return ui.row("Submitted as", chosen[1], "" if conf.submit else RECOMMENDED)
85
+
86
+
87
+ def _commands_row(key: str) -> str:
88
+ conf = settings().language_settings(key)
89
+ if conf.build is not None or conf.run is not None:
90
+ return ui.row("Build and run", "your own commands")
91
+ if conf.flags:
92
+ return ui.row("Build and run", f"recommended + {' '.join(conf.flags)}")
93
+ return ui.row("Build and run", "the recommended commands", RECOMMENDED)
94
+
95
+
96
+ def _starter_row(key: str) -> str:
97
+ try:
98
+ _, origin = languages.starter(key, settings())
99
+ except CFError:
100
+ return ui.row("Starter code", f"{settings().language_settings(key).template} (missing)")
101
+ if origin == "built-in":
102
+ return ui.row("Starter code", "the built-in one", RECOMMENDED)
103
+ return ui.row("Starter code", short(origin))
104
+
105
+
106
+ # ┌──────────────────────────────────────────────────────────┐
107
+ # │ Picking one │
108
+ # └──────────────────────────────────────────────────────────┘
109
+
110
+
111
+ def pick_language() -> None:
112
+ current = settings()
113
+ keys = [*LISTED, *(k for k in current.languages if k not in languages.LANGUAGES)]
114
+ key = ui.select(
115
+ "Which language do you write in? (cf get writes your starter code in it)",
116
+ [(languages.display_name(k), k) for k in keys],
117
+ default=current.language or "cpp",
118
+ )
119
+ current.update("language", key)
120
+
121
+
122
+ def pick_compiler(key: str) -> None:
123
+ """A language's compiler (or interpreter): the best one found here, one
124
+ of the others found, or a program named by hand."""
125
+ tools = _tools(key)
126
+ conf = settings().language_settings(key)
127
+ choices = []
128
+ if tools:
129
+ choices.append((ui.row(tools[0].label, "", RECOMMENDED), DEFAULT))
130
+ choices += [(tool.label, tool.program) for tool in tools[1:]]
131
+ else:
132
+ ui.warn(f"No compiler found here: {languages.LANGUAGES[key].install_hint()}")
133
+ if conf.compiler and conf.compiler not in [value for _, value in choices]:
134
+ choices.append((conf.compiler, conf.compiler))
135
+ choices.append(("Another program...", OTHER))
136
+ program = ui.select(
137
+ f"Which {languages.display_name(key)} compiler?", choices, default=conf.compiler or DEFAULT
138
+ )
139
+ if program == OTHER:
140
+ program = ui.path(
141
+ "The program (its name on PATH, or its path; Tab completes):",
142
+ validate=lambda s: bool(shutil.which(str(Path(s.strip()).expanduser())))
143
+ or "Not a program that runs",
144
+ )
145
+ program = str(Path(program).expanduser()) if "/" in program else program
146
+ if program == DEFAULT:
147
+ settings().remove("compiler", table=key)
148
+ else:
149
+ settings().update("compiler", program, table=key)
150
+
151
+
152
+ def pick_submit(key: str) -> None:
153
+ """The Codeforces language a language is submitted as: the newest of
154
+ its family, or another picked by name (saved as its id)."""
155
+ offered = _offered()
156
+ options = languages.family(key, offered)
157
+ name = languages.display_name(key)
158
+ if not options:
159
+ ui.warn(f"Codeforces offers no {name} language that cf-tool knows of")
160
+ return
161
+ conf = settings().language_settings(key)
162
+ chosen = languages.choose_submit(key, conf.submit, offered) if conf.submit else None
163
+ lang_id = ui.select(
164
+ f"Submit {name} to Codeforces as:",
165
+ [(ui.row(options[0][1], "", RECOMMENDED), DEFAULT)] + [(n, lid) for lid, n in options[1:]],
166
+ default=chosen[0] if chosen and chosen != options[0] else DEFAULT,
167
+ search=len(options) > ui.MAX_SHORTCUTS,
168
+ )
169
+ if lang_id == DEFAULT:
170
+ settings().remove("submit", table=key)
171
+ else:
172
+ settings().update("submit", lang_id, table=key)
173
+
174
+
175
+ def pick_commands(key: str) -> None:
176
+ """How a language is built and run: the recommended commands, those with
177
+ flags of yours added, or commands of your own."""
178
+ name = languages.display_name(key)
179
+ conf = settings().language_settings(key)
180
+ recommended = _commands(key, recommended=True)
181
+ if recommended is None:
182
+ ui.warn(f"No {name} compiler found here: pick one first")
183
+ return
184
+ build, run = recommended
185
+ ui.line(("Recommended ", "dim"), ("build ", "dim"), (_shown(build), "cyan"))
186
+ ui.line((" ", "dim"), ("run ", "dim"), (_shown(run), "cyan"))
187
+ ui.dim("{src} is your code file, {exe} the program built from it (kept out of your folder)")
188
+ own = conf.build is not None or conf.run is not None
189
+ action = ui.select(
190
+ f"How should {name} be built and run?",
191
+ [
192
+ (ui.row("These", "", RECOMMENDED), DEFAULT),
193
+ ("These, with flags of mine added (like -DLOCAL)...", "flags"),
194
+ ("Commands of my own...", "own"),
195
+ ],
196
+ default="own" if own else "flags" if conf.flags else DEFAULT,
197
+ )
198
+ if action == "own":
199
+ build_text = ui.text(
200
+ "Build command (empty: nothing to build):",
201
+ default=" ".join(conf.build) if conf.build is not None else build,
202
+ )
203
+ run_text = ui.text(
204
+ "Run command:",
205
+ default=" ".join(conf.run) if conf.run is not None else run,
206
+ allow_empty=False,
207
+ )
208
+ settings().update("build", build_text, table=key)
209
+ settings().update("run", run_text, table=key)
210
+ settings().remove("flags", table=key)
211
+ return
212
+ flags = ui.text("Flags to add:", default=" ".join(conf.flags)) if action == "flags" else ""
213
+ for setting in ("build", "run"):
214
+ settings().remove(setting, table=key)
215
+ if flags:
216
+ settings().update("flags", flags, table=key)
217
+ else:
218
+ settings().remove("flags", table=key)
219
+
220
+
221
+ def pick_starter(key: str) -> None:
222
+ """A language's starter code: the built-in one (or yours in
223
+ templates/template.<ext>), a file of yours anywhere, or the built-in one
224
+ copied to templates/ to change it."""
225
+ own = paths.templates_dir() / f"template.{key}"
226
+ conf = settings().language_settings(key)
227
+ if own.is_file():
228
+ choices = [(ui.row(f"Yours: {short(own)}", "", RECOMMENDED), DEFAULT)]
229
+ else:
230
+ choices = [(ui.row("The built-in one", "", RECOMMENDED), DEFAULT)]
231
+ if conf.template:
232
+ choices.append((conf.template, conf.template))
233
+ choices.append(("A file of mine...", OTHER))
234
+ if not own.is_file() and key in languages.LANGUAGES:
235
+ choices.append((f"The built-in one, copied to {short(own)} for me to change", "copy"))
236
+ picked = ui.select(
237
+ f"Starter code for {languages.display_name(key)}:",
238
+ choices,
239
+ default=conf.template or DEFAULT,
240
+ )
241
+ if picked == "copy":
242
+ paths.write_atomic(own, languages.LANGUAGES[key].starter)
243
+ settings().remove("template", table=key)
244
+ ui.success(f"Wrote {short(own)}: change it as you like; cf get starts from it")
245
+ return
246
+ if picked == OTHER:
247
+ picked = ui.path(
248
+ "The file (Tab completes):",
249
+ validate=lambda s: (bool(s.strip()) and Path(s.strip()).expanduser().is_file())
250
+ or "Not a file",
251
+ )
252
+ if picked == DEFAULT:
253
+ settings().remove("template", table=key)
254
+ else:
255
+ settings().update("template", picked, table=key)
256
+
257
+
258
+ def pick_proxy() -> None:
259
+ current = settings()
260
+ proxy = ui.text(
261
+ 'Proxy, like "socks5://127.0.0.1:1080" (empty: none, or the environment\'s):',
262
+ default=current.proxy,
263
+ validate=lambda s: (not s.strip())
264
+ or bool(re.fullmatch(r"[\w+-]+://[\w.-]+(:\d+)?/?", s.strip()))
265
+ or "Like http://127.0.0.1:7890",
266
+ )
267
+ if proxy:
268
+ current.update("proxy", proxy)
269
+ else:
270
+ current.remove("proxy")
271
+
272
+
273
+ def edit_file() -> None:
274
+ """Open config.toml in the editor; check it once closed."""
275
+ path = paths.config_file()
276
+ current: Settings | None
277
+ try:
278
+ current = settings()
279
+ except CFError as e: # NOTE: a broken file is opened all the same, to fix it
280
+ ui.warn(str(e))
281
+ current = None
282
+ if current is not None and not path.exists():
283
+ current.write_default()
284
+ click.edit(filename=str(path))
285
+ reset()
286
+ try:
287
+ reloaded = Settings.load(path)
288
+ except CFError as e:
289
+ raise CFError(f"{e}\nRun cf config --edit again to fix it") from e
290
+ for warning in reloaded.warnings:
291
+ ui.warn(warning)
292
+ ui.success(f"Saved {short(path)}")
293
+
294
+
295
+ # ┌──────────────────────────────────────────────────────────┐
296
+ # │ The menu │
297
+ # └──────────────────────────────────────────────────────────┘
298
+
299
+ _PICKERS = {
300
+ "compiler": pick_compiler,
301
+ "submit": pick_submit,
302
+ "commands": pick_commands,
303
+ "template": pick_starter,
304
+ }
305
+
306
+
307
+ def _shown_languages() -> tuple[list[str], list[str]]:
308
+ """(the languages the menu shows, those it leaves out): the one for new
309
+ problems first, then those installed here or set in config.toml."""
310
+ current = settings()
311
+ main = current.language or "cpp"
312
+ shown, missing = [main], []
313
+ for key in LISTED:
314
+ if key == main:
315
+ continue
316
+ if key in current.languages or _tools(key):
317
+ shown.append(key)
318
+ else:
319
+ missing.append(key)
320
+ shown += [k for k in current.languages if k not in languages.LANGUAGES and k not in shown]
321
+ return shown, missing
322
+
323
+
324
+ def _language_rows(key: str, offered: dict[str, str]) -> list:
325
+ rows: list = [ui.Heading(f"── {languages.display_name(key)} ──")]
326
+ if key in languages.LANGUAGES:
327
+ rows.append((_compiler_row(key), ("compiler", key)))
328
+ rows.append((_submit_row(key, offered), ("submit", key)))
329
+ if key in languages.LANGUAGES: # NOTE: config.toml's own languages: edit the file
330
+ rows.append((_commands_row(key), ("commands", key)))
331
+ rows.append((_starter_row(key), ("template", key)))
332
+ return rows
333
+
334
+
335
+ def menu() -> None:
336
+ """cf config: every setting in one list, until Done or Esc."""
337
+ action = "language"
338
+ while True:
339
+ current = settings()
340
+ offered = _offered()
341
+ shown, missing = _shown_languages()
342
+ who = client().handle or "not logged in"
343
+ rows: list = [
344
+ ui.Heading("── General ──"),
345
+ (
346
+ ui.row(
347
+ "Language for new problems",
348
+ languages.display_name(current.language or "cpp"),
349
+ "" if current.language else "not picked yet",
350
+ ),
351
+ "language",
352
+ ),
353
+ (ui.row("Proxy", current.proxy or "none"), "proxy"),
354
+ ]
355
+ for key in shown:
356
+ rows += _language_rows(key, offered)
357
+ if missing:
358
+ names = ", ".join(languages.display_name(k) for k in missing)
359
+ rows.append(ui.Heading(f"── not installed here: {names} ──"))
360
+ rows += [
361
+ ui.Heading(""),
362
+ ("Edit config.toml in your editor", "edit"),
363
+ ("Done", "done"),
364
+ ]
365
+ values = [r[1] for r in rows if not isinstance(r, ui.Heading)]
366
+ try:
367
+ action = ui.select(
368
+ f"cf-tool settings · {who} · {short(current.file)}",
369
+ rows,
370
+ default=action if action in values else "language",
371
+ )
372
+ except ui.Canceled:
373
+ return # NOTE: Esc on the menu itself: done
374
+ if action == "done":
375
+ return
376
+ try:
377
+ if isinstance(action, tuple):
378
+ _PICKERS[action[0]](action[1])
379
+ elif action == "language":
380
+ pick_language()
381
+ elif action == "proxy":
382
+ pick_proxy()
383
+ elif action == "edit":
384
+ edit_file()
385
+ except ui.Canceled:
386
+ pass # NOTE: Esc in a list: back to the menu