# -*- 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