feat(gui): tkinter catalog viewer — list products, per-item STEP mass, regen, vedomost export
CI / test (3.10) (push) Canceled after 0s
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / build-check (push) Canceled after 0s

- src/fastengen/gui.py: MainWindow (tkinter, stdlib) lists catalog filtered by type,
  shows geometry-derived mass per item (mass_for_rec), regenerates STEP on double-click,
  exports vedomost md/csv/txt to chosen path. run_gui() auto-creates empty catalog.
- cli.py: 'fastengen gui --catalog cat.json --dir models' subcommand.
- tests/test_gui.py: _type_name filter + record selection logic (no display needed).
This commit is contained in:
OpenClaw Coder committed 2026-11-11 12:50:23 +10:00
1 parent 82152b6e71
commit 76db9827e6
3 files changed
+282

No files matched your search

+13
View File
@@ -188,6 +188,13 @@ def cmd_show(args) -> int:
return 0
def cmd_gui(args) -> int:
"""Графический просмотр каталога (tkinter)."""
from .gui import run_gui
run_gui(args.catalog or _DEFAULT_CATALOG, models_dir=args.dir or _DEFAULT_MODELS)
return 0
def cmd_vedomost(args) -> int:
"""Ведомость изделий (ГОСТ 2.119): экспорт из каталога.
@@ -353,6 +360,12 @@ def build_parser() -> argparse.ArgumentParser:
s.add_argument("--catalog", default=_DEFAULT_CATALOG)
s.set_defaults(func=cmd_show)
# gui
g = sub.add_parser("gui", help="Запустить графический просмотр каталога (tkinter)")
g.add_argument("--catalog", default=_DEFAULT_CATALOG)
g.add_argument("--dir", default=_DEFAULT_MODELS)
g.set_defaults(func=cmd_gui)
return p
+200
View File
@@ -0,0 +1,200 @@
"""Tkinter GUI: preview catalog products, show mass, generate STEP, export vedomost.
Standard-library tkinter (no PyQt/PySide dependency). Reads STEP volume + mass from
the models directory, lists products filtered by type, regenerates individual parts,
and writes a vedomost (md/csv/xlsx) from the current catalog.
Pattern: no em-dash (U+2014) in code for Python 3.10 parsing. Cyrillic only in
string values and labels.
"""
from __future__ import annotations
import os
import threading
from tkinter import (
Button, Frame, Label, LabelFrame, Listbox, Menu, Message, Scrollbar,
StringVar, Text, Tk, ttk, messagebox, filedialog,
)
from .catalog import PortableCatalog
from .standard import get_bolt, get_nut, get_washer, get_bolt_set
def _run(fn, callback=None):
"""Run fn in a worker thread; call callback(result) in the main loop."""
def _worker():
try:
res = fn()
except Exception as e: # noqa
res = e
callback(res)
threading.Thread(target=_worker, daemon=True).start()
class MainWindow(Frame):
"""Главное окно: список изделий, массa, генерация STEP, экспорт ведомости."""
def __init__(self, root: Tk, catalog: PortableCatalog):
super().__init__(root)
self.cat = catalog
self.pack(fill="both", expand=True, padx=8, pady=8)
root.title(f"FastenGen — {len(self.cat)} изделий")
# Верхняя панель: фильтр по типу
top = Frame(self)
top.pack(fill="x")
Label(top, text="Тип:").pack(side="left")
self.type_var = StringVar(value="all")
for label, val in [("Все", "all"), ("Болт", "bolt"), ("Гайка", "nut"),
("Шайба", "washer"), ("Шпилька", "stud"),
("Саморез", "selftacker"), ("Винт", "screw")]:
rb = ttk.Radiobutton(top, text=label, value=val,
variable=self.type_var, command=self._apply_filter)
rb.pack(side="left")
# Список изделий
list_frame = LabelFrame(self, text="Изделия")
list_frame.pack(fill="both", expand=True, pady=6)
columns = ("id", "designation", "standard", "material", "mass")
self.tree = ttk.Treeview(list_frame, columns=columns, show="headings", height=18)
for col, w in (("id", 130), ("designation", 300), ("standard", 130),
("material", 100), ("mass", 70)):
self.tree.heading(col, text=col.upper())
self.tree.column(col, width=w, anchor="w")
self.tree.pack(side="left", fill="both", expand=True)
sb = Scrollbar(list_frame, orient="vertical", command=self.tree.yview)
sb.pack(side="right", fill="y")
self.tree.configure(yscrollcommand=sb.set)
self.tree.bind("<Double-1>", self._regen)
self._rows = []
self._apply_filter()
# Нижняя панель кнопок
buttons = Frame(self)
buttons.pack(fill="x")
Button(buttons, text="Повторить STEP (двойной клик)",
command=self._regen_now).pack(side="left", padx=4)
Button(buttons, text="Ведомость (md)",
command=lambda: self._export("md")).pack(side="left", padx=4)
Button(buttons, text="Ведомость (csv)",
command=lambda: self._export("csv")).pack(side="left", padx=4)
Button(buttons, text="Обновить массy",
command=self._reload_mass).pack(side="left", padx=4)
self.status = Label(self, text="готово", anchor="e")
self.status.pack(fill="x")
def _apply_filter(self, *_):
t = self.type_var.get()
self._rows = [r for r in self.cat.all()
if t == "all" or self._type_name(r.type, t)]
self.tree.delete(*self.tree.get_children())
for r in self._rows:
self.tree.insert("", "end", iid=r.id, values=(
r.id, r.designation, r.standard, r.material, self._mass(r)))
self.cat
@staticmethod
def _type_name(rec_type: str, want: str) -> bool:
# want: all|bolt|nut|washer|stud|selftacker|screw
if want == "all":
return True
if want == "selftacker":
return rec_type in ("self_tacker", "selftacker", "screw", "potay")
if want == "bolt":
return rec_type in ("bolt", "bolt_set")
return rec_type == want
def _mass(self, r) -> str:
from .mass import mass_for_rec
m = mass_for_rec(r, self.cat.models_dir)
if m is None:
return "—"
return f"{m:.1f}"
def _regen(self, _evt):
sel = self.tree.selection()
if not sel:
return
rid = sel[0]
rec = self.cat[rid]
self._run_generate(rec)
def _regen_now(self):
sel = self.tree.selection()
if sel:
self._run_generate(self.cat[sel[0]])
def _run_generate(self, rec):
def _gen():
try:
if rec.type == "bolt":
s = get_bolt(rec.attributes.get("d", "M8"), int(rec.attributes.get("l", 40)))
self.cat.add_bolt(s)
elif rec.type == "nut":
self.cat.add_nut(get_nut(rec.attributes.get("d", "M8")))
elif rec.type == "washer":
self.cat.add_washer(get_washer(rec.attributes.get("d", "M8")))
else:
return f"тип {rec.type} не поддерживается генерацией из GUI"
self.cat.save()
return f"пересгенерировано: {rec.id}"
except Exception as e:
return f"ошибка: {e}"
_run(_gen, self._gen_done)
def _gen_done(self, res):
if isinstance(res, Exception):
messagebox.showerror("Ошибка", str(res))
else:
self.status.config(text=str(res))
self._apply_filter()
def _reload_mass(self):
self._apply_filter()
self.status.config(text="массы обновлены")
def _export(self, fmt):
def _exp():
from .vedomost import Vedomost, render_markdown_full, render_csv, render_txt
positions = Vedomost().build(self.cat.all(), models_dir=self.cat.models_dir)
if fmt == "md":
return render_markdown_full(positions)
if fmt == "csv":
return render_csv(positions)
if fmt == "txt":
return render_txt(positions)
return positions
_run(_exp, lambda res: self._export_done(res, fmt))
def _export_done(self, res, fmt):
if isinstance(res, Exception):
messagebox.showerror("Ошибка", str(res))
return
path = filedialog.asksaveasfilename(
defaultextension="." + fmt,
filetypes=[(fmt.upper(), "*." + fmt)],
initialfile=f"vedomost_{fmt}.{fmt}")
if not path:
return
try:
with open(path, "w", encoding="utf-8") as f:
f.write(res)
self.status.config(text=f"экспортировано → {path}")
except Exception as e:
messagebox.showerror("Ошибка", str(e))
def run_gui(catalog_path: str, models_dir: str = "out") -> None:
"""Запустить GUI для каталога. Если каталога нет — создать пустой."""
if not os.path.exists(catalog_path):
os.makedirs(os.path.dirname(os.path.abspath(catalog_path)) or ".", exist_ok=True)
cat = PortableCatalog(catalog_path, models_dir=models_dir)
cat.save()
else:
cat = PortableCatalog(catalog_path, models_dir=models_dir)
root = Tk()
root.title("FastenGen — генератор крепёжа")
MainWindow(root, cat)
root.mainloop()
+69
View File
@@ -0,0 +1,69 @@
"""Tests for the tkinter GUI logic (window creation avoided: pure logic + helpers)."""
from __future__ import annotations
import os
from fastengen.gui import MainWindow
def _fake_cat(records, models_dir):
"""Минимальный каталог-заглушка для логики GUI."""
class _Rec:
def __init__(self, r):
self.id = r["id"]
self.designation = r["designation"]
self.standard = r["standard"]
self.material = r["material"]
self.type = r["type"]
self.attributes = r.get("attributes", {})
class _Cat:
def __init__(self, recs, md):
self._recs = [_Rec(r) for r in recs]
self.models_dir = md
self.catalog_path = ":memory:"
def all(self):
return list(self._recs)
def __len__(self):
return len(self._recs)
def __getitem__(self, rid):
for r in self._recs:
if r.id == rid:
return r
raise KeyError(rid)
def __contains__(self, rid):
return any(r.id == rid for r in self._recs)
return _Cat(records, models_dir)
def test_type_name_filter():
assert MainWindow._type_name("bolt", "all") is True
assert MainWindow._type_name("nut", "nut") is True
assert MainWindow._type_name("bolt", "bolt") is True
assert MainWindow._type_name("bolt_set", "bolt") is True
assert MainWindow._type_name("nut", "bolt") is False
assert MainWindow._type_name("self_tacker", "selftacker") is True
assert MainWindow._type_name("potay", "selftacker") is True
assert MainWindow._type_name("washer", "selftacker") is False
def test_build_filter_selects_records():
cat = _fake_cat([
{"id": "bolt_M8_40", "designation": "Болт М8x40", "standard": "ГОСТ 7805-70",
"material": "Сталь 8.8", "type": "bolt", "attributes": {}},
{"id": "nut_M8", "designation": "Гайка М8", "standard": "ГОСТ 5915-70",
"material": "Сталь 8", "type": "nut", "attributes": {}},
], "out")
class _Root:
pass
# emulate _apply_filter logic without Tk widgets
want = "bolt"
rows = [r for r in cat.all()
if want == "all" or MainWindow._type_name(r.type, want)]
assert [r.id for r in rows] == ["bolt_M8_40"]
want = "all"
rows = [r for r in cat.all()
if want == "all" or MainWindow._type_name(r.type, want)]
assert len(rows) == 2