Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
922ca1c
Regroup todo.md by area, and leave out what is done
SimonCropp Oct 3, 2026
7403586
Take one press for each accept or discard in the macOS viewer, howeve…
SimonCropp Oct 3, 2026
4ca1882
Match the macOS viewer's zoom keys on what was typed, as the brackets…
SimonCropp Oct 3, 2026
682d0c1
Stop a long title a character short of the subtitle in the macOS viewer
SimonCropp Oct 3, 2026
fdb69a2
Keep a scaled copy for each pane when both panes of the macOS viewer …
SimonCropp Oct 3, 2026
e5a2e7f
Keep holding a delete whose file an accepted move wrote, and say why
SimonCropp Oct 3, 2026
c2795b5
Remove the tray's version marker when the session ends
SimonCropp Oct 3, 2026
a3ab518
Discard tracked files off the thread that asked, and say when the que…
SimonCropp Oct 3, 2026
d083c18
Say once when the tray's scan keeps failing
SimonCropp Oct 3, 2026
44053c0
Count a tracked file put back after a failed accept as a change to th…
SimonCropp Oct 3, 2026
dd348bc
Forget what a pair was found to be once its move has left
SimonCropp Oct 3, 2026
1132937
Stop KeyRegisterTests registering a real global hot key
SimonCropp Oct 3, 2026
d341b54
Track a process as a move's tool only when it was started with the re…
SimonCropp Oct 3, 2026
0b2833b
Hold a failed compose against its size rather than against the picture
SimonCropp Oct 3, 2026
954b324
Draw an ampersand in a footer button's label as an ampersand
SimonCropp Oct 3, 2026
6df4f24
Keep a decoded picture premultiplied, as the buffer it is painted int…
SimonCropp Oct 3, 2026
f8993aa
Prove by throwing that an exception comes out of a pump in the test host
SimonCropp Oct 3, 2026
8e29229
Keep the keyboard of whoever is at the machine out of the tests that …
SimonCropp Oct 3, 2026
0f56680
Wrap the footer's buttons, and give a status with no room a line of i…
SimonCropp Oct 3, 2026
48f74fd
Give back the MaxInstance slot of a viewer launch that failed
SimonCropp Oct 3, 2026
48ba7fc
Benchmark reading the listener table beside open connections
SimonCropp Oct 3, 2026
fac9172
Ask Windows for its listeners alone
SimonCropp Oct 3, 2026
6b36fa6
Ask again after a second about a port the listener table found empty
SimonCropp Oct 3, 2026
a4d44dd
Show a port nobody holds is not connected to without timing the send
SimonCropp Oct 3, 2026
7ae5365
Remember a port as unowned only when the connect was refused
SimonCropp Oct 3, 2026
f82ccc9
Keep the kept connection out of child processes on .NET Framework
SimonCropp Oct 3, 2026
1d1245a
Send listings down a kept connection of their own
SimonCropp Oct 3, 2026
fc74790
Describe only the queue rows that fit
SimonCropp Oct 3, 2026
4cb92e2
Encode a row for the native heads as far as a pane can show it
SimonCropp Oct 3, 2026
879eb46
Measure a diff of the same lines with runs of them moved
SimonCropp Oct 3, 2026
8ee249c
Keep the longest run of lines still in order when a diff's search set…
SimonCropp Oct 3, 2026
89dbb0e
Leave an open menu open when a file is written again with what it held
SimonCropp Oct 3, 2026
6d6bc65
Measure what one change to a long queue costs the session
SimonCropp Oct 3, 2026
6f9194b
Change one entry of the display list when the queue changed one
SimonCropp Oct 3, 2026
1584135
Throw a bulk discard's received files away outside the session's lock
SimonCropp Oct 3, 2026
df7fc4f
Read the holes of an F# interpolated string as the compiler does
SimonCropp Oct 3, 2026
edb5567
Keep the line of a statement a Remove takes when a Snapshot call is u…
SimonCropp Oct 3, 2026
044017e
Refuse an Append whose line names no call when two calls could take it
SimonCropp Oct 3, 2026
67d67ed
Lex a file once for a batch, and around each edit after that
SimonCropp Oct 3, 2026
8933661
Answer a dry run from the last reading of a file that has not changed
SimonCropp Oct 3, 2026
ff1b8e1
Tell a patch that needed no write what is true of the file when a bat…
SimonCropp Oct 3, 2026
aa7dd45
Give a held letter one command in the Linux head, unless it is naviga…
SimonCropp Oct 3, 2026
949f9f8
Declare the vertex offset in a capture's ImGui context, as the window…
SimonCropp Oct 3, 2026
17d6153
Test that the Linux head leaves a window alone when nothing happens t…
SimonCropp Oct 3, 2026
bb3c9ce
Build and draw nothing for a hidden window in the Linux head
SimonCropp Oct 3, 2026
f5ad63e
Read [ ] 0 - and = by position on a layout with no Latin letters
SimonCropp Oct 3, 2026
d0cc73f
Sample a picture near its own size without choosing among its reduced…
SimonCropp Oct 3, 2026
8e25001
Draw a picture larger than a texture can be, brought down to one that…
SimonCropp Oct 3, 2026
6c5731b
Close what this round fixed in todo.md, and describe what changed
SimonCropp Oct 3, 2026
c6c7ae6
Rebuild native renderer binaries (#928)
github-actions[bot] Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 68 additions & 15 deletions claude.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/mdsource/tray.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Exiting the tray writes any still-pending inline snapshots back to disk, under t

"Accept all" will accept all pending moves, deletes and inline snapshots. Snapshots whose target frameworks disagree about the content are skipped rather than picked between; resolve those in the viewer.

The deletes it carries out are the ones that were pending when it began. A delete can be the last copy of a snapshot that is moving inline, so the deletes are held back, and the tray says so, when a snapshot could not be written or when a viewer that owns the queue did not answer. A delete of a file that a move in the same accept has written is left pending rather than carried out.
The deletes it carries out are the ones that were pending when it began. A delete can be the last copy of a snapshot that is moving inline, so the deletes are held back, and the tray says so, when a snapshot could not be written or when a viewer that owns the queue did not answer. A delete of a file that an accepted move has written is left pending rather than carried out, and stays that way through later "Accept all"s: it is marked `!` in the menu, with the reason. It goes when it is accepted on its own, or when a test run raises the delete again.

A long queue takes a while to accept. An open [DiffEngineViewer](/docs/viewer.md) window shows how far it has got, with each snapshot leaving the list as it lands.

Expand Down
2 changes: 1 addition & 1 deletion docs/tray.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Exiting the tray writes any still-pending inline snapshots back to disk, under t

"Accept all" will accept all pending moves, deletes and inline snapshots. Snapshots whose target frameworks disagree about the content are skipped rather than picked between; resolve those in the viewer.

The deletes it carries out are the ones that were pending when it began. A delete can be the last copy of a snapshot that is moving inline, so the deletes are held back, and the tray says so, when a snapshot could not be written or when a viewer that owns the queue did not answer. A delete of a file that a move in the same accept has written is left pending rather than carried out.
The deletes it carries out are the ones that were pending when it began. A delete can be the last copy of a snapshot that is moving inline, so the deletes are held back, and the tray says so, when a snapshot could not be written or when a viewer that owns the queue did not answer. A delete of a file that an accepted move has written is left pending rather than carried out, and stays that way through later "Accept all"s: it is marked `!` in the menu, with the reason. It goes when it is accepted on its own, or when a test run raises the delete again.

A long queue takes a while to accept. An open [DiffEngineViewer](/docs/viewer.md) window shows how far it has got, with each snapshot leaving the list as it lands.

Expand Down
395 changes: 328 additions & 67 deletions native/src/deview.cpp

Large diffs are not rendered by default.

44 changes: 37 additions & 7 deletions native/swift/Sources/Deview/Renderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,16 @@ final class Renderer {
private var dirty: CGRect?

/// Both panes name the one picture in the draw in progress, as a byte equal pair of documents
/// does: their pages are kept under the hash of the document. There is one scaled copy a
/// picture and the two panes can be a point apart in width, so each would have the copy made
/// for its own size in turn, without end. An enlarged pair is not given the chance: it is
/// drawn from the picture, as every enlarged picture was before it had a copy.
/// does: their pages are kept under the hash of the document. The two panes can be a point
/// apart in width, and so can ask for copies of different sizes. With one copy a picture each
/// had it made for its own size in turn, without end: every one that landed was a redraw, in
/// which the other pane found the copy was not its own and asked again. So a picture both
/// panes name keeps the last two copies made, which is one for each: see `Picture.spare`.
/// An enlarged pair makes none: it is drawn from the picture, as every enlarged picture was
/// before it had a copy.
///
/// As the last draw to begin left it. `settle` reads it for a copy that has landed, which
/// was asked for by that draw or by one before it.
private var shared = false

/// Decoded pictures, keyed by the path the screen model handed over and invalidated by the
Expand Down Expand Up @@ -105,6 +111,10 @@ final class Renderer {
/// goes when a copy for another size lands, and with the picture.
var scaled: CGImage?

/// The copy `scaled` took the place of, kept only while both panes name this picture: see
/// `shared`. Each pane then has the copy made for its own size, one here and one there.
var spare: CGImage?

/// Being decoded on `work`. The pane shows a spinner until it lands.
var decoding = false

Expand Down Expand Up @@ -407,10 +417,18 @@ final class Renderer {
let panesWidth = max(cell.width * 2, content - Renderer.padding - panesLeft)
let half = (panesWidth / 2).rounded(.down)

text(frame.title, in: rect(top: Renderer.padding, left: Renderer.padding, width: size.width - Renderer.padding * 2, height: line, size), Palette.text, context)
// A title that would run on under the subtitle stops a character short of it, where the
// subtitle used to be drawn over whatever of the title had got that far. It is the title
// that gives way, as in the Linux head: what it says is also in the pane headers and the
// queue, and which entry of the queue this is is said only by the subtitle. One that fits
// has the whole row, as it always had, so it is drawn exactly as it was.
let titleWidth = size.width - Renderer.padding * 2
let subtitleWidth = CGFloat(frame.subtitle.count) * cell.width
let runsUnder = !frame.subtitle.isEmpty && CGFloat(frame.title.count) * cell.width > titleWidth - subtitleWidth
let titleRoom = runsUnder ? titleWidth - subtitleWidth - cell.width : titleWidth
text(frame.title, in: rect(top: Renderer.padding, left: Renderer.padding, width: titleRoom, height: line, size), Palette.text, context)
if !frame.subtitle.isEmpty {
let width = CGFloat(frame.subtitle.count) * cell.width
text(frame.subtitle, in: rect(top: Renderer.padding, left: size.width - Renderer.padding - width, width: width, height: line, size), Palette.dim, context)
text(frame.subtitle, in: rect(top: Renderer.padding, left: size.width - Renderer.padding - subtitleWidth, width: subtitleWidth, height: line, size), Palette.dim, context)
}

let firstRule = Renderer.padding + line + Renderer.gap
Expand Down Expand Up @@ -1049,6 +1067,14 @@ final class Renderer {
return scaled
}

// The other of the two copies a picture in both panes keeps, which is this pane's when
// the last one to land was made for the other
if let spare = entry.spare,
spare.width == width,
spare.height == height {
return spare
}

let size = Pixels(width: width, height: height)
if capturing {
guard let scaled = Renderer.scale(picture, to: size) else {
Expand Down Expand Up @@ -1145,6 +1171,10 @@ final class Renderer {

entry.scaling = nil
if let image {
// The copy this one takes the place of is the other pane's, when both panes
// name the picture, and is kept for it. Otherwise it was made for a size
// the pane no longer has, and goes.
entry.spare = shared ? entry.scaled : nil
entry.scaled = image
} else {
entry.unscalable = true
Expand Down
26 changes: 26 additions & 0 deletions native/swift/Sources/Deview/ViewerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -395,9 +395,27 @@ final class ViewerView: NSView, NSViewToolTipOwner {
return
}

// A key held past the repeat delay arrives again many times a second, and each one is
// queued. That is what a held Down is for. A held a accepted the entry on screen and then
// every one that took its place, into source, none of them read, and the ones queued
// while a frame was slow were still handed over after the key came up. So what changes
// the queue takes a press each, as it does in the WinForms head. Swallowed rather than
// passed on: it is still this key.
if event.isARepeat, ViewerView.changesQueue(key) {
return
}

Runtime.shared.post(.key(key))
}

/// The keys that act on the queue rather than on the view: the ones of
/// `ViewerSession.ChangesQueue` that a key here can be.
private static func changesQueue(_ key: Int32) -> Bool {
key == DEVIEW_KEY_ACCEPT.value ||
key == DEVIEW_KEY_ACCEPT_ALL.value ||
key == DEVIEW_KEY_DISCARD.value
}

/// Matches ReadKey in deview.cpp and the WinForms head's Map, which is the keymap the docs
/// publish.
private static func map(_ event: NSEvent) -> Int32 {
Expand Down Expand Up @@ -450,11 +468,19 @@ final class ViewerView: NSView, NSViewToolTipOwner {
// was. German, French, Nordic, Spanish and Italian layouts have them behind Option, and
// with the modifiers left out the key is the digit or the letter printed on it, so those
// readers could not turn a page from the keyboard.
//
// The zoom keys the same way, for a layout that has one of them behind Option. Shift is
// no part of this: it is kept by both readings, so equals unshifted and plus with shift
// held are each what was typed on a US keyboard, and each zooms in, as they did.
switch event.characters {
case "[":
return DEVIEW_KEY_PREVIOUS_PAGE.value
case "]":
return DEVIEW_KEY_NEXT_PAGE.value
case "=", "+":
return DEVIEW_KEY_ZOOM_IN.value
case "-":
return DEVIEW_KEY_ZOOM_OUT.value
default:
break
}
Expand Down
34 changes: 8 additions & 26 deletions src/DiffEngine.Benchmarks/InlineAcceptBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public void Setup()
source = text;
pristine = new UTF8Encoding(false).GetBytes(text);
File.WriteAllBytes(path, pristine);
// As a source file is when a test run asks about it: written at the last save, and not
// in the last few seconds, which is the file a probe keeps what it read of
File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddMinutes(-1));
patches = new InlinePatch[CallSites];
appends = new InlinePatch[CallSites];
for (var index = 0; index < CallSites; index++)
Expand Down Expand Up @@ -113,36 +116,15 @@ public int AnchorEach()
return Every(anchored);
}

// The patcher alone over the same patches, each applied to what the last one left: what the
// accepts above cost with the file system, the encoding and the locks taken out
// The patcher alone over the same patches, each applied to what the last one left: what
// AcceptTogether costs with the file system, the encoding and the locks taken out
[Benchmark]
public int PatchInMemory()
{
var current = source;
var applied = 0;
foreach (var patch in patches)
{
var status = InlinePatcher.TryApply(
SourceLanguage.CSharp,
current,
patch.LineHint,
patch.Mode,
patch.OriginalExpression,
patch.OriginalValue,
patch.MemberName,
patch.EntryPoints,
false,
patch.NewContent,
out var patched,
out _);
if (status == PatchStatus.Applied)
{
current = patched;
applied++;
}
}

return Every(applied);
var results = new InlineApplyResult[patches.Length];
InlineApplier.PatchInTurn(SourceLanguage.CSharp, ref current, patches, path, results);
return Every(results.Count(_ => _.Status == InlineApplyStatus.Applied));
}

// The lexing every one of those starts with
Expand Down
72 changes: 72 additions & 0 deletions src/DiffEngine.Benchmarks/ListenerTableBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Net;
using System.Net.Sockets;
using BenchmarkDotNet.Attributes;

namespace DiffEngine.Benchmarks;

// What it costs to ask the operating system whether anything is listening on a port, which is
// asked in front of a connect that would otherwise wait two seconds to be refused: by ViewerClient
// for a port it has not just talked to, and by PiperClient on every move and delete.
//
// The answer is no here, which is the answer that has to look at every row there is. What is
// varied is how many connections the machine has open beside the listeners, since that is what
// the cost was found to follow: each loopback connection made here is two rows, one for each end.
//
// Never the real ports, which a tray on the machine running this owns. The port asked about is
// one this process binds and does not listen on, as in UnownedPortBenchmarks.
[MemoryDiagnoser]
public class ListenerTableBenchmarks
{
[Params(0, 1500)]
public int Connections;

Socket holder = null!;
int port;
TcpListener listener = null!;
List<TcpClient> ends = [];

[GlobalSetup]
public void Setup()
{
holder = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
{
ExclusiveAddressUse = true
};
holder.Bind(new IPEndPoint(IPAddress.Loopback, 0));
port = ((IPEndPoint) holder.LocalEndPoint!).Port;

listener = new(IPAddress.Loopback, 0);
listener.Start();
var target = ((IPEndPoint) listener.LocalEndpoint).Port;
for (var index = 0; index < Connections; index++)
{
var client = new TcpClient();
client.Connect(IPAddress.Loopback, target);
ends.Add(client);
ends.Add(listener.AcceptTcpClient());
}
}

[GlobalCleanup]
public void Cleanup()
{
foreach (var end in ends)
{
end.Dispose();
}

ends.Clear();
listener.Stop();
holder.Dispose();
}

[Benchmark]
public bool NobodyListening() =>
ListenerTable.IsHeld(port);

// What IsHeld read before the listeners were asked for alone, and still reads where they
// cannot be
[Benchmark]
public bool NobodyListeningOutOfEveryRow() =>
ListenerTable.Enumerated(port);
}
17 changes: 17 additions & 0 deletions src/DiffEngine.Benchmarks/TextDiffBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,25 @@ public void Setup()
var lines = expected.Split('\n');
Array.Reverse(lines);
reordered = string.Join('\n', lines);
// Every line survives, in runs of fifty, one run in four moved to somewhere else: what
// sorting a list by another key, or moving its sections about, leaves. Reversed, nothing
// is still in order, so there is nothing for a diff to find however it looks. Here most
// of an order survives, and a diff is as good as how much of it is kept.
var random = new Random(7);
var runs = expected.Split('\n').Chunk(50).ToArray();
var places = Enumerable.Range(0, runs.Length)
.Select(_ => random.Next(4) == 0 ? random.Next(runs.Length) : _)
.ToArray();
Array.Sort(places, runs);
runsMoved = string.Join('\n', runs.SelectMany(_ => _));
}

string runsMoved = "";

[Benchmark]
public int SameLinesWithRunsMoved() =>
LineDiff.Build(expected, runsMoved).Entries.Count;

[Benchmark]
public int OnePercentChanged() =>
LineDiff.Build(expected, onePercentChanged).Entries.Count;
Expand Down
Loading
Loading