feat(gui): tkinter catalog viewer — list products, per-item STEP mass, regen, vedomost export
- 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:
1 parent
82152b6e71
commit
76db9827e6
3 files changed
+282
No files matched your search
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user