diff --git a/src/fastengen/cli.py b/src/fastengen/cli.py index 449159e..ef2ec46 100644 --- a/src/fastengen/cli.py +++ b/src/fastengen/cli.py @@ -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 diff --git a/src/fastengen/gui.py b/src/fastengen/gui.py new file mode 100644 index 0000000..dd156a3 --- /dev/null +++ b/src/fastengen/gui.py @@ -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("", 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() diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..0dee215 --- /dev/null +++ b/tests/test_gui.py @@ -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