This document covers the project-scheduling side of the workspace: the
projcore engine, the yppxy terminal app, and the mppread legacy reader —
the mpp → yppx third of the doc → docx, xls → xlsx, mpp → yppx
trilogy. For the spreadsheet side see SPREADSHEET.md; for the
apps and keys see the README.
Microsoft Project is a scheduling engine wrapped in a UI. A .mpp file is a
list of tasks linked by dependencies, interpreted against working-time
calendars, optionally staffed by resources — and the app computes when
everything happens (the Critical Path Method). We rebuild that as a small,
dependency-free Rust engine plus a terminal app, exactly as gridcore/xlsxy
rebuilt Excel.
The interop insight that makes this tractable: Project's MSPDI XML
(File ▸ Save As ▸ XML) is a documented open format. So projcore never has
to decode the undocumented binary .mpp to exchange schedules with Project — it
reads/writes MSPDI, and keeps its own native package, .yppx.
projcore— the engine.std-only, on top ofopccore(shared ZIP/XML plumbing). No third-party dependencies.yppxy— the TUI (ratatui): task outline + live terminal Gantt, the same ribbon/backstage UX as docxy/xlsxy.mppread—std-only reader of MS Project.mppfiles, on top ofopccore's OLE2 Compound File (MS-CFB) reader.
Built bottom-up, each a pure module:
| Module | Responsibility |
|---|---|
datetime |
a civil wall-clock instant (minutes since 1970), proleptic-Gregorian conversion, MSPDI ISO parse/format |
model |
the pure domain: Task, Predecessor, Resource, Assignment, Calendar, Project; LinkType/ConstraintType with MSPDI's integer codes pinned once |
mspdi |
read and write MS Project's MSPDI XML — the interop bridge |
mpx |
read MPX, Project's older comma-separated interchange text (.mpx, or any unclaimed file starting MPX, or MPX;); never written (#309) |
schedule |
the CPM engine + resource leveling |
assign |
assignment dates and costs from the schedule and rate tables, and the refresh of stored totals an edit made stale |
editor |
shared editing, selection, dirty tracking, undo/redo, and live scheduling |
gantt |
export a scheduled project as a Markdown/Mermaid Gantt chart, with a task table (dates, Deadline with a missed-deadline ⚠, duration, slack) |
report |
Project's Report › View Reports as Markdown, from the plan's stored values |
yppx |
the native .yppx OPC package (ZIP + [Content_Types].xml + project.xml, plus formatting.xml when the plan has formatting) |
formatting |
the plan's formatting (#1532, #1390): bar palette id, text styles per category and per cell, the gridlines (#1575, formatting::gridlines: Project's Gridlines dialog as data, a type, colour and interval per line, <gridline line type color interval interval-type interval-color>, only lines set away from their default stored) and link-line style, the named view switches a UI plugin owns (#1553, <switch name on>), the view's Bar Styles table and the per-task Format Bar overrides (#1696, formatting::bars: <bar name show-for row from to start-* middle-* end-* text-*> and <task-bar uid ...>; Formatting::bars, task_bars); Project::formatting, edited as one undo step by Editor::edit_formatting, saved in formatting.xml, never in MSPDI |
formats |
the ProjectFormat trait and the Registry of formats hosts open and save (built-in .yppx and .xml), and, behind the plugins feature, the format and UI plugin loader |
plugin_abi |
the C ABI a format plugin implements, with the optional version-target and UI symbols (see Format plugins and UI plugins) |
json |
the hand-written JSON value the plugin ABI's payloads use |
uiplugin |
the typed JSON of a UI plugin: Target, Manifest, StateCtx/State, Command/CommandResult, OverlayCtx/Shape |
The model is pure input — the scheduler never mutates it; it returns a
separate Schedule. MSPDI's own computed Start/Finish are captured as
stored_* and used as an oracle for the scheduler. A save writes an auto
task's Start/Finish from docxy's schedule (its EarlyStart/EarlyFinish),
not its stored dates, which go stale once anything the task depends on moves.
It keeps the stored dates where they are not docxy's to compute (manual tasks,
blank rows, external placeholders, tasks the schedule skips, auto summaries with
nothing scheduled below them), and for every task of a plan with an input
Project schedules by and docxy's schedule ignores, whose effect reaches every
task downstream: ScheduleFromStart 0, a non-zero assignment
LevelingDelay (a task's is part of docxy's schedule since #1594), an
elapsed task DurationFormat, or a
work resource whose calendar has other working time than its task's (a
resource calendar that states nothing of its own and derives from the task's
calendar, as Project makes one per resource, does not count). Known limits no
cheap check detects, where a save still writes docxy's dates and they can
differ from Project's, spreading through links and rollups: recurring calendar
exceptions (Type 2-8), which are not scheduled, untracked splits, and on a
.mpp import an assignment LevelingDelay or ScheduleFromStart 0, neither
of which the importer decodes (it does decode each resource's calendar). An assignment Delay is not such an
input: it moves the assignment within its task, whose duration includes it. The
editor rewrites the stored dates for a manual task
whose dates it edits, so a save's Start/Finish agree with its
ManualStart/ManualDuration (Project does not reschedule manual tasks on
open). A manual summary saves its own dates there too, as Project does; a task
or summary switched back to auto is restamped where it is now scheduled. Likewise the editor
refreshes the stored assignment dates, costs and remaining work, and the
resource, task and summary totals, that an edit made stale
(assign::refresh, run from Editor::reschedule during an edit); values no
edit touched are saved exactly as read. A structural edit likewise renumbers
a stored WBS code that equals the task's outline number (treated as
generated) and keeps one that differs (an explicit override or a masked
code). Project-level options the model does not hold (ScheduleFromStart,
currency, file identity, ...) are kept verbatim in
Project::options and written back on save. docxy still schedules forward
when ScheduleFromStart is 0, and a save then keeps every task's stored
Start/Finish; a .mpp import does not decode the option, so there it
cannot. Six options are
typed when their text parses: NewTasksEffortDriven, NewTasksEstimated,
DefaultTaskType, Autolink, CriticalSlackLimit and MultipleCriticalPaths.
A save writes them in canonical form (true as 1), keeps unparseable text
verbatim, and leaves an option the file did not state absent. docxy acts on
them: new tasks take the stated task type, effort-driven default and estimated
default duration, a task added between two others autolinks, and the critical
flag follows the slack limit and multiple critical paths (Project's defaults
apply when absent).
projcore::editor::Editor owns the editable project, its 20-entry undo history,
selection, dirty flag, computed schedule and optional leveling overlay. Validated
edits snapshot once and reschedule; rejected edits preserve the whole session.
yppxy supplies the keys, status messages and file I/O, and its project control
verbs use the same Editor through dispatch_editor. Opening or creating a project
clears history while retaining the find query and leveling preference. As in
Project (default Undo levels 20), a successful save clears both undo and redo
(Editor::commit_save, used by yppxy and the suite's Project tab); a failed
save keeps them (#863).
- Tasks have a duration in working minutes, an outline level (summary
tasks own the deeper rows below them), and may be milestones (zero duration).
A task is auto-scheduled or manually scheduled (MSPDI
Manual, withManualStart/ManualFinish/ManualDuration); tasks added to a plan follow itsNewTasksAreManualdefault. Switching a task to manual (Task ▸ Tasks ▸ Manually Schedule, the Task Mode column, yppxy'sm, ortask.set {manual: true}) pins it at the start and finish it is shown at, the leveled ones while leveling is on, and stamps its saved Start/Finish; switching it to auto clears the pin and the scheduler places it by its links and constraints again. Either is one undo step. A summary switched to manual keeps the dates it shows instead of rolling up (see Manual summaries below). The status bar's⚙/📌 New Tasks: …(yppxy'sM) switches the plan's default. - Dependencies are the four link types with lag/lead: Finish-to-Start,
Start-to-Start, Finish-to-Finish, Start-to-Finish. A lag is working time,
elapsed calendar time (
+2ed, counted from the predecessor's instant, after which the link acts as a zero-lag one), or a percentage of the predecessor's duration (+50%). Its MSPDILagFormatselects the unit ofLinkLag: tenths of a minute of working or elapsed time, or the percentage itself for formats 19/51 — one of several unit traps the reader normalizes. The format is kept and written back; unsupported formats fail the read. - Inactive tasks use MSPDI
Active. Task ▸ Schedule ▸ Inactivate toggles a task, cascading to its subtasks in one undo step. Inactive tasks keep their own dates and can follow active or inactive predecessors, but they do not drive active successors, active summary rollups, the active project's bounds, or its critical path. When no active leaf can be scheduled, dormant dates bound the project. A summary whose subtasks are all inactive keeps their rollup. - Constraints pin dates: ASAP/ALAP and the six hard ones (SNET/SNLT/FNET/FNLT/MSO/MFO).
- Calendars define working time per weekday (e.g. Mon–Fri 08:00–12:00, 13:00–17:00); weekends and off-days are skipped.
- Resources & assignments staff tasks (units × work).
- Baselines snapshot the saved plan for planned-vs-current variance.
The core trick is working-minute index space. Wall-clock scheduling is
awkward — 5pm Friday + 1 working hour is 9am Monday. So each calendar maps to a
monotonic timeline: a function from an instant to "working minutes elapsed
since the project anchor" (to_index) and its inverse (abs_start/
abs_finish). In index space, finish = start + duration,
successor = predecessor + lag, and slack are all integer arithmetic; we only
convert back to a wall-clock DateTime at the end.
A subtle but essential detail: start and finish use different boundary conventions. An index that lands exactly on an end-of-day boundary maps to the next morning as a start, but to this evening as a finish. That is what makes "a 2-day task from Monday finishes Tuesday 17:00" and "its successor starts Wednesday 08:00" both come out right.
- Forward pass → early start/finish, honoring links + lag, ASAP by default,
plus SNET/FNET date bounds. With
HonorConstraintsenabled (the default, persisted as MSPDIHonorConstraints, which Project writes and reads inverted:0on,1off, #1504), MSO/MFO/FNLT/SNLT override conflicting links; with it off, links can delay tasks past those constraint dates. Corpus files 30 and 31 pin both modes against Project 2024. An SNET or FNET milestone holds its constraint instant itself, even at the end of a working period or inside a non-working gap (Fri 17:00, Wed 19:00), as Project 2024 does (#118, #133, #779, corpus file 29); a date before the project start is floored there. A binding MFO or FNLT milestone holds the constraint's own instant: its morning when the date is a working start, else its evening (#89, #132). An FS milestone holds its predecessor's finish instant (#59). Other milestones (MSO, SNLT, an unconstrained one) still snap to the next working start, and a later link still wins. Slack is unaffected: a held instant shares its working-minute index with the next working start. A date typed without a time for SNET or FNET (a Start cell, orsnet/fnet DATE) means that day's first working time. - Backward pass → late start/finish from the project finish, plus the backward-affecting constraints (MFO/FNLT/SNLT/MSO).
- Total & free slack: link conflicts produce negative total slack in both
precedence modes. When a constraint moves a task earlier than its links allow,
total slack uses the link-driven start. Dates are limited by the timeline
horizon; pre-start constraints do not pull unlinked tasks before the project
start. Also computed: the critical flag (total slack at or below the plan's
CriticalSlackLimit, 0 days by default; withMultipleCriticalPaths, a leaf without successors is measured to its own early finish rather than the project finish, so each such chain is a critical path) and summary rollup (a summary's dates derive from its descendants, unless it is a manual summary). - Manual tasks stay at their pinned dates: the manual start (else the stored start) and the manual finish, else start + duration on the task calendar. Links and constraints never move them; auto successors schedule from the pinned dates. A violated link shows as negative total slack (at least the gap between the pinned and the link-driven start); an unlinked manual task gets no slack penalty, even before the project start. A manual task without any start schedules like an auto task.
- Manual summaries (#124) keep their own dates instead of rolling up, as
measured in Project over COM: the manual start (else the stored start) and
the manual finish, else start +
ManualDurationon the summary calendar. Their subtasks' span stays available as the rollup (Schedule::rolled_up,Editor::disp_rollup). Project's warning (schedule::manual_warning,Editor::summary_warning) flags a manual summary whose subtasks finish after its manual finish, and a manual task finishing after its direct parent when that parent is a manual summary (an auto summary in between breaks this); starting early never warns. Hosts show it on manual summaries only (projctl'swarning, the docxy and yppxy summary bars); a manual leaf's row does not show it yet. The manual start floors every ASAP auto subtask, from the nearest manual-summary ancestor (auto summaries in between pass it on); a later link still wins, and a subtask with any other constraint, even SNET, ignores it. The floor never enters the backward pass or the slack's link-driven start. The manual finish counts toward the project finish, so every task's slack is measured to it. A manual summary's late window spans its subtasks' late dates, but never starts before its own start nor finishes before its own finish; its slack is the smaller of its start and finish slack, so it is never negative, and it is critical only when that slack is at or below the plan'sCriticalSlackLimit(0 days by default; critical subtasks do not make it so). Every ancestor, manual or auto, rolls up through its own span and sees it as fixed: the nested summary's late window is its own span, and an auto summary over one measures its slack from its late window. A manual summary with no start (TBD) rolls up as an auto summary. FS/SS links into an auto summary drive leaves below auto summary levels; a nested manual summary stops inheritance. Links out of an auto summary use its rolled start or finish, while links out of a manual summary use its own dates. A link into a manual summary changes predecessor slack but does not move that summary or its children. Auto summary start floors and backward date bounds apply to inherited leaves. Summary FF/SF predecessors and some summary constraint types remain unsupported; the provisional late-bound rules await verification in Microsoft Project.
Leaf tasks are ordered by a Kahn topological sort of the dependency graph; cycles fall back to input order.
There's no free high-fidelity oracle for scheduling (Project isn't scriptable in
CI), so the corpus is self-oracling: corpus/mspdi/ holds thirty-one tiny
one-feature MSPDI files, each embedding every task's Start/Finish,
TotalSlack and Critical. In files 01–18 these are the values Microsoft
Project 2024 computes: corpus/tools/verify_mspdi_project.py checked every
one against Project over COM (#74), with the oracle elements removed from the
copy Project schedules. Files 19 (manual tasks, #77) and 20 (stored task
fields and a blank row, #80) are hand-derived from our scheduler and not yet
verified in Project. File 27 (manual summaries, #124) was checked against
the local Project by verify_mspdi_project.py, as generated and as
write_mspdi writes it; the rollup and the warning have no MSPDI field, so
unit tests check them against Project's COM values. Blank rows (IsNull)
carry no oracle.
projcore/tests/corpus.rs reads each file, runs the scheduler, and asserts the
computed dates, slack and critical flags match — and also runs each file
through MSPDI → .yppx → back to prove the writer and OPC container are
lossless. Regenerate with python3 corpus/tools/gen_mspdi_corpus.py.
corpus/mpx/ holds MPXJ's MPX of each of those files, in the three date
orders; projcore/tests/mpx_corpus.rs reads each and asserts it schedules as
its MSPDI twin, but where the MPX file cannot carry what the twin does
(corpus/mpx/README.md).
schedule::level(proj) runs CPM, then delays tasks so no work resource is
booked beyond its capacity. A resource's capacity is its Max. Units, or, when it
has availability periods, each period's units over its dates and none outside
them (as Project 2024 does). It processes tasks in topological order; each task
starts no earlier than (a) its CPM early start and (b) the earliest time all its
resources have capacity for it: every stretch between booking and capacity
changes must hold the load plus the task's units (fractional units allowed),
and the search jumps to the next booking end or capacity change. Each
assignment books its resource from its Delay into the task to the task's
finish; a stored LevelingDelay, Project's last leveling, is not added. A
resource that is free and available somewhere takes a task above its capacity
there, and one with no capacity anywhere later is left overallocated: the task
goes where every other resource fits, or keeps its earliest start when it has
no other. An active predecessor's leveling delay propagates to the active
successors the leveler may move, preserving their link gaps, except where a
successor's own honored constraint (a Must Start/Finish On date, a No Later
Than cap) holds it earlier, which breaks the link as the forward pass does; a
held successor (priority 1000, ALAP, outside the scope) keeps its CPM dates
in this overlay and follows only once the delays are committed and the
forward pass moves it. Inactive tasks book no capacity and
keep their CPM dates, even when an active predecessor is delayed by leveling.
v1 is single-calendar and delay-only,
and treats a task's occupation as its wall-clock span; multi-calendar leveling
and task splitting are future work. Leveling never moves a manual
task: its bookings are placed first, and auto tasks level around them.
yppxy toggles the overlay with L (View ▸ Level).
Leveling is also a plan edit (#1594, projcore::leveling, Editor::level_tasks):
Level Selection (a summary stands for its subtasks) and Level Resource run
the leveler over a scope (schedule::level_with: the tasks outside it take
no delay of their own and are booked at the dates the plan gives them now,
an automatic one over each assignment's own window; a task of priority 1000
is never leveled) and write
each leveled task's own LevelingDelay (elapsed minutes from the start its
links, constraints and leveled predecessors drive it to, so a task that only
follows a delayed predecessor records none, as Project does; in tenths of a
minute with LevelingDelayFormat 8, as Project 2024 stores it) and
PreLeveledStart/Finish (its unleveled dates), one undo step; only the
delays the leveler asks for are written, so a task a constraint or another
predecessor already holds takes none, as Project leaves it. An automatic
task outside the scope still follows its links once the delays are written,
so the pass repeats on the plan with its delays written while a round still
changes the overallocation (a smaller overbooked extent, or conflicts not
seen before) and drops a round that leaves the same conflicts; a conflict
that remains is reported as
overallocated, and a later pass resolves it only with the follower in its
scope or with clear_first off (the default rebuilds the same cleared
plan); an ALAP task is neither delayed nor
booked (Project 2024 refuses a delay on one, and its late dates float with
the finish, so an overallocation it causes stays); a delayed task's
predecessors have the zero slack
Project shows them. Level All stays the display overlay (no level.all edit
yet). The scheduler honours a
stored LevelingDelay (the task starts that much elapsed time after its
links and constraints allow, snapped to working time; measured against
Project 2024 over COM: a delay typed by hand moves Start, and Level All's
delay is the elapsed gap), so the leveled dates are the plan's dates, a
save writes them, and schedule_reproduces no longer treats a task delay
as an input the schedule ignores. Clear Leveling removes the delays, one
undo step. Leveling Options (LevelOptions) are plan options
(LevelingOrder, LevelWithinSlack, LevelAdjustAssignments,
LevelCanSplit, LevelClearBeforeLeveling, a default stored as nothing):
the order is Standard (the topological order above), ID (the lowest ID
first among the ready tasks) or Priority (the highest priority first);
within slack never delays a task past its CPM late start (the slack a
leveled predecessor used counts) nor moves the project finish or turns a
task's total slack negative (a task whose delay would do either, through a
summary's percentage lag, stays put, overallocated);
clear-first (the default) drops the
scope's stored delays before the pass, a priority-1000 task's too (measured
on Project 2024: it then holds at its unleveled start), else the pass builds
on them. Adjusting assignments and splitting are kept as settings
only. The display overlay stays for the display-only mode; level.*
control verbs drive both (docs/agent-control.md).
- MSPDI (
.xml) — Project's documented interchange format.projcorereads and writes it; this is how schedules move to and from real Project. .yppx— the native package: an OPC ZIP ([Content_Types].xml+project.xml) built onopccore, the project analog of.docx/.xlsx. Theproject.xmlpart is MSPDI-compatible, so.yppxstays interoperable — unzip, rename, and Project opens it — while giving us a container to grow. The first growth isformatting.xml(projcore::formatting, #1532): the plan's bar palette, text styles per category and per cell, gridlines and link style, and a UI plugin's named view switches (#1553), written only when the plan has any, declared in the content types with its own override, and read back intoProject::formatting; MSPDI carries none of it, as in Project (a format plugin gets it through the optional_exsymbols below)..mpp— the legacy binary. It's an OLE2 Compound File (MS-CFB), whichmppreadreads exactly — including the storage tree, so nested blocks are addressable by path (read_path("TBkndTask/FixedData")). Its metadata streams are OLE property sets (MS-OLEPS). For recognized MPP9, MPP12 and current Project task layouts,mppreaduses countedFixedMetaandVarMetarecords to locate rows and names, then reads dates, outline levels and validated task fields at known offsets. MPP12 (Project 2007) currently imports only core task names, dates, outline and dependencies; calendars, resources, assignments, progress and other task fields remain unsupported. Its 50 snapshots are checked against XML with pinned downgrade differences: inactive rows disappear, manual tasks become automatic, and lag units may become minutes. Unvalidated blank-row shapes and lag formats are refused. Current Project blank rows keep their ID and UID; GUID, type, flags, priority, deadline, Notes and leveling options survive an import and MSPDI save. Explicit WBS codes and generated codes under the default numeric mask also survive; generated codes under a custom mask remain absent. Current-layout over-allocation is derived from each task's direct assignments and their resources' availability. A valid empty assignment table gives false; a missing or unrecognized assignment table, or a needed resource table, leaves the field absent. The value is kept after edits. Recurring tasks are validated against a UI-authored Project sample. Stored task IDs determine display order; stable UIDs connect predecessor links fromTBkndCons. An unrecognized layout or malformed row causes an import error. Legacy MPP9 automatic leaves, including childless inserted subprojects, are pinned to decoded starts. Current-layout tasks keep their recorded constraints; an automatic leaf receives a Must-Start-On pin only when the scheduler cannot otherwise reproduce its stored start. Automatic leaves retain decoded durations, and outline summaries roll up from their children. Nonzero link lag still lacks a real-file oracle.
mppread::raw is the inspection API for tools and tests that look at a
.mpp's TBknd* tables directly. It re-exports the validated readers the
importer runs, without a second, looser decoder: the task FixedMeta index and
its Fixed2 pairing, the resource and assignment table reader with its layouts,
VarMeta entries and Var2Data blocks, the Props entries and keys, the decoded
task, calendar, resource and assignment tables, and the timestamp, duration
and text decoders. Each item's docs give the record sizes and offsets it
assumes and the Project generation it is validated for.
mppread/tests/raw_api.rs walks the corpus snapshots through it and checks
the counts against project_from_mpp.
A host can open and save more file types through format plugins: shared
libraries named yppx_* with the platform's library extension (.dll,
.so, .dylib). yppxy loads, once at start, each one in
<config>/yppxy/plugins (%APPDATA%\yppxy\plugins on Windows, else
$XDG_CONFIG_HOME/yppxy/plugins or ~/.config/yppxy/plugins, beside its
control discovery files), then each in $YPPX_PLUGIN_DIR when set. The
desktop suite loads them the same way for its Project tabs, from
<config>/docxy/plugins (beside its session.json: under
DOCXY_CONFIG_DIR when that is set, else %APPDATA%\docxy\plugins on
Windows, ~/Library/Application Support/docxy/plugins on macOS and
~/.config/docxy/plugins elsewhere), then $YPPX_PLUGIN_DIR. No plugin
ships with the workspace; without one, every list, status line and save
refusal is what it was before plugins existed.
projcore::formatsholds theProjectFormattrait (extensions, label, reads/writes,read(bytes),write(project, source),is_default) and theRegistryhosts consult for Open's file list, Save As's types, reads and writes, and the save-path check. The built-in.yppx,.xml(MSPDI) and the read-only.mpx(MPX, #309) come first, then what the host registers:mppread::project::MppImport, the read-only.mppimport, then the plugins. A later registration for an extension replaces, in place, its reader (when it reads) and its writer (when it writes), so a plugin that reads.mpptakes over from the import and one that only writes it keeps the import as the reader.yppx::save_targetis the built-in registry's, so its refusal ("Project schedules can only be saved as .yppx or .xml (MSPDI)") is unchanged; a registry with a plugin writer lists that type too.- A format plugin may be the default save type: a formats JSON entry
with
"default": trueon a type the plugin writes. Save As proposesRegistry::default_save_extension(opened): the type the plan was opened from when something writes it, else the type of the last registered format declaring itself the default that still writes it, else.yppx. Both hosts list it first (Registry::save_types_default_first) and give the suggested name its extension (Project1.<ext>for a new plan,plan.<ext>for an import nothing writes); Save of a plan from a writable type still writes in place, and a save path without an extension is still.yppx. projcore::plugin_abiis the C ABI, version 1: sixextern "C"functions (yppx_plugin_abi,yppx_plugin_formats,yppx_plugin_read,yppx_plugin_write,yppx_plugin_free,yppx_plugin_last_error), aYppxBuf { ptr, len }the plugin owns and frees, return codes (0 ok, 1 unsupported extension, 2 malformed input, 3 unsupported content, 4 internal error) and the formats JSON ([{"ext":"mpp","label":"Project","read":true,"write":true,"default":true}];read,writeanddefaultare false when absent, other keys are ignored). MSPDI XML text crosses the boundary, never model structs. A save to the type the plan was opened from passes the plugin that file's bytes, so it can carry over what the model does not hold. Two more symbols are optional and independent (#1702; the ABI stays 1):yppx_plugin_read_exalso returns the plan'sformatting.xmlin a second buffer, andyppx_plugin_write_exalso takes it (NULL and 0 when the plan has neither formatting nor rich task notes); each is called instead of its plain twin when present, since MSPDI holds no formatting. The text also carries the tasks' rich notes as<note uid markup/>children (#1801), which read_ex must return (with the MSPDI<Notes>equal to their plain text) for them to survive.projcore::formats::plugin, behind the cargo featureplugins(it addslibloading; hosts enable it, so the default build and the wasm builds stay dependency-free), loads the libraries, checks the ABI version and the six symbols, and wraps each declared type as aProjectFormatwhosereadisyppx_plugin_read(or_read_ex, whose formatting must parse or the read fails) thenread_mspdi, and whosewriteiswrite_mspdithenyppx_plugin_write(or_write_exwith the formatting). A library stays loaded for the process and calls into it are serialised. One that fails to load is skipped with a message: headlessyppxyprints it on stderr, the editor shows the first on its status line.- In
yppxythe registry drives the Open browser's list, every read,--saveand Save / Save As, soyppxy plan.xml --save out.<ext>works for any registered writer. A plugin writer adds a Save as type list to Save As. A file of a type a plugin also writes is not an import: opening one says "Opened x.mpp — N tasks" rather than "… .mpp tasks imported", and Save writes it back in place. - In the desktop suite (
suite/docxy/src/project_formats.rs) one registry, built at start before any tab loads, drives what opens as a Project tab (the Open dialog's lists, the command line, Recent), every read, Save and Save As (the native dialog's types are the registry's writable ones, the proposed type first and the dialog opening on it, then in its order), the harness'ssave-asandsave-types(default) and the controlproj.saveandproj.path(default_save_ext). A type nothing writes is an import: its status says "imported from .; Save As .yppx or MSPDI to keep edits", Save goes to Save As andproj.pathreportsimported: true; with a plugin that writes.mpp, an opened.mppis a plan like any other and Save writes it in place. The file a plan was opened from (re-read for one restored from its sidecar) goes to a plugin's save of its type as the source. A plugin that does not load is reported on stderr, in the crash log and on the first window's status line, never in a dialog. - The fixture:
projcore/tests/plugin_fixtureis a dependency-free cdylib (publish = false) that implements the ABI by hand for a fake.fakeprojtype, passing MSPDI through (with the plan's formatting as a hex trailer comment, through the_exsymbols), and declares it the default save type.projcore/tests/plugins.rs(run with--features plugins) andyppxy/tests/plugins.rsbuild it with cargo, copy it into a plugin directory asyppx_fixture.<dll|so|dylib>, and round-trip a plan through it,yppxy plan.xml --save out.fakeprojincluded. The suite'suiharness/cases/project-plugin.uitstarts withplugin yppx_fixture, which copies the fixture (built beside uiharness bycargo build --release -p uiharness -p plugin_fixture) into the sandbox'sdocxy/pluginsbefore the suite starts.
The same yppx_* library may also contribute user interface as data:
ribbon groups and controls, context-menu items, Task Information pages,
dialogs, Gantt overlay shapes, bar palettes, text style categories, and
commands that edit the plan. The hosts define the extension points and call
into whatever library is present; they know no plugin's features or names
(nothing in this repository refers to any plugin but the test fixture), and
without one nothing changes.
- Version target.
yppx_plugin_target(YppxBuf*)(an optional symbol) returns{"suite": "<req>", "yppxy": "<req>", "built_against": "<commit>"}: a semver-style requirement per host name (0.1.*,^0.1.2,~0.1,>=0.1, <0.3,=0.1.0;projcore::uiplugin::version_satisfies). Each host checks the requirement for its own name (suite,yppxy) against itsCARGO_PKG_VERSIONbefore any other symbol and skips a library that does not match ("<file>targets suite 0.1.*, this is 0.2.0": stderr, the crash log, the first window's status line, never a dialog). A library without the symbol is a format plugin only: its formats load and the UI symbols are not looked up. - The UI symbols, all five or none, looked up only behind a matching
target:
uint32_t yppx_ui_abi(void)(1),int32_t yppx_ui_manifest(YppxBuf*), andint32_t yppx_ui_state / yppx_ui_command / yppx_ui_overlay(const uint8_t* json, size_t len, YppxBuf* out). Payloads are UTF-8 JSON inYppxBuf, freed withyppx_plugin_free; return codes andyppx_plugin_last_erroras for the format calls; one call at a time.projcore::uipluginholds the typed shapes andprojcore::jsonthe JSON value (hand-written, like the formats declaration). - Manifest (
yppx_ui_manifest, static):ribbon(per project tab: groups to append, or controls for an existing group by title; a control is{id, kind: button|toggle|split|dropdown|gallery|combo, label, tip, keytip, icon, large, items, swatches}; a swatch is{id, label, color, preview, section}),menus(items perrow,bar,column,chart,heading-filtermenu with anafterlabel hint),dialogs(specs by id: pages of controls with kindstext,number,date,duration,checkbox,radio,dropdown,list,gridwith per-column kinds,multiline,label,sample(a read-only preview box the state feeds, #1609),color,font; thetask-informationspec's pages add to the host's Task Information dialog, one named like a built-in page replacing it),palettes(bar colours and shapes per kind) andtext_style_categories. A contributed control, menu item or page whose id (or page name) is a built-in's replaces it in place. - State (
yppx_ui_state): the host sends{plan: <MSPDI>, selection: {uids, cols}, view, dialog?: {id, uid}}when the plan, the selection or the view changes (never per frame) and gets{controls: {id: {enabled, checked}}, gallery: {id: swatch}, values: {name: value}}back. - Commands (
yppx_ui_command):{id, plan, selection, view, values?}in,{ops, status, dialog?, view?}out.opsareprojctl's editor verbs (task.set,task.add,task.del,link.add,link.del,assign.*,resource.*,proj.set,level.all,cal.*, ...) as{verb, args}, applied byprojctl::apply_opsin oneEditor::batch: one undo step, all or nothing, the refusal on the status line.dialognames a spec to open, whose OK sends the same id again withvalues;viewsets host view settings (the palette, flags, andgridlines: an object of Project's line names, each{type, color, interval, interval_type, interval_color}or null for its default, or null for every default, #1575). Code 1 (unsupported) means "not my command", so several plugins can coexist. A dialog spec may list buttons of its own (#1602:{id, label, default?, cancel?}; a press sends the command withbutton: <id>and the values, and the dialog stays open unless the reply saysclose: true; the state greys a button by its id), bemodeless(no backdrop; the host asks the state again, with the dialog'svalues, whenever the plan's revision or the selection changes and applies its values, items and enabled flags), and itsgridrows select (valuescarryname.selected_row, and the state'svaluesmay move or drop the selection with the same key) and edit by hand (acheckcolumn kind is Project's check mark; a grid withcommit_commandsends the command on every cell commit withedit: {control, row, col, value}, its ops applying at once). A change of any plugin control asks the state again withdialog.values(#1567) and applies the items, the enabled flags, thelabelcontrols' values (a label may follow another control, #1610; at open the state sees the spec's declared values asdialog.values) andname.selected_row; when the plan or the selection moved since the dialog last saw them (under a modal dialog, through its own command) every value the state gives applies, so a modal dialog's grid shows the rows its ops made. Layout hints (#1609), all optional: a dialog'ssize: [w, h](fixed, shared by every page),placement: "bottom" | "right"(Help bottom left and the rest bottom right, or a column down the right),header: [...](controls drawn above every page); a page'scolumns: n(controls per row, labels aligned per column); a control'sgroup(a titled box around consecutive controls naming it),span(columns it takes),min_rows(a grid's visible rows, 1 to 64, empty rows drawn, more scroll with a scrollbar, whose track appears only then; a plugin grid's default is 8),fill(a multiline fills the remaining height of a dialog with asize), and thesamplekind (a read-only box drawing{text, font, size, bold, italic, underline, strikethrough, color, background, pattern}, the formatting model's text style). AHelpbutton is the host's: it opens the spec'shelpURL (recorded for the harness'slast-url), else says the documentation is coming; it is never sent to the plugin and never the default. A tabbed dialog without asizesizes to the shown page, so switching pages may resize it: a stable size needs an explicitsize. Atask-informationspec's pages takecolumns,group,spanandmin_rows(the page's columns lay out the plugin's controls; the built-in Name, Duration and Estimated row keeps its one-column shape on every page), and itsheadercontrols are drawn above that row on every page, first in the Tab order;size,placement,fillandhelpbelong to a plugin's own dialog (Task Information's Help is the host's and says the documentation is coming). - Overlay (
yppx_ui_overlay): the rows in view (uid,row_y, the bar rectangle, kind flags, since #1548name,percent_complete,deadline_x,constraint {type, x}andresource_names, and since #1552status(Project's Status text),late_start_x,late_finish_x,slack_x(where the free slack ends),total_slack_min,free_slack_minandbaselines [{slot, x, w}]), the scale (origin_day,day_w,row_h), the links (eachdrivingwhen it sets the successor's dates,Editor::driving_predecessors), the status and current dates, theselectedUIDs (the cursor's first), the project start and finish x and the view flags (the switches, the plan'spalette,link_styleandgridlines, the plugins'switches, the formatting'stext_stylesand the selected cells'cells, #1572, and itsbarsandtask_bars, #1696) in; shapes in chart pixels out (polyline,dashed,arrow,bracket,rect,text, each with a colour and width), drawn over the bars. - Loader.
projcore::formats::plugin::load_plugins_for(dirs, Some(Host {name, version}))returnsLoaded {formats, ui: Vec<Arc<UiPlugin>>, plugins: Vec<PluginInfo>, errors, skipped};UiPluginhasmanifest(),state(&StateCtx),command(&Command)(Nonewhen not its id) andoverlay(&OverlayCtx). The oldload_plugins_with_errorsis the nameless form: no target check, no UI. Both hosts'app-inforeportsplugins: {loaded: [{file, formats, ui, built_against}], ui: [{file, commands}], skipped: [{file, reason}]}. - yppxy ignores the UI contributions except commands:
yppxy plan.xml --command <id> [--save out]runs one headless with the first task selected and prints the status, which is how QA drives a plugin's commands. - The desktop suite (
suite/docxy/src/plugin_ui.rs) draws the contributions: ribbon groups and controls after each project tab's own (a control with a built-in command's id replaces it in place; a gallery's swatches as palette miniatures whenpreviewnames a declared palette, else colour tiles, undersectionheader rows in the flyout; KeyTips as declared), items in the row, bar, column and chart menus (after theafterlabel, else at the end), thetask-informationspec's pages in the Task Information dialog, a command's dialog withmultilineandgridcontrols, the overlay's shapes over the chart after the bars, the palette the plan's formatting names on the bars, the formatting's text styles on the table, and its gridlines (#1575) as the table's row and column rules, the chart's row rules, tier columns and date lines and the timescale's title lines. State (enabled, checked, the checked swatch, dialog prefills) is asked when the plan's revision, the selection or the view changes, never per frame; a command's ops andvieware one undo step. The harness marks what came from a plugin with"plugin": true(ribbon-read,menu-read,dialog-read), reads the overlay withproject-overlay, anduiharness/cases/project-plugin-ui.uitcovers the fixture's. - The fixture exports the target (
0.1.*for both hosts) and the UI symbols with one of everything (seeprojcore/tests/plugin_fixture/src/ui.rs);projcore/tests/plugins.rsround-trips them throughlibloading, andyppxy/tests/plugins.rsruns--command fixture.rename.
The suite's Project tab edits Task Mode, Name, Duration, Start, Finish,
Predecessors, and Resource Names directly in the selected cell. Task Mode takes
Manually Scheduled or Auto Scheduled, or any start of them (m, auto). Enter/F2 or a double-click opens
the existing value; typing replaces it. Enter commits and moves down,
Shift+Enter commits and moves up, Tab and Shift+Tab commit and move between
columns, and Escape cancels. Invalid input stays open for correction. ID and an auto summary's dates/duration are
read-only. A manual summary's Start, Finish and Duration set its own span as
they do for a manual task, without touching its subtasks; its Gantt row adds
the subtasks' rolled-up span as a thin bar above its own, the part past its
finish in a warning colour (yppxy marks those days ╍), else its own finish
day (an overrun within that day, or a finish past its manual parent).
Durations show Project's ? for an estimated task (1 day?, 2.5 days?,
in the suite, yppxy and projctl's task JSON (estimated); the cell reopens
and copies as typed, 1d?). Typing a duration with a
trailing ? marks it estimated; typing it without ? commits the estimate,
even at the same duration (1d over 1d?), as one undo step. A task never
marked estimated stays unmarked. A summary shows ? when any task below it is
estimated; that rollup is shown, not saved, and a summary takes no ? of its
own.
A task that finishes after its Deadline gets Project's missed-deadline
indicator (#170), judged by Task::misses_deadline: Finish later than the
Deadline instant, as stored. yppxy's grid shows a ⚠ at the end of the task's
row (after Slack) when its displayed, possibly leveled, finish misses it, and
the header then names it for the selected task (⚠ B: finishes after its deadline 2026-03-06), the text Project gives in the indicator's tooltip. The
Markdown Gantt export's table has a Deadline column: the task's deadline,
followed by ⚠ when the row's own Finish is after it. The suite's grid has no
indicator yet.
As in Project, the blank row below the last task is the entry row: clicking any
empty row below the tasks, or Down from the last task, puts the cell cursor
there, and typing into it then committing appends a task (1 day?, or
1 day when the plan's NewTasksEstimated is off, unless a duration is
typed), as one undo step. A new plan starts there, so typing a name creates its
first task. Commands that act on the selected task (Delete on ID,
milestone, indent/outdent, clear resources, the task prompts) do nothing on the
entry row; Insert inserts a blank row just above it, and Find searches from the
first task.
Task › Insert › Blank Row (keytip Alt, T, B) and the Insert key insert an empty
row above the selected row, or just above the entry row, as one undo step, and
select it, keeping the column, as Project's Insert Task › Blank Row does. The
row is outside the outline and the schedule until something is typed into it;
then it becomes a task at the level of the task above where it sits (a summary's
first child). yppxy has the same command on its ribbon and on N.
Arrow keys move the cell cursor. Home and End (or Ctrl+Left/Right) go to the
first and last field of the row, as in Project; Ctrl+Up/Down go to the first
and last task, keeping the column, and Ctrl+Home/End to the first task's first
field and the last task's last field. PageUp/PageDown move a screen of rows,
keeping the column; past the last task PageDown lands on the entry row. Insert
inserts a blank row above the
current row, keeping the column. Delete clears the active Name, Predecessors, or
Resource Names cell; on the ID column, it deletes the task (a summary asks
first: Enter deletes it with its subtasks, Esc cancels). Ctrl+Delete clears the
cell or resets it to its default, as one undo step: Name, Predecessors and
Resource Names are cleared as Delete does, Duration becomes 1 day (estimated
unless the plan's NewTasksEstimated is off; an auto summary's is refused), and
Task Mode becomes the plan's mode for new tasks. On an auto task, Start drops a
Start No Earlier Than constraint (the one typing a Start adds, however it was
set) and Finish a Finish No Earlier Than one, leaving As Soon As Possible; with
any other constraint it reports <column> has no constraint to reset. A manual
task's Start and Finish are pinned dates that cannot be blank, so they report
<column> can't be cleared, as does ID; an auto summary's is refused as its
Duration is. Ctrl+Delete never deletes the task, and it leaves a blank row and
the entry row alone. A task added with Task › Insert
› Task (keytip Alt, T, N; here, in yppxy and through projctl's task.add) is 1
day, estimated unless the plan's NewTasksEstimated is off, and with the plan's
Autolink on (the default) it is linked into the finish-to-start chain it
splits: A→B becomes A→N→B, N→B keeping the lag. Alt+Shift+Right/Left
indent/outdent; Alt+Right/Left pan the Gantt and Alt+Home / Alt+End move it
to the project start / finish, as in Project;
Ctrl+Shift+L toggles leveling. Ctrl+F2 links the selected task (it opens
the Predecessor prompt, as Task › Schedule › Link Tasks does) and
Ctrl+Shift+F2 removes its links, as Project does. Ctrl+D fills the selected
range down (#1213). Ctrl+F, F3, Ctrl+Z/Y/S/E
retain find, repeat find,
undo/redo, save, and export. The former bare-letter commands are available on
the ribbon; letters now start cell edits.
F11 opens a new, empty project in its own tab, as Backstage › New ›
Project does (#372).
The ribbon (suite and yppxy) holds only Microsoft Project's commands, each with
Project 2024's label and screentip, which differ for icon-only commands:
Task › Schedule › Indent / Outdent / Link Tasks show the screentips Indent
Task, Outdent Task and Link the Selected Tasks; Information... is View Task
Information; Move, Milestone and Blank Row are Move Task, Insert Milestone and
Insert Blank Row; View › Timeline is Timeline View. The harness's
ribbon-click finds a command by either name. Project › Schedule › Set
Baseline is a split button (#397): its upper half sets the baseline at once,
and its lower half opens Set Baseline... and Clear Baseline.... From the
keyboard Alt, P, B opens that menu, and B or L picks the item (#1212). Project
2024 draws a menu button there, with no one-press half; the mouse keeps the
one press.
Right-clicking a task row's table cells selects the row and opens Project's
row menu, in Project's order (#397); a right-click inside the selected range
keeps the range and the cursor instead, and the menu's task items still act
on the clicked row (#1213). Cut, Copy, Paste, Scroll to Task, Insert
Task (a blank row above), Delete Task (the task whatever column the cursor is
on; a summary asks first), Inactivate Task, Manually / Auto Schedule, Assign
Resources... and Information... run their commands, and Link opens the
hyperlink prompt (below); Notes... opens the notes prompt (below). Fill Down
(Ctrl+D; no longer a row item, #1537) gives every shown row below the range's top row that row's value
in each range column but ID, as one undo step, and keeps the range; a value
that cannot apply cancels the whole fill, naming the cell. Paste Special...,
Text Styles... and Add to Timeline are drawn greyed until they exist. A mini
formatting toolbar sits above the row menu and the column-heading menu (#1537):
Font and Size drop-downs, Bold, Italic, Fill Color and Font Color (which style
the selected cells' direct format, one undo step), then Link Tasks, Unlink
Tasks, Outdent, Indent and a Task Details arrow that run the existing
commands. Right-clicking a task's Gantt bar (or
milestone diamond) selects the task and opens Project's bar menu (#1207):
Information..., Notes... and Hyperlink... run the row menu's commands, and
Format Bar Styles..., Format Bar... and Add to Timeline are drawn greyed.
Right-clicking a column heading of the entry table selects that column over
the shown tasks and opens Project's column menu (#1208): Cut, Copy and Paste
run the table's own clipboard commands over the column, and Paste Special...,
Wrap Text, Insert Column, Hide Column, Font..., Text Styles..., Field
Settings, Custom Fields and Data Type are drawn greyed. The ribbon's
right-click opens Collapse the Ribbon (#590), and the document's Bold /
Italic menu never opens on a Project. docxy's extras have no ribbon
button: rename and set durations in the cells (yppxy: Enter/F2 and d),
delete a task with Delete on its ID or the row menu's Delete Task (yppxy x), clear resources with Delete
on Resource Names (yppxy: Assign with an empty name), export with Ctrl+E or
File › Export, and scroll the Gantt with Alt+Left/Right and Alt+Home / Alt+End (yppxy
h/l and Alt+Home / Alt+End).
The Report tab's View Reports group has Project's four menus (#1123), each
listing only the reports projcore backs with the plan's own data: Dashboards ›
Project Overview; Resources › Resource Overview and Overallocated Resources;
Costs › Task Cost Overview and Resource Cost Overview; In Progress › Critical
Tasks, Late Tasks and Milestone Report. Alt, R opens the tab, D / R / C / I a
menu, and the item's letter runs it (Alt, R, I, L is Late Tasks). Project
draws a report on a canvas; docxy writes it as Markdown beside the plan, as
<stem>-<slug>.md (plan-late-tasks.md) by Export's rules: an unsaved plan
asks for a name (a harness instance refuses), the plan itself is never the
target, and the plan, its dirty flag and its history stay as they were. yppxy
has no menus, so its Report tab has a group per menu (Report › Costs › Task
Cost Overview), and yppxy <in> --report-md <slug> <out.md> writes one
headless. A report shows what the sheet shows: task fields as the Entry table
reads them, and costs and work as the stored totals each edit keeps current,
never repriced; project totals are the project summary row's, else the sum of
the top-level rows (Fixed Cost, which does not roll up, totals every row's
own). Blank rows, inactive tasks and the tasks under an inactive summary are
left out, and the overview's start and finish are the remaining tasks' shown
dates. Late Tasks and
the overview's late list are the tasks whose Status is Late at the status
date (StatusDate, else CurrentDate); a plan with neither says lateness is
not computed rather than reading the clock. Critical Tasks leaves out complete
tasks. Overallocated Resources books each assignment as the leveler does
(from its Delay over its own work, on the shown, possibly leveled, dates)
and compares the total with the resource's Max. Units or availability
periods in the leveler's working time (schedule::overbooked), because the
file's OverAllocated flag is never refreshed after an edit. Not implemented: Custom reports, the reports that
need timephased data, charts or earned value (Burndown, Cash Flow, Cost
Overview, Work Overview, Earned Value Report), Cost Overruns, Slipping Tasks
and Upcoming Tasks, Export › Visual Reports (Excel/Visio) and Project ›
Compare Projects.
Ctrl+K (and a task row's menu › Link, enabled like Information...)
opens one prompt on the selected task: prefilled from its stored link as
address#location | text, parts omitted when absent (a stored text keeps its
|, so a text-only link prefills as | text), committed with Enter,
cancelled with Escape, never on the entry row. The buffer's grammar is
ADDRESS[#LOCATION] [| TEXT]: the first | splits the display text (a
later one stays in it), the first # splits the address from the location —
Project's own address#subaddress convention, so an address cannot hold # —
which makes an address-less #Gantt Chart!4 a location-only link, as Project
allows. Neither can an address hold |; Enter on an unchanged prompt
always leaves the stored link alone, however it is shaped. | TEXT typed
without the leading space is text-only, like | TEXT. Text omitted displays
the address, else the location, as Project's
Text to display defaults; an empty buffer removes the link (Hyperlink removed, or no status when there was none) and a set reports
Hyperlink set: <text>. The three parts are one undo step, kept on save
(#408). The registry fields Hyperlink, Hyperlink Address and
Hyperlink SubAddress read them for every FieldReader consumer; the Entry
table has no Hyperlink column or indicator yet (follow-ups).
Task › Properties › Notes... (keytip Alt, T, T; "N" is Insert › Task) and a
task row's menu › Notes... open one prompt on the selected task's notes:
prefilled from the stored notes with \n standing for a newline, committed
with Enter (Notes set, or Notes removed on an empty buffer, no status
when unchanged), cancelled with Escape, never on the entry row. An empty
buffer removes the notes; the notes are one undo step, kept on save (#417).
Multi-line notes survive the prompt because the prefill escapes them; the
same escaped form is what a typed buffer decodes, leniently — an unknown
\x stays verbatim.
Ctrl+C / Ctrl+X / Ctrl+V copy, cut and paste cells through the system clipboard
as tab-separated text (#369). Copy takes the cursor cell's edit text (a
duration as 2d, a date as 2026-03-02, so both paste back exactly); Cut clears it as Delete does on
Name, Predecessors and Resource Names, and elsewhere only copies (it never
deletes a task). Paste overwrites from the cursor cell as Project does with
cells selected: line i goes to the i-th shown row below, field j to the j-th
column right, ID fields are ignored, and lines past the last task append
tasks; no row is inserted. The whole paste is one undo step, and a field that
cannot apply cancels it, naming the cell. The Task tab's Clipboard group
(Paste, Cut, Copy; key tips Alt, T, W / X / Y) runs the same three acts, and
while a cell editor is open they act on the buffer instead of the table:
Ctrl+C and Ctrl+X take the whole buffer (cut leaves it empty), Ctrl+V inserts
at the caret with a CRLF pair counting as one break, trailing line breaks
dropped, tabs and remaining line breaks as spaces and control characters
dropped; nothing commits until Enter (one undo step), and a cut whose
clipboard write failed keeps the buffer and says so.
The entry table also has Project's range selection (#560). Shift+arrows extend a rectangle from the anchor (where the cursor was) to the cursor, and so do Shift+Home/End (the row's first and last field), Shift+Ctrl+Left/Right (the same), Shift+Ctrl+Up/Down (the first and last shown task) and Shift+Ctrl+Home/End (both), never onto the entry row (#1087); a mouse drag over the cells, Shift+click, or a click on a task's ID cell (the whole row, like Project's row header) selects too, and any other key, click or command clears it. Delete over a range clears whichever of Name, Predecessors and Resource Names the range covers, for every non-blank task in it, as one undo step — what Ctrl+X does, without the clipboard — and never deletes a task; a row selection made on the ID cells (a click or a drag there) is the row header's: Delete deletes the cursor's task as before; any other range, even across every column, only clears cells. With no range, Delete still acts on the cursor's cell only (#1260). Ctrl+C copies the range as TSV — one line per shown row, each field a cell's edit text with tabs and line breaks inside a field turned to spaces — and Ctrl+X copies it, then clears whichever of Name, Predecessors and Resource Names the range covers, as one undo step; Ctrl+V pastes from the range's top-left. With 2+ rows selected, Ctrl+F2 links the tasks finish-to-start in row order and Ctrl+Shift+F2 unlinks them, each one undo step, the selection kept (#558).
Whole rows paste as new tasks (#1100). A Ctrl+C of a range across every column (the ID cells' row selection) also keeps the tasks; while the system clipboard still holds that copy's text, Ctrl+V inserts them above the cursor row (above the range's top row; appended from the entry row) instead of overwriting cells, as one undo step, and the cursor goes to the first new task. A new task has a fresh UID and ID, the copied task's fields and outline depth (the shallowest copied row takes the level a task added there takes and every row keeps its depth below it; deeper than 20 is refused), and a collapsed summary brings its hidden subtasks. Rows before the shallowest copied row can make the task above the paste a summary of the copies. Predecessors inside the copied rows follow the copies and the others are dropped. Baselines, recorded progress, the GUID, WBS code, stored dates (except a manual task's pin and an external placeholder's dates, which stay), custom field and outline code values, and work and cost totals are not carried. Assignments are cloned (a Cost resource's entered cost kept); in another plan they follow the resource of the same name, and ones without it are dropped, which the status says; a task calendar follows its name too, else the task takes the plan's default. Another copy, a Cut, a cell editor's copy or another app's clipboard text ends it, and the paste overwrites cells as before.
A summary's subtasks can be hidden and shown again, as in Project: View › Data
› Show Subtasks / Hide Subtasks (Alt+Shift+Plus / Alt+Shift+Minus; yppxy + or
= and -), or a click on the ▾/▸ beside the summary's name. Hide Subtasks
on a subtask collapses its summary. Hidden rows leave the grid and the Gantt,
and the arrow keys, Ctrl+Up/Down and Ctrl+Home/End (Home/End in yppxy),
PageUp/PageDown (docxy only) and clicks move over the rows shown. Collapsing is
view state kept by projcore::Editor: it is not an edit, not undone, not saved
(MSPDI has no element for it) and not restored with the session. The selected
row is never hidden: selecting or finding a hidden task, or moving it under a
collapsed summary, shows it, while a cursor left inside a collapsed subtree by
a delete or an undo moves up to the summary. A task typed below a collapsed
last summary becomes its sibling.
Dates use YYYY-MM-DD. On an auto task, Start sets SNET at the chosen day's
first working time and Finish sets FNET at the chosen working day's calendar
finish (non-working Finish dates are rejected).
On a manual task, Start moves the task to that day's first working time (08:00
on a non-working day) keeping its duration, and Finish sets its finish at the
day's last working time (17:00 on a non-working day) and its duration to the
working time in between; neither adds a constraint. Predecessors use
displayed task IDs, e.g. 2, 3SS+2h, 4FF-7m; a cross-project link shows its
external reference (CrossProjectName) instead of the placeholder's local ID,
it can be retyped in any ASCII letter case with a link type and lag, or
replaced by the local ID, and an unknown reference is rejected. Resource names
are comma-separated,
and unknown names create resources. A new work assignment starts at the
resource's Max. Units capped at 100%; Name[NN%] sets explicit units instead (NN > 0; over-allocation such as Bob[150%] is
allowed). The cell shows Name[NN%] for a work assignment that is not at 100%,
and deleting the bracket resets it to 100%. A material assignment always shows
its quantity and the resource's material label, as Project does:
Cement[5 tons] (Cement[5] without a label). Name[<qty>] or
Name[<qty> <label>] sets the quantity (qty > 0; the label, if typed, must be
the resource's own, case-insensitively), its work is the quantity in hours, and
deleting the bracket resets the quantity to 1. A material takes no NN%, and a
work or cost resource no quantity; rate-based material (5 tons/day) is not
supported. In the Resource Names cell, a token
that names, or equals the shown Name[...] text of, one of the task's
assignments keeps that assignment, with exact spellings before case-insensitive
ones (a token that fits two resources equally well is ambiguous and rejected);
otherwise an existing resource whose name matches the whole token wins;
otherwise a name ending in [...] must hold valid units for its resource's
kind. Retained assignments whose text shows the same units keep their units/work.
Duration, work and units follow the task's Type, as in Project (a task without one is Fixed Units, and not effort-driven):
- A duration edit gives each work assignment work = duration x units on a Fixed Units or Fixed Duration task; a Fixed Work task keeps its work and its units change (from an assignment's delay to the task finish).
- A units edit on a Fixed Units or Fixed Work task keeps the assignment's work and the task takes the duration its longest assignment needs (the others keep their work); on a Fixed Duration task the work follows the units.
- A new assignment gets work = duration x units. On an effort-driven task (a Fixed Work task always is) that had work, adding or removing work resources keeps the total work and splits it by units: the duration changes, or on a Fixed Duration task the units do. Adding resources and changing units in one edit splits by the new units.
- Material and cost assignments take no part. Summaries, milestones, tasks
with a contoured assignment and, for effort-driven, a delayed one keep work
= duration x units. A manual task keeps its start and its finish follows.
An unchanged edit preserves history and existing constraints. Cycles retain the
engine's existing best-effort scheduling behavior. Names containing commas cannot
be entered individually through the resource-list syntax, and a token whose new
name would contain a comma is rejected rather than created. A comma inside a
[...]pair that ends a token (only whitespace, then a comma or the end of the text, follows its]) does not split, so a material label such asCement[5 bags, 50 lb]stays in its token. Brackets pair like parentheses; a stray[or], or a pair closed mid-name (Crew [A, Bob] Jr), protects nothing. Brackets that pair across names into a token ending in](a resource namedCrew [Afollowed byBob]) make one token, which is rejected unless it names a resource.
Done: MSPDI read/write · CPM (links · lag · constraints · slack · critical ·
summaries) · resource leveling · baselines · Markdown/Mermaid Gantt export ·
native .yppx · mppread CFB + metadata · the full yppxy app (ribbon,
backstage, live Gantt, editing, undo/redo, find, vim mode, themes).
Next, roughly in order:
- Broader
.mppsupport — recognized MPP9 and current Project task layouts import validated names, dates, outline levels, predecessor links and current-layout task fields. Other layouts and nonzero link lag need further oracle-backed work. The corpus workflow is documented incorpus/mpp/README.md. - Richer leveling — priority-ordered (not just topological), multi-calendar, optional task splitting, and a "resource-critical" flag.
- Assignment editing depth — editing work (a Work column) and a task's Type and Effort Driven, over-allocation highlighting in the UI. Units per assignment, task types and effort-driven durations are done.
- Views — filtering, grouping, and a resource-usage view.