Source code for simudo.gui.panels.materials

"""
Simudo GUI — Materials panel.

Materials supply property defaults at PRIORITY_MATERIAL (lowest).  Two kinds:
  • Inline      — key/value/unit pairs stored directly in the YAML.
  • Library ref — ``source: library://ClassName``, loaded by the runner.

The panel shows a browseable catalogue of classes found in library directories
and a list of project-specific materials.
"""

from __future__ import annotations
import os
from typing import TYPE_CHECKING, Dict, List, Optional, Set

import panel as pn

from simudo.gui.model import Material
from simudo.gui.panels.mat_ast import ClassInfo, PropertyInfo, scan_library_dirs
from simudo.gui.panels.shared import (
    INPUT_SS, SELECT_SS, BTN_LIGHT_SS, RADIO_SS, ADD_BTN_SS,
    sec_header, label,
)

if TYPE_CHECKING:
    from simudo.gui.app import SimudoApp

# ── Styles ─────────────────────────────────────────────────────────────────────

_DOT_SS = ["""
:host { display:inline-flex; align-items:center; }
.bk-btn { width:14px !important; height:14px !important; border-radius:50% !important;
    border:2px solid transparent !important; padding:0 !important;
    cursor:pointer !important; min-width:0 !important; flex-shrink:0 !important; }
.bk-btn:hover { filter:brightness(1.3) !important; }
"""]

_ROW_BTN_SS = ["""
:host { flex:1; min-width:0; display:block; }
.bk-btn { background:transparent !important; border:none !important;
    color:#c8d6e5 !important; font-size:12px !important;
    text-align:left !important; padding:0 !important; cursor:pointer !important;
    width:100% !important; white-space:nowrap !important;
    overflow:hidden !important; text-overflow:ellipsis !important; }
.bk-btn:hover { color:#4a9eff !important; }
"""]

_SMALL_BTN_SS = ["""
.bk-btn { background:#1a2435 !important; color:#8a9bb5 !important;
    border:1px solid #2d3748 !important; border-radius:3px !important;
    font-size:11px !important; padding:0 6px !important; cursor:pointer !important;
    height:22px !important; white-space:nowrap !important; }
.bk-btn:hover { color:#c8d6e5 !important; border-color:#4a9eff !important; }
"""]

_DEL_SS = ["""
:host { display:inline-flex; }
.bk-btn { background:transparent !important; border:none !important;
    color:#556070 !important; font-size:14px !important;
    padding:0 4px !important; cursor:pointer !important;
    line-height:1 !important; width:22px !important; }
.bk-btn:hover { color:#e74c3c !important; }
"""]

_BROWSE_SS = ["""
.bk-btn { background:#1a2435 !important; color:#8a9bb5 !important;
    border:1px solid #2d3748 !important; border-radius:3px !important;
    font-size:12px !important; padding:0 8px !important; cursor:pointer !important; }
.bk-btn:hover { color:#c8d6e5 !important; border-color:#4a9eff !important; }
"""]

_SUBSEC = (
    "font-size:11px;font-weight:700;letter-spacing:0.08em;color:#8a9bb5;"
    "border-left:2px solid #2d4a6e;padding-left:6px;margin-top:2px;"
)
_MAT_COLORS = [
    '#4a9eff', '#e07b39', '#27ae60', '#9b59b6', '#e74c3c',
    '#1abc9c', '#f39c12', '#2980b9', '#8e44ad', '#16a085',
]


def _mat_color(i: int) -> str:
    return _MAT_COLORS[i % len(_MAT_COLORS)]


def _sub(title: str) -> pn.pane.HTML:
    return pn.pane.HTML(
        f'<div style="{_SUBSEC}">{title}</div>',
        sizing_mode="stretch_width", height=20, margin=(6, 0, 2, 0),
    )


def _note(text: str, color: str = "#556070") -> pn.pane.HTML:
    return pn.pane.HTML(
        f'<div style="font-size:11px;color:{color};padding:2px 0 4px 0;">{text}</div>',
        sizing_mode="stretch_width", margin=0,
    )


def _sanitize_name(s: str) -> str:
    import re
    s = re.sub(r'[^a-zA-Z0-9_]', '_', s.strip())
    if s and s[0].isdigit():
        s = '_' + s
    return s or "material"


def _unique_name(base: str, taken: Set[str]) -> str:
    name, n = base, 2
    while name in taken:
        name = f"{base}_{n}"; n += 1
    return name


def _tk_pick_dir() -> Optional[str]:
    import threading
    result = [None]
    def _run():
        try:
            import tkinter as tk
            from tkinter import filedialog
            root = tk.Tk(); root.withdraw(); root.wm_attributes("-topmost", 1)
            path = filedialog.askdirectory(title="Select material library directory")
            root.destroy()
            result[0] = path or None
        except Exception:
            pass
    t = threading.Thread(target=_run); t.start(); t.join(timeout=60)
    return result[0]


# ── Panel ──────────────────────────────────────────────────────────────────────

[docs] class MaterialsPanel: def __init__(self, app: "SimudoApp"): self.app = app self._sel_lib: Optional[int] = None # index into _lib_classes (single-select) # Set of expanded project material indices (multi-expand) self._expanded_mats: Set[int] = set() self._lib_classes: List[ClassInfo] = [] # Persistent record of which property keys couldn't be copied per material name self._skipped: Dict[str, List[str]] = {} self._view = pn.Column( sizing_mode="stretch_width", styles={"padding": "12px", "gap": "4px", "overflow-y": "auto"}, )
[docs] def view(self) -> pn.Column: self._rebuild_view() return self._view
# ── Helpers ──────────────────────────────────────────────────────────────── def _lib_class_names(self) -> Set[str]: return {c.class_name for c in self._lib_classes} def _project_mat_names(self) -> Set[str]: return {m.name for m in self.app.project.materials} # ── Top-level rebuild ────────────────────────────────────────────────────── def _rebuild_view(self): self._lib_classes = scan_library_dirs(self.app.get_library_dirs()) # Clamp expanded set to valid indices n = len(self.app.project.materials) self._expanded_mats = {i for i in self._expanded_mats if i < n} n_classes = len(self._lib_classes) lib_status = ( f'{n_classes} class{"es" if n_classes != 1 else ""} found' ) # Library-related cards grouped with left accent lib_group = pn.Column( pn.pane.HTML( f'<div style="font-size:10px;font-weight:700;letter-spacing:0.1em;' f'color:#4a9eff;margin-bottom:3px;">LIBRARY — {lib_status}</div>', sizing_mode="stretch_width", margin=0, ), self._build_lib_dirs_card(), self._build_library_section(), sizing_mode="stretch_width", styles={ "border-left": "3px solid #2d4a6e", "padding-left": "10px", "gap": "4px", }, ) objects = [ sec_header("MATERIALS"), pn.pane.HTML( '<div style="font-size:12px;color:#8a9bb5;padding:2px 0 6px 0;">' 'Materials are optional. They let you give the same parameter values ' 'to multiple layers at once.<br>' 'Properties set here have <em>low priority</em> — anything set in ' 'the Layers panel overrides them.</div>', sizing_mode="stretch_width", margin=0, ), lib_group, self._build_project_section(), self._build_validation_warning(), ] self._view.objects = objects # ── Library-directories config card ─────────────────────────────────────── def _build_lib_dirs_card(self) -> pn.Column: bundled = self.app.bundled_lib_dir bundled_ok = os.path.isdir(bundled) bundled_html = ( f'<span style="font-family:monospace;font-size:11px;color:#8a9bb5;">' f'{bundled}</span>' + (' <span style="color:#27ae60;font-size:10px;">(found)</span>' if bundled_ok else ' <span style="color:#e74c3c;font-size:10px;">(not found)</span>') ) user_in = pn.widgets.TextInput( value=self.app.user_lib_dir, placeholder="Path to a folder of .py material files (optional)", height=26, margin=0, stylesheets=INPUT_SS, sizing_mode="stretch_width", ) browse_btn = pn.widgets.Button( name="Browse…", height=26, margin=0, stylesheets=_BROWSE_SS, ) def _on_user(e): self.app.set_user_lib_dir(e.new.strip()) def _on_browse(e): path = _tk_pick_dir() if path: user_in.value = path self.app.set_user_lib_dir(path) self._rebuild_view() user_in.param.watch(_on_user, "value") browse_btn.on_click(_on_browse) lib_dirs = self.app.get_library_dirs() n = len(self._lib_classes) status = (f'{n} class{"es" if n != 1 else ""} found in ' f'{len(lib_dirs)} director{"ies" if len(lib_dirs) != 1 else "y"}. ' f'User directory is searched first (takes precedence over bundled).') card = pn.Card( pn.Row( pn.pane.HTML('<span style="font-size:11px;color:#8a9bb5;">Bundled:</span>', width=58, height=20, margin=0), pn.pane.HTML(bundled_html, sizing_mode="stretch_width", height=20, margin=0), margin=0, styles={"align-items": "center", "gap": "4px"}, sizing_mode="stretch_width", ), pn.Row( pn.pane.HTML('<span style="font-size:11px;color:#8a9bb5;">User dir:</span>', width=58, height=26, margin=0), user_in, browse_btn, margin=0, styles={"gap": "4px", "align-items": "center"}, sizing_mode="stretch_width", ), _note(status), title="Materials file location", collapsed=True, sizing_mode="stretch_width", styles={"background": "#1a2435", "border": "1px solid #2d3748", "border-radius": "4px"}, margin=0, ) hint = pn.pane.HTML( '<div style="font-size:11px;color:#556070;padding:1px 0 3px 0;">' 'Folders containing Python <code>.py</code> material class files. ' 'Expand to add a user directory.</div>', sizing_mode="stretch_width", margin=0, ) return pn.Column(hint, card, sizing_mode="stretch_width", styles={"gap": "3px"}, margin=(0, 0, 4, 0)) # ── Library classes section ──────────────────────────────────────────────── def _build_library_section(self) -> pn.Card: # Detect duplicates within the library scan seen_names: Dict[str, int] = {} for cls in self._lib_classes: seen_names[cls.class_name] = seen_names.get(cls.class_name, 0) + 1 dup_names = {n for n, cnt in seen_names.items() if cnt > 1} rows = [] if not self._lib_classes: rows.append(_note("No classes found. Check library directories above.")) for i, cls in enumerate(self._lib_classes): is_sel = (self._sel_lib == i) # Name line: class name + optional material_name name_line = f'<strong style="color:{"#4a9eff" if is_sel else "#c8d6e5"};">{cls.class_name}</strong>' if cls.material_name and cls.material_name != cls.class_name: name_line += (f' <span style="font-size:10px;color:#556070;">' f'name: "{cls.material_name}"</span>') # Duplicate badge dup_badge = "" if cls.class_name in dup_names: dup_badge = (' <span style="font-size:9px;background:#5a1a1a;color:#e74c3c;' 'border-radius:2px;padding:1px 5px;border:1px solid #e74c3c;">' 'duplicate class name</span>') src_line = (f'<span style="font-size:10px;color:#556070;">' f'{os.path.basename(cls.file_path)}</span>') view_btn = pn.widgets.Button( name="View", height=22, margin=0, stylesheets=_SMALL_BTN_SS, ) derive_btn = pn.widgets.Button( name="Derive from this", height=22, margin=0, stylesheets=_SMALL_BTN_SS, ) ref_btn = pn.widgets.Button( name="Add to project", height=22, margin=0, stylesheets=_SMALL_BTN_SS, ) view_btn.on_click(lambda e, idx=i: self._select_lib(idx)) derive_btn.on_click(lambda e, idx=i: self._derive_from_lib_class(idx)) ref_btn.on_click(lambda e, idx=i: self._add_lib_ref(idx)) bg = "rgba(74,158,255,0.06)" if is_sel else "transparent" rows.append(pn.Row( pn.pane.HTML( f'{name_line}{dup_badge}<br>{src_line}', sizing_mode="stretch_width", margin=0, ), pn.Column(view_btn, ref_btn, derive_btn, margin=0, styles={"gap": "2px", "flex-shrink": "0"}), margin=(0, 0, 4, 0), styles={"align-items": "flex-start", "gap": "8px", "background": bg, "border-radius": "4px", "padding": "4px 6px"}, sizing_mode="stretch_width", )) # If a library class is selected, show its detail inline if self._sel_lib is not None and self._sel_lib < len(self._lib_classes): rows.append(pn.pane.HTML( '<hr style="border:none;border-top:1px solid #1e2d3d;margin:6px 0;">', sizing_mode="stretch_width", margin=0, )) rows += list(self._build_lib_detail(self._lib_classes[self._sel_lib])) # Legend rows.append(pn.pane.HTML( '<div style="font-size:10px;color:#445060;margin-top:6px;">' '<strong>Add to project</strong>: pure library reference — all properties ' 'including computed ones are available at runtime, no overrides. ' '<strong>Derive from this</strong>: creates a derived material that uses the ' 'library class as a base; you can then add or override specific numeric values ' 'while keeping all computed properties.</div>', sizing_mode="stretch_width", margin=0, )) return pn.Card( *rows, title="Library materials", collapsed=False, sizing_mode="stretch_width", styles={"background": "#161d2e", "border": "1px solid #2d3748", "border-radius": "4px"}, margin=(0, 0, 8, 0), ) def _build_lib_detail(self, cls: ClassInfo): n_copy = len(cls.copyable_properties) n_comp = len(cls.computed_properties) yield _note( f'<strong style="color:#c8d6e5;">{cls.class_name}</strong> — ' f'{len(cls.properties)} properties: ' f'{n_copy} with extractable values' + (f', {n_comp} computed at runtime' if n_comp else '') + '. Read-only. Use <em>Add to project</em> or <em>Add editable copy</em> to use in your project.' ) if cls.properties: yield self._prop_table_readonly(cls.properties) else: yield _note("No properties found (no get_dict() or empty).", color="#7a8caa") # ── Project materials section ────────────────────────────────────────────── def _build_project_section(self) -> pn.Column: lib_names = self._lib_class_names() mat_names = [m.name for m in self.app.project.materials] # Detect duplicates within project materials seen: Dict[str, int] = {} for n in mat_names: seen[n] = seen.get(n, 0) + 1 dup_mat = {n for n, cnt in seen.items() if cnt > 1} rows = [] _title = pn.pane.HTML( f'<div style="{_SUBSEC}">Project materials</div>', sizing_mode="stretch_width", height=20, margin=(6, 0, 2, 0), ) rows.append(_title) rows.append(_note( "Defined here, saved in the project file, and referenced by layers." )) materials = self.app.project.materials if not materials: rows.append(_note("None yet — use Create below.", color="#7a8caa")) for i, mat in enumerate(materials): is_expanded = i in self._expanded_mats color = _mat_color(i) # Name badges badges = "" if mat.name in dup_mat: badges += (' <span style="font-size:9px;background:#5a1a1a;color:#e74c3c;' 'border-radius:2px;padding:1px 5px;border:1px solid #e74c3c;">' 'duplicate name</span>') if mat.name in lib_names: badges += (' <span style="font-size:9px;background:#2d1a00;color:#e07b39;' 'border-radius:2px;padding:1px 5px;border:1px solid #e07b39;">' 'same name as library class</span>') dot = pn.widgets.Button( name="", height=14, width=14, margin=0, stylesheets=_DOT_SS, styles={"background": color, "border-color": "#ffffff" if is_expanded else color}, ) name_btn = pn.widgets.Button( name=mat.name, margin=0, stylesheets=_ROW_BTN_SS, styles={"font-weight": "600" if is_expanded else "normal"}, ) src_label = mat.source or "inline" src_pane = pn.pane.HTML( f'<span style="font-size:10px;color:#556070;">{src_label}</span>' + badges, sizing_mode="stretch_width", margin=0, ) del_btn = pn.widgets.Button(name="✕", margin=0, stylesheets=_DEL_SS) dot.on_click(lambda e, idx=i: self._toggle_mat(idx)) name_btn.on_click(lambda e, idx=i: self._toggle_mat(idx)) del_btn.on_click(lambda e, idx=i: self._delete_mat(idx)) bg = "rgba(74,158,255,0.07)" if is_expanded else "transparent" rows.append(pn.Row( dot, pn.Column( name_btn, src_pane, margin=0, styles={"gap": "0px", "flex": "1", "min-width": "0"}, ), del_btn, margin=(0, 0, 2, 0), styles={"align-items": "center", "gap": "6px", "background": bg, "border-radius": "4px", "padding": "2px 4px"}, sizing_mode="stretch_width", )) # Inline detail when expanded (independent of other materials) if is_expanded: rows.append(pn.Column( *self._build_mat_detail(mat), sizing_mode="stretch_width", styles={"background": "#1a2435", "border": "1px solid #2d3748", "border-radius": "4px", "padding": "8px 10px", "margin": "2px 0 6px 22px"}, )) rows.append(self._build_create_form()) return pn.Column(*rows, sizing_mode="stretch_width") # ── Create new material form ─────────────────────────────────────────────── def _build_create_form(self) -> pn.Column: lib_names = self._lib_class_names() mat_names = self._project_mat_names() name_in = pn.widgets.TextInput( placeholder="material name", height=26, margin=0, stylesheets=INPUT_SS, sizing_mode="stretch_width", ) name_err = pn.pane.HTML("", visible=False, margin=0, sizing_mode="stretch_width") inherit_opts = ["(blank — start empty)"] inherit_opts += [f"library: {c.class_name}" for c in self._lib_classes] inherit_opts += [f"copy: {m.name}" for m in self.app.project.materials] inherit_sel = pn.widgets.Select( options=inherit_opts, value=inherit_opts[0], height=26, margin=0, stylesheets=SELECT_SS, sizing_mode="stretch_width", ) create_btn = pn.widgets.Button( name="+ Create material", button_type="light", height=26, margin=0, stylesheets=ADD_BTN_SS, sizing_mode="stretch_width", ) def _check_name(e): raw = e.new.strip() if not raw: name_err.object = ""; name_err.visible = False; return san = _sanitize_name(raw) if san in lib_names: name_err.object = ( '<span style="font-size:11px;color:#e74c3c;">' f'⚠ "{san}" is also a library class name. Choose a different name ' 'to avoid confusion.</span>' ) name_err.visible = True elif san in mat_names: name_err.object = ( '<span style="font-size:11px;color:#e74c3c;">' f'⚠ A project material named "{san}" already exists.</span>' ) name_err.visible = True else: name_err.object = ""; name_err.visible = False name_in.param.watch(_check_name, "value") def _create(e): raw = name_in.value.strip() name = _sanitize_name(raw) if raw else "" if not name: return choice = inherit_sel.value skipped: List[str] = [] if choice == "(blank — start empty)": mat = Material(name=name) elif choice.startswith("library: "): cls_name = choice[len("library: "):] # Create a derived material (library source + empty overrides). # Computed properties remain available at runtime via the library class. mat = Material(name=name, source=f"library://{cls_name}", properties={}) elif choice.startswith("copy: "): src_name = choice[len("copy: "):] src = next((m for m in self.app.project.materials if m.name == src_name), None) import copy as _copy mat = Material(name=name, properties=_copy.deepcopy(src.properties) if src else {}) else: mat = Material(name=name) if skipped: self._skipped[name] = skipped self.app.project.materials.append(mat) new_idx = len(self.app.project.materials) - 1 self._expanded_mats.add(new_idx) self._sel_lib = None name_in.value = "" inherit_sel.value = inherit_opts[0] name_err.object = ""; name_err.visible = False self.app.autosave() self.app.refresh_layers_panel() self._rebuild_view() create_btn.on_click(_create) return pn.Column( pn.pane.HTML( f'<div style="{_SUBSEC}">Create new material</div>', sizing_mode="stretch_width", height=20, margin=(10, 0, 2, 0), ), name_in, name_err, pn.Row( pn.pane.HTML('<span style="font-size:11px;color:#8a9bb5;">From:</span>', width=34, height=26, margin=0), inherit_sel, margin=(2, 0, 0, 0), styles={"gap": "4px", "align-items": "center"}, sizing_mode="stretch_width", ), create_btn, sizing_mode="stretch_width", ) # ── Material detail (editable) ───────────────────────────────────────────── def _build_mat_detail(self, mat: Material): lib_names = self._lib_class_names() # ── Skipped-properties warning ───────────────────────────────────────── skipped = self._skipped.get(mat.name, []) if skipped: skip_items = "".join( f'<li style="font-family:monospace;font-size:11px;">{k}</li>' for k in skipped ) # Always-visible description line yield pn.pane.HTML( f'<div style="font-size:11px;color:#e07b39;' f'background:rgba(224,123,57,0.08);' f'border:1px solid rgba(224,123,57,0.4);border-radius:4px;' f'padding:5px 8px;margin-bottom:3px;">' f'⚠ {len(skipped)} propert{"ies" if len(skipped)!=1 else "y"} ' f'computed at runtime by the library class could not be copied as ' f'plain numbers. Expand below to see the list.</div>', sizing_mode="stretch_width", margin=0, ) # Collapsible list of keys yield pn.Card( pn.pane.HTML( f'<ul style="margin:4px 0 0 0;padding-left:16px;color:#e07b39;">' f'{skip_items}</ul>', sizing_mode="stretch_width", margin=0, ), title=f"Show {len(skipped)} skipped properties", collapsed=True, sizing_mode="stretch_width", styles={"background": "rgba(224,123,57,0.06)", "border": "1px solid rgba(224,123,57,0.3)", "border-radius": "4px"}, margin=(0, 0, 6, 0), ) # ── Name warning ─────────────────────────────────────────────────────── if mat.name in lib_names: yield pn.pane.HTML( '<div style="font-size:11px;color:#e07b39;padding:2px 0 4px 0;">' f'⚠ This material has the same name as a library class ' f'(<code>{mat.name}</code>). Consider renaming to avoid confusion.</div>', sizing_mode="stretch_width", margin=0, ) # ── Name ────────────────────────────────────────────────────────────── name_in = pn.widgets.TextInput( value=mat.name, height=26, margin=0, stylesheets=INPUT_SS, sizing_mode="stretch_width", ) name_err = pn.pane.HTML("", visible=False, margin=0, sizing_mode="stretch_width") mat_names_other = {m.name for m in self.app.project.materials if m is not mat} def _on_name(e): new = _sanitize_name(e.new) if e.new.strip() else mat.name if new in mat_names_other: name_err.object = ( f'<span style="font-size:11px;color:#e74c3c;">' f'⚠ Name "{new}" already in use.</span>' ) name_err.visible = True return # Transfer skipped record if name changes if mat.name in self._skipped: self._skipped[new] = self._skipped.pop(mat.name) mat.name = new name_err.object = ""; name_err.visible = False self.app.autosave() self._rebuild_view() name_in.param.watch(_on_name, "value") yield pn.Row(label("Name:", 46), name_in, margin=0, styles={"gap": "4px", "align-items": "center"}, sizing_mode="stretch_width") yield name_err if mat.source: yield from self._build_derived_detail(mat) else: yield _note("Inline properties — applied at lowest priority on all layers " "using this material.") yield self._build_prop_editor(mat) def _build_derived_detail(self, mat: Material): """Detail panel for library-reference and derived materials. Shows all library properties (numeric + computed) as an editable table. Numeric rows at library-default are dimmed; overridden rows are amber with a "Restore library value" button. Computed rows show a "calculated" badge when not overridden; once edited they become amber with a ✕ delete button. Custom keys (in mat.properties but not from the library) appear after the library section. A form at the bottom adds new keys. """ from simudo.gui.panels.mat_ast import extract_classes_from_file # ── Resolve library class info ──────────────────────────────────────── info = None if mat.source.startswith("library://"): cls_name = mat.source[len("library://"):] info = next((c for c in self._lib_classes if c.class_name == cls_name), None) elif mat.source.startswith("file://"): rest = mat.source[len("file://"):] path, _, cls_name = rest.rpartition("::") infos = extract_classes_from_file(path) info = next((c for c in infos if c.class_name == cls_name), None) lib_copyable: dict = {} # key → PropertyInfo (numeric library props) lib_computed_keys: list = [] # ordered list of computed key names lib_computed_set: set = set() if info: for p in info.copyable_properties: lib_copyable[p.key] = p lib_computed_keys = [p.key for p in info.computed_properties] lib_computed_set = set(lib_computed_keys) # ── Source info header ──────────────────────────────────────────────── yield pn.pane.HTML( f'<div style="font-size:11px;color:#8a9bb5;padding:4px 0 4px 0;">' f'Source: <code style="color:#4a9eff;">{mat.source}</code> — ' f'library properties applied at base priority; overrides (amber) win.</div>', sizing_mode="stretch_width", margin=0, ) KEY_WIDTH = 400 # wide enough for ~60 monospace characters at 11 px def _make_row(key, val_str, unit_str, is_override, is_custom, is_computed=False): """Build one property row widget. is_computed — runtime-computed library property (no fixed library value). is_custom — not in the library at all (user-added key). is_override — currently stored in mat.properties. A single ✕ button appears only when is_override; clicking it removes the key from mat.properties (restores library default for library props, or removes the row entirely for custom keys). """ if is_custom: _dflt_color = "#c8d6e5" elif is_computed: _dflt_color = "#5a7090" else: _dflt_color = "#7a8caa" def _key_html(overridden: bool) -> str: color = "#c8a060" if overridden else _dflt_color badge = "" if is_computed and not overridden: badge = ('<span style="font-size:9px;background:#1e2d3d;color:#5a8aaa;' 'border-radius:2px;padding:1px 4px;border:1px solid #2d4a6e;' 'margin-left:4px;white-space:nowrap;">calculated</span>') return ( f'<span style="font-size:11px;color:{color};font-family:monospace;' f'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;' f'display:inline-block;max-width:{KEY_WIDTH - 10}px;" title="{key}">' f'{key}</span>{badge}' ) key_pane = pn.pane.HTML( _key_html(is_override), width=KEY_WIDTH, height=24, margin=0, sizing_mode="fixed", ) val_in = pn.widgets.TextInput( value=val_str, placeholder="type to override" if is_computed and not is_override else "", height=24, margin=0, stylesheets=INPUT_SS, width=100, ) unit_in = pn.widgets.TextInput( value=unit_str, height=24, margin=0, stylesheets=INPUT_SS, width=80, ) # ✕ visible only when the property is overridden; removes override on click clear_btn = pn.widgets.Button( name="✕", margin=0, stylesheets=_DEL_SS, width=22, visible=is_override, ) def _set_overridden(): key_pane.object = _key_html(True) row.styles = { "align-items": "center", "gap": "3px", "background": "rgba(200,160,60,0.10)", "border-radius": "3px", "padding": "1px 4px", } clear_btn.visible = True def _on_val(e, k=key, u_in=unit_in): if not e.new.strip(): return try: v = float(e.new) except ValueError: v = e.new mat.properties[k] = {"value": v, "unit": u_in.value.strip()} _set_overridden() self.app.autosave() self.app.refresh_layers_panel() def _on_unit(e, k=key, v_in=val_in): raw = v_in.value.strip() if not raw: return try: v = float(raw) except ValueError: v = raw mat.properties[k] = {"value": v, "unit": e.new.strip()} _set_overridden() self.app.autosave() def _on_clear(e, k=key): mat.properties.pop(k, None) self.app.autosave() self.app.refresh_layers_panel() self._rebuild_view() val_in.param.watch(_on_val, "value") unit_in.param.watch(_on_unit, "value") clear_btn.on_click(_on_clear) bg = "rgba(200,160,60,0.10)" if is_override else "transparent" row = pn.Row( key_pane, val_in, unit_in, clear_btn, pn.Spacer(sizing_mode="stretch_width"), margin=(0, 0, 1, 0), sizing_mode="stretch_width", styles={"align-items": "center", "gap": "3px", "background": bg, "border-radius": "3px", "padding": "1px 4px"}, ) return row # ── Build property rows ─────────────────────────────────────────────── rows = [] lib_all = list(lib_copyable) + lib_computed_keys if lib_all: rows.append(pn.pane.HTML( '<div style="font-size:10px;color:#556070;font-weight:600;' 'letter-spacing:0.08em;margin:4px 0 2px 0;">LIBRARY PROPERTIES</div>', sizing_mode="stretch_width", margin=0, )) # Numeric (copyable) properties first for key, p in lib_copyable.items(): is_override = key in mat.properties if is_override: entry = mat.properties[key] val_str = str(entry.get("value", "")) if isinstance(entry, dict) else str(entry) unit_str = entry.get("unit", "") if isinstance(entry, dict) else "" else: val_str = str(p.value) if p.value is not None else "" unit_str = p.unit or "" rows.append(_make_row(key, val_str, unit_str, is_override=is_override, is_custom=False, is_computed=False)) # Computed properties after for key in lib_computed_keys: is_override = key in mat.properties if is_override: entry = mat.properties[key] val_str = str(entry.get("value", "")) if isinstance(entry, dict) else str(entry) unit_str = entry.get("unit", "") if isinstance(entry, dict) else "" else: val_str = ""; unit_str = "" rows.append(_make_row(key, val_str, unit_str, is_override=is_override, is_custom=False, is_computed=True)) # ── Custom keys (in mat.properties but not in library at all) ───────── custom_keys = [k for k in mat.properties if k not in lib_copyable and k not in lib_computed_set] if custom_keys: rows.append(pn.pane.HTML( '<div style="font-size:10px;color:#556070;font-weight:600;' 'letter-spacing:0.08em;margin:8px 0 2px 0;">CUSTOM OVERRIDES</div>', sizing_mode="stretch_width", margin=0, )) for key in custom_keys: entry = mat.properties[key] val_str = str(entry.get("value", "")) if isinstance(entry, dict) else str(entry) unit_str = entry.get("unit", "") if isinstance(entry, dict) else "" rows.append(_make_row(key, val_str, unit_str, is_override=True, is_custom=True, is_computed=False)) if not info: rows.append(_note("Could not read class (file not found or parse error).", color="#e07b39")) elif not lib_all: rows.append(_note("No properties found in this class.", color="#7a8caa")) yield pn.Column(*rows, sizing_mode="stretch_width") # ── Add new custom key ──────────────────────────────────────────────── add_key_in = pn.widgets.TextInput( placeholder="new key (e.g. CB/mobility)", height=24, margin=0, stylesheets=INPUT_SS, sizing_mode="stretch_width", ) add_val_in = pn.widgets.TextInput( placeholder="value", height=24, margin=0, stylesheets=INPUT_SS, width=100, ) add_unit_in = pn.widgets.TextInput( placeholder="unit", height=24, margin=0, stylesheets=INPUT_SS, width=80, ) add_btn = pn.widgets.Button( name="+ Add key", height=24, margin=0, stylesheets=ADD_BTN_SS, sizing_mode="stretch_width", ) def _add_key(e): k = add_key_in.value.strip() if not k: return try: v = float(add_val_in.value.strip()) except ValueError: v = add_val_in.value.strip() u = add_unit_in.value.strip() mat.properties[k] = {"value": v, "unit": u} if u else v add_key_in.value = add_val_in.value = add_unit_in.value = "" self.app.autosave() self.app.refresh_layers_panel() self._rebuild_view() add_btn.on_click(_add_key) yield pn.Column( pn.pane.HTML( '<div style="font-size:10px;color:#556070;font-weight:600;' 'letter-spacing:0.08em;margin:8px 0 2px 0;">ADD KEY</div>', sizing_mode="stretch_width", margin=0, ), pn.Row(add_key_in, add_val_in, add_unit_in, margin=0, styles={"gap": "3px", "align-items": "center"}), add_btn, sizing_mode="stretch_width", ) # ── Read-only property table ─────────────────────────────────────────────── def _prop_table_readonly(self, props: List[PropertyInfo]) -> pn.pane.HTML: rows = [] for p in props: color = "#c8d6e5" if p.copyable else "#7a8caa" badge = "" if not p.copyable: badge = (' <span style="font-size:9px;background:#2d3748;color:#7a8caa;' 'border-radius:2px;padding:1px 4px;">runtime</span>') rows.append( f'<tr>' f'<td style="padding:2px 8px 2px 0;font-family:monospace;font-size:11px;' f'color:#8a9bb5;">{p.key}</td>' f'<td style="padding:2px 0;font-size:11px;color:{color};">' f'{p.display}{badge}</td>' f'</tr>' ) return pn.pane.HTML( '<table style="border-collapse:collapse;width:100%;margin-top:4px;">' '<thead><tr>' '<th style="text-align:left;font-size:10px;color:#556070;' 'font-weight:600;padding-bottom:3px;min-width:200px;">Key</th>' '<th style="text-align:left;font-size:10px;color:#556070;' 'font-weight:600;padding-bottom:3px;">Value</th>' '</tr></thead><tbody>' + "".join(rows) + '</tbody></table>', sizing_mode="stretch_width", margin=0, ) # ── Inline property editor ───────────────────────────────────────────────── def _build_prop_editor(self, mat: Material) -> pn.Column: rows = [] for key, entry in list(mat.properties.items()): if isinstance(entry, dict): val_str = str(entry.get("value", "")) unit_str = str(entry.get("unit", "")) else: val_str = str(entry); unit_str = "" key_pane = pn.pane.HTML( f'<span style="font-size:11px;color:#8a9bb5;font-family:monospace;' f'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;' f'display:inline-block;max-width:220px;">{key}</span>', width=220, height=24, margin=0, ) val_in = pn.widgets.TextInput( value=val_str, height=24, margin=0, stylesheets=INPUT_SS, width=100, ) unit_in = pn.widgets.TextInput( value=unit_str, height=24, margin=0, stylesheets=INPUT_SS, width=90, ) del_btn = pn.widgets.Button(name="✕", margin=0, stylesheets=_DEL_SS, width=22) def _on_val(e, k=key): try: v = float(e.new) except ValueError: v = e.new e2 = mat.properties.get(k, {}) if isinstance(e2, dict): e2["value"] = v; mat.properties[k] = e2 else: mat.properties[k] = v self.app.autosave() self.app.refresh_layers_panel() def _on_unit(e, k=key): e2 = mat.properties.get(k, {}) if isinstance(e2, dict): e2["unit"] = e.new.strip(); mat.properties[k] = e2 self.app.autosave() def _on_del(e, k=key): mat.properties.pop(k, None) self.app.autosave() self.app.refresh_layers_panel() self._rebuild_view() val_in.param.watch(_on_val, "value") unit_in.param.watch(_on_unit, "value") del_btn.on_click(_on_del) rows.append(pn.Row( key_pane, pn.Spacer(sizing_mode="stretch_width"), val_in, unit_in, del_btn, margin=(0, 0, 2, 0), styles={"align-items": "center", "gap": "4px"}, )) key_in = pn.widgets.TextInput( placeholder="key (e.g. CB/energy_level)", height=24, margin=0, stylesheets=INPUT_SS, width=220, ) val_in2 = pn.widgets.TextInput( placeholder="value", height=24, margin=0, stylesheets=INPUT_SS, width=100, ) unit_in2 = pn.widgets.TextInput( placeholder="unit", height=24, margin=0, stylesheets=INPUT_SS, width=90, ) add_btn = pn.widgets.Button( name="+", height=24, width=26, margin=0, stylesheets=ADD_BTN_SS, ) def _add_prop(e): k = key_in.value.strip() if not k: return try: v = float(val_in2.value.strip()) except ValueError: v = val_in2.value.strip() u = unit_in2.value.strip() mat.properties[k] = {"value": v, "unit": u} if u else v key_in.value = val_in2.value = unit_in2.value = "" self.app.autosave() self.app.refresh_layers_panel() self._rebuild_view() add_btn.on_click(_add_prop) return pn.Column( *rows, pn.Row(key_in, val_in2, unit_in2, add_btn, margin=(4, 0, 0, 0), styles={"gap": "4px", "align-items": "center"}), sizing_mode="stretch_width", ) # ── Validation warning ───────────────────────────────────────────────────── def _build_validation_warning(self) -> pn.pane.HTML: defined = {m.name for m in self.app.project.materials} refs = [(l.name, l.material) for l in self.app.project.layers if l.material and l.material not in defined] if not refs: return pn.pane.HTML("", visible=False) items = "".join( f"<li><code style='color:#4a9eff'>{ln}</code> → " f"<code style='color:#e07b39'>{mn}</code></li>" for ln, mn in refs ) return pn.pane.HTML( '<div style="font-size:11px;color:#e07b39;background:rgba(224,123,57,0.08);' 'border:1px solid rgba(224,123,57,0.3);border-radius:4px;' 'padding:6px 10px;margin-top:6px;">' '<strong>Layers reference undefined material names:</strong>' f'<ul style="margin:4px 0 0 0;padding-left:18px;">{items}</ul>' 'Add a matching entry above, or clear the material field on those layers.</div>', sizing_mode="stretch_width", margin=0, ) # ── Actions ──────────────────────────────────────────────────────────────── def _select_lib(self, idx: int): self._sel_lib = idx if self._sel_lib != idx else None self._rebuild_view() def _toggle_mat(self, idx: int): if idx in self._expanded_mats: self._expanded_mats.discard(idx) else: self._expanded_mats.add(idx) self._rebuild_view() def _delete_mat(self, idx: int): mat = self.app.project.materials[idx] self._skipped.pop(mat.name, None) self.app.project.materials.pop(idx) # Remove deleted index; shift all higher indices down by 1 self._expanded_mats.discard(idx) self._expanded_mats = {i - 1 if i > idx else i for i in self._expanded_mats} self.app.autosave() self.app.refresh_layers_panel() self._rebuild_view() def _derive_from_lib_class(self, idx: int): """Create a derived material from a library class (source + empty overrides).""" cls = self._lib_classes[idx] base_name = _sanitize_name(cls.material_name or cls.class_name) taken = self._lib_class_names() | self._project_mat_names() name = _unique_name(base_name, taken) self.app.project.materials.append( Material(name=name, source=f"library://{cls.class_name}", properties={}) ) new_idx = len(self.app.project.materials) - 1 self._expanded_mats.add(new_idx) self._sel_lib = None self.app.autosave() self.app.refresh_layers_panel() self._rebuild_view() def _copy_lib_class(self, idx: int): """Create an inline copy of a library class (numeric values only). Kept for backward compatibility; the library browser now uses _derive_from_lib_class instead. """ cls = self._lib_classes[idx] base_name = _sanitize_name(cls.material_name or cls.class_name) taken = self._lib_class_names() | self._project_mat_names() name = _unique_name(base_name, taken) props = {p.key: {"value": p.value, "unit": p.unit} for p in cls.copyable_properties} skipped = [p.key for p in cls.computed_properties] if skipped: self._skipped[name] = skipped self.app.project.materials.append(Material(name=name, properties=props)) new_idx = len(self.app.project.materials) - 1 self._expanded_mats.add(new_idx) self._sel_lib = None self.app.autosave() self.app.refresh_layers_panel() self._rebuild_view() def _add_lib_ref(self, idx: int): """Create a library-reference material (covers all properties including computed).""" cls = self._lib_classes[idx] base_name = _sanitize_name(cls.material_name or cls.class_name) taken = self._lib_class_names() | self._project_mat_names() name = _unique_name(base_name, taken) self.app.project.materials.append( Material(name=name, source=f"library://{cls.class_name}") ) new_idx = len(self.app.project.materials) - 1 self._expanded_mats.add(new_idx) self._sel_lib = None self.app.autosave() self.app.refresh_layers_panel() self._rebuild_view()