25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
1589 lines
68 KiB
Python
1589 lines
68 KiB
Python
# -*- coding: utf-8 -*-
|
|
#
|
|
# Change the part of this project's configuration that is this JOB's to decide.
|
|
#
|
|
# ONE WINDOW, FOUR MODULES, AND EVERYTHING ELSE ABSENT ON PURPOSE.
|
|
# The owner, 2026-08-30:
|
|
#
|
|
# "重组 project config ... 点开以后分几个模块:file_path; lifing&handeling;
|
|
# ticektqc; job_knowledge。剩下的都不在这里显示,让用户手动打开 config 文件去
|
|
# 修改剩余的部分。后面三个模块全部做成可以互动的样子。比如说 stripping rig
|
|
# mode 后面跟一个 static 的按钮,按一下变成 rolling。sling angle 那个地方直接
|
|
# 让用户可以自己填写。"
|
|
#
|
|
# So this is not a report with an edit menu. It is four boxes of controls: a
|
|
# value you can see and click, a number you can type over. What is not in one
|
|
# of the four is not on screen at all -- there is one line at the bottom that
|
|
# says so and a button that opens the file.
|
|
#
|
|
# The mapping from setting to module is `vibe.configform.MODULE_OF`, and the
|
|
# settings deliberately left out are `configform.NOT_SHOWN` with the reason
|
|
# each one is out. `tests/test_configform.py` fails if a setting is in
|
|
# neither, so a knob added to `configmodel` cannot quietly ship with nowhere
|
|
# to change it.
|
|
#
|
|
# WRITES GO TO THE JOB LAYER AND NOWHERE ELSE, through the same
|
|
# `configmodel.splice` / `verify` pair as before: find the key's own lines,
|
|
# replace exactly those, read the file back and refuse if anything but that
|
|
# key moved. The plant and catalogue layers are not in this window at all --
|
|
# one plant edit reaches every job, so it stays a deliberate act somewhere
|
|
# else. Run-time options are the one exception and they say so where they
|
|
# sit: they are not knowledge, they have no layers, and they go to
|
|
# `run-options.yaml` beside the model.
|
|
#
|
|
# ---------------------------------------------------------------------------
|
|
# THE FOUR DEFECTS THIS ALSO FIXES, AND WHAT THEY REALLY WERE.
|
|
#
|
|
# 1. THE EXPLANATION WAS INSIDE THE POPUP AND THE POPUP CUT IT OFF.
|
|
# `forms.CommandSwitchWindow`'s `message` is a WPF `Label`, docked left in
|
|
# a 36-pixel row it shares with the search box, in a window fixed at 600
|
|
# wide with `ResizeMode="NoResize"` (pyRevit's
|
|
# `forms/CommandSwitchWindow.xaml`). A Label does not wrap and the row does
|
|
# not grow. `forms.ask_for_string`'s prompt is a `TextBlock` at the default
|
|
# `TextWrapping="NoWrap"` in a 500-wide window, so it truncates too. The
|
|
# fix is not a shorter sentence: it is a real window, where the header
|
|
# wraps and every field carries its own note under it.
|
|
#
|
|
# 2. THE PAGE FLASHED AND WENT. The old button printed its page in the middle
|
|
# of a `while True:` menu loop, and the very next statement re-opened a
|
|
# modal `CommandSwitchWindow` -- `WindowStartupLocation="CenterScreen"`,
|
|
# `ShowInTaskbar="False"`, `MouseUp="handle_click"` bound on the WHOLE
|
|
# window -- centred on top of the page that had just appeared. Any mouse-up
|
|
# on that window closes it with `response = None`, including one aimed at
|
|
# the page behind it, and the loop read that as `if not answer: break`, so
|
|
# the script ended. And a modal dialog closing re-activates its OWNER,
|
|
# which is Revit's main window: full-screen, and the output window is not
|
|
# owned by it, so the page went behind Revit in the same instant. Page
|
|
# appears, popup lands on it, one click both dismisses the popup and ends
|
|
# the run, Revit comes forward over the page. That is the flash, and there
|
|
# was no `close()` anywhere in it.
|
|
#
|
|
# So: NOTHING IS PRINTED UNTIL EVERY WINDOW IS SHUT. The panel is written
|
|
# once, after the dialog returns, and then raised (`show_page`). The page
|
|
# is the last thing a press does, which is the only arrangement a modal
|
|
# dialog cannot undo.
|
|
#
|
|
# 3. THE OPTIONS WERE NAMED BY THEIR DOTTED KEYS. `configmodel.SETTINGS` has
|
|
# carried `label` ("Report folder") and `section` ("Output") since the day
|
|
# it was written and the screen showed `handling.report_dir`. Every control
|
|
# in this window is labelled with the label; the dotted key is small print
|
|
# beside it, because it is what somebody grepping the YAML needs.
|
|
#
|
|
# 4. THE WINDOW OUTGREW A SCREEN. Four modules, one of them the whole ticket
|
|
# checklist, is more rows than fit without scrolling past the box somebody
|
|
# came to change. The owner, 2026-09-01 (his own words are on
|
|
# `vibe.configform`'s folding section, quoted there rather than twice):
|
|
# every module should fold, folded is the default, and you open the one
|
|
# you came to adjust. So each of the four `GroupBox`es below became an
|
|
# `Expander`, collapsed on the window's very first paint in a session.
|
|
#
|
|
# THE OBVIOUS WAY TO BREAK THIS IS TO FOLD A LIVE OVERRIDE OUT OF SIGHT.
|
|
# A folded module that reads exactly like an empty one is the same defect
|
|
# this whole tool exists to catch one screen over -- a value nobody can
|
|
# see is a value that might as well not be set. So the header is built
|
|
# from `configform.modified_count` BEFORE the fold, every time: "Lifting
|
|
# and handling (2 of 9 set)" tells you there is something to look at
|
|
# without opening it, and "(0 of 9 set)" -- read as plain "(9)" when
|
|
# nothing is set, so a job running on nothing but defaults is not told to
|
|
# go looking for a change that is not there -- says just as plainly that
|
|
# there is not.
|
|
#
|
|
# SESSION MEMORY, NOT A SETTING. Which of the four was left open is kept
|
|
# in `vibe.configform._EXPANDED`, a plain module-level dict -- see that
|
|
# module's own note on why it, and not this script, is where a
|
|
# Revit-session-long memory can live at all. It is never written to disk:
|
|
# closing Revit forgets it, on purpose, because it is a screen convenience
|
|
# for one sitting and must never grow into a persisted preference nobody
|
|
# asked for.
|
|
#
|
|
# ---------------------------------------------------------------------------
|
|
# WHY A WPF WINDOW, AND WHAT IS IN THE XAML.
|
|
#
|
|
# In-place toggles and editable fields are not something the stock pyRevit
|
|
# forms can do -- `CommandSwitchWindow` returns one choice and closes;
|
|
# `ask_for_string` returns one string. `forms.WPFWindow` + a XAML file in the
|
|
# bundle is pyRevit's sanctioned path for a real window (`script.load_ui`
|
|
# resolves the file against the button folder), and this is the first one in
|
|
# this repository.
|
|
#
|
|
# `ProjectConfig.xaml` is CHROME ONLY: four named `StackPanel`s, a reason
|
|
# line, four buttons. Every field is built here in Python, because the field
|
|
# list is data -- it depends on which build of `configmodel` this session is
|
|
# holding, on how many stages the job has, and on how many items are muted.
|
|
# A XAML template would have had to know all three.
|
|
#
|
|
# `vibe.configform` is the half with no Revit in it: which setting is in which
|
|
# module, what kind of control it deserves, what one click of a toggle means,
|
|
# and the mute rules. That is what `tests/test_configform.py` exercises, and
|
|
# it is as far as a host machine can get: THE WINDOW ITSELF CANNOT BE OPENED
|
|
# WITHOUT REVIT, so what a press must still prove is listed at the end of that
|
|
# test file.
|
|
|
|
# pylint: disable=import-error,invalid-name,broad-except
|
|
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
|
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
|
# trimming a bundle file cannot bring an old name back. One line, no
|
|
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
|
# line to put one on.
|
|
__title__ = 'Project Config'
|
|
|
|
import datetime
|
|
import io
|
|
import os
|
|
import traceback
|
|
|
|
from pyrevit import forms, framework, revit, script
|
|
|
|
from vibe import (configdump, configform, configmodel, configpage, designspec,
|
|
knowledge, yamlkit)
|
|
from vibe import foldreport as fold
|
|
from vibe import runlog
|
|
|
|
doc = revit.doc
|
|
output = script.get_output()
|
|
output.close_others()
|
|
|
|
WHO = 'Project Config'
|
|
|
|
C = framework.Controls
|
|
W = framework.Windows
|
|
M = framework.Media
|
|
|
|
LABEL_WIDTH = 210
|
|
CHIP_WIDTH = 118
|
|
GREY = '#6b7a89'
|
|
DARK = '#2c3e50'
|
|
WARN = '#c7254e'
|
|
|
|
|
|
# --------------------------------------------------------------- the layers
|
|
|
|
def read_layers():
|
|
"""Every layer including the project config file, which is the one that
|
|
wins while it is live. An editor that hid it would show a plant value
|
|
nothing is reading."""
|
|
return configdump.layer_values(doc, include_config=True)
|
|
|
|
|
|
def job_path():
|
|
return designspec.knowledge_path(doc)
|
|
|
|
|
|
def job_text():
|
|
path = job_path()
|
|
if not os.path.isfile(path):
|
|
return None
|
|
with io.open(path, 'r', encoding='utf-8') as fh:
|
|
return fh.read()
|
|
|
|
|
|
def run_options_path():
|
|
return configmodel.run_options_path(designspec.root_dir(doc))
|
|
|
|
|
|
def read_run_options():
|
|
return configmodel.load_run_options(designspec.root_dir(doc))
|
|
|
|
|
|
def env_rows():
|
|
return [(name, os.environ.get(name) or '(not set)')
|
|
for name in ('WILLTOOLS_KNOWLEDGE', 'WILLTOOLS_JOBS',
|
|
'WILLTOOLS_PYTHON')]
|
|
|
|
|
|
# ---------------------------------------------------- what this build can do
|
|
#
|
|
# `script.py` is re-read on every press. The `vibe` modules are NOT: the engine
|
|
# holds them until Revit restarts, so this file can be newer than the
|
|
# `configmodel` it is calling. The honest thing is to ask that module what it
|
|
# knows rather than assume, and to SAY on screen what is missing -- a window
|
|
# that silently does less is a window that lies about the job.
|
|
|
|
def can_edit_maps():
|
|
"""Whether the loaded `configmodel` understands a `map` value.
|
|
|
|
`checks.muted` is a map. A build that predates it has no such row, so
|
|
`configmodel.editable()` never offers it and the Ticket QC module has no
|
|
mute list to show. Probed rather than version-checked: `parse_input` is
|
|
pure, and a probe cannot be wrong about the module actually loaded.
|
|
"""
|
|
try:
|
|
ok, value, _why = configmodel.parse_input('map', 'x = y')
|
|
except Exception:
|
|
return False
|
|
return bool(ok and isinstance(value, dict))
|
|
|
|
|
|
def build_notes(layers=None):
|
|
"""What this session cannot do, in sentences, for the window and the page.
|
|
|
|
`layers` is the read layers, including the config file, so a chosen
|
|
plant/public knowledge file that has since gone missing can be named
|
|
LOUDLY here -- the same discipline `knowledge._config_missing` /
|
|
`_plant_missing` apply inside `knowledge.load()`, carried to the per-job
|
|
pointers this window writes. See `configmodel.knowledge_source_problems`.
|
|
"""
|
|
notes = []
|
|
if layers is not None and hasattr(configmodel, 'knowledge_source_problems'):
|
|
for key, label, path in configmodel.knowledge_source_problems(layers):
|
|
notes.append(
|
|
'{0} is set to {1!r} in the job (or config) layer, and that '
|
|
'file is not there right now. This is NOT a silent '
|
|
'fallback: {2} carries the missing pointer as written until '
|
|
'somebody picks a real file or clears the box in the Job '
|
|
'knowledge section below.'.format(label, path, key))
|
|
if configmodel.setting('checks.muted') is None or not can_edit_maps():
|
|
notes.append(
|
|
'This Revit session is holding an older build of configmodel, '
|
|
'which has no muted-checklist setting. The mute list is not in '
|
|
'the Ticket QC box below and cannot be edited here. RESTART '
|
|
'REVIT for it -- a pyRevit reload is never the answer.')
|
|
elif not can_list_checks():
|
|
notes.append(
|
|
'This Revit session is holding an older build of configform, '
|
|
'which cannot list the checklist or tell a plant mute from a job '
|
|
'one. Muting is not available in the Ticket QC box; whatever is '
|
|
'muted stays muted. RESTART REVIT -- a pyRevit reload is never '
|
|
'the answer.')
|
|
elif not configform.checklist_items():
|
|
# The list of rows comes from `ticketpage.CHECKLIST_ITEMS` and from
|
|
# nowhere else, on purpose. When that module will not load, the
|
|
# honest thing is an empty list and this sentence -- not a copy of
|
|
# the table kept here, which is how 5.3 came to print red for a day.
|
|
notes.append(
|
|
'The ticket checklist table could not be read in this Revit '
|
|
'session, so the Ticket QC box cannot list the rows to mute. '
|
|
'Nothing already muted has changed. RESTART REVIT -- a pyRevit '
|
|
'reload is never the answer.')
|
|
return notes
|
|
|
|
|
|
def can_list_checks():
|
|
"""Whether the loaded `configform` can draw the checklist and read the
|
|
merge. `script.py` is re-read on every press and `vibe` is not, so this
|
|
file can be newer than the modules it calls -- and a window that silently
|
|
does less is a window that lies about the job."""
|
|
for name in ('mute_table', 'mute_summary', 'checklist_items',
|
|
'WRITE_LAYER'):
|
|
if not hasattr(configform, name):
|
|
return False
|
|
return hasattr(configmodel, 'merged_map')
|
|
|
|
|
|
# ------------------------------------------------------------- derived paths
|
|
|
|
def info_rows(know):
|
|
"""The paths this job writes to that are NOT settings.
|
|
|
|
They are derived from where the model is (or from WILLTOOLS_JOBS), so
|
|
there is nothing here to edit -- but leaving them off the File paths box
|
|
would send somebody hunting for output that is exactly where the tool
|
|
said it would be. Shown, and shown as not editable.
|
|
"""
|
|
out = []
|
|
try:
|
|
root = designspec.root_dir(doc)
|
|
except Exception:
|
|
return out
|
|
override = os.environ.get('WILLTOOLS_JOBS')
|
|
out.append((configform.FILE_PATH, 'Job data folder', root,
|
|
'Everything below sits in here. Set WILLTOOLS_JOBS to move '
|
|
'it off the model folder{0}.'.format(
|
|
' (it is set)' if override else '')))
|
|
for name, label in (('specs', 'Extracted specs'),
|
|
('evidence', 'Evidence'),
|
|
('log', 'Logs')):
|
|
out.append((configform.FILE_PATH, label, os.path.join(root, name),
|
|
'Follows the job data folder. Not a setting.'))
|
|
out.append((configform.FILE_PATH, 'Reports go to',
|
|
configdump.report_dir(doc, know),
|
|
'What the report folder above resolves to right now.'))
|
|
out.extend(knowledge_source_info_rows())
|
|
return out
|
|
|
|
|
|
def plant_choice_line():
|
|
"""One sentence for the 'Producing plant' dropdown: what
|
|
`knowledge.plant_choice(doc)` answers right now, and which level decided
|
|
it -- so the box a person is about to change sits beside the truth about
|
|
what a run reads today, whether or not the two currently agree.
|
|
|
|
Reads through the SAME three-level resolution `detect_plant`/
|
|
`plant_choice` do (Gitea will/bridge issue #2): the plant knowledge file
|
|
picked below wins outright if one is set; otherwise `job.plant`;
|
|
otherwise the model's Project Information producer name matched against
|
|
the plant folders' aliases, then (only if that misses) the titleblock
|
|
family names. Never raises -- a model that cannot be read this way says
|
|
so instead of leaving the row blank.
|
|
"""
|
|
try:
|
|
choice = knowledge.plant_choice(doc)
|
|
except Exception as exc:
|
|
return ('knowledge.plant_choice(doc) could not be read: '
|
|
'{0}'.format(exc))
|
|
plant = choice.get('plant') or '(none resolved)'
|
|
basis = choice.get('basis') or '(no basis given)'
|
|
return 'Currently reads as: {0} -- {1}'.format(plant, basis)
|
|
|
|
|
|
def knowledge_source_info_rows():
|
|
"""What `knowledge.load()` is ACTUALLY reading for the plant and public
|
|
layers right now, from `knowledge.sources(doc)` -- the ground truth
|
|
regardless of whether the pickers below have been wired into the read
|
|
side yet (see `configmodel.KNOWLEDGE_SOURCE_KEYS`). Sits above the two
|
|
filepick rows in the Job knowledge box so a person sees where the layer
|
|
is really coming from before choosing to point it somewhere else.
|
|
"""
|
|
out = []
|
|
try:
|
|
rows = knowledge.sources(doc)
|
|
except Exception:
|
|
return out
|
|
by_label = dict((label, (path, exists)) for label, path, exists in rows)
|
|
basis = None
|
|
try:
|
|
basis = knowledge.plant_choice(doc).get('basis')
|
|
except Exception:
|
|
basis = None
|
|
for label, title in (
|
|
('plant', 'Plant knowledge -- currently read from'),
|
|
('catalogue', 'Public knowledge -- currently read from')):
|
|
path, exists = by_label.get(label, (None, False))
|
|
if not path:
|
|
value = '(nothing resolves)'
|
|
elif exists:
|
|
value = path
|
|
else:
|
|
value = '{0} -- FILE NOT FOUND'.format(path)
|
|
if label == 'plant' and basis:
|
|
value = '{0}\n chosen by: {1}'.format(value, basis)
|
|
out.append((configform.JOB_KNOWLEDGE, title, value,
|
|
'What a run reads today, and why. The plant layer is '
|
|
'chosen in this order: the plant knowledge file picked '
|
|
'in the box below, then job.plant in the job file, then '
|
|
'the precast producer named on the model\'s Project '
|
|
"Information matched against the plant folders' aliases, "
|
|
"then the tool's default file. This line is the truth "
|
|
'about what is actually in force right now.'))
|
|
return out
|
|
|
|
|
|
# ----------------------------------------------------------- small WPF parts
|
|
|
|
def brush(text):
|
|
try:
|
|
return M.BrushConverter().ConvertFrom(text)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def text_block(body, width=None, colour=None, bold=False, size=None,
|
|
wrap=True):
|
|
block = C.TextBlock()
|
|
block.Text = body or ''
|
|
if wrap:
|
|
block.TextWrapping = W.TextWrapping.Wrap
|
|
block.VerticalAlignment = W.VerticalAlignment.Center
|
|
if width:
|
|
block.Width = width
|
|
if colour:
|
|
painted = brush(colour)
|
|
if painted:
|
|
block.Foreground = painted
|
|
if bold:
|
|
block.FontWeight = W.FontWeights.Bold
|
|
if size:
|
|
block.FontSize = size
|
|
return block
|
|
|
|
|
|
def dock(child, side):
|
|
C.DockPanel.SetDock(child, side)
|
|
return child
|
|
|
|
|
|
def margin(element, left, top, right, bottom):
|
|
element.Margin = W.Thickness(left, top, right, bottom)
|
|
return element
|
|
|
|
|
|
# ------------------------------------------------------------- the window
|
|
|
|
class ConfigWindow(forms.WPFWindow):
|
|
"""The four modules, built from `configform` rows.
|
|
|
|
Holds the pending edits and nothing else: no file is touched until Save,
|
|
and Save is the only path that writes. Cancel throws the pending edits
|
|
away, which is what a person expects a window called Close to do.
|
|
"""
|
|
|
|
def __init__(self, state):
|
|
self.state = state
|
|
self.pending = {} # dotted key -> value, for the job layer
|
|
self.pending_run = {} # option key -> value, for run-options.yaml
|
|
self.typed = {} # dotted key -> raw text still in a box
|
|
self.rows = {} # dotted key -> the resolved row
|
|
self.controls = {} # dotted key -> its control
|
|
# The mute state, and why it is THREE things rather than one. `mutes`
|
|
# is the JOB layer's own map, because that is the only thing Save may
|
|
# replace. `mute_merged` is what the run actually reads, entry by
|
|
# entry, with the layer each came from -- the plant's mutes are in
|
|
# there and are not this window's to remove. `mute_asking` is the one
|
|
# row whose reason box is open, because a reason has to be typed
|
|
# before a mute exists and there is no default to fall back on.
|
|
self.mutes = {}
|
|
self.mute_merged = {}
|
|
self.mute_panel = None
|
|
self.mute_asking = None
|
|
self.mute_draft = ''
|
|
self.mute_summary = None
|
|
self.saved = False
|
|
self.want_full = False
|
|
self.written = []
|
|
# Sentences from `configmodel.warnings` about what WAS written. The
|
|
# window is gone by the time anybody reads the summary, so a caution
|
|
# that only lived in the status line was a caution nobody kept.
|
|
self.cautioned = []
|
|
|
|
# ---- building ------------------------------------------------------
|
|
|
|
def setup(self):
|
|
state = self.state
|
|
self.Title = '{0} -- {1}'.format(WHO, state['title'])
|
|
self.header.Text = '{0}: the settings this JOB decides'.format(
|
|
state['title'])
|
|
self.subheader.Text = (
|
|
'Click a value to change it; type over a number. TO QUIETEN A '
|
|
'CHECKLIST ROW, see Ticket QC below: every row is listed with a '
|
|
'speaker beside it, and the check goes on running whatever the '
|
|
'speaker says. Nothing is written until you press Save. '
|
|
'Everything here goes to the job layer -- {0}'
|
|
.format(state['job_path']))
|
|
|
|
for note in state['notes']:
|
|
self.panel_file_path.Children.Add(
|
|
margin(text_block(note, colour=WARN, bold=True), 0, 0, 0, 8))
|
|
|
|
rows = configform.resolve_all(
|
|
configform.fields(info=state['info']), state['layers'],
|
|
state['run_options'])
|
|
panels = {configform.FILE_PATH: self.panel_file_path,
|
|
configform.LIFTING: self.panel_lifting,
|
|
configform.TICKETQC: self.panel_ticketqc,
|
|
configform.JOB_KNOWLEDGE: self.panel_job_knowledge}
|
|
boxes = {configform.FILE_PATH: self.group_file_path,
|
|
configform.LIFTING: self.group_lifting,
|
|
configform.TICKETQC: self.group_ticketqc,
|
|
configform.JOB_KNOWLEDGE: self.group_job_knowledge}
|
|
|
|
for key, title, note, group in configform.by_module(rows):
|
|
panel = panels[key]
|
|
box = boxes[key]
|
|
# HEADER SAYS HOW MANY ROWS ARE NOT THE SHIPPED DEFAULT, because
|
|
# this box starts folded (below) and a folded section that does
|
|
# not say so is exactly how a live override goes unnoticed --
|
|
# the failure `vibe.configform.is_modified` exists to keep out of
|
|
# this window. Set BEFORE folding so the very first paint already
|
|
# carries it, never after a click reveals it.
|
|
changed = configform.modified_count(group)
|
|
if changed:
|
|
box.Header = '{0} ({1} of {2} set)'.format(
|
|
title, changed, len(group))
|
|
else:
|
|
box.Header = '{0} ({1})'.format(title, len(group))
|
|
# FOLDED BY DEFAULT, EVERY TIME THIS SESSION HAS NOT TOUCHED IT.
|
|
# `remembered_expanded` is session memory only -- see its own
|
|
# docstring -- so the very first press of this button in a fresh
|
|
# Revit process folds every one of the four, and only a module
|
|
# somebody opened earlier THIS session comes back open.
|
|
box.Tag = key
|
|
box.IsExpanded = configform.remembered_expanded(key)
|
|
box.Expanded += self.section_toggled
|
|
box.Collapsed += self.section_toggled
|
|
panel.Children.Add(
|
|
margin(text_block(note, colour=GREY, size=11), 0, 0, 0, 8))
|
|
for row in group:
|
|
self.rows[row['key']] = row
|
|
if row['key'] == 'job.plant':
|
|
# What `knowledge.plant_choice(doc)` answers RIGHT NOW --
|
|
# doc-dependent, so `configform` (Revit-free) cannot
|
|
# compute it; stashed onto the row here so `add_row`
|
|
# can print it beside the dropdown regardless of what the
|
|
# dropdown itself is about to be changed to.
|
|
row['plant_choice_line'] = plant_choice_line()
|
|
self.add_row(panel, row)
|
|
if not group:
|
|
panel.Children.Add(text_block(
|
|
'Nothing in this session -- see the note above.',
|
|
colour=GREY))
|
|
|
|
skipped = configform.not_shown_rows()
|
|
self.rest_line.Text = (
|
|
'Everything else ({0} more settings, and every plant and '
|
|
'catalogue value) is NOT in this window on purpose: it is set '
|
|
'once and left alone. Edit the config file directly -- the "Open '
|
|
'the job file" button below opens it. "Full view" prints the '
|
|
'whole configuration, with the provenance of every value, to the '
|
|
'output panel.'.format(len(skipped)))
|
|
|
|
def add_row(self, panel, row):
|
|
holder = C.StackPanel()
|
|
line = C.DockPanel()
|
|
line.LastChildFill = True
|
|
|
|
label = text_block(row.get('label') or row['key'], width=LABEL_WIDTH,
|
|
bold=True, colour=DARK)
|
|
line.Children.Add(dock(label, C.Dock.Left))
|
|
|
|
chip = text_block('[{0}] {1}'.format(row['chip'], row['layer']),
|
|
width=CHIP_WIDTH, colour=GREY, size=10)
|
|
line.Children.Add(dock(chip, C.Dock.Right))
|
|
|
|
line.Children.Add(self.control_for(row))
|
|
holder.Children.Add(line)
|
|
|
|
note = row.get('note')
|
|
if note:
|
|
holder.Children.Add(margin(
|
|
text_block(note, colour=GREY, size=10), LABEL_WIDTH + 4, 1, 0,
|
|
0))
|
|
if row['kind'] == 'list':
|
|
holder.Children.Add(margin(text_block(
|
|
'LISTS REPLACE. Writing this to the job layer replaces every '
|
|
'entry in force, not just the one you change.',
|
|
colour=WARN, size=10), LABEL_WIDTH + 4, 1, 0, 0))
|
|
if row['kind'] == 'filepick' and row.get('res') is not None \
|
|
and hasattr(configform, 'knowledge_source_state'):
|
|
# NEVER SET and CLEARED are different states -- see
|
|
# `configform.knowledge_source_state`'s own docstring -- and
|
|
# both must be said out loud rather than left for the empty box
|
|
# to imply, which cannot tell them apart.
|
|
holder.Children.Add(margin(text_block(
|
|
configform.knowledge_source_state(row['res']), colour=GREY,
|
|
size=10), LABEL_WIDTH + 4, 1, 0, 0))
|
|
if row['key'] == 'job.plant' and row.get('plant_choice_line'):
|
|
# The ground truth beside the box that is about to change it --
|
|
# see `plant_choice_line()`. Printed regardless of what the
|
|
# dropdown is currently set to, the same way the filepick rows'
|
|
# "currently read from" line above does not wait for a save.
|
|
holder.Children.Add(margin(text_block(
|
|
row['plant_choice_line'], colour=GREY, size=10),
|
|
LABEL_WIDTH + 4, 1, 0, 0))
|
|
panel.Children.Add(margin(holder, 0, 0, 0, 8))
|
|
|
|
def control_for(self, row):
|
|
kind = row['kind']
|
|
if kind == 'info':
|
|
return text_block(row['display'], colour=GREY)
|
|
if kind == 'inert':
|
|
# A key that is declared, settable in the file, and read by
|
|
# nothing -- shown as text and coloured like a warning, because
|
|
# the failure being prevented is somebody typing a number into it
|
|
# and believing a check got stricter. The note under the row
|
|
# carries the ruling and its date.
|
|
return text_block(row['display'], colour=WARN)
|
|
if kind == 'toggle':
|
|
return self.toggle_for(row)
|
|
if kind == 'combo':
|
|
return self.combo_for(row)
|
|
if kind == 'stages':
|
|
return self.stages_for(row)
|
|
if kind == 'mutes':
|
|
return self.mutes_for(row)
|
|
if kind == 'filepick':
|
|
return self.filepick_for(row)
|
|
return self.box_for(row)
|
|
|
|
def toggle_for(self, row):
|
|
button = C.Button()
|
|
button.Content = row['display']
|
|
button.Tag = row['key']
|
|
button.HorizontalAlignment = W.HorizontalAlignment.Left
|
|
button.MinWidth = 130
|
|
button.Click += self.toggle_click
|
|
self.controls[row['key']] = button
|
|
return button
|
|
|
|
def combo_for(self, row):
|
|
"""A real dropdown -- `configform.plant_options()`'s list is open
|
|
one click, not cycled through the way `toggle_for`'s two or three
|
|
states are (see the 'combo' kind in `vibe.configform`'s own note).
|
|
|
|
Each `ComboBoxItem.Tag` carries the REAL value (`None` for
|
|
`(auto-detect)`, a plant id string otherwise); `.Content` carries the
|
|
words on screen. `combo_changed` reads `.Tag`, never the text, so a
|
|
plant folder that happened to be named the same as the auto-detect
|
|
label could not be confused with it (it cannot be, in fact --
|
|
`plantpath.valid_plant_id` refuses a leading `(` -- but the control
|
|
does not rely on that to be safe).
|
|
|
|
The job's CURRENT value is kept selectable even when it is no longer
|
|
one of today's plant folders (a plant folder renamed or removed out
|
|
from under a job that already named it): dropping it silently would
|
|
change the job's setting the moment this window opens, before anyone
|
|
has clicked anything.
|
|
"""
|
|
box = C.ComboBox()
|
|
box.Tag = row['key']
|
|
box.MinWidth = 220
|
|
box.HorizontalAlignment = W.HorizontalAlignment.Left
|
|
current = row['value']
|
|
options = list(configform.plant_options())
|
|
stale = current is not None and current not in options
|
|
if stale:
|
|
options.append(current)
|
|
selected_index = 0
|
|
for index, option in enumerate(options):
|
|
item = C.ComboBoxItem()
|
|
if option == configform.AUTO_DETECT:
|
|
item.Content = configform.AUTO_DETECT
|
|
item.Tag = None
|
|
elif stale and option == current and index == len(options) - 1:
|
|
item.Content = '{0} -- no such plant folder right now' \
|
|
.format(option)
|
|
item.Tag = option
|
|
else:
|
|
item.Content = option
|
|
item.Tag = option
|
|
box.Items.Add(item)
|
|
if item.Tag == current:
|
|
selected_index = index
|
|
box.SelectedIndex = selected_index
|
|
box.SelectionChanged += self.combo_changed
|
|
self.controls[row['key']] = box
|
|
return box
|
|
|
|
def box_for(self, row):
|
|
box = C.TextBox()
|
|
box.Text = row['display']
|
|
box.Tag = row['key']
|
|
box.MinWidth = 240
|
|
box.TextChanged += self.text_changed
|
|
self.controls[row['key']] = box
|
|
return box
|
|
|
|
def filepick_for(self, row):
|
|
"""A text box the owner can still type/clear by hand, plus a Browse
|
|
button that opens a real file picker filtered to YAML -- what the
|
|
owner asked for: "let the user choose the [...] file". The box is
|
|
the control of record; Browse only ever fills it in, the same as
|
|
typing the path would, so Save, refusal and the empty-vs-absent
|
|
state all go through the one path `box_for` already uses.
|
|
"""
|
|
holder = C.DockPanel()
|
|
holder.LastChildFill = True
|
|
browse = C.Button()
|
|
browse.Content = 'Browse...'
|
|
browse.Tag = row['key']
|
|
browse.MinWidth = 84
|
|
browse.Click += self.filepick_click
|
|
holder.Children.Add(dock(browse, C.Dock.Right))
|
|
box = C.TextBox()
|
|
box.Text = row['display']
|
|
box.Tag = row['key']
|
|
box.MinWidth = 200
|
|
box.TextChanged += self.text_changed
|
|
holder.Children.Add(box)
|
|
# `self.controls` keeps the TEXT BOX, not the DockPanel wrapper --
|
|
# nothing else looks a filepick row up by its control, but keeping
|
|
# the same shape as `box_for` means any future code that does can
|
|
# treat every text-entering row alike.
|
|
self.controls[row['key']] = box
|
|
return holder
|
|
|
|
def stages_for(self, row):
|
|
"""One line per stage: its name, its rig mode as a button, on/off.
|
|
|
|
The owner's example, exactly: the stage called stripping with a button
|
|
after it reading `static`, and one click makes it `rolling`. Editing
|
|
any cell writes the WHOLE list -- `handling.stages` is one list and
|
|
lists replace -- so the pending value is always every stage.
|
|
"""
|
|
panel = C.StackPanel()
|
|
stages = row['value'] if isinstance(row['value'], list) else []
|
|
if not stages:
|
|
panel.Children.Add(text_block(
|
|
'No stages are set in any layer. Set handling.stages in the '
|
|
'plant file first.', colour=WARN))
|
|
return panel
|
|
for index, name, mode, enabled in configform.stage_rows(stages):
|
|
line = C.DockPanel()
|
|
line.Children.Add(dock(
|
|
text_block(name, width=150, colour=DARK), C.Dock.Left))
|
|
mode_button = C.Button()
|
|
mode_button.Content = configform.stage_display(mode)
|
|
mode_button.Tag = (index, 'rig_mode')
|
|
mode_button.MinWidth = 110
|
|
mode_button.Click += self.stage_click
|
|
line.Children.Add(dock(mode_button, C.Dock.Left))
|
|
on_button = C.Button()
|
|
on_button.Content = 'on' if enabled else 'off'
|
|
on_button.Tag = (index, 'enabled')
|
|
on_button.MinWidth = 60
|
|
on_button.Click += self.stage_click
|
|
line.Children.Add(dock(on_button, C.Dock.Left))
|
|
line.Children.Add(text_block('', width=10))
|
|
panel.Children.Add(margin(line, 0, 0, 0, 3))
|
|
panel.Children.Add(text_block(
|
|
'A change to one stage writes ALL of them to the job layer, '
|
|
'replacing the list in force.', colour=WARN, size=10))
|
|
return panel
|
|
|
|
def mutes_for(self, row):
|
|
"""THE WHOLE CHECKLIST, one row each, with a speaker beside it.
|
|
|
|
The defect: the owner looked for this control twice, on two days, and
|
|
did not find it either time. It was here both times, as a 60-pixel box
|
|
you typed a checklist number into. A control you can only work if you
|
|
already know the answer is not a control; he had a check in mind, not
|
|
a number, and there was nothing on screen that named a check.
|
|
|
|
So every row the checklist asks is listed, from `ticketpage.
|
|
CHECKLIST_ITEMS` and never from a copy kept here -- a hand-kept second
|
|
list is what made 5.3 print red for a day -- and the control is the
|
|
Windows volume icon, which he asked for by name.
|
|
|
|
MUTING IS A VOLUME CONTROL, NOT A SWITCH. The check runs on every
|
|
ticket whatever this says; a muted row that did not fail reports
|
|
quietly rather than as a red NOT RUN. See `configform.MUTED_STATE`.
|
|
|
|
THREE RULES ARE VISIBLE IN WHAT THIS DRAWS, and each is a thing that
|
|
would otherwise fail silently:
|
|
|
|
* a mute set by the PLANT layer is shown, and has no speaker. The
|
|
layers MERGE this map, so a job entry cannot take a plant mute
|
|
away -- a working-looking button there would change nothing.
|
|
* a job mute sitting on top of a lower layer's mute of the same item
|
|
says that unmuting falls back rather than unmutes.
|
|
* a reason is typed, by a person, before the mute exists. There is
|
|
no placeholder in the box and no default anywhere in this file.
|
|
"""
|
|
self.mutes = dict(row['value']) if isinstance(row['value'], dict) \
|
|
else {}
|
|
self.mute_merged = row.get('merged') or {}
|
|
panel = C.StackPanel()
|
|
|
|
self.mute_summary = text_block('', colour=DARK, bold=True)
|
|
panel.Children.Add(margin(self.mute_summary, 0, 0, 0, 4))
|
|
|
|
# The list scrolls inside the box rather than pushing the other three
|
|
# modules off the window: forty-odd rows is taller than the screen.
|
|
# The outer ScrollViewer in the XAML still moves the window; this one
|
|
# only moves the checklist.
|
|
scroller = C.ScrollViewer()
|
|
scroller.VerticalScrollBarVisibility = C.ScrollBarVisibility.Auto
|
|
scroller.HorizontalScrollBarVisibility = C.ScrollBarVisibility.Disabled
|
|
scroller.MaxHeight = 300
|
|
self.mute_panel = C.StackPanel()
|
|
scroller.Content = self.mute_panel
|
|
panel.Children.Add(scroller)
|
|
|
|
self.redraw_mutes()
|
|
return panel
|
|
|
|
def redraw_mutes(self):
|
|
"""The list rebuilt from scratch. Cheap, and the only way the open
|
|
reason box, the button set and the summary line cannot disagree."""
|
|
if self.mute_panel is None:
|
|
return
|
|
rows = configform.mute_table(self.mute_merged, self.mutes)
|
|
if self.mute_summary is not None:
|
|
self.mute_summary.Text = configform.mute_summary(rows)
|
|
self.mute_panel.Children.Clear()
|
|
group = None
|
|
for row in rows:
|
|
head = ('{0}'.format(row['number']).split('.') or [''])[0]
|
|
if row['known'] and head != group:
|
|
group = head
|
|
self.mute_panel.Children.Add(margin(
|
|
text_block('checklist {0}'.format(head), colour=GREY,
|
|
size=10), 0, 6, 0, 1))
|
|
self.mute_panel.Children.Add(self.mute_line(row))
|
|
|
|
def mute_line(self, row):
|
|
holder = C.StackPanel()
|
|
line = C.DockPanel()
|
|
line.LastChildFill = True
|
|
line.Children.Add(dock(
|
|
text_block(row['number'], width=34, bold=True,
|
|
colour=GREY if row['muted'] else DARK), C.Dock.Left))
|
|
line.Children.Add(dock(
|
|
text_block(row['grade'] or '-', width=16, colour=GREY, size=10),
|
|
C.Dock.Left))
|
|
|
|
if row['muted'] and not row['editable']:
|
|
# No speaker at all. A disabled one invites a second and a third
|
|
# click and says nothing; the sentence under the row says what to
|
|
# do instead.
|
|
line.Children.Add(dock(text_block(
|
|
'muted by {0}'.format(row['layer']), width=110, colour=GREY,
|
|
size=10), C.Dock.Right))
|
|
elif row['editable']:
|
|
line.Children.Add(dock(self.speaker_for(row), C.Dock.Right))
|
|
# GREY, not red. A muted row is not a warning and not a defect: the
|
|
# check runs and the result is fine unless it says otherwise. Red here
|
|
# would be the same training-the-reader-to-skip-red problem that
|
|
# muting exists to solve.
|
|
line.Children.Add(text_block(
|
|
row['name'], colour=GREY if row['muted'] else DARK, size=11))
|
|
holder.Children.Add(line)
|
|
|
|
if row['muted'] and row['reason']:
|
|
holder.Children.Add(margin(
|
|
text_block(row['reason'], colour=GREY, size=10), 50, 0, 0, 0))
|
|
for sentence in (row['why_not'], row['falls_back']):
|
|
if sentence:
|
|
holder.Children.Add(margin(
|
|
text_block(sentence, colour=WARN, size=10), 50, 0, 0, 0))
|
|
if self.mute_asking == row['number']:
|
|
holder.Children.Add(self.mute_reason_row(row))
|
|
return margin(holder, 0, 0, 0, 2)
|
|
|
|
def speaker_for(self, row):
|
|
"""The Windows volume icon, which is what he asked for by name.
|
|
|
|
Segoe MDL2 Assets, because those two code points ARE the glyphs the
|
|
Windows volume flyout draws and the font ships with the OS. The font
|
|
is set explicitly rather than inherited: with the window's default
|
|
family the code points are private-use and render as an empty box, and
|
|
an empty box beside every checklist row is worse than a word.
|
|
|
|
The tooltip says what the CLICK WILL DO, not what the state is -- the
|
|
state is already the glyph, the colour and the reason under the row,
|
|
and a tooltip repeating it would leave the one question a speaker
|
|
cannot answer ("does this stop the check?") unanswered. It is the one
|
|
tooltip in this extension, and it is on a WPF control built in code;
|
|
the no-hover-text rule is about pyRevit ribbon bundles, which read
|
|
`bundle.yaml` and a module docstring, neither of which this is.
|
|
"""
|
|
button = C.Button()
|
|
button.Content = (configform.SPEAKER_MUTED if row['muted']
|
|
else configform.SPEAKER_ON)
|
|
try:
|
|
button.FontFamily = M.FontFamily(configform.SPEAKER_FONT)
|
|
except Exception:
|
|
pass
|
|
button.FontSize = 15
|
|
button.Tag = row['number']
|
|
button.MinWidth = 34
|
|
button.Width = 34
|
|
button.ToolTip = configform.SPEAKER_HINT[bool(row['muted'])]
|
|
button.Click += (self.mute_remove_click if row['muted']
|
|
else self.mute_start_click)
|
|
return button
|
|
|
|
def mute_reason_row(self, row):
|
|
"""The reason box, open on one row at a time, with NOTHING in it.
|
|
|
|
No placeholder, no suggested sentence, no "muted via UI". A reason
|
|
somebody accepted without reading is the same as no reason on the day
|
|
it has to be argued with, and this whole checklist exists because
|
|
silence looks like a pass.
|
|
"""
|
|
holder = C.StackPanel()
|
|
holder.Children.Add(margin(text_block(
|
|
'Why should {0} report quietly on this job? Required, in your own '
|
|
'words -- the date is added for you. The check still runs.'
|
|
.format(row['number']), colour=DARK, size=10), 50, 3, 0, 2))
|
|
line = C.DockPanel()
|
|
confirm = C.Button()
|
|
confirm.Content = 'Mute it'
|
|
confirm.Tag = row['number']
|
|
confirm.MinWidth = 72
|
|
confirm.Click += self.mute_add_click
|
|
line.Children.Add(dock(confirm, C.Dock.Right))
|
|
drop = C.Button()
|
|
drop.Content = 'Cancel'
|
|
drop.MinWidth = 62
|
|
drop.Click += self.mute_cancel_click
|
|
line.Children.Add(dock(drop, C.Dock.Right))
|
|
box = C.TextBox()
|
|
box.Text = self.mute_draft or ''
|
|
box.MinWidth = 300
|
|
box.TextChanged += self.mute_reason_changed
|
|
line.Children.Add(box)
|
|
holder.Children.Add(margin(line, 50, 0, 0, 4))
|
|
try:
|
|
box.Focus()
|
|
except Exception:
|
|
pass
|
|
return holder
|
|
|
|
# ---- reacting ------------------------------------------------------
|
|
|
|
def section_toggled(self, sender, args):
|
|
"""One module folded or opened. Kept for THIS Revit session only --
|
|
see `configform.remember_expanded` -- so pressing the button again
|
|
a minute later comes back to what was open, and a fresh session
|
|
still opens on all four folded."""
|
|
configform.remember_expanded(sender.Tag, sender.IsExpanded)
|
|
|
|
def filepick_click(self, sender, args):
|
|
"""Browse, filtered to YAML. Cancel changes nothing -- `pick_file`
|
|
returns None and the box is left exactly as it was.
|
|
|
|
The chosen path is written into the SAME text box Save already
|
|
reads, so a missing-file refusal (`configmodel.validate`) and the
|
|
empty-vs-absent distinction both come from the one path `box_for`
|
|
rows use -- Browse is a shortcut for typing, not a second mechanism.
|
|
"""
|
|
key = sender.Tag
|
|
row = self.rows.get(key)
|
|
box = self.controls.get(key)
|
|
if row is None or box is None:
|
|
return
|
|
try:
|
|
chosen = forms.pick_file(
|
|
file_ext='yaml', title='Pick the {0}'.format(
|
|
row.get('label') or key))
|
|
except Exception as exc:
|
|
self.say('The file picker could not be opened: {0}'.format(exc),
|
|
bad=True)
|
|
return
|
|
if not chosen:
|
|
return
|
|
box.Text = chosen
|
|
self.typed[key] = chosen
|
|
self.refresh_status()
|
|
|
|
def toggle_click(self, sender, args):
|
|
key = sender.Tag
|
|
row = self.rows.get(key)
|
|
if row is None:
|
|
return
|
|
before = self.current(key)
|
|
after = configform.next_value(row, before)
|
|
self.remember(row, after)
|
|
sender.Content = configform.display(row, after)
|
|
self.refresh_status()
|
|
|
|
def combo_changed(self, sender, args):
|
|
key = sender.Tag
|
|
row = self.rows.get(key)
|
|
if row is None:
|
|
return
|
|
item = sender.SelectedItem
|
|
value = item.Tag if item is not None else None
|
|
self.remember(row, value)
|
|
self.refresh_status()
|
|
|
|
def stage_click(self, sender, args):
|
|
index, name = sender.Tag
|
|
row = self.rows.get('handling.stages')
|
|
if row is None:
|
|
return
|
|
stages = self.current('handling.stages')
|
|
if not isinstance(stages, list):
|
|
return
|
|
stage = stages[index] if index < len(stages) else {}
|
|
if name == 'rig_mode':
|
|
value = configform.next_value(
|
|
{'values': configform.STAGE_MODES, 'kind': 'toggle',
|
|
'source': 'job'}, stage.get('rig_mode'))
|
|
sender.Content = configform.stage_display(value)
|
|
else:
|
|
value = not (stage.get('enabled', True) is not False)
|
|
sender.Content = 'on' if value else 'off'
|
|
self.remember(row, configform.set_stage(stages, index, name, value))
|
|
self.refresh_status()
|
|
|
|
def mute_start_click(self, sender, args):
|
|
"""Ask for the reason. Nothing is muted by this click.
|
|
|
|
The mute does not exist until a sentence does. Opening a box rather
|
|
than muting-then-asking is the difference: a mute created first and
|
|
annotated afterwards is a mute that survives somebody changing their
|
|
mind halfway, with no reason on it.
|
|
"""
|
|
self.mute_asking = sender.Tag
|
|
self.mute_draft = ''
|
|
self.redraw_mutes()
|
|
self.say('Type why {0} should report quietly on this job, then press '
|
|
'"Mute it". The check keeps running; the reason is written '
|
|
'into the job file with today\'s date.'.format(sender.Tag))
|
|
|
|
def mute_cancel_click(self, sender, args):
|
|
self.mute_asking = None
|
|
self.mute_draft = ''
|
|
self.redraw_mutes()
|
|
self.refresh_status()
|
|
|
|
def mute_reason_changed(self, sender, args):
|
|
# Held on the window, not read off the box at Confirm time: the box is
|
|
# rebuilt whenever the list redraws, and a half-typed sentence that
|
|
# disappeared because something else on screen moved is the kind of
|
|
# loss nobody reports and everybody remembers.
|
|
self.mute_draft = sender.Text
|
|
|
|
def mute_add_click(self, sender, args):
|
|
ok, mutes, why = configform.add_mute(
|
|
self.mutes, sender.Tag, self.mute_draft,
|
|
known=self.state['checklist_numbers'])
|
|
if not ok:
|
|
self.say(why, bad=True)
|
|
return
|
|
self.mutes = mutes
|
|
self.mute_asking = None
|
|
self.mute_draft = ''
|
|
self.remember(self.rows['checks.muted'], dict(self.mutes))
|
|
self.remerge_mutes()
|
|
self.redraw_mutes()
|
|
self.refresh_status()
|
|
|
|
def mute_remove_click(self, sender, args):
|
|
ok, mutes, why = configform.remove_mute(self.mutes, sender.Tag)
|
|
if not ok:
|
|
self.say(why, bad=True)
|
|
return
|
|
self.mutes = mutes
|
|
self.remember(self.rows['checks.muted'], dict(self.mutes))
|
|
self.remerge_mutes()
|
|
self.redraw_mutes()
|
|
self.refresh_status()
|
|
|
|
def remerge_mutes(self):
|
|
"""The merged view brought back in step with the pending job edits.
|
|
|
|
The list on screen is the MERGE -- what a run would read -- and the
|
|
pending edits are the job layer's half of it. Redrawing from the merge
|
|
as it was read at open would show a mute the window has just taken
|
|
away, or hide one it has just made, until the window is reopened. So
|
|
the job layer's entries are recomputed over the layers below, and only
|
|
the job layer's: nothing here can add or remove a plant entry.
|
|
"""
|
|
merged = {}
|
|
for number in self.mute_merged:
|
|
entry = self.mute_merged[number]
|
|
if entry['layer'] != configform.WRITE_LAYER:
|
|
merged[number] = entry
|
|
continue
|
|
# The job entry is about to be replaced by the pending set. What
|
|
# it BEAT is not: a plant mute of the same item is still in the
|
|
# plant file, so it comes back to the surface rather than
|
|
# vanishing with the job entry. Dropping it would draw the row as
|
|
# un-muted the instant Unmute is pressed -- which is precisely the
|
|
# lie this window is being fixed for.
|
|
beaten = list(entry.get('overrides') or [])
|
|
if beaten:
|
|
label, value = beaten[-1]
|
|
merged[number] = {'value': value, 'layer': label,
|
|
'path': None, 'overrides': beaten[:-1]}
|
|
for number in self.mutes:
|
|
beaten = merged.get(number)
|
|
overrides = list(beaten['overrides']) if beaten else []
|
|
if beaten is not None:
|
|
overrides.append((beaten['layer'], beaten['value']))
|
|
merged[number] = {'value': self.mutes[number],
|
|
'layer': configform.WRITE_LAYER,
|
|
'path': self.state['job_path'],
|
|
'overrides': overrides}
|
|
self.mute_merged = merged
|
|
|
|
def text_changed(self, sender, args):
|
|
self.typed[sender.Tag] = sender.Text
|
|
self.refresh_status()
|
|
|
|
def current(self, key):
|
|
"""What the window believes the value is: pending first, then read."""
|
|
row = self.rows.get(key)
|
|
if row is None:
|
|
return None
|
|
if row['source'] == 'run' and key in self.pending_run:
|
|
return self.pending_run[key]
|
|
if key in self.pending:
|
|
return self.pending[key]
|
|
return row.get('value')
|
|
|
|
def remember(self, row, value):
|
|
"""One pending edit, dropped again if it puts the value back."""
|
|
store = self.pending_run if row['source'] == 'run' else self.pending
|
|
if configform.same(row.get('value'), value):
|
|
if row['key'] in store:
|
|
del store[row['key']]
|
|
return
|
|
store[row['key']] = value
|
|
|
|
# ---- the pending set -----------------------------------------------
|
|
|
|
def collect(self):
|
|
"""(changes, complaints). Everything typed is parsed here, once.
|
|
|
|
Parsing on every keystroke would refuse `1.` on the way to `1.5`, so
|
|
the boxes take any text and this is where text becomes a value.
|
|
"""
|
|
changes = []
|
|
complaints = []
|
|
for key in sorted(self.typed):
|
|
row = self.rows.get(key)
|
|
if row is None:
|
|
continue
|
|
ok, value, why = configform.parse(row, self.typed[key])
|
|
if not ok:
|
|
complaints.append('{0}: {1}'.format(
|
|
row.get('label') or key, why))
|
|
continue
|
|
self.remember(row, value)
|
|
for key in sorted(self.pending):
|
|
row = self.rows.get(key)
|
|
if row is not None:
|
|
changes.append((row, row.get('value'), self.pending[key]))
|
|
for key in sorted(self.pending_run):
|
|
row = self.rows.get(key)
|
|
if row is not None:
|
|
changes.append((row, row.get('value'), self.pending_run[key]))
|
|
return changes, complaints
|
|
|
|
def cautions(self, changes):
|
|
"""`configmodel.warnings` for the pending set: allowed, worth saying.
|
|
|
|
Nothing called this before, so every sentence in `warnings()` was
|
|
written and never shown -- and the settings it speaks about are the
|
|
ones where a defensible value and a typo look identical. The stress
|
|
limit is the whole reason it now has a caller: 5.0 and 7.5 are both
|
|
real positions and neither may be refused, so the only thing left is
|
|
to say what the number in the box means before it is written.
|
|
"""
|
|
out = []
|
|
know = self.state['know']
|
|
for row, _before, after in changes:
|
|
if row['source'] != 'job':
|
|
continue
|
|
for line in configmodel.warnings(row['key'], after, know):
|
|
out.append('{0}: {1}'.format(
|
|
row.get('label') or row['key'], line))
|
|
return out
|
|
|
|
def refresh_status(self):
|
|
changes, complaints = self.collect()
|
|
if complaints:
|
|
self.say(' / '.join(complaints), bad=True)
|
|
return
|
|
if not changes:
|
|
self.say('Nothing changed yet.')
|
|
return
|
|
body = '{0} change(s) waiting for Save: {1}'.format(
|
|
len(changes), '; '.join(configform.change_lines(changes)))
|
|
cautions = self.cautions(changes)
|
|
if cautions:
|
|
# Coloured like a refusal and worded like the opposite of one.
|
|
# The colour is what gets somebody to read the line; the first
|
|
# three words are what stop them reading it as a rejection.
|
|
self.say('{0} -- ALLOWED, WITH A NOTE: {1}'.format(
|
|
body, ' / '.join(cautions)), bad=True)
|
|
return
|
|
self.say(body)
|
|
|
|
def say(self, body, bad=False):
|
|
self.status.Text = body or ''
|
|
painted = brush(WARN if bad else DARK)
|
|
if painted:
|
|
self.status.Foreground = painted
|
|
|
|
# ---- the four buttons ----------------------------------------------
|
|
|
|
def save_click(self, sender, args):
|
|
changes, complaints = self.collect()
|
|
if complaints:
|
|
self.say('Nothing has been written. ' + ' / '.join(complaints),
|
|
bad=True)
|
|
return
|
|
if not changes:
|
|
self.say('Nothing has changed, so nothing was written.')
|
|
return
|
|
|
|
know = self.state['know']
|
|
refusals = []
|
|
for row, _before, after in changes:
|
|
if row['source'] != 'job':
|
|
continue
|
|
for line in configmodel.validate(row['key'], after, know):
|
|
refusals.append('{0}: {1}'.format(
|
|
row.get('label') or row['key'], line))
|
|
if refusals:
|
|
self.say('Refused, and nothing has been written. ' +
|
|
' / '.join(refusals), bad=True)
|
|
return
|
|
|
|
job_changes = [c for c in changes if c[0]['source'] == 'job']
|
|
reason = (self.reason_box.Text or '').strip()
|
|
if job_changes and not reason:
|
|
self.say('Say why first. It is written into the file as a '
|
|
'comment above each value -- an override and a typo '
|
|
'look identical six months later.', bad=True)
|
|
return
|
|
|
|
try:
|
|
written = write_all(job_changes, reason)
|
|
except Exception as exc:
|
|
self.say('The write failed and nothing was changed: '
|
|
'{0}'.format(exc), bad=True)
|
|
return
|
|
if not written:
|
|
self.say('The edit was refused; see the dialog. Nothing has been '
|
|
'written.', bad=True)
|
|
return
|
|
|
|
run_changes = [c for c in changes if c[0]['source'] == 'run']
|
|
if run_changes:
|
|
options = read_run_options()
|
|
for row, _before, after in run_changes:
|
|
options[row['key']] = after
|
|
configmodel.save_run_options(designspec.root_dir(doc), options)
|
|
|
|
self.written = changes
|
|
self.cautioned = self.cautions(changes)
|
|
self.saved = True
|
|
self.Close()
|
|
|
|
def cancel_click(self, sender, args):
|
|
self.Close()
|
|
|
|
def openfile_click(self, sender, args):
|
|
path = job_path()
|
|
if not os.path.isfile(path):
|
|
try:
|
|
knowledge.write_starter(doc)
|
|
except Exception as exc:
|
|
self.say('The job knowledge file could not be created: '
|
|
'{0}'.format(exc), bad=True)
|
|
return
|
|
opened = False
|
|
try:
|
|
os.startfile(path) # noqa -- Windows only, by design
|
|
opened = True
|
|
except Exception:
|
|
try:
|
|
script.show_file_in_explorer(path)
|
|
opened = True
|
|
except Exception:
|
|
opened = False
|
|
if opened:
|
|
self.say('Opened {0}. Anything this window does not carry is '
|
|
'edited there.'.format(path))
|
|
else:
|
|
self.say('Could not open {0}. It is on the output panel as a '
|
|
'link when this window closes.'.format(path), bad=True)
|
|
|
|
def fullview_click(self, sender, args):
|
|
self.want_full = True
|
|
self.Close()
|
|
|
|
|
|
# ---------------------------------------------------------------- the write
|
|
|
|
def write_all(changes, reason):
|
|
"""Every job-layer change spliced into the job file, or none of them.
|
|
|
|
One backup of the file as it was, then one splice per key, each verified
|
|
against the reader before the next. `verify` re-parses both versions and
|
|
insists the ONLY difference is the key asked for, so a splice that ate the
|
|
block below it fails here rather than in a run three weeks later.
|
|
|
|
True when the file was rewritten, False when anything refused -- and on a
|
|
refusal not one byte has moved.
|
|
"""
|
|
if not changes:
|
|
return True
|
|
path = job_path()
|
|
if not os.path.isfile(path):
|
|
knowledge.write_starter(doc)
|
|
text = job_text()
|
|
if text is None:
|
|
forms.alert('The job knowledge file could not be created:\n\n'
|
|
'{0}'.format(path), title=WHO, warn_icon=True)
|
|
return False
|
|
|
|
try:
|
|
ours = yamlkit.parse(text) or {}
|
|
except Exception as exc:
|
|
forms.alert(
|
|
'This job knowledge file cannot be read well enough to edit '
|
|
'safely:\n\n{0}\n\n{1}\n\nNothing has been written. Splicing a '
|
|
'file the reader is already failing on would compound it -- edit '
|
|
'it by hand.'.format(path, exc), title=WHO, warn_icon=True)
|
|
return False
|
|
|
|
theirs = {}
|
|
knowledge._merge_file(theirs, path)
|
|
|
|
# ASKED FIRST, because it is the question the per-key check cannot ask.
|
|
# A reader that stops two thirds of the way down the file disagrees about
|
|
# every key below the cut, and the per-key message then names whichever
|
|
# one was being written -- which is how "the two YAML readers disagree
|
|
# about snapshot.detached_dir" came to be reported for a file whose
|
|
# `snapshot` section was simply never reached. See
|
|
# `configmodel.reader_coverage`.
|
|
trouble = []
|
|
try:
|
|
trouble = configmodel.reader_coverage(theirs, ours, text)
|
|
except Exception:
|
|
trouble = []
|
|
if trouble:
|
|
forms.alert('\n\n'.join(trouble) + '\n\n' + path, title=WHO,
|
|
warn_icon=True)
|
|
return False
|
|
|
|
for row, _before, _after in changes:
|
|
complaint = configmodel.reader_disagreement(theirs, ours, row['key'])
|
|
if complaint:
|
|
forms.alert(complaint + '\n\n' + path, title=WHO, warn_icon=True)
|
|
return False
|
|
|
|
# EVERY SPLICE IS DONE AND VERIFIED BEFORE ANYTHING REACHES DISK. A half
|
|
# written batch -- three keys in, the fourth refused -- is a file nobody
|
|
# asked for and nobody can describe.
|
|
working = text
|
|
for row, _before, after in changes:
|
|
note = configmodel.note_lines(row['key'], row.get('res'), reason,
|
|
who=WHO)
|
|
try:
|
|
spliced = configmodel.splice(working, row['key'], after, note)
|
|
trouble = configmodel.verify(working, spliced, row['key'], after)
|
|
except Exception as exc:
|
|
trouble = ['{0}'.format(exc)]
|
|
if trouble:
|
|
forms.alert('The edit was refused:\n\n{0}\n\n{1}\n\nNothing has '
|
|
'been written.'.format(row.get('label') or row['key'],
|
|
'\n'.join(trouble)),
|
|
title=WHO, warn_icon=True)
|
|
return False
|
|
working = spliced
|
|
|
|
backup = path + '.bak-' + datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
|
|
with io.open(backup, 'w', encoding='utf-8') as fh:
|
|
fh.write(text)
|
|
with io.open(path, 'w', encoding='utf-8') as fh:
|
|
fh.write(working)
|
|
return True
|
|
|
|
|
|
# ------------------------------------------------------------------ the page
|
|
|
|
def show_page():
|
|
"""Bring the output panel to the front, and never raise doing it.
|
|
|
|
A modal dialog closing re-activates its owner -- Revit's main window --
|
|
which covers the output window. Everything here is guarded and optional:
|
|
the console's API surface differs between pyRevit builds, and a page that
|
|
is merely behind something is a far smaller failure than a button that
|
|
dies trying to raise it.
|
|
"""
|
|
try:
|
|
output.show()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
window = output.window
|
|
except Exception:
|
|
return
|
|
if window is None:
|
|
return
|
|
for name in ('Activate', 'BringToFront', 'Focus'):
|
|
method = getattr(window, name, None)
|
|
if method is None:
|
|
continue
|
|
try:
|
|
method()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def report(win, state):
|
|
"""What the press did, printed once, after every window has shut."""
|
|
output.print_md('# {0} — {1}'.format(WHO, state['title']))
|
|
for note in state['notes']:
|
|
output.print_md('**{0}**'.format(note))
|
|
|
|
if win.saved and win.written:
|
|
output.print_md('## Written to the job layer')
|
|
output.print_table(
|
|
[[row.get('label') or row['key'],
|
|
configform.display(row, before, known=row.get('known', True)),
|
|
configform.display(row, after),
|
|
'`{0}`'.format(row['key']),
|
|
'job file' if row['source'] == 'job' else 'run options']
|
|
for row, before, after in win.written],
|
|
columns=['setting', 'was', 'is now', 'key', 'written to'])
|
|
output.print_md('`{0}`'.format(state['job_path']))
|
|
# Written, and worth a second look. Never a refusal -- everything
|
|
# here is a value somebody is entitled to; the note is what it
|
|
# means, printed where it survives the window closing.
|
|
for note in win.cautioned:
|
|
output.print_md('> **Allowed, with a note.** {0}'.format(note))
|
|
current = designspec.active_config_path(doc)
|
|
if current:
|
|
output.print_md(
|
|
'> **This edit does not reach this model yet.** It runs on '
|
|
'the project config file `{0}`, which is the last word. '
|
|
'Refresh that file from the layers, or stop using it, before '
|
|
'the change takes effect.'.format(current))
|
|
else:
|
|
output.print_md('Nothing was written.')
|
|
|
|
output.print_md(
|
|
'Anything the four boxes do not carry is edited in the file itself: '
|
|
'[{0}]({1})'.format(state['job_path'],
|
|
configform.file_url(state['job_path'])))
|
|
skipped = configform.not_shown_rows()
|
|
if skipped:
|
|
output.print_md('## Not in the window, on purpose')
|
|
output.print_table([[label, why, '`{0}`'.format(key)]
|
|
for label, key, why in skipped],
|
|
columns=['setting', 'why it is not a control',
|
|
'key'])
|
|
output.print_md('> Press **{0}** again and click **Full view** for every '
|
|
'setting, every layer and the provenance of every '
|
|
'value.'.format(WHO))
|
|
|
|
|
|
def full_view(state):
|
|
"""The whole configuration, foldreport-styled. The secondary surface.
|
|
|
|
Not the product any more -- the window is -- but it is where the four
|
|
boxes' "everything else" actually lives, so it stays one click away.
|
|
"""
|
|
know = state['know']
|
|
layers = state['layers']
|
|
subtrees = []
|
|
for name, note in configmodel.SUBTREES:
|
|
branch = knowledge.get(know, name)
|
|
if not isinstance(branch, dict):
|
|
continue
|
|
leaves = []
|
|
for key in sorted(configdump._paths({name: branch})):
|
|
res = configmodel.resolve(layers, key)
|
|
shown = key[len(name) + 1:] if key.startswith(name + '.') else key
|
|
leaves.append((shown,
|
|
configmodel.format_value(res['value'])
|
|
if res['known'] else configpage.NOT_SET,
|
|
res['layer'] or 'nothing'))
|
|
if leaves:
|
|
subtrees.append((name, note, leaves))
|
|
|
|
current = designspec.active_config_path(doc)
|
|
kwargs = {
|
|
'title': state['title'], 'layers': layers,
|
|
'problems': knowledge.config_problems(doc, know),
|
|
'job_path': state['job_path'], 'config_path': current,
|
|
'config_missing': bool(current and not os.path.isfile(current)),
|
|
'env': env_rows(), 'run_options': state['run_options'],
|
|
'run_options_path': run_options_path(),
|
|
'locations': configdump.output_locations(doc, know),
|
|
'report_dir': configdump.report_dir(doc, know), 'subtrees': subtrees,
|
|
'muted': knowledge.get(know, 'checks.muted') or {},
|
|
# The same rows the window's Ticket QC box draws, so the page's
|
|
# `set by` column and the window's buttons cannot disagree about
|
|
# whose mute an entry is. Built here because it needs the ticket
|
|
# checklist table, which `configpage` has no business loading.
|
|
'muted_rows': configform.mute_table(
|
|
configmodel.merged_map(layers, 'checks.muted'),
|
|
configmodel.layer_map(layers, 'checks.muted',
|
|
configform.WRITE_LAYER))
|
|
if can_list_checks() else None,
|
|
'muted_note': None, 'notes': state['notes'],
|
|
}
|
|
sections = configpage.render(**kwargs)
|
|
|
|
# Written first, then rebuilt: the headline says where the file is, so
|
|
# that sentence must not appear on a page that failed to write one.
|
|
# `render` is pure, so building it twice costs nothing worth saving.
|
|
path = None
|
|
try:
|
|
path = os.path.join(designspec.sub_dir(doc, 'config'),
|
|
'last-page.txt')
|
|
handle = io.open(path, 'w', encoding='utf-8')
|
|
try:
|
|
handle.write(fold.text(sections, show_all=True))
|
|
finally:
|
|
handle.close()
|
|
except Exception:
|
|
path = None
|
|
if path:
|
|
kwargs['full_path'] = path
|
|
sections = configpage.render(**kwargs)
|
|
|
|
for block in sections[0].blocks:
|
|
if block[0] == fold.TABLE:
|
|
output.print_table(block[2], columns=block[1])
|
|
else:
|
|
output.print_md(block[1])
|
|
tail = sections[1:]
|
|
if not tail:
|
|
return
|
|
output.add_style(fold.STYLE)
|
|
output.inject_script(fold.SCRIPT)
|
|
opened = fold.sections_with_problems(tail)
|
|
output.print_md('---')
|
|
if opened:
|
|
output.print_md(
|
|
'**{0} of the {1} sections below opened themselves — they '
|
|
'have something in them that needs you.** The rest are folded; '
|
|
'click a header to open one.'.format(len(opened), len(tail)))
|
|
else:
|
|
output.print_md('**Nothing below needs you.** Every section is '
|
|
'folded; click a header to open one.')
|
|
output.print_html(fold.html(tail))
|
|
|
|
|
|
# --------------------------------------------------------------------- main
|
|
#
|
|
# NOTHING IS PRINTED WHILE A WINDOW IS OPEN. The panel is written once, after
|
|
# the dialog returns, and raised -- so the page is the last thing the press
|
|
# does and no modal can bury it on the way out.
|
|
#
|
|
# The body is wrapped so an unexpected exception still leaves a run-log record
|
|
# (outcome='error') before pyRevit shows its own error dialog.
|
|
|
|
try:
|
|
know = knowledge.load(doc)
|
|
layers = read_layers()
|
|
state = {
|
|
'title': doc.Title,
|
|
'know': know,
|
|
'layers': layers,
|
|
'job_path': job_path(),
|
|
'run_options': read_run_options(),
|
|
'notes': build_notes(layers),
|
|
# Read once, from `ticketpage.CHECKLIST_ITEMS` and nowhere else, and
|
|
# handed to `add_mute` so a number that is not a checklist row is
|
|
# refused at the click rather than at Save. Empty means the table
|
|
# could not be read, and `add_mute` then takes the number on trust
|
|
# instead of refusing everything.
|
|
'checklist_numbers': (configform.checklist_numbers() or None)
|
|
if can_list_checks() else None,
|
|
}
|
|
state['info'] = info_rows(know)
|
|
|
|
win = script.load_ui(ConfigWindow(state), 'ProjectConfig.xaml')
|
|
win.show_dialog()
|
|
|
|
# Every exit from the modal runs through this single point -- Save wrote,
|
|
# Cancel changed nothing, Full view asks for the page -- so one record
|
|
# here covers each of them with the outcome they earned.
|
|
if win.written:
|
|
runlog.record(
|
|
WHO, doc_title=doc.Title, outcome='ok',
|
|
report_text='Saved {0} change(s): {1}.'.format(
|
|
len(win.written),
|
|
'; '.join(configform.change_lines(win.written))),
|
|
report_path=state['job_path'],
|
|
values={'wrote': [state['job_path']],
|
|
'run_options': run_options_path()})
|
|
else:
|
|
runlog.record(
|
|
WHO, doc_title=doc.Title, outcome='not-run',
|
|
report_text='Nothing was written: the window was closed without '
|
|
'saving.')
|
|
|
|
report(win, state)
|
|
if win.want_full:
|
|
output.print_md('---')
|
|
full_view(state)
|
|
show_page()
|
|
except Exception as exc:
|
|
runlog.record(WHO,
|
|
doc_title=doc.Title if doc is not None else None,
|
|
outcome='error', error=str(exc),
|
|
traceback_text=traceback.format_exc())
|
|
raise
|