Deepseek v4.1 Flash: How I develop and debug MulleUI

· nat's blog


I let Deepseek v4.1 Flash develop 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:

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:


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 #


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