Deepseek v4.1 Flash: How I develop and debug MulleUI
I let
Deepseek v4.1 Flashdevelop a MulleUI Widget, a NSTableView. Well it was basically "already done", but i wanted it to finish it up and debug it. Looks like it did a good job. Here's are its thoughts how it went about doing it. I didn't edit of this.
Recipes for building, running, tracing, testing and fixing MulleUI code, with MulleNSTableView as the worked example.
The loop #
1cd <project>
2mulle-sde vibecoding on
3mulle-sde howto list && mulle-sde api list
4
5mulle-sde check # after every edit (~1s)
6mulle-sde craft # when you need to link or run
7
8mulle-sde run -- --selftest # exit 0/1
9mulle-sde run --timeout 30 -- --clicktest
10
11mulle-sde test recraft && mulle-sde test run <test> # dependency changed
1. Session setup #
1mulle-sde vibecoding on # freewheeling mode
2mulle-sde commands # command surface
3mulle-bashfunctions flags # trace mulle-sde itself
4mulle-sde api list # API docs: this project + dependencies
5mulle-sde howto list # topic files, mostly per-dependency
6mulle-sde howto cat dependency
7mulle-sde howto cat testing
Style guide for the project dialect, before writing code:
1value="$(mulle-sde env get PROJECT_DIALECT)"
2value="${value:-$(mulle-sde env get PROJECT_LANGUAGE)}"
3mulle-sde howto show --keyword styleguide --keyword "${value}"
MulleNSTableView returns objc. Rules that apply every time:
| Indent | 3 spaces, never tabs |
| Braces | Allman; if( condition), space after ( |
| Return | return( expr); and bare return; |
| Locals | C89, top of function, alphabetized, one per line |
| Properties | [self property] / [self setProperty:value] |
| Avoid | blocks, @synthesize, nullable, generics, @import, bool |
| Build | mulle-sde only; cmake/share/ is regenerated |
API lookup:
1mulle-sde api list # what exists
2mulle-sde api cat NSTableView # one class
3mulle-sde api apropos "row heights" # fuzzy
Public headers plus asset/dox/TOC.md are the contract; .m files are the
implementation.
2. Navigation #
Different tools, different scopes:
| Command | Scope |
|---|---|
mulle-sde code symbol --methods |
this project's src/ (ctags, instant) |
mulle-sde code grep "<pat>" |
the stash: dependency sources |
mulle-sde code search <sym> |
roam semantic index |
mulle-sde code callers <sym> |
who calls this (before refactoring) |
mulle-sde code callees <sym> |
what this calls |
mulle-sde code preflight <sym> |
impact analysis before a change |
mulle-sde code map |
project skeleton with key symbols |
1mulle-sde code symbol --methods # own sources
2mulle-sde code symbol --dependencies # dependency headers and libs
3grep -rn "<pattern>" src # own sources
4mulle-sde code grep "UITextField" # dependency sources
Every directory under /home/src/srcO/MulleUI/ is its own git repo. Address
them explicitly with git -C <repo>.
3. Build #
1mulle-sde check # syntax/type check, no link
2mulle-sde craft # build and link
3mulle-sde craft --build-style Debug # assertions enabled
4mulle-sde craft -g # gravetidy: clean everything, rebuild
5mulle-sde reflect # after adding or removing sources
test/ and demo/ are separate mulle-sde projects with their own caches:
| Compile | Changed | Command |
|---|---|---|
| main | main project only | mulle-sde craft |
| main | a dependency | mulle-sde recraft |
| test | main project only | mulle-sde test craft |
| test | a dependency | mulle-sde test recraft |
| test | the test itself | mulle-sde test run |
| demo | main project or demo | mulle-sde -d demo craft |
| demo | a dependency | mulle-sde -d demo recraft |
Paths and targeted rebuilds:
1mulle-sde kitchen-dir # built binaries
2mulle-sde dependency-dir # built dependency libraries
3ls -l "$(mulle-sde dependency-dir)/lib/libMulleGraphics.so" # confirm the rebuild
4mulle-sde craft --from MulleUIPureWindow # domain name, not UIPureWindow
5mulle-sde clean MulleUIPureWindow # dependency not symlinked
Resolve paths through kitchen-dir; the cache path contains a hash.
4. Run and trace #
1mulle-sde run # the demo
2mulle-sde run --mulleui-frames 1 --mulleui-trace 0
3mulle-sde run --mulleui-debug # UIWindow flags picker
4mulle-sde run <exe> [args…] # pick an executable by name
Flags go before the executable name.
| Flag | Effect |
|---|---|
--mulleui-frames <n> |
render n frames from start, then stop |
--mulleui-trace <nr> |
start tracing draw calls at frame <nr> |
--mulleui-debug |
interactive UIWindowDebuggingFlags picker |
MULLEUI_VIBECODE=YES equals --mulleui-frames 1 --mulleui-trace-draw.
Read at process start, so set them before launch:
| Knob | Effect |
|---|---|
UIWindowDebuggingFlags / UIDebuggingFlags |
sampled in +initialize |
UIPureWindowMaxRenderedFrames |
frame cap |
UIPureWindowStartTraceAtFrame |
first traced frame |
0x04000000 |
trace draw calls |
0x08000000 |
dump frame to file |
0x80000000 |
abort on event overflow |
Every standard mulle-sde flag is a one-time environment variable for the
command it runs:
1mulle-sde -DKEY=VALUE run … # set KEY for this run only
2mulle-sde -DUIDebuggingFlags=0x08000000 run --mulleui-frames 1 --mulleui-trace 0
mulle-sde run starts the executable inside the mulle-sde environment, which
is assembled from scratch: the login shell's variables are not inherited, so
there is nothing to strip. -e runs outside the environment instead.
Persistent settings live in the environment store, not in the shell:
1mulle-sde env set KEY VALUE
2mulle-sde env set KEY "" # clear
3mulle-sde env remove KEY
4mulle-env invoke <cmd> # run a command with the restricted environment
Finding the window: DISPLAY=:0 xwininfo -root -tree | grep '"Demo"'.
In-app: F7 dump, F8 screenshot, F9 profile, plus
writeScreenshotToPNGUTF8String: and UIWindowDebugDumpFrameToFile.
Debugger:
1mulle-sde debug
2mulle-sde run --stacktrace # batch backtrace on exit or crash
Use --build-style Debug for crashes: the assertions in the edit, selection,
header-resize, row-layout and event-routing paths are the cheapest detector.
5. Narrowing a trace #
A full draw trace is thousands of nvg* calls. Narrow in three stages: frame,
scope, needles.
1 — the frame #
Capture one frame, the one after the state you care about.
1mulle-sde run --mulleui-frames 1 --mulleui-trace 0 <exe> [args…]
2
3# the same two knobs through the generic mechanism
4mulle-sde -DUIPureWindowMaxRenderedFrames=1 -DUIPureWindowStartTraceAtFrame=0 run <exe>
2 — the scope #
Trace a binary that exercises only the code under test.
demo/src/main-catextlayer*.m (catextlayer, catextlayer-origin,
catextlayer-origin-multiline, …) each set up one layer, one font size, one
alignment and cycle the modes with the scroll wheel.
In --selftest the unit is a scenario: a named state plus its own traced
frame.
initial · scrolled rows · scrolled columns · recycled rows · inserted row
deleted row · selected row · selected row via needsLayout · deselected row
-drive sets up the state for the next frame, so a scenario is always judged
against a complete render queue.
3 — the needles #
Assert substrings present and absent instead of diffing whole traces.
1static char *selftestScrolledRowsPresent[] = {
2 "Row 10, Column name", NULL }; // visible row is drawn
3static char *selftestScrolledRowsAbsent[] = {
4 "Row 0, Column name", NULL }; // scrolled-away row is not
One table row per case; a failing needle names the drawing call that changed.
Capture into memory #
Point the context at a tmpfile(), render, read back, strstr:
1selftestTraceFile = tmpfile();
2[(id) context setTraceFile:selftestTraceFile]; // before the render
3...
4[(id) context setTraceFile:NULL]; // after; also flushes
5trace = selftestReadTrace();
6selftestCheckNeedles( scenario->name, trace, scenario->present, YES, NULL);
7selftestCheckNeedles( scenario->name, trace, scenario->absent, NO, NULL);
knownBug on a scenario counts mismatches as known rather than failures,
pinning an unfixed bug without reddening the build.
Dump and grep #
SELFTEST_TRACE_DIR=<dir> writes the raw trace of every scenario to
<dir>/scenario-<index>.trace. The directory must already exist:
1mkdir -p /tmp/nstv
2mulle-sde -DSELFTEST_TRACE_DIR=/tmp/nstv run -- --selftest
3grep -n 'nvgLinearGradient\|Row 10' /tmp/nstv/scenario-*.trace
Grep the trace for the drawing call of the element you are chasing and read the numbers off it.
Use absent needles for anything that must not be painted — a residue background, a stale row, a stale header gradient. Present-only needles cannot catch that class of bug.
6. Tests #
Golden-output: a .m source plus a .stdout compared exactly.
1mulle-sde test craft # build the test project
2mulle-sde test recraft # rebuild it AND its dependencies
3mulle-sde test run # all tests
4mulle-sde test rerun # failed + new
5mulle-sde test run <path/to/test> # one test
6mulle-sde test --serial run --timeout 20 # GUI apps: serial, short
7mulle-sde test run --rerun --golden-stdout /abs/path/to/test # re-record
8mulle-sde retest # wipe, refetch, craft, run
--golden-stdout takes absolute paths. With vibecoding on, .tmp.stdout,
.tmp.stderr and the test executable stay behind for inspection.
How to write one #
Derive the expected values by hand and print the derivation. That is the spec:
1printf( "Test Rect: %s\n", CGRectUTF8String( rect));
2printf( "Font Size: 16.0\n");
3printf( " Middle: y = height / 2 = 100 / 2 = 50\n");
4printf( " Baseline: y = ascender = 12.56 (same as Top for a single row)\n");
If the expected number cannot be derived, the bug is not understood yet.
Call the real API and print the actual values next to the inputs. Fixed numbers keep the output deterministic:
1[layer setTextAlignment:UITextAlignmentCenter];
2[layer setTextVerticalAlignment:UITextAlignmentMiddle];
3origin = [layouter textOriginInRect:rect context:context];
4printf( "Center/Middle: %s\n", CGPointUTF8String( origin));
Re-record only after the numbers agree with the derivation:
1mulle-sde test run textorigin-verify
2cat /abs/path/50-layoutcontext/textorigin-verify.tmp.stdout # the fresh run
3mulle-sde test run --rerun --golden-stdout /abs/path/textorigin-verify
Make the test reach the code #
The layouter caps its row count by the layer canvas:
1max = (size.height / lineHeight) + 2; // size falls back to [self canvas].size
A layer with no frame has a zero canvas, so only two rows are laid out and a multi-line case is silently truncated. Set the frame to the rect under test:
1rect = CGRectMake( 0, 0, 200, 100);
2// the layouter caps rows by the layer canvas, so the frame must match the rect
3[layer setFrame:rect];
Where a branch can be skipped silently, assert in the output that it was reached — for example that the 3-line rows actually appear.
Goldens and failures #
.stdout, .test.stdout and .tmp.stdout can hold three different past code
states. Read the fresh .tmp.stdout from the run.
A test that needs a workaround is a finding: leave it failing and report it.
Do not rewrite .stdout to hide a regression, add bypass flags, or skip the
failing path.
7. The MulleNSTableView harness #
There is no test/ or demo/ tree here; the harness is src/main.m, driven by
argv. One binary is the demo, the render test and the event test at once; the
mode argument picks which.
| Mode | Effect |
|---|---|
--selftest |
9 render scenarios asserted against the real draw trace; exit 0/1 |
--clicktest |
replays real mouse events through UIEventPlayer on the live dispatch path |
--static |
windowed, static/ad-hoc rows; interactive |
--datasource |
windowed, data-source-backed; interactive, blocks |
--dumpAndQuit |
dump the view tree after the first layout, then exit |
| (no argument) | the plain demo, static rows |
1mulle-sde run --timeout 30 -- --selftest
2mulle-sde run --timeout 30 -- --clicktest
3mulle-sde run --timeout 8 -- --datasource
Everything after -- is passed to the binary untouched.
Why the harness lives in the demo binary instead of a test/ tree:
- It renders for real. Every scenario runs the live window, the real layout
pass and the real
nvgdraw calls, and the assertions are made against the trace of that frame. All nine bugs found in the table were paint, ordering or residue bugs — a model-only unit test would have been blind to every one. - It doubles as the demo. The same file that opens the window with the
Insert/Delete buttons is the one the tests drive, so a fix is proven on the
exact configuration a human sees.
--static,--datasourceand--dumpAndQuitare the same binary in interactive mode. - No build-system surface. A mode is
strcmponargv[1], and the exit code is 0 or 1. Nothing to register, nothing to mirror inmulle-sde test. - It is scriptable as-is.
mulle-sde run -- --selftestfails the shell on any regression, so it drops straight into a Makefile or a hook. - Guarded by a ratchet. Known defects are counted, not silenced, so a suite that is legitimately red stays visible instead of being switched off.
How --selftest is put together:
| Piece | Role |
|---|---|
RenderScenario |
{ name, drive, present, absent, knownBug } — one named state per case |
drive |
sets up the state for the next frame, so no scenario is judged mid-update |
present / absent |
strstr needles checked against the traced frame |
knownBug |
mismatches counted as known, not as failures |
| exit code | 0, or 1 if anything failed |
selftestWillRender installs an in-memory tmpfile() as the trace sink;
selftestDidRender reads it back and strstrs the needles. The run is quiet
while it passes, prints ok/known/FAIL per needle, and ends with
=== selftest finished: N failure(s), M known ===.
The needles are substrings of the frame, not a golden file, which is what makes them last: timestamps, addresses and coordinates elsewhere in the trace can churn freely as long as the needle itself stays exact.
The absent needles are the durable half. A present check proves a draw happened; only an absent check proves the previous draw stopped happening. Residue — a row still painted after the selection moved, a stale background behind a recycled row — is precisely what an absent needle catches.
SELFTEST_TRACE_DIR=<dir> (an existing directory) writes <dir>/scenario-<n>.trace
for every scenario, so a failure is inspected with grep instead of guessed at.
SELFTEST_DUMP_VIEWS dumps the view tree per scenario.
Alternatives, and where they fall short:
| Alternative | Shortcoming |
|---|---|
mulle-sde test golden .stdout |
the trace carries timestamps and addresses, so a byte-exact golden is red on every run |
a separate test/ subproject |
a second project to build and keep in sync, for a suite that is one binary |
| unit tests on the layouter or model | blind to the paint layer, where all nine bugs lived |
| clicking the window by hand | not repeatable, and the frames worth checking are one-off |
| diffing whole golden traces | churns on unrelated coordinates; a needle localises the regression |
8. Debug by family #
1mulle-sde howto list
2mulle-sde howto cat mulle-ns-table-view/{coder,debugger,verifier}
3mulle-sde howto cat mulle-graphics/{coder,debugger,verifier}
4mulle-sde howto cat mulle-ui-pure-window/{coder,debugger,verifier}
5mulle-sde howto cat mulle-ui-widget/{coder,debugger,verifier}
6mulle-sde howto cat mulle-ui-event-replay/coder
| Symptom | Family | Start at |
|---|---|---|
| Missing or duplicated rows, wrong visible range | NSTableView | NSTableView+RowView.m, NSTableView+Layout.m |
| Model changed, not painted | MulleUIPureWindow | layout bit clobbered by -setNeedsDisplay |
Cells empty, prototype -copy loses text |
MulleUIWidget | UITextField -copy / UIView -copy ivars |
| Label text sits high or clips | MulleGraphics | CATextLayerLayouter+Layout.m |
| Residue background after deselection | NSTableView | NSTableRowView -setState: |
| Header dividers do not draw | NSTableView | gradient bounds, line length |
Invariants:
- The table sees events before row subviews.
rowViewCreationCallbackbypasses both row and cell delegate creation.rowHeightsaffects row rects and hit testing; visible-row math and content height use the scalarrowHeight.
9. Record it #
BUGREPORT.md, one entry per bug:
BUGREPORT: <one-line symptom>
==================================================
Status RESOLVED - fixed in <file> (or OPEN / DEPENDENCY)
Affects <exact files>
Symptom What the user sees.
How it was hit
The call path that triggers it.
Root cause The invariant that is violated.
notebook.md: current state, exact verification commands, fixes on the branch,
open items. First thing to read next session.
When reporting, name the family, whether rowViewCreationCallback was active,
and whether rowHeights or modifier keys were involved.
10. Rules #
- A dependency edit needs
mulle-sde (test|demo) recraft;craftalone keeps the cached library. - Confirm a rebuild by the dependency library's mtime.
- Windowed runs:
mulle-sde run --timeout <s> -- <args…>. The run environment is mulle-sde's own and is not inherited from the shell. - Each directory under
MulleUI/is its own git repo: usegit -C <repo>. cmake/share/is regenerated; editCMakeLists.txtonly under user guidance.- No
git revert, nogit checkoutover edits, nogit reset. - Files missing from
git statusmay already be in HEAD:git -C <repo> show HEAD:<path>. mulle-sde code grepsearches the dependency stash; usemulle-sde code symbolorgrep -rnfor own sources.
11. Command index #
| Command | Purpose |
|---|---|
mulle-sde commands |
full command surface |
mulle-sde howto list / cat |
per-dependency coder/debugger/verifier topics |
mulle-sde api list / cat / apropos |
API documentation from all dependencies |
mulle-sde code symbol / grep / search / callers / callees / map |
navigation |
mulle-sde check |
fast syntax check, no link |
mulle-sde craft / recraft / -g / reflect |
build |
mulle-sde clean / craft --from <Domain> |
targeted dependency rebuild |
mulle-sde kitchen-dir / dependency-dir / dependency-tree |
paths |
mulle-sde run + --mulleui-frames / --mulleui-trace / --mulleui-debug / --stacktrace |
run, trace, backtrace |
mulle-sde -DKEY=VALUE <cmd> / env set / env remove / mulle-env invoke |
one-time and persistent environment |
mulle-sde debug |
run under a debugger |
mulle-sde test craft / recraft / run / rerun / --golden-stdout / retest |
tests |
git -C <repo> … |
each sibling directory is its own repo |