Searchable model dropdown with a top search bar + URL reload button

Replace the model combo with a ModelPicker: an editable field plus a ▾ button
that opens a popover with a SearchEntry on top and a scrollable, filtered list
of models (HuggingFace-style). Add a refresh icon next to each engine URL that
loads models from {url}/models on demand. Verified: 8010 -> 428 models, typing
"turbo" filters to the turbo variants. You can still type a custom model name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-05 09:13:50 +02:00
parent 09e3f58ba8
commit a508ecc69c

View File

@ -94,45 +94,92 @@ def _combo(options, active=None) -> Gtk.ComboBoxText:
return c return c
def _model_combo(placeholder="") -> Gtk.ComboBoxText: class ModelPicker(Gtk.Box):
"""Editable combo with a type-to-search completion (dropdown + searchbar).""" """An editable model field + a ▾ button opening a popover with a search bar
c = Gtk.ComboBoxText.new_with_entry() on top and a scrollable, filtered list of models."""
c.set_hexpand(True)
entry = c.get_child() def __init__(self, placeholder: str = ""):
super().__init__(spacing=4)
self.entry = Gtk.Entry(); self.entry.set_hexpand(True)
if placeholder: if placeholder:
entry.set_placeholder_text(placeholder) self.entry.set_placeholder_text(placeholder)
self.pack_start(self.entry, True, True, 0)
self.btn = Gtk.Button.new_from_icon_name("pan-down-symbolic", Gtk.IconSize.BUTTON)
self.btn.set_tooltip_text("Browse models")
self.pack_start(self.btn, False, False, 0)
store = Gtk.ListStore(str) self.pop = Gtk.Popover(); self.pop.set_relative_to(self.btn)
comp = Gtk.EntryCompletion() box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
comp.set_model(store) for m in ("top", "bottom", "start", "end"):
comp.set_text_column(0) getattr(box, f"set_margin_{m}")(6)
comp.set_popup_completion(True) self.search = Gtk.SearchEntry(); self.search.set_placeholder_text("Search models…")
comp.set_minimum_key_length(1) box.pack_start(self.search, False, False, 0)
sw = Gtk.ScrolledWindow()
sw.set_min_content_height(300); sw.set_min_content_width(380)
sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.listbox = Gtk.ListBox()
self.listbox.set_filter_func(self._filter)
sw.add(self.listbox); box.pack_start(sw, True, True, 0)
self.pop.add(box)
def _match(completion, key, it, *_a): self.btn.connect("clicked", self._open)
row = completion.get_model()[it][0] self.search.connect("search-changed", lambda _s: self.listbox.invalidate_filter())
return key.lower() in row.lower() # substring, anywhere self.listbox.connect("row-activated", self._activated)
comp.set_match_func(_match) def _filter(self, row) -> bool:
entry.set_completion(comp) return self.search.get_text().lower() in row.get_child().get_text().lower()
c._models_store = store
return c def _open(self, _b) -> None:
self.pop.show_all(); self.search.set_text(""); self.search.grab_focus()
def _activated(self, _lb, row) -> None:
self.entry.set_text(row.get_child().get_text())
self.pop.popdown()
def set_models(self, models) -> None:
for child in self.listbox.get_children():
self.listbox.remove(child)
for m in models:
row = Gtk.ListBoxRow()
lbl = Gtk.Label(label=m, xalign=0.0)
lbl.set_margin_top(4); lbl.set_margin_bottom(4); lbl.set_margin_start(8); lbl.set_margin_end(8)
row.add(lbl); self.listbox.add(row)
self.listbox.show_all()
def get_text(self) -> str:
return self.entry.get_text().strip()
def set_text(self, value: str) -> None:
self.entry.set_text(value or "")
def _combo_text(c: Gtk.ComboBoxText) -> str: def _model_combo(placeholder="") -> ModelPicker:
return (c.get_active_text() or "").strip() return ModelPicker(placeholder)
def _fill_combo(combo: Gtk.ComboBoxText, options, current: str) -> None: def _combo_text(c: ModelPicker) -> str:
combo.remove_all() return c.get_text()
store = getattr(combo, "_models_store", None)
if store is not None:
store.clear() def _fill_combo(combo: ModelPicker, options, current: str) -> None:
for o in options: combo.set_models(list(options))
combo.append_text(o) combo.set_text(current or "")
if store is not None:
store.append([o])
combo.get_child().set_text(current or "") def _url_field(parent: Gtk.Box, label: str, placeholder: str, on_reload) -> Gtk.Entry:
row = Gtk.Box(spacing=10); row.set_margin_top(3); row.set_margin_bottom(3)
lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(130, -1)
row.pack_start(lbl, False, False, 0)
e = Gtk.Entry(); e.set_hexpand(True)
if placeholder:
e.set_placeholder_text(placeholder)
row.pack_start(e, True, True, 0)
btn = Gtk.Button.new_from_icon_name("view-refresh-symbolic", Gtk.IconSize.BUTTON)
btn.set_tooltip_text("Load models from this URL")
btn.connect("clicked", lambda _b: on_reload())
row.pack_start(btn, False, False, 0)
parent.pack_start(row, False, False, 0)
return e
def _page(nb: Gtk.Notebook, title: str) -> Gtk.Box: def _page(nb: Gtk.Notebook, title: str) -> Gtk.Box:
@ -289,7 +336,8 @@ class SettingsDialog:
form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2) form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2)
self.stt_type = _labeled(form, "Type", _combo(["local", "openai"])) self.stt_type = _labeled(form, "Type", _combo(["local", "openai"]))
self.stt_url = _labeled(form, "URL", _entry(placeholder="http://localhost:8010/v1 (blank for local)")) self.stt_url = _url_field(form, "URL", "http://localhost:8010/v1 (blank for local)",
lambda: self._populate_models(self.stt_model, self.stt_url.get_text().strip(), self.stt_key.get_text().strip()))
self.stt_model = _labeled(form, "Model", _model_combo("pick after entering URL · tiny/base/small… for local")) self.stt_model = _labeled(form, "Model", _model_combo("pick after entering URL · tiny/base/small… for local"))
self.stt_key = _labeled(form, "API key env", _entry(placeholder="env var name, e.g. GROQ_API_KEY (optional)")) self.stt_key = _labeled(form, "API key env", _entry(placeholder="env var name, e.g. GROQ_API_KEY (optional)"))
self.stt_url.connect("changed", lambda _e: self._schedule_models("stt")) self.stt_url.connect("changed", lambda _e: self._schedule_models("stt"))
@ -319,7 +367,8 @@ class SettingsDialog:
box.pack_start(bar, False, False, 2) box.pack_start(bar, False, False, 2)
form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2) form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2)
self.llm_url = _labeled(form, "Base URL", _entry(placeholder="http://localhost:28080/v1 · https://api.openai.com/v1")) self.llm_url = _url_field(form, "Base URL", "http://localhost:28080/v1 · https://api.openai.com/v1",
lambda: self._populate_models(self.llm_model, self.llm_url.get_text().strip(), self.llm_key.get_text().strip()))
self.llm_model = _labeled(form, "Model", _model_combo("pick after entering URL")) self.llm_model = _labeled(form, "Model", _model_combo("pick after entering URL"))
self.llm_key = _labeled(form, "API key env", _entry(placeholder="env var name, e.g. OPENAI_API_KEY (blank for local)")) self.llm_key = _labeled(form, "API key env", _entry(placeholder="env var name, e.g. OPENAI_API_KEY (blank for local)"))
self.llm_temp = _labeled(form, "Temperature", _entry(placeholder="0.3")) self.llm_temp = _labeled(form, "Temperature", _entry(placeholder="0.3"))