Fix the next batch of open items: the library, the inline patcher, the tray, the viewer's model and the three heads - #927
Merged
Merged
Conversation
The file was three lists by where an item came from, each opening with what had been fixed. It is now one list an area of what is open, with the history, the legend of evidence tags and three notes that described a change rather than work to do taken out.
…r long the key is held A held key repeats, and ViewerView.keyDown queued every repeat. For Down that is the point. For a, A and d it accepted or discarded the entry on screen and then each one that took its place, none of them read, and repeats queued while a frame was slow were still handed over after the key came up. keyDown now drops an event that is a repeat (NSEvent.isARepeat) when the key it maps to is accept, accept all or discard: the three of ViewerSession.ChangesQueue that a key can be in this head. The WinForms head does the same in ProcessCmdKey. Every other key repeats as before, and the menu bar's items do not come through keyDown. Not compiled and not run: nothing on the machine this was written on can build Swift against AppKit. CI's macos-14 job compiles it, and no capture exercises a key. To check on a Mac: with five or more entries queued, hold a for two seconds. One entry is accepted, where every entry was. The same for d and for shift+a. Hold Down in a long file: the panes keep scrolling.
… are Plus, minus and equals were matched only on charactersIgnoringModifiers, which is the key with Option left out. On a layout that has one of them behind Option that is the digit or letter printed on the key, so the reader could not zoom from the keyboard. The brackets were moved to `characters` last round for the same reason, and these three now sit beside them in that switch. The matches on charactersIgnoringModifiers below stay, so nothing that zoomed stops: both readings keep Shift, so on a US layout equals unshifted, plus with Shift held and minus arrive as themselves either way. The command and control chords are left on charactersIgnoringModifiers, since `characters` with control held is a control character. Not compiled and not run: nothing on the machine this was written on can build Swift against AppKit. CI's macos-14 job compiles it, and no capture exercises a key. To check on a Mac: on the US layout, over a picture pair, = zooms in, shift+= zooms in, - zooms out and 0 fits, as before. Then add a layout that has plus, minus or equals behind Option, type it there, and it zooms where it did nothing.
The title was drawn across the whole row and the subtitle over its right hand end, so a title long enough to reach it was two texts on top of one another, and neither could be read there. A deep path in a narrow window does it. Renderer.draw now gives a title that would run on under the subtitle the row up to one cell before it, and the text is clipped there. The title gives way, as it does in the Linux head: what it says is also in the pane headers and the queue, and which entry this is is said only by the subtitle. A title that fits beside its subtitle is handed the same rect as before, so it is drawn as it was. That is every capture: the widest title in the ten macOS pixel scenes is 42 cells and its subtitle 4, in a row of 120. Widths are counted in cells, as the footer's are, so a title of characters a fallback font draws wider than a cell can still reach the subtitle when the count says it fits. Not compiled and not run: nothing on the machine this was written on can build Swift against AppKit. CI's macos-14 job compiles it and its captures show the title row unchanged. To check on a Mac: `DiffEngineViewer --diff` two files whose names are sixty characters each, and narrow the window. The title stops one character before "diff", where it ran on under it.
…name one picture A byte equal pair of documents names one page's png in both panes, since pages are kept under the document's hash. The left pane is half the panes' width rounded down and the right one the rest, so they can be a point apart, and a page fitted to its pane's width is then asked for at two sizes. Renderer kept one scaled copy a picture. Read through, that does not settle: the left pane's copy lands and is a redraw, in which the right pane finds the copy is not its size and asks for its own; that lands and is a redraw, in which the left pane asks again. A scale of the page on the work queue and a redraw of the window, for as long as the pair is on screen. A picture both panes name now keeps the last two copies made: when a copy lands, the one it takes the place of goes into Picture.spare rather than away, and `fitted` looks there after `scaled`. Two sizes are asked for, so that is one each, and nothing is asked for again until the window is resized. A picture only one pane names keeps one copy, as before. A capture is unchanged. It scales there and then for each pane and writes only `scaled`, as it did, and none of the macOS pixel scenes has one picture in both panes. The enlarged path still draws such a pair from the picture. Not compiled and not run: nothing on the machine this was written on can build Swift against AppKit. The loop was found by reading, and so was its end. CI's macos-14 job compiles this and its captures do not reach it. To check on a Mac: `DiffEngineViewer --diff a.pdf b.pdf` with b a copy of a, and drag the window's edge a point at a time until the page fills its pane's width. In Instruments, CGContextDrawImage under Renderer.scale on the pictures queue: called without end before, twice a resize after. CPU for the process falls to nothing once the window is left alone.
A delete and a move pending on the same verified file are only tracked together when the delete arrived second, and an accept-all then carries out the move and leaves the delete. But the sweep forgot why as it ended: the delete stayed in the menu looking like any other, with a log line as the only account of it, and pressing "Accept all" again deleted the snapshot the first press had just accepted. Accepting the move from its own item and then pressing "Accept all" lost the file the same way, with no sweep having held anything. The reason is now on the delete. A move that writes a file marks the delete pending on it, whichever way the move was accepted, and no accept-all, from the menu, a hot key or the wire, carries a marked delete out. Accepting the delete on its own still does, from its item, the header over the deletes or its key over the wire. A test run that raises the delete again clears the mark, since that run looked at the file the move wrote. A delete waiting on a move that is still pending is held as before, for as long as the move is there. The user is told in a balloon when a sweep keeps one, and the menu marks the delete with "!" and carries the reason on the item and in its tip, as a snapshot that was not written does. The debug view has a Held field for it. Checked by TrackerMoveOntoDeleteTest (ASecondAcceptAllStillHoldsTheDelete, AMoveAcceptedOnItsOwnHoldsTheDeleteFromALaterAcceptAll, ASecondWireSweepStillHoldsTheDelete and TheDebugViewSaysWhyADeleteIsHeld fail without the fix, the first three by the file being deleted) and by MenuBuilderTest.ADeleteAcceptAllWouldKeepCarriesItsReason, which builds the items without opening the menu.
A logoff or a shutdown never returns from Application.Run(), so the TrayVersionFile.Delete() after it never ran and the marker went on saying a tray of this version was running. Only a tray that owned the inline queue listened for the session ending at all, and what it did then was stage its queue. Every tray now keeps the window that hears WM_ENDSESSION, and what it runs is Program.SessionEnding: the queue staged where it is held here, then the marker removed, the second whether or not the first worked. Checked by SessionEndWindowTest, with the removal handed in so the marker of a tray running on the machine is not touched: ATrayThatDoesNotOwnTheQueueRemovesItsMarkerAsTheSessionEnds and AnOwningTrayStagesItsQueueAndThenRemovesItsMarker. The line in Program.Inner that wires it is read, not run: nothing starts a tray in a test, and no session was ended to see it.
…ue would not "Discard (n)" is a menu click or a hot key, so it runs on the thread drawing everything. Only the queue's half of it had been moved to a worker: the tracked moves were still discarded before the call returned, and discarding a move ends its diff tool and waits up to half a second for it to go. The whole of it now runs on the worker, files first, so a queue that is slow to answer holds up nothing else. A queue that would not discard, or could not be reached, keeps its snapshots in the menu under the button just pressed to be rid of them, and only the log said why. It now goes to the balloon as well. Checked by TrackerClearTest. TheFilesAreNotDiscardedOnTheThreadThatAsked asks from a thread of its own and fails without the fix, as does ABulkDiscardTheQueueDidNotCarryOutIsSaid. The first needed somewhere to see the thread from, so the files half is a virtual method a test overrides. TrayViewerSyncTest still passes.
A scan that fails is logged and the next one runs, which is right for one that fails alone. But a tray whose every scan failed looked like one with nothing wrong, while its icon and menu stopped following the files on disk. Three failures running are now said in a balloon, once for the run and not for each scan in it, which would be a balloon every two seconds for as long as the cause stood. A scan that works ends the run, so a later run of failures is said afresh. Nothing modal, and every failure is still logged. Checked by TrackerScanTest: AScanThatKeepsFailingIsSaidOnce and AScanThatFailsAgainAfterWorkingIsSaidAgain fail without the fix, and ASingleFailedScanIsNotSaid pins the threshold. The tests run scans themselves beside the tracker's own timer over the same failing queue, and what they assert holds however many of the timer's land in between.
…e listing An accept takes its move out of the tracker for as long as the move takes, seconds when a file is locked, and puts the same object back when it could not be carried out. The queue owner takes its listing tag and then builds the listing, so a listing built in that gap went out without the move under a tag taken while it was there. Once the move was back the tracker held exactly the objects it had held at the tag, so every later poll was answered "unchanged" and the displaying viewer went on missing a pending file until something else changed. A delete that could not be deleted has the same gap, a shorter one. Version() tells a change by which objects are tracked, and that stays. The two places that put the same object back are counted as well, and a count that has moved is a change. Checked by TrackerVersionTest: AMoveTakenOutAndPutBackIsAChange lists from inside the failed accept, sees no move, and then asks for the version, and ADeleteTakenOutAndPutBackIsAChange does the same for a delete. Both fail without the fix, with the version unmoved.
The scan remembers a pair it found different, by the two files' sizes and write times, so it does not read both through again every two seconds. Nothing took an entry out when its move was accepted, discarded or settled, so a tray that stayed up kept one for every received file it had ever compared. Pruned in the scan, once a pass, rather than wherever a move leaves: there are a score of such places, and a scan still comparing a move as it leaves writes its entry after any of them had run. Checked by TrackerScanTest.WhatAMoveWasFoundToBeIsForgottenOnceItHasLeft, which fails without the fix.
The tests took Ctrl+Alt+Shift+F24 from the whole desktop for the length of each, so they failed wherever something else held it and took it from whatever wanted it meanwhile. What they are about is what happens to a press once a key is bound, which the desktop has no part in. KeyRegister has an internal constructor taking the two calls it makes to register and give back a key, and the public one passes user32's. The tests pass their own, so nothing is registered, and two more pin what the real calls were only assumed to do: a key the desktop refuses is not bound, and every key bound is given back on dispose. Checked by running KeyRegisterTests. Not shown failing first: that would mean holding the key from another process on the owner's desktop.
…ceived file A move's process id is a claim, and it was believed when the process holding the id ran the executable the move names. That tells which program a process is and not which window: a stale id Windows had handed to another copy of the same tool, open on another snapshot or started by hand for a merge, was tracked as this pair's and ended when the pair was accepted. Every tool DiffEngine starts is given the two paths as arguments, the received file exactly as the move carries it, so the process is now also asked for its command line and tracked only when that names the received file as the whole of a path. It is the question ProcessCleanup.StillRunning asks before a newer library sends an id, asked again here for a library that does not. Read through NtQueryInformationProcess(ProcessCommandLineInformation), which needs only the handle already held and reads nothing out of the other process's memory. A process's start time was the other candidate and settles nothing: the id is checked as the move arrives, when everything there is to find was started before it, the process that took over a closed tool's id included. Checked by TrackerProcessImageTest with processes the test starts: AnotherCopyOfTheToolIsNotTrackedAndNotEnded, ACopyOfTheToolShowingALongerPathIsNotTracked and AReRunNamingAnotherCopyOfTheToolLeavesTheMoveWithNone fail without the fix, the first with the copy tracked. The tests elsewhere that stand a process in for a diff tool now start it with the received file on its command line, as a tool is.
ImageCache remembered that a compose on the pool had failed as a flag on the picture, so that a pane asking on every step of its spinner did not start it again for good. What fails is nearly always the memory for one size, the whole of a large picture at half its own, and the flag also stopped the fitted copy being made again: after the next resize the pane had the copy from before it stretched into place for as long as the entry was on screen. What failed is now kept as the size and the way of building it. That one is still not started again, and any other is. AComposeThatFailsLeavesTheOtherSizesToBeMade was run against the flag and failed there, waiting for a fitted compose that was never started.
The footer's buttons left UseMnemonic on, as the status label once did. A label is the model's words, and an ampersand in one was not drawn: the letter after it was underlined and became an Alt chord that pressed the button. A_footer_button_does_not_read_one_as_a_mnemonic was run without the change and failed. The button row is named so the test can find it.
…o is
Between half its own size and its own size an enlarged picture is scaled on
every paint, straight from the decoded picture, since a copy of the whole of
it at that size would be up to the decoded picture again. The decoded picture
was kept as the decoder hands it over, so GDI+ multiplied every pixel it read
by its alpha on every one of those paints.
ImageCache.Load now copies the decode into Format32bppPArgb, once.
PicturePaintBenchmarks, a pair of 4000 by 3000 pictures at 400% in the window
a viewer opens at, fifteen iterations, on a machine busy with other builds:
before after
Still 20.2 ms 8.6 ms
Dragged 15.4 ms 9.3 ms
150% and 200%, which copy from a scaled copy, are 2 ms either way. No more
memory is held.
What it costs is that a translucent pixel is rounded when it is multiplied
rather than after it is filtered. Against the committed baselines the five
scenes with a picture in them differ by one level in 255, in translucent
pixels only (1,006 pixels in Images, 54,608 in ImagesEnlarged), and by nothing
in an opaque one. All of them still pass at the 0.9999 the suite compares
with, so no baseline is replaced.
DecodesPremultiplied failed against the decode as it was.
The mode the module initializer sets was only read back, through an internal of WinForms, because finding out by throwing is what put WinForms' dialog on a desktop. It can be thrown where no dialog is reachable. NativeWindow.Callback catches what a WndProc throws and either rethrows it, in the mode asked for, or hands it to the window's OnThreadException. It is Control's override of that which goes on to the dialog; a bare NativeWindow's does nothing. So the new test throws from a bare, message only window: in the right mode the exception comes out of the DoEvents that dispatched the message, and in the wrong one it would be swallowed and the test would fail having shown nothing. Under that, the throwing thread has a handler on Application.ThreadException, which is what WinForms looks for before it makes a dialog. The thread is the test's own and catches everything. Not run against a host without the mode: that is the rule, and the test does not need it to be safe, but it has only been seen to pass.
…post input TwoKeysInOnePumpAreTwoCommands, ATextEntryHasNoPictureForTheWheelToFind and TheWheelOverAPictureZoomsAndOverTheRowsScrolls failed once while somebody was using the machine. Two things about the keyboard reached them. A key message carries no modifiers and WinForms reads a wheel turn the same way: both are put beside Control.ModifierKeys, which is the thread's own table of what is held, and that follows the keyboard whenever input reaches the thread. With Alt in that table a posted Down is no command, and with Control a wheel turn over the rows zooms, which are the failures seen. The hosts now say that no modifier is held before each message is read (ThreadKeys), for the calling thread only. And the forms were the foreground window. Shown the ordinary way a form is activated, and parked off every display it still had the keyboard: what was typed at the machine while a test ran went to the test, as its commands, and not to what it was typed into. ViewerForm.Parked shows it without activating it, the tests' own forms are a ParkedForm, and a capture parks the window it shows for itself, which had the same effect for as long as it took. Checked without sending anything to the desktop. With Alt or Control set in the thread's table and the hosts not clearing it, the first two tests failed as reported; with the hosts clearing it they pass, which APostedKeyIsNoChordWhateverTheThreadTakesToBeHeld and WithControlHeldTheWheelZoomsOverTheRows now hold. A ViewerForm shown as the tests showed it was GetForegroundWindow; AFormATestPostsToDoesNotTakeTheKeyboard holds that it is not.
…ts own The WinForms footer was one row of buttons and a label in whatever they left, two lines high. A window narrower than its buttons had the rest of them past its edge, where they could not be reached, and a status beside a document's ten buttons had the width of a few words: what did not fit in two lines of that was lost behind an ellipsis. It is now laid out as the other two heads lay theirs out. The buttons wrap onto another row where one does not hold them. The status goes beside the last row when it fits there on one line, and under the buttons when it does not, on as many lines as it needs up to three. The label is as tall as those lines at the font it has and they start at its top, so on a scaled display it cannot show the middle of a status as a label of a fixed two lines, centred, could. A taller footer hides no rows: it is docked under the canvas, which reports what it has left, and the model slices for that. So nothing was needed of the core. A status under the buttons goes back beside them only with room to spare, since what it says can turn on how many rows the body has and the body has a row more with it beside them. The buttons are the footer's own children now: a panel around them is as wide as the footer once they wrap, and lay over the status in a capture. Three baselines. StatusThatDoesNotFit and DocumentPageEnlarged each had a status that did not fit beside a document's buttons, wrapped in two lines there and the first cut short; each now has it whole on a line under them, with the footer a line taller and the pages that much smaller. FooterThatWraps is new: the same in a window 560 wide, two rows of buttons and a status of two lines. Every other scene is the pixels it was. ButtonsThatDoNotFitOneRowWrap, ATallerFooterIsRowsTheBodyIsNotAskedFor, AStatusWithNoRoomBesideTheButtonsHasLinesOfItsOwn, TheStatusIsAsTallAsItsLinesAtALargerFont and AStatusUnderTheButtonsIsSlowToGoBackBesideThem all failed against the footer as it was. No display was scaled: a test cannot, so the font is made larger instead.
The launch gate asks MaxInstance for a slot before it starts a viewer, since that is the only moment the cap can stop one. A launch that then failed - no viewer resolved, or the one started exited with a failure before anything held the queue - kept the slot, though no window came of it. Five of those in one process, which is five failing snapshots against a copy too old for its arguments, and every pair after them was answered TooManyRunningDiffTools with nothing running. The gate now returns the slot on the two paths that report Failed from a launch. A launch cancelled while its viewer is binding keeps it, since that viewer is running and will open its window. A test that supplies its own canLaunch gets a giveBack that does nothing unless it supplies that too. AFailedLaunchGivesItsSlotBack and its async twin fail without the two calls (Capped where Failed and Launched were expected) and pass with them. ALaunchThatOpenedAViewerKeepsItsSlot holds the other side.
ListenerTable.IsHeld is asked in front of every connect that might wait to be refused, and what it reads is every TCP row the machine has, filtered down to the listeners. This measures it for a port nobody listens on, with nothing added and with 1,500 loopback connections of this process's own, which are 3,000 rows. Committed ahead of the change to how the table is read, so the earlier number can be had again from history. On the machine it was written on: 1.3 ms and 99 KB with nothing added, 9.3 ms and 694 KB with the 1,500.
Whether anything listens on a port was read out of what .NET lists, which on Windows is every TCP row the machine has, marshalled into objects and then filtered to the listening ones. So it grew with the machine's connections, and PiperClient reads it on every move and delete. ListenerTable now calls GetExtendedTcpTable for the listener class, once per address family, into a byte buffer it reads the ports out of. No rows for connections cross into the process. Wherever that has no answer - off Windows, the call missing or failing, a table not laid out as expected - it reads what it read before, and a table that cannot be read at all still leaves the connect to decide. ListenerTableBenchmarks, a port nobody listens on, same machine and run: connections added listeners alone out of every row 0 0.13 ms, 8 KB 0.34 ms, 18 KB 1,500 (3,000 rows) 0.32 ms, 8 KB 13.1 ms, 952 KB It still grows a little, because Windows passes over the connections to find the listeners; what went is the cost of handing them over. ListenerTableTests holds the hand read rows to the truth on Windows, on net10.0 and net48: an IPv4 and an IPv6 listener are found on their ports, a port bound and not listening is answered no by both tables, and two hundred listeners, more than the first buffer holds, are all found.
A port found with nobody on it is not connected to again by the telling sends for ten minutes, which was set when finding out meant two seconds waiting to be refused. On Windows it is now a read of the listener table, a tenth of a millisecond, and the ten minutes were only how long a tray or a viewer started after the test process heard nothing of its settles and moves. The memory now records how a port was found empty. What the table said stands for a second (RecheckUnlistedAfter). What a connect found stands for the ten minutes it did, since asking again is the connect again: off Windows, where the table could not be read, and for a port held by something that is not a viewer. A second rather than nothing, because settles come as fast as tests pass and each would otherwise read the table: ten thousand of them are over a second of reads, and at one read a second it is a ten thousandth of the run. It is also the second an owner that answered is trusted for. AnOwnerStartedLaterIsFoundByTheTellingSends runs with the shipped values and waits for a telling send to reach an owner bound after the first one found the port empty. With the ten minutes back it failed at its sixty second limit. APortAConnectFoundEmptyIsNotAskedAboutAgainSoSoon holds the other half. The rest of ViewerClientUnownedTests are about there being a memory, so they give the table's answer the connect's ten minutes rather than race a second.
APortNobodyHoldsIsNotWaitedOn and its async twin sent to a free port and asserted the send returned inside a second, where the refusal it avoids takes two. That is a fixed short wait around a real clock, which a two core runner under load does not promise, and it could only tell the two apart by time. They now put an owner on the port and make the table say nobody listens there. A send that connected is answered and heard; one that took the table's word returns no owner, the owner hears nothing and accepts no connection. Nothing is timed. APortNobodyHoldsIsNotProbed does the same for IsOwned, which the launch gate polls. That the real table says no of a real port with no listener is ListenerTableTests.ABoundPortThatIsNotListeningIsNotHeld. Run with the table check in NothingListening turned off, all three fail: the send is accepted and the probe finds the owner.
Any connect that failed was recorded as a port with nobody on it. A machine with no ports left to connect from fails every connect as well, with the owner listening throughout, and from then on every settle and move was skipped for ten minutes: on exactly the machine the kept connection was added for. Which error Windows gives when it runs out was never established, and this does not need it. ViewerClient.NobodyThere names the two failures that do say nobody is there, a refusal and a connect that was never answered, and anything else leaves the memory as it was. The send still reports no owner; it is only not remembered, so the next one asks again. A connect given up on at this library's own deadline is still recorded, as before. OnlyARefusalSaysNobodyIsThere holds the rule. For a real connect that fails without being refused, AConnectThatCouldNotBeMadeSaysNothingAboutThePort connects to port zero, which Windows fails at once as an address that is not valid: with the rule taken out it fails, FoundUnowned being true, on net10.0 and net48. No machine was run out of ports to check the case itself.
Read and never run, this turned out to be true. .NET Framework makes its sockets inheritable and a process started without ShellExecute is handed every inheritable handle, so a child a test started held the test process's kept connection to the queue's owner. Run outside the tests, on private ports, with a .NET Framework client that connected, started a child that lived eight seconds and went away without closing anything: client exited owner saw the connection close 5.8 s later client killed 8.1 s later, when the child exited .NET 10 client killed at once, child still running Framework, flag cleared at once, child still running What that costs the owner is a connection and a pending read it keeps for a process that has gone, for as long as the child lives. The kept connection's socket now has its inherit flag cleared, under NETFRAMEWORK only, on the socket the client's constructor has already made and before it connects. Only the kept one: an exchange on a connection of its own ends by shutting down its sending half, which reaches the owner whoever else holds the handle. TheKeptConnectionIsNotOneAChildProcessIsGiven reads the flag off the kept socket. Without the call it fails on net48, the flag being set, and passes on net10.0 either way. It asks about the handle rather than starting a child, because closing the connection in the test shuts it down, which the owner sees whoever holds it; the case needs the host to go without closing, and a test cannot do that to its own process.
Every asking send was a connection each. The ones there are many of are the listings: a window showing an owner's queue lists five times a second for as long as it is open, and a tray driving a viewer lists on a timer, each leaving a port in TIME_WAIT. List and ListFull now go down a kept connection, as the telling sends do, where the owner's reply says it keeps one. Nothing changes on the wire: an owner already answers any verb on a kept connection, an older owner never says it keeps one, and an older client never asks. It is a second connection, not the settles'. An owner answers one connection's requests in turn, so a listing it is slow over would otherwise stand in front of every settle behind it. And a listing does not wait for it: the callers list on their own clocks with waits of their own, from half a second to fifteen, so one that finds another in flight is a connection each, as before. The wait a caller gives is set on the connection for its send. Only the listings. A request written to a kept connection whose owner has just gone is sent again as an ordinary exchange, since nothing says whether the first was acted on. That is harmless for a listing and not for an accept or a discard, so what changes the queue is still a connection each. So is the async send, which would have to block a pool thread on a connection it shares, or be given an async twin of the whole kept exchange. KeptConnectionTests: ListingsShareAConnectionOfTheirOwn (four connections for twenty listings and twenty settles; twenty two without the change), AListingArrivesWhole, ASlowListingHoldsUpNeitherASettleNorAnotherListing (nothing timed: the owner holds the listing until the others are back), ACommandIsStillAConnectionEach, AnOwnerThatPredatesItIsListedAConnectionEach and AListingFindsItsOwnerGoneAndTheNextOne. The viewer's and the tray's tests, which list through this file, pass as they were, on two cores too.
A screen is built whenever the state is another one, which a scroll or a drag makes it on every frame, and the queue column described every row of the queue - label, tooltip, a header's members - to draw the forty that fit: 0.75 ms and 1.1 MB a screen at 2,000 entries. QueueProjection now walks the queue into slots that say where each row is and nothing about it, slices those, and describes the slice. The same walk answers VisibleEntries, which had its own undescribed rows. A label is grown by collisions anywhere in the queue, so labels are still decided over all of it, but once a queue: they are kept, weakly, against the queue's list, which is replaced and never changed. FrameBenchmarks, 2,000 entries: 745 us and 1,077 KB before, 33 us and 49 KB after for a state whose queue is the list it was, and 222 us and 358 KB for the first screen of a changed queue, which the benchmark now measures too. QueueSliceTests holds the slice to the rows of the whole list for every selection and fold of forty random queues, a label to a collision that is off screen, and the allocation of a screen after a scroll, which failed at 967,760 bytes against the old projection.
ScreenPayload cut a row at the window's width counted in characters, so a row of two cell characters was encoded four times as far as either pane could show it, a segment a character: 3.6 ms and 3 MB a changed frame for a 4K window of 300 character CJK lines. A row is now cut in cells, at Screen.PaneCells: half the window rounded up and one more. That is a bound rather than a pane's width, which is each head's to decide, but both native heads split the panes equally, so neither pane is wider, and the gutter in front of the text is room to spare. What is left out was never drawn, so the ABI and the captures are as they were. The cut is found by the walk that segments the row (CellGrid.Segments(text, cells, out end)) and that much of the row's own string is encoded, so a row costs no string of its own. A row much longer than a pane goes through RowText.Shown first, which reads it from the front: flattening all of a megabyte line was a copy of it a frame. ScreenPayloadBenchmarks, EncodeAChangedScreen: cjk 3,646 us and 3,069 KB before, 1,661 us and 808 KB after; box 266 us to 179 us; ascii unchanged at about 45 us and 15 KB. What is left for CJK is CellGrid classifying each cluster, about 60 ns each. ScreenPayloadTests.ARowIsEncodedAsFarAsAPaneCanShowIt failed against the old cut (200 characters encoded where 51 fit), and CellGridTests holds the new overload to Segments of the row cut at Index, for every cut of 300 random rows.
TextDiffBenchmarks had the same lines reversed, where nothing is still in order and there is nothing for a diff to keep however it looks. Runs of fifty lines, one in four moved somewhere else, is the shape that sorting a list by another key leaves, and most of its order survives. Committed ahead of the change to what a search that settles does with it, so the earlier number can be had again: 48 ms at 10,000 lines and 311 ms at 40,000.
…tles Past its budget a diff split wherever its search had got to, or where the start of one side was in the other. That finds one block moved whole. The same lines in another order are edits from end to end, so the search gets nowhere in them, and they came out as nearly all changed. A search that settles now asks first for the lines each side has once (LineAnchors), pairs them, and takes the longest run of the pairs still in order, by patience sorting: two sorts of the part and one of the pairs. The sequences are split at every line of the run at once, and each part between two of them is diffed with whatever budget is left. A run is used when it is at least sixteen long and longer than what the settled split had going for it, the lines its search passed or the run a displaced start led to. Only a search that has already given up on the smallest diff asks, so everything that was minimal is still minimal and costs what it did. Where every line is on both sides once, the run is the most that can be unchanged, so those diffs are minimal again at any length. A part is looked at for anchors once, then nothing inside one that had no run, and inside one that was split only parts of half its size or less, so the looking is a few sorts of the whole however often searches settle. Timed in one process, old against new, lowest of seven (the machine was busy, and the two were within noise of each other in time): 40,000 lines with one run of fifty in four moved kept 16,112 lines before and 28,800 after, 400,000 kept 164,732 and 299,400, in 364 ms and 195 ms; 400,000 lines shuffled kept 127 and 1,240, which is all that are still in order. TextDiffTests: LinesInAnotherOrderKeepTheLongestRunStillInOrder failed before at 29,872 of 31,200 and 133 of 328; the anchors are checked against the quadratic way of finding them, a diff that settles onto them is checked for correctness on 20,000 small random pairs, and the same elements in another order are minimal with nothing to spend.
TrackedWatch hands ViewerSession.Refresh the entries whose files changed. A file written again with the same content comes back as the queued entry with new stamps, and Refresh put it in through Remove, which closes the context menu as it does for an entry that changed. A test that keeps failing the same way writes its received file on every run, so a menu the reader had open closed once a run. Refresh now tells a pass that only restamped entries from one that changed or dropped any: the restamped copy has the very rows of the entry it replaces. Such a pass replaces the queue with the stamps taken and nothing else, as the same pair arriving again over the socket already does (Restaged). A pass with any entry gone or rebuilt goes the way it did. TrackedWatchTests.AFileWrittenAgainWithWhatItHeldLeavesAnOpenMenuOpen failed before, for the entry on screen and for another, and AFileWrittenAgainWithSomethingElseStillClosesAnOpenMenu holds the other half.
An arrival, a re-run with other content, a settle and a single accept each rebuild the whole display list from the whole queue under the session's lock. QueueChangeBenchmarks measures the four, ahead of the change to how the list is rebuilt: about 2.2 ms and 1.8 MB each at 2,000 entries, 0.13 ms and 180 KB at 200.
An arrival, a settle, a discard and a single accept each rebuilt the display list from the whole queue under the session's lock: a dictionary of the entries, every entry's patches compared with the queue's, and an ordering of the result. InlineQueue hands back the items it did not touch as the items it was given, so what a change did can be read off the queue before and after it. ViewerSession.TryRebuildChanged does that and edits the list where it stands: an entry replaced in place when its solution and test are what they were, taken out, or put after the last entry of its test or the last snapshot of its solution. Anything else falls back to the whole rebuild, which is still there as RebuildWhole: a first snapshot in a solution, a list not in the order a rebuild leaves (snapshots ahead of files), and a removal that leaves a solution with only files, which a rebuild moves. QueueChangeBenchmarks at 2,000 entries, before and after: an arrival 2,187 us and 1,793 KB to 241 us and 942 KB, a re-run 2,190 us to 94 us and 325 KB, a settle 2,101 us to 95 us, an accept 2,235 us to 160 us. What is left is InlineQueue's own: the session still makes a PendingInline an entry to ask it, and it copies its list and makes a key an item to search. QueueRebuildTests drives 300 random queues through 60 random changes each and holds every result to RebuildWhole of the same queue; it failed with the check after a removal taken out. ARerunInALongQueueIsAboutOneEntry failed at 1.3 MB with the short way switched off.
Discard-all and a header's discard were one transition that deleted the received file of every pending pair: under the lock every arrival waits on, and for a window's own discard on the thread that draws. They are now the batch an accept-all is (AcceptBatch.Discarding). The snapshots and the pending deletes go in the transition that begins it, since neither touches a file, and the moves are left in the queue to be claimed one at a time, each file thrown away outside the lock and recorded under it. A window's discard is only begun by its frame and carried out by the runner's worker; the socket's is driven on the listener thread, as its accept-all is, waiting out a batch already running rather than doing nothing behind it. ViewerSession.Apply still carries one out whole, which is what the tests and file mode drive. While one runs the status line says "Discarding n of m" and the window refuses what changes the queue. It is not put on a listing: the wire's progress is read by whoever displays the queue as an accept under way. What a discard says when done is word for word what it said. DiscardBatchTests: AFileIsThrownAwayOutsideTheLock has another thread take the lock from inside each delete, and failed after its full wait with the socket's discard put back as one mutation. The rest hold the steps, the kept files, a group's batch and what a window's frame leaves.
A hole is code, and the scanner counted every brace in one and took every
quote for a string. So $"{'{'}" never closed, $"{'"'}" closed on a string
that ran to the end of the file, and a triple quoted literal ended at the
first three quotes whatever they were in, which a verbatim string in a hole
that ends in a doubled quote has. Each left the calls under it inside a
literal, where no patch finds them.
A hole is now lexed as code: char literals, comments, strings and backticked
names are stepped over whole. A triple quoted literal's holes are found by
the braces its dollars ask for, and a backslash in front of a brace is a
backslash and then a hole.
The other half of the item, a tick after an identifier character, is how
F# reads it too: nothing fsi compiles was read differently.
Checked by FsCompilerRoundTripTests.TicksAndHolesAreReadAsTheCompilerReadsThem,
which patches a call under each of 59 lines fsi compiles and has fsi run the
result. Five of the first set lost the call before the change.
…nder it
A Remove of settings.Snapshot("dup"); takes the statement's lines, which
brought the line under it up onto the line the patch names. Where that was
other.Snapshot("dup");, or a verify call with the same literal chained onto
it, the next apply of the same Remove - a second framework's, or the next
case of a test that ignores its parameters - had the same line and the same
anchor and took the sibling.
The statement now leaves one empty line where a Snapshot call would
otherwise come up onto its first line, as a chained call taken with its line
already did. RemovedAtHint reads an empty line over a line holding a Snapshot
call as the call removed, since a statement on a variable has no verify call
above it to be recognised by.
Only where the Snapshot call is on the statement's first line. No existing
expectation moved: a statement with anything else under it still goes whole.
Checked by RemoveOfAStatementAppliedTwiceLeavesTheSiblingUnderIt (both
siblings), RemoveOfAStatementOverSeveralLinesAppliedTwiceLeavesTheSiblingUnderIt
and RemoveOfAStatementWithNoSnapshotCallUnderItKeepsNoLine in
InlinePatcherTests, the first two failing before the change, and by a
removedStatement shape in FsCompilerRoundTripTests that fsi compiles and runs.
An Append carries no anchor. With its hint gone stale it took the first call
in the member with no Snapshot call chained onto it, and where an earlier
call there is verified through files, or through settings.Snapshot, that
call was given the snapshot of the one after it.
Two things now. A call passed a name that Snapshot is called on in the same
member (settings.Snapshot("A"); then Verify(a, settings)) has its snapshot
and is passed over. And where more than one call is still left, nothing in
the source says which the patch was for, so it is refused with a reason that
says to re-run, which brings the line the call is on now. A hint that lands
on a call still names it, a member with one such call still takes it, and a
patch with no member still goes to the nearest call, all as before.
No existing expectation moved. Checked by
AppendWithAStaleHintPassesOverACallVerifiedThroughItsSettings,
AppendWithAStaleHintIsRefusedWhenTwoCallsCouldTakeIt,
AppendTakesTheCallOnTheRecordedLineAmongSeveral and
AppendWithAStaleHintTakesTheOnlyCallWithNoSnapshot in InlinePatcherTests,
the first two failing before the change.
A batch applies each patch to what the one before it left, so the file was lexed whole for every patch, and its line starts, line ending and indent step were worked out whole again too, the last with a string a line. Five hundred patches to a 600 KB file were 0.77 s of patching around one write, and a gigabyte allocated. The scan is now carried from each patch that edits to the next. SourceScan.Edited keeps the spans before the edit, lexes again from the start of the line the edit begins on, and stops at the first line start past the edit that both scans have as code, taking the rest of the old scan's spans moved by the change in length. A line that follows a backslash is not started from, since a backslash is the one thing either lexer reads across a line break from in front of it. The line starts are carried the same way, and the line ending and the indent step are counted from them without allocating. What each patch is told is unchanged, because the scan it is given answers as a scan of the whole text does. That is what the tests hold it to: - SourceScanTests.AScanMadeFromAnotherIsTheScanOfTheWholeText, both languages: 400 runs of 25 random edits made of everything that opens, closes or escapes a comment or a literal, every answer at every offset compared with a scan of the whole text after each. Taking the backslash rule out fails it. - AScanMadeFromAnotherOfTheSuitesOwnSourceIsTheScanOfTheWholeText, the same over two files of thousands of lines. - InlineApplierBatchTests.ACarriedScanTellsEachPatchWhatLexingAgainWould, both languages: 60 shuffled batches of Set, Append and Remove patches, outcomes, reasons and final source compared with lexing again for each. - The existing batch tests, which compare ApplyAll with Apply in turn. InlineAcceptBenchmarks, 500 call sites, before and after, on a machine busy with other builds: PatchInMemory 766.5 ms, 1,048 MB -> 285.8 ms, 711 MB AcceptTogether 727.6 ms -> 354.3 ms PatchInMemory now goes through InlineApplier.PatchInTurn, which is the loop the batch runs. What is left is a copy of the source for each patch that edits, 1.2 MB each in that file.
A test run asks CanAnchor once for each call site it has not seen before, and each asking took the file's lock and mutex and read, decoded and lexed the whole file: 0.7 s for the five hundred call sites of a 600 KB file, to be told five hundred things about the same text. A dry run writes nothing, so what one read stands for as long as the file does. The applier now keeps the scan a dry run made, for up to four files, under the file's length and write time, and CanAnchor and CanApply are answered from it while those are unchanged, with no lock taken. A write time cannot tell a second write inside the same tick of the file system's clock, so a file is kept only when it had been left alone for three seconds when it was read. One being edited, or one an accept has just written, is read every time, as before. Checked by ASecondProbeOfAFileDoesNotReadItAgain, which holds the file open with nothing shared and fails without the change, AProbeReadsAFileThatHasChangedSinceTheLast and AProbeOfAFileJustWrittenIsNotKept in InlineApplierTests. InlineAcceptBenchmarks.AnchorEach, on a machine busy with other builds: 25 call sites 5.3 ms, 3.5 MB -> 1.3 ms, 0.08 MB 500 call sites 833.5 ms, 1,303 MB -> 50.8 ms, 1.6 MB The benchmark's file is now dated a minute back, as a source file is by the time a run asks about it.
…ch's write fails A batch's one write that failed failed every patch from the first edit on, whatever each had been judged to be, because what they were judged against was never written. That is right for a patch that edited, and for one that was already applied only because an earlier patch in the batch had written its literal. It is not right for one whose snapshot was in the source all along, which stayed queued as a failure, or for one whose call site was never there, which was not told to re-run. A patch from the first edit on that made no edit is now asked again of the file as it was read, which is the file as it still is. Already applied or not found there is what it is told. Where it would have had to edit that file, it reports the write as the others do. A patch judged before the first edit keeps its answer, as it did. The file's mutex is still held from the read to the write: that is what one read and one write are, and the patching between them is shorter than it was. Checked by AWriteThatFailsLeavesAPatchThatDidNotNeedItItsOwnAnswer in InlineApplierBatchTests, which was five failures before the change, and by AWriteThatFailsFailsEveryPatchItCarried, unchanged.
…tion A key held past the repeat delay is reported again at the keyboard's repeat rate, and the Linux head acted on every one of them for a letter, as it does for an arrow. A held a accepted the entry on screen and then every one that took its place, into source, none of them read. The WinForms head gives what changes the queue a press each for that reason. Which commands go on while their key is held is now asked in one place, Repeats: scrolling and paging, next and previous change, the next variant, the pages of a document and zooming in and out. Everything else acts once: accept, accept all, discard, quit, the two toggles, the next projection and zoom reset, with Home, End, Tab and Escape, which already did. A chord with Control is still never repeated. Checked in the ubuntu:24.04 container under Xvfb, with xdotool holding a key for a second and a half over a queue of three pairs, against a build of the shim that says which keys it hands over. Before: Down, n, m and ] 23 each, and a three times, which was the whole queue accepted. After: Down, n and ] 23 each, m once, and a once with two pairs still pending. No test in the repository can press a key in the shim: input is behind the C ABI.
…'s does deview_capture makes a context of its own for its one frame, and that context did not say the renderer applies a draw command's vertex offset, which the window's context says and RenderTriangles does. Without it ImGui lets one draw list grow past what a sixteen bit index can address, the indices wrap, and the capture comes out scrambled. Checked in the ubuntu:24.04 container with a capture made for it: two panes of 220 character lines at 3840 by 2160, 53,124 characters in the rows on screen. Before, the rows stop at line 46 and the ones above have their colours on the wrong lines; after, all 114 rows are there. No scene in PixelTests is that large, and all nineteen Linux baselines are the same files byte for byte with the flag declared.
…o it Nothing in DiffEngineViewer.Tests failed if deview_present went back to drawing every frame: a capture draws one frame into a texture and cannot tell, and NativeIdleBenchmarks, which shows it in its Drawn column, is run by hand. PixelTests.AWindowLeftAloneIsNotDrawn shows the shared window, presents one screen a second at a time, and asks OpenGL after each present whether anything was drawn, by the primitives generated, as NativeHead in the benchmarks does. It passes once a second goes by with nothing drawn, and fails after thirty seconds without one. It also asserts that the first second after the window is shown did draw, or the count would be of nothing. It waits for a quiet second rather than asserting which second is quiet, so a slow runner or the window system asking for the window again does not fail it. In the ubuntu:24.04 container, set up as the unix job is, it passed 25 times of 25 pinned to two processors with DOTNET_PROCESSOR_COUNT=2, in about two and a half seconds each. Against a build of the shim altered to build and draw every frame it fails, after its thirty seconds.
deview_present left an unchanged window alone, but a hidden one whose screen changed was still built and drawn, and a viewer hidden behind a tray is handed another screen by every arrival in its queue: the whole queue laid out and the whole window filled, for a window nobody can see. A hidden window now ends its turn after taking in what arrived: decoded pictures and found fonts are still taken, the screen is still kept as the last one handed over, and the fonts for the characters it holds are still asked for, so they are there by the time it is shown. Showing it marks it stale and is itself an arrival, so the first present after it builds and draws the screen it is handed. A window that has never had a frame built is built once wherever it is, since the grid the managed side slices by is measured from a frame and ImGui has no font to measure with before its first. PixelTests.AHiddenWindowIsNotDrawn presents two screens in turn to the hidden window sixty times and asks OpenGL how many presents drew: sixty before, none now, and one for the first present after it is shown. Checked further in the ubuntu:24.04 container by photographing the window off the X server: handed a queue and then pictures while hidden, it shows each when shown or focused, the same pixels as when they were presented to it on screen, and its other sixteen photographs are those of the shim before. OutsideTheFont still has the machine's fonts merged while it presents to the hidden window, two of them in the container, which is what it is about. All nineteen Linux baselines are the same files byte for byte.
The Linux head reads a key by the character it types, and falls back to where the key is for the letters on a layout that types none of them. The five keys that are not letters had no such fallback. Russian and Arabic type letters of their own from the keys a US keyboard has [ and ] on, so the pages of a document had no key there, and Persian types its own digits, so zoom reset had none. Those five are now read as what a US keyboard has in their place when the key typed something outside ASCII and the layout has no key that types any of a to z unshifted. Only then: a German keyboard types u with a diaeresis where [ is and a French one a with a grave where 0 is, and both have the character itself elsewhere, so neither is given a command for a letter of its own language. Checked in the ubuntu:24.04 container under Xvfb with setxkbmap and xdotool, against a build of the shim that says which keys it hands over. Russian: the keys for ha and the hard sign gave nothing and now give previous and next page, and - = 0 still zoom as they did. Persian: its zero gave nothing and now resets the zoom, and jeem and tcheh turn the page. German u with a diaeresis and French a with a grave still give nothing, and German + still zooms in. No test in the repository can press a key in the shim.
… copies, and make those only for a picture drawn small A frame of two pictures cost about a tenth more under llvmpipe since pictures were given reduced copies, and the guess was the trilinear sampling of a picture drawn under half its size. It was not: the 4K benchmark fits its pictures at 0.99 of their size, where nothing is sampled trilinearly. The cause is raylib's SetTextureFilter, which for any texture that has reduced copies makes "bilinear" GL_LINEAR_MIPMAP_NEAREST: which copy to read is worked out for every pixel drawn, and at that size the answer is always the picture itself. A build that makes no reduced copies at all showed it, 27.1 ms against 30.4. The filters are now set directly. From three quarters of its size up a picture is sampled with GL_LINEAR, which never asks. Under half it is trilinear, as before. Between the two it is still GL_LINEAR_MIPMAP_NEAREST, kept as its own case, because under about seven tenths that draws from the half size copy, in which a grid of one pixel lines is even: photographed both ways at two thirds of its size, GL_LINEAR alone leaves some of the lines dark and some faint. Three quarters rather than seven tenths so the change of filter is clear of the size where GL's own answer changes, and every picture is the pixels it was. A texture is now made with reduced copies only for a picture drawn at under three quarters of its size, which is known when it is asked for. One that comes to be drawn that small later, in a window made narrower, is decoded again with them on the decoder's thread while the texture it has goes on being drawn, and swapped when it lands, so no frame waits for them. A capture makes them there and then. In the ubuntu:24.04 container, llvmpipe on four threads, NativeFrameBenchmarks with 30 iterations, before and after run in turn twice: OpaquePictures at 3840x2160 29.7 and 30.4 ms before, 27.1 and 27.4 after (CPU 101 ms to 91); TranslucentPictures 37.0 to 33.6 and 34.2; both unchanged at 1100x700, where the pictures are under a third of their size. Two opaque pictures 2400 square in a 4K window add 49 MB to the process where they added 94; two 3600 square, drawn under three quarters, add 154 as before. Pixels: all nineteen Linux baselines are the same files byte for byte, as are the 179 captures of the earlier round's picture scenes, at every zoom step. A window photographed off the X server at 1900, 1500 and 1100 pixels wide, made narrower in two steps and opened afresh at each, shows the same pixels as the shim before at all five, which a build with no reduced copies does not.
… fits A picture longer on a side than GL_MAX_TEXTURE_SIZE was drawn as nothing in the Linux head, under rows that said what it was. The limit is 16384 under llvmpipe, and a screenshot of the whole of a long page is past it. Such a picture is now resampled as it is read to the largest size of its own shape a texture takes (FitToATexture), each pixel of what is left an average of the ones it stands for, and drawn from that, placed by the size the model carries as any picture is. It happens in ReadPicture, so on the decoder's thread for the window and there and then for a capture, and nowhere else. What it costs, measured in the ubuntu:24.04 container on one thread: the copy is made beside the picture as read, so both are held until it is done. A picture 20,000 square with no alpha channel is 1.2 GB read and 0.8 GB more for the copy, 2.1 s to read and 1.2 s to bring down. One 17,000 by 9,000 with an alpha channel took 0.4 s and 0.9 s, and a screenshot 1,920 by 30,000 took 0.3 s and 0.2 s. Reading is as it was: a picture 16,000 square, which fits, already took the process to 3.7 GB. PixelTests.ImageTooLargeForATexture keeps its picture, 16385 by 512, and its baseline is now that picture drawn, a grey bar across the left pane, where it was an empty pane: looked at before it was approved, and the test fails against the shim before this. The other eighteen Linux baselines are the same files byte for byte. The window was photographed off the X server showing a picture 17,000 by 300 beside one that fits: drawn, where it was not, and its other sixteen still scenes are the pixels they were.
todo.md loses the items fixed and gains what each fix left, and for each item that was looked at and left, what stood in the way. claude.md describes the changed behaviour: the scan a batch carries, the Append that is refused, the batch a bulk discard now is, the list edited where it stands, the diff's anchors, the listener table and the second kept connection, the tray's held deletes and command line check, the Windows footer, and the Linux head's hidden window and sampling. docs/tray.md says what a held delete looks like.
Co-authored-by: SimonCropp <122666+SimonCropp@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The next batch of what
todo.mdhad open: 61 items taken up across the seven areas, one commit each. 44 are fixed, one of them in part, 5 turned out not to be problems or needed no change, and 12 were looked at and left, each with what stood in the way now written intotodo.md.Needs attention before merging
macos-14job and not run by a person.Appendwith a stale hint is now refused when more than one call in the member could take it, with "Re-run the test", where it took the first and was wrong about half the time, silently. Accept-all over a file with several such members takes a run each. One commit ("Refuse an Append whose line names no call when two calls could take it"), so it can be dropped alone.!with the reason, and goes when accepted on its own or when a run raises it again.native/changed, both the C++ and the Swift, sobuild-nativewill open its binaries PR against this branch. No ABI change.Library (7 of 9)
MaxInstanceslot back.PortIsHeldtaking any failure as "may be held" is right as it is.Inline patcher (6 of 9)
dotnet fsiover 59 lines.Tray (8 of 9)
Tracker.differingis pruned.KeyRegisterTestsno longer registers a real global hot key. The command line check, above.Viewer model (6 of 9)
Windows head (6 of 8)
Linux head (7 of 10), built, run and photographed in an
ubuntu:24.04container[ ] 0 - =fall back to position on a layout with no Latin letters. A capture declares the vertex offset. Two tests fail if an idle or hidden window is drawn.All nineteen Linux baselines reproduce byte for byte, eighteen unchanged.
macOS head (4 of 7), not compiled or run
Tests
dotnet build src --configuration Releaseis clean anddotnet test --solution src/DiffEngine.slnx --configuration Releasepasses on Windows: 3,297 tests, 0 failed, 34 skipped. The library's and the viewer's suites also pass with the process held to two cores, which is what found last round's three CI failures.Also in here
todo.mdis regrouped by area and loses what is done; it gains what each fix left and why each item left alone was.claude.mdanddocs/tray.mddescribe the changed behaviour.