- Command Surface
- Code Map
- Algorithm (from the current implementation)
- Examples
- Practical Testing Checklist
- FAQ (code-grounded)
- Quick Regression Matrix (who to press where)
#Command Surface
- Menu / Shortcut: Organization → Hierarchy Swap (F8), defined in
tsframe.h. The label reads “Hierarchy Swap” and the tooltip explains that all cells with the selected text are swapped with their parents at the current level (or above). The menu entry lives under the “Organization” cascade, grouped with flattening and hierarchify commands. - Action ID:
A_HSWAPinmain.cpp. This ID connects the menu, keyboard shortcut, and dispatcher switch statement; it is also used in the toolbar accelerator table. - Validation and dispatch:
Document::Action()atdocument.henforces:- The selected cell has a parent and grandparent (minimum depth requirement) and therefore the command is disabled at the document root.
- Both parent and grandparent grids are 1×N or N×1 (unidimensional constraint). If either grid fails the shape test, the action aborts before any structural edits occur.
- An undo step is recorded before calling the swap, so the operation is fully reversible.
- The returned
Selectionis installed, and the layout is reset plus canvas refresh is requested to reflect the new tree topology.
- Failure modes (user-facing):
- No grandparent → “Cannot move this cell up in the hierarchy.”
- Parent grid not 1×N or N×1 → “Can only move this cell from a Nx1 or 1xN grid.”
- Grandparent grid not 1×N or N×1 → “Can only move this cell into a Nx1 or 1xN grid.”
- Selection has no grid ancestry because the document is a single flat row/column → the menu item will be disabled due to the grandparent check.
- Selection rule: The operation is invoked on the currently selected cell; the returned selection usually points to the promoted/merged tag at the grandparent level. If merges occur, the earliest tag inserted into the target grid becomes the selection anchor and is reused for subsequent merges.
- UI invariants to watch: because
HierarchySwapruns inside a single undo step, the canvas redraw and layout reset happen once per keypress, even when multiple promotions/merges occur viagoto lookformore.
#Code Map
| Area | Location | Role |
| — | — | — |
| Grid-level search | Grid::FindExact | Recursively finds the first cell whose text matches the selected tag. Walks depth-first through a child grid. |
| Main algorithm | Grid::HierarchySwap | Promotes each match, rebuilds its parent chain under it, merges like-tag peers, and restarts the search until no matches remain. |
| Parent cleanup | Grid::DeleteTagParent | Removes the promoted node from its old location, deleting empty 1×1 ancestors on the way up. |
| Merge helpers | Grid::MergeTagCell and Grid::MergeTagAll | Merge promoted cells (and their grids) when the target level already contains the same tag. |
| Parent pointer fix-up | Grid::ReParent | Retargets parent pointers whenever a grid is transplanted. |
| Cell-level match | Cell::FindExact | Base-case exact-text comparison used by Grid::FindExact. |
#How these pieces cooperate
Document::Action()validates shape/depth, then callsHierarchySwapon the grandparent grid, passing the selected cell text.HierarchySwapiterates each direct child cell of that grid that has a subgrid, scanning it withFindExact.- When a match is found, the ancestor chain is reversed into nested children (using
ReParentfor correctness), original containers are removed (DeleteTagParent), and the promoted tag is merged (MergeTagCell/MergeTagAll). - The search restarts (
goto lookformore) so newly created structure is considered; the returnedSelectionpoints to the first merged tag at the target grid.
#Algorithm (from the current implementation)
The following is a line-by-line translation of the active codepath, emphasizing what actually happens rather than a simplified mental model.
- Search scope: Start in the grandparent grid of the selected cell (the grid that owns the parent’s parent). Iterate each direct child that has a grid. The current child being scanned is referenced by
cellinside the function. - Find first match: Use
Grid::FindExact(tag)to locate the first cell in that child grid whose text equals the selected tag (case-sensitive). If none, continue to the next sibling with a grid. - Build reversed chain: For the found cell
f, walk its parent chain up to (but not including) the grandparent cell that owns the running grid. For each ancestorp:- If
p->textmatches the tag, setdone = trueto stop after this promotion and avoid infinite swaps through same-named ancestors. - Clone
pinto a new cell attached underf, transferf’s current grid to that clone, callReParent, and givefa fresh 1×1 grid containing the clone. This makes every ancestor become a child nested under the promoted tag, preserving the original ordering from nearest to farthest parent.
- If
- Detach the original chain: Call
DeleteTagParentrepeatedly while walking upward, deleting empty 1×1 ancestors and cleaning up the spot where the match used to live. This pruning happens before any merge so that empties are not left behind in the original branch. - Merge at target level:
- If the target grid was empty, place
fthere and mark it as the selection. - Otherwise call
MergeTagCell, which either mergesfinto an existing like-named cell (combining grids viaMergeTagAllwhen both have grids) or appends it if no duplicate exists. The first merged/added cell becomes the returned selection, and subsequent merges fold into that.
- If the target grid was empty, place
- Restart search:
goto lookformorerestarts the sweep so newly created structure is also scanned. The loop ends when no further matches remain ordonewas set because an ancestor already matched the tag (the ancestor-match guard). - Return selection: The function returns a
Selectionpointing to the promoted/merged tag at the target level. The caller re-applies the selection and refreshes layout/UI.
#Behavioral Notes
- Grid shape: The operation only runs on 1×N or N×1 grids (checked before calling the algorithm). This keeps parent/child inversion unambiguous and ensures
DeleteCellscan safely collapse rows/columns when null slots appear. - Exact text match: Matching is literal and case-sensitive (
Cell::FindExact), so “Red” ≠ “red”. Hidden formatting (bold/italic) does not affect the match because onlytext.tis compared. - Search order: Because the search restarts after every promotion, newly merged structures can be processed in subsequent passes; order is depth-first within each child grid. This restart is why multi-match merges can happen within one keypress.
- Ancestor protection: If an ancestor already has the same tag,
donestops further promotions after that chain is processed to prevent cycling the same text upward forever. That means repeated-tag chains only promote once, even when additional matches exist deeper in the tree. - Merge semantics: When the target grid already contains the tag, children from both structures are merged under the surviving tag cell. Subgrid merging preserves existing rows/columns as appended sibling rows, and
MergeTagAllwill recursively merge duplicate-tag grandchildren as well. If a promoted match brings in a cloned ancestor with the same name but no grid,MergeTagCellshort-circuits on the first match and leaves only one copy. - Two-level hops: Each keypress works against the selected cell’s grandparent grid, so very deep matches may need multiple presses to bubble all the way to the top-level grid where siblings live. The “press-count” tables below assume this two-level stride.
- Undo/redo alignment: An undo point is established before running the algorithm; all structural edits (promote, delete, merge) live inside that single undo step. Redo will replay the full set of promotions, merges, and deletions.
- Selection stability: The first promoted/merged cell at the target grid is returned as the new selection; subsequent merges do not change that pointer. This stability matters for keyboard users repeating swaps.
- Empty-shell cleanup: Because
DeleteTagParentprunes empty 1×1 ancestors, the final tree omits placeholder shells that lost all children during promotion. When the grid has multiple rows, empty rows are physically removed viaDeleteCells. - Grid ownership:
ReParentis called every time a grid is re-attached so parent pointers remain accurate for all transplanted children. This invariant is critical for subsequent operations such as copy/paste or further swaps. - Shape preservation for bystanders: Cells in the grandparent grid that do not contain a matching tag remain in place (apart from row removal when a null slot is deleted). Use this property to predict stable ordering of unrelated siblings.
#Implementation Walkthrough (annotated pseudocode)
Below is an exact-structure pseudocode sketch that matches the current C++ implementation, including the restart logic:
HierarchySwap(tag):
selcell = nullptr
done = false
lookformore:
for each cell in this grid where cell has a subgrid:
found = cell->grid->FindExact(tag)
if not found: continue
// Reverse the ancestor chain into children under `found`
for p = found->parent; p != cell; p = p->parent:
if p->text == tag: done = true
clone = new Cell(found, p)
clone->text = p->text
clone->grid = found->grid
if clone->grid: clone->grid->ReParent(clone)
found->grid = new Grid(1, 1)
found->grid->cell = found
*found->grid->cells = clone
// Remove the original chain (prunes empties)
for r = found; r && r != cell; r = r->parent->grid->DeleteTagParent(r, cell, found);
// Merge or insert at the target level
if !cells[0]:
*cells = found
selcell = found
else:
MergeTagCell(found, selcell)
if !done: goto lookformore
return Selection(this, selcell)
Key takeaways from this structure:
- The
gotointentionally restarts theforso the modified grid is scanned anew. doneonly flips when a same-named ancestor exists; otherwise every match reachable from the scanning grid will be processed.- The merge target is always the grid on which
HierarchySwapis called (the grandparent grid chosen by the caller). - The loop condition
p != cellstops cloning at the grid’s owning cell (the grandparent), so only ancestors strictly below that owning cell are nested under the promoted tag.
#Examples
The following scenarios are constructed directly against the algorithm above:
#1) Baseline: Single Match Promotion
Before (select “Alice”, grandparent grid owns Project):
Projects
Project A
Alice
Project B
Bob
After F8 on “Alice”:
Projects
Project B
Bob
Alice
Project A
- The
Alicebranch moves to the grandparent grid.Project Abecomes a child underAlice. Other siblings stay put. - No merge occurs because only one
Aliceexists. Undo reverses the promotion cleanly. - Plain text before:
Projects\n Project A\n Alice\n Project B\n Bob\n - Plain text after:
Projects\n Project B\n Bob\n Alice\n Project A\n
#2) Multiple Matches at Different Depths
Before (grandparent grid is Colors):
Colors
Warm
Red
Orange
Cool
Blue
Mixed
Purple
Red
After F8 on either “Red”:
Colors
Warm
Orange
Cool
Blue
Red
Warm
Mixed
Purple
- First match promotes
WarmunderRed, leavingOrangebehind. - Second match promotes
Mixed → Purpleunder anotherRed. - The two
Redresults merge at theColorslevel; children from both chains are preserved. - Merge order is deterministic because the scan restarts after each promotion: the first child grid containing a match is processed fully before later siblings are scanned again.
- Plain text (pre-swap):
colors\n warm\n red\n orange\n cool\n blue\n mixed\n purple\n red\n - Plain text (post-swap):
colors\n warm\n orange\n cool\n blue\n red\n warm\n mixed\n purple\n - XML (pre) mirrors the hierarchical listing; XML (post) reflects the merged
Rednode withWarmandMixedchildren.
#3) Single-Path with Repeated Tags
Before (select the deeper “Tag”, grandparent grid is the root):
Root
Tag
Tag
Item
After F8 on inner “Tag”:
Root
Tag
Tag
Item
- The inner match is promoted to the root grid.
- The original outer tag is preserved as a child under the promoted tag (the ancestor clone step).
- Because an ancestor shared the tag,
donestops further passes after this promotion. That guard prevents repeatedly flipping the two tags back and forth. - Variants to test: add a third nested
Tagto confirm only one promotion occurs when a same-named ancestor exists.
#4) Grid Merge with Existing Hierarchy
Before (select any tag cell; grandparent grid is main):
main
branch1
tag
a
b
c
branch2
tag
d
e
f
After F8 on tag:
main
tag
branch1
a
b
c
branch2
d
e
f
- Each
tagis promoted; their parent chains (branch1,branch2) become children under the promoted tags. - The promoted tags merge at the
mainlevel, combining both sub-branches under onetag. - Empty intermediate grids do not survive because
DeleteTagParentprunes 1×1 shells. - Regression hint: if a third
tagexisted underbranch3, it would also merge into the single top-leveltagduring the same keypress because the search restarts until no matches remain.
#5) Deep Match with Mixed Siblings (depth-dependent passes)
This example illustrates the “two-level hop” rule: each keypress processes the current selection’s grandparent grid. Deeper matches can require multiple presses to reach the shared ancestor where merging occurs.
Before (same starting state for all runs):
Departments
Sales
Q4
Jamie
Support
Jamie
Engineering
Backend
Team A
Jamie
If you press F8 on the shallow Jamie (under Sales → Q4):
- After the first press (scope =
Salesgrid):Departments Sales Jamie Q4 Support Jamie Engineering Backend Team A Jamie - After the second press (scope =
Departmentsgrid):Departments Jamie Sales Q4 Support Engineering Backend Team A- First press bubbles the match two levels (grandparent =
Sales). - Second press runs at
Departments, finds all threeJamiematches, promotes each, and merges them at the top level.
- First press bubbles the match two levels (grandparent =
If you press F8 on the mid-depth Jamie (under Support):
Departments
Jamie
Sales
Q4
Support
Engineering
Backend
Team A
- One press is enough because the selected cell’s grandparent grid is already
Departments, so all three matches are discovered and merged in a single pass. The scan restarts after each promotion, but all matches reside directly in the processed scope, so the merge completes immediately.
If you press F8 on the deep Jamie (under Engineering → Backend → Team A):
- After one press (scope =
Backendgrid):Departments Sales Q4 Jamie Support Jamie Engineering Backend Jamie Team A - After two presses (scope =
Engineeringgrid):Departments Sales Q4 Jamie Support Jamie Engineering Jamie Backend Team A - After three presses (scope =
Departmentsgrid):Departments Jamie Sales Q4 Support Engineering Backend Team A - Each press operates two levels up from the current selection, so the deep match must be swapped three times to reach the shared
Departmentsgrid where the merge can occur. At that point, allJamienodes are coalesced.
Press counts by depth:
| Selected Jamie | Initial depth relative to Departments | Presses to merge all three | Why |
| — | — | — | — |
| Shallow (Sales → Q4 → Jamie) | Great-grandchild | 2 | First press moves into the parent’s parent (Sales); second press runs in Departments and merges everything. |
| Mid-depth (Support → Jamie) | Grandchild | 1 | Already two levels below Departments; one pass finds all matches. |
| Deep (Engineering → Backend → Team A → Jamie) | Great-great-grandchild | 3 | Needs three hops (Backend → Engineering → Departments) because each swap uses the current grandparent grid. |
Alternate representations for automated checks:
- Pre-swap plain text:
Departments\n Sales\n Q4\n Jamie\n Support\n Jamie\n Engineering\n Backend\n Team A\n Jamie\n - Post-merge plain text (after mid-depth or second shallow press or third deep press):
Departments\n Jamie\n Sales\n Q4\n Support\n Engineering\n Backend\n Team A\n - XML versions can mirror these structures to diff serialized
.ctsfiles.
#6) Flat Sibling Merge (single pass, no depth hops)
This mirrors a shallow multi-match merge with no ancestor reuse beyond the shared parent. The grandparent grid is the document root; its only child with a subgrid is Root.
Before (select any Tag; grandparent grid = document root, scanning the Root grid):
<doc root>
Root
Tag
Tag
Tag
Other
After one F8 on any Tag:
<doc root>
Root
Other
Tag
Root
- Each
Tagis promoted out ofRootand merged at the document root grid in a single pass because the restart (goto lookformore) continues scanning until no matches remain. - The ancestor-clone step adds a
Rootchild under every promotedTag, but because clones share the same text and carry no grids,MergeTagCellcollapses them into a singleRootchild on the survivingTag. Rootkeeps only the non-matchingOtherchild becauseDeleteTagParentremoves eachTagrow from its grid but does not delete the grid itself (it is not 1×1).- Regression tip: if one of the
Tagnodes had its own grid, that grid would merge into the survivingTagas well viaMergeTagAll; duplicates without grids are dropped as shown here.
#7) Partial Empty Parents (slot deletion)
This showcases how null slots are deleted when a promoted child leaves behind an empty 1×1 grid, while non-empty siblings remain.
Before (select the first Target; grandparent grid = Main’s parent):
Main
Holder1
Target
Sibling
Holder2
Target
After F8 on Target:
Main
Holder1
Sibling
Target
Holder1
Holder2
- The first promotion clones
Holder1under the newTargetand removes the originalTargetrow, leavingHolder1with onlySibling. - The second promotion clones
Holder2under a secondTarget; becauseHolder2’s grid becomes empty (1×1),DeleteTagParentremoves that grid and returns theHolder2cell to the caller. MergeTagCellfolds the second promotedTargetinto the first, merging the two cloned parents (Holder1,Holder2) under the survivingTargetwhile keeping theHolder1branch withSiblingintact.- This example is a good probe for the
DeleteCellspath that removes a null slot from a multi-row grid and for the merge helper when one parent clone already exists.
#Practical Testing Checklist
Use these focused checks to validate behavior after any code change touching the swap logic:
- Verify swaps only run when both parent and grandparent grids are 1×N or N×1 (exercise all three error messages).
- Confirm merged results keep every child grid (use Examples 2 and 4 to see that no payload is dropped during merges).
- Exercise the ancestor-tag guard by creating a chain with repeated tags (Example 3) and ensure only one promotion occurs.
- Walk the depth-dependent paths (Example 5) to ensure press counts still match the two-level-hop rule.
- Ensure undo works: perform a swap and Ctrl+Z to restore; redo should reapply the promotion without divergence.
- Serialize to
.ctsand diff the XML snippets above to confirm structural equivalence in file form.
#FAQ (code-grounded)
- Why restart with
goto? The grid topology changes during promotion and merge. Restarting ensures newly attached grids are eligible for immediate scanning without complex iterator management. - Why prune empty 1×1 grids? Promotion often hollows out ancestor shells. Cleaning them avoids empty visual rows/columns and keeps selection paths short.
- Why does an ancestor tag stop further passes? Without
done, a repeated tag could ping-pong upward indefinitely. The guard enforces a single promotion when the chain already contains the tag. - Can swaps occur across unrelated branches? No. The scope is the selected cell’s grandparent grid; only descendants of each child grid under that grandparent are scanned.
- What happens when both merge candidates have grids?
MergeTagAllmerges every cell from the source grid into the destination grid, preserving ordering; if one side lacks a grid, the other grid is kept intact.
#Quick Regression Matrix (who to press where)
| Scenario | Selection | Expected presses | Expected top-level result |
| — | — | — | — |
| Single match | Alice under Project A | 1 | Alice at Projects with Project A child |
| Two matches, differing depths | Any Red | 1 | Single Red at Colors with Warm and Mixed → Purple children |
| Repeated ancestor tag | Inner Tag | 1 | Two nested Tag nodes under Root, no further passes |
| Parallel tag merges | Any tag under branch1/2 | 1 | Single tag under main with both branches merged |
| Depth-varied siblings | Shallow Jamie | 2 | Single Jamie under Departments with all departments beneath |
| Depth-varied siblings | Mid Jamie | 1 | Same as above |
| Depth-varied siblings | Deep Jamie | 3 | Same as above |