diff --git a/claude.md b/claude.md index bcc68c3e..57acae09 100644 --- a/claude.md +++ b/claude.md @@ -119,6 +119,19 @@ keeps offering a snapshot that is already in the source. Whether an entry is hidden is always read back out of `VisibleEntries`, never recomputed — the rules about when a header exists at all live in one place and must stay there. A fold is a view: `AcceptAll` still sweeps what it hides, which `CollapseTests` pins. +- Images (`Images/`, extensions in `DiffEngine/Viewer/ImageExtensions.cs`, linked into the viewer so + the tool registration and the renderer cannot disagree) are a side, not a mode. `FileSide.Read` + decides text or picture **by extension**, because the expected side of a new snapshot has no bytes + to sniff, and `ImageRows` produces the same aligned `Row` lists `DiffRows` does — one per property, + coloured against the other side. So every head compares images today with no ABI change. Whether + the two are the same file belongs to the pair rather than to a side, so it is the status line. + `Pane.Image` is an **enrichment**: all three heads paint the picture under those rows, each with + its toolkit's own decoder (GDI+, ImageIO, raylib), so *which formats draw* is per platform while + *what the comparison says* is not. Nothing about a comparison may become expressible only through + the picture, or the text snapshots stop describing what a head without that decoder shows. All + three fit from `ImagePane.Width/Height` — the file header's numbers, not the decoder's — one blank + line under the pane's rows, so the placement rule lives once. Headers are sniffed by hand + (`ImageHeader`) rather than by System.Drawing, which does not exist on macOS or Linux. - Queue tooltips are composed once in `QueueProjection`, not per head, and are **null when they would only repeat the row**. Labels are already the shortest distinguishing form, so the tip is what the label left off — path, test, frameworks, failure text. `QueueTooltipTests` snapshots the @@ -149,7 +162,11 @@ keeps offering a snapshot that is already in the source. any of that, because the binaries are committed. - `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: eight exports taking one flat blittable frame description. The ABI is `native/include/deview.h`; bump - `DEVIEW_VERSION` whenever the structs change **or a field changes meaning**. + `DEVIEW_VERSION` whenever the structs change **or a field changes meaning**. The managed side + refuses a library whose version is not an exact match, so a bump and a binaries rebuild land + together: change `native/`, run `build-native`, merge the PR it opens. Between the two, the + `native` CI job — the one that loads the committed binaries — reports the mismatch, which is the + check working. - Built binaries are **committed** to `src/DiffEngineViewer.{Linux,Mac}/runtimes/{rid}/native/`, so a plain `dotnet build` produces a shippable package and contributors never need CMake. Regenerate them with the `build-native` GitHub workflow, which opens a PR. diff --git a/docs/diff-tool.md b/docs/diff-tool.md index 9c382b34..22d35f53 100644 --- a/docs/diff-tool.md +++ b/docs/diff-tool.md @@ -272,6 +272,7 @@ DiffTools.UseOrder(DiffTool.DeltaWalker); * Use shell execute: False * Create no window: True * Environment variable for custom install location: `DiffEngine_DiffEngineViewer` + * Supported binaries: .bmp, .gif, .ico, .jpeg, .jpg, .png, .webp #### Tool order: @@ -287,6 +288,8 @@ DiffTools.UseOrder(DiffTool.DiffEngineViewer); * Also available standalone as `DiffEngineViewer.Windows`, `.Mac` or `.Linux` * Renders natively per platform: WinForms on Windows, AppKit and Core Text on macOS, Dear ImGui through raylib on Linux + * Compares images by format, dimensions and content, and draws them, with + whichever formats each platform's own decoder reads #### Windows settings: diff --git a/docs/mdsource/viewer.source.md b/docs/mdsource/viewer.source.md index 622176be..927e9813 100644 --- a/docs/mdsource/viewer.source.md +++ b/docs/mdsource/viewer.source.md @@ -1,7 +1,7 @@ # DiffEngineViewer -DiffEngineViewer is a cross platform diff tool for text files and inline snapshots. It is the -reviewer for [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): +DiffEngineViewer is a cross platform diff tool for text files, images and inline snapshots. It is +the reviewer for [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): it shows the received text against the expected text, and accepting rewrites the literal in the source file. @@ -168,6 +168,43 @@ A viewer that owns the queue itself never shows moves or deletes, because DiffEn them to a running tray. +## Images + +`.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.webp` and `.ico` are compared as pictures rather than as +text, wherever they turn up: a pair passed on the command line, or a move or delete the tray is +holding. + +Each pane lists what its own side is — format, pixel dimensions and byte count — with a property +that matches the other side reading as unchanged and one that does not reading as modified, the same +colouring a line of text gets. Whether the two are the same picture belongs to the pair rather than +to either side, so it is stated in the status line: **images are identical**, **images differ**, or +**only \ exists** when one side has nothing yet, which is the normal state of a brand new +image snapshot. + +The extension decides, not the content. A `.png` holding something that is not one is still an +image side, and says its format was not recognized instead of rendering the bytes as text. + +Each pane also draws the picture itself, one blank line under those rows: fitted to the space, +never enlarged past its own size, on a checkerboard so transparency reads as transparent. All three +heads place it identically, from the size the file's own header gave rather than from whatever +their decoder reported. + +Which formats can be drawn is the platform's answer rather than the viewer's, because each head +uses the decoder its toolkit ships with: + +| Head | Drawn | +| --- | --- | +| Windows (GDI+) | `.png` `.jpg` `.jpeg` `.gif` `.bmp` `.ico` | +| macOS (ImageIO) | all seven | +| Linux (raylib) | `.png` `.jpg` `.jpeg` `.gif` `.bmp` | + +A format a head cannot decode draws nothing, and the comparison is still there in the rows above +it. That is why those rows are the description and the picture is an addition to it. + +Accepting is the same act it is for text — copy the received file over the expected one, or forward +the move to the tray — so nothing about reviewing an image changes what accepting one does. + + ## With DiffEngineTray The tray starts at login, so it normally binds the port first and holds the queue. The viewer then diff --git a/docs/viewer.md b/docs/viewer.md index a39e9f85..3ef110e4 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -7,8 +7,8 @@ To change this file edit the source file and then run MarkdownSnippets. # DiffEngineViewer -DiffEngineViewer is a cross platform diff tool for text files and inline snapshots. It is the -reviewer for [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): +DiffEngineViewer is a cross platform diff tool for text files, images and inline snapshots. It is +the reviewer for [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): it shows the received text against the expected text, and accepting rewrites the literal in the source file. @@ -175,6 +175,43 @@ A viewer that owns the queue itself never shows moves or deletes, because DiffEn them to a running tray. +## Images + +`.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.webp` and `.ico` are compared as pictures rather than as +text, wherever they turn up: a pair passed on the command line, or a move or delete the tray is +holding. + +Each pane lists what its own side is — format, pixel dimensions and byte count — with a property +that matches the other side reading as unchanged and one that does not reading as modified, the same +colouring a line of text gets. Whether the two are the same picture belongs to the pair rather than +to either side, so it is stated in the status line: **images are identical**, **images differ**, or +**only \ exists** when one side has nothing yet, which is the normal state of a brand new +image snapshot. + +The extension decides, not the content. A `.png` holding something that is not one is still an +image side, and says its format was not recognized instead of rendering the bytes as text. + +Each pane also draws the picture itself, one blank line under those rows: fitted to the space, +never enlarged past its own size, on a checkerboard so transparency reads as transparent. All three +heads place it identically, from the size the file's own header gave rather than from whatever +their decoder reported. + +Which formats can be drawn is the platform's answer rather than the viewer's, because each head +uses the decoder its toolkit ships with: + +| Head | Drawn | +| --- | --- | +| Windows (GDI+) | `.png` `.jpg` `.jpeg` `.gif` `.bmp` `.ico` | +| macOS (ImageIO) | all seven | +| Linux (raylib) | `.png` `.jpg` `.jpeg` `.gif` `.bmp` | + +A format a head cannot decode draws nothing, and the comparison is still there in the rows above +it. That is why those rows are the description and the picture is an addition to it. + +Accepting is the same act it is for text — copy the received file over the expected one, or forward +the move to the tray — so nothing about reviewing an image changes what accepting one does. + + ## With DiffEngineTray The tray starts at login, so it normally binds the port first and holds the queue. The viewer then diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 7542c3b2..c9198df8 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -32,7 +32,13 @@ set(BUILD_GAMES OFF CACHE BOOL "" FORCE) set(CUSTOMIZE_BUILD ON CACHE BOOL "" FORCE) set(SUPPORT_MODULE_RAUDIO OFF CACHE BOOL "" FORCE) set(SUPPORT_MODULE_RMODELS OFF CACHE BOOL "" FORCE) +# The formats the viewer compares as pictures and raylib can decode. WebP and ICO are also +# compared, and raylib has no decoder for either, so on this platform they draw as their property +# rows alone — which is what those rows are for. set(SUPPORT_FILEFORMAT_PNG ON CACHE BOOL "" FORCE) +set(SUPPORT_FILEFORMAT_JPG ON CACHE BOOL "" FORCE) +set(SUPPORT_FILEFORMAT_BMP ON CACHE BOOL "" FORCE) +set(SUPPORT_FILEFORMAT_GIF ON CACHE BOOL "" FORCE) FetchContent_Declare( raylib diff --git a/native/include/deview.h b/native/include/deview.h index fa3d9f4c..dcd182cf 100644 --- a/native/include/deview.h +++ b/native/include/deview.h @@ -60,6 +60,26 @@ typedef struct DeviewPane { int32_t rowCount; int32_t scrollTop; int32_t totalRows; + + /* + * The picture this side is, to be drawn under its rows: a path on this machine, carried in the + * same string blob as everything else. + * + * The rows remain the description of an image comparison — format, dimensions and byte count, + * coloured against the other side — and every head draws those. This is an addition on top for + * a head that has a decoder, so a format the platform cannot decode draws nothing and still + * reads correctly. Nothing about a comparison may become expressible only through this. + * + * imagePathLength is 0 for a text side, and for an image side whose bytes could not be read or + * recognized. The managed side has already made that judgement, so a renderer never repeats it. + * + * The size is the one the file's own header gave, not whatever a decoder reports, so all three + * heads fit a picture into a pane from identical numbers. + */ + int32_t imagePathOffset; + int32_t imagePathLength; + int32_t imageWidth; + int32_t imageHeight; } DeviewPane; typedef struct DeviewButton { @@ -191,8 +211,11 @@ typedef struct DeviewInput { * and a context menu dismissed without a choice. Between them these are what a head needs to * draw the queue tip, the pane scrollbar and the menu with the platform's own controls rather * than its own rectangles. + * 6: DeviewPane carries the picture the side is, so an image comparison is drawn rather than only + * described. A widened array element, so an older library reads every pane after the first at + * the wrong offset — this is the bump that matters most to honour. */ -#define DEVIEW_VERSION 5 +#define DEVIEW_VERSION 6 /* * The Swift implementation imports this header for the struct layouts, because Swift does not diff --git a/native/src/deview.cpp b/native/src/deview.cpp index 0fe7c140..aba2d7a1 100644 --- a/native/src/deview.cpp +++ b/native/src/deview.cpp @@ -18,8 +18,12 @@ #include "rlgl.h" #include +#include #include +#include +#include #include +#include #include /* @@ -75,6 +79,30 @@ constexpr float grabWidth = 4.0f; */ constexpr float emScale = 1.32f; +/* + * The side of a checker square behind a picture, so an image with transparency reads as transparent + * rather than as whatever colour the pane happens to be. Matches the WinForms head. + */ +constexpr float checkerSize = 8.0f; + +/* + * One decoded picture, kept because BuildFrame runs sixty times a second and decoding an image per + * frame is what turns a window that is merely showing something into one that is busy. + * + * A false `loaded` is a remembered failure. raylib is built here with decoders for PNG, JPEG, BMP + * and GIF and has none for WebP or ICO, so a pane can legitimately carry a path this build cannot + * read; remembering that means attempting it once rather than once a frame. Nothing is lost when it + * happens — the rows already say what the file is, and they are the description an image comparison + * is made of. + */ +struct CachedTexture +{ + Texture2D texture{}; + bool loaded = false; + std::uintmax_t length = 0; + std::filesystem::file_time_type written{}; +}; + struct State { bool initialised = false; @@ -96,6 +124,10 @@ struct State /* What was last handed to raylib, so an idle frame is not a window system call. */ int cursor = MOUSE_CURSOR_DEFAULT; + + /* Keyed by the path the screen model handed over. std::map rather than unordered, because the + * entries are handed out as pointers and this one does not move them. */ + std::map pictures; }; State state; @@ -213,6 +245,96 @@ char RowMarker(int kind) } } +/* ---- pictures ---- */ + +void ForgetPicture(const std::string& path) +{ + const auto found = state.pictures.find(path); + if (found == state.pictures.end()) + { + return; + } + + if (found->second.loaded) + { + UnloadTexture(found->second.texture); + } + + state.pictures.erase(found); +} + +/* + * The decoded picture for a path, or null when this build cannot read it. + * + * Invalidated by the file's write time and length, which is the same freshness test the managed + * queue poller uses: a re-run that rewrites a received image has to refresh the pane rather than + * leave the previous one up. + */ +const Texture2D* Picture(const std::string& path) +{ + if (path.empty()) + { + return nullptr; + } + + const std::filesystem::path file(path); + std::error_code error; + const auto written = std::filesystem::last_write_time(file, error); + if (error) + { + ForgetPicture(path); + return nullptr; + } + + const auto length = std::filesystem::file_size(file, error); + if (error) + { + ForgetPicture(path); + return nullptr; + } + + const auto found = state.pictures.find(path); + if (found != state.pictures.end()) + { + if (found->second.written == written && + found->second.length == length) + { + return found->second.loaded ? &found->second.texture : nullptr; + } + + ForgetPicture(path); + } + + CachedTexture entry; + entry.written = written; + entry.length = length; + const Texture2D texture = LoadTexture(path.c_str()); + if (IsTextureValid(texture)) + { + entry.texture = texture; + entry.loaded = true; + /* A picture is only ever scaled down here, so bilinear is the whole of what the filter has + * to do. */ + SetTextureFilter(entry.texture, TEXTURE_FILTER_BILINEAR); + } + + const auto inserted = state.pictures.emplace(path, entry).first; + return inserted->second.loaded ? &inserted->second.texture : nullptr; +} + +void UnloadPictures() +{ + for (auto& entry : state.pictures) + { + if (entry.second.loaded) + { + UnloadTexture(entry.second.texture); + } + } + + state.pictures.clear(); +} + /* ---- texture protocol (ImGuiBackendFlags_RendererHasTextures) ---- */ void UpdateTexture(ImTextureData* texture) @@ -438,6 +560,132 @@ void DrawRow(const DeviewScreen* screen, const DeviewPane& pane, int index, int ImGui::PopStyleColor(); } +/* + * Where a pane's picture goes, gathered from the table that drew the rows rather than recomputed. + * The table owns the pane split, so asking it is the only way to place something under a column + * that agrees with the column. + */ +struct PaneImage +{ + float left = 0.0f; + float width = 0.0f; + + /* + * The top of row zero and the pitch between rows. Together they put a picture one blank line + * under the pane's own rows, which is the rule all three heads follow — and they are readable + * from the first two rows rather than from a row past the pane's, which the table does not + * always have. + */ + float first = -1.0f; + float pitch = 0.0f; +}; + +/* Called from inside the cell, which is the only place these are knowable. */ +void RecordPaneImage(PaneImage& bounds, const DeviewPane& pane, int index) +{ + if (pane.imagePathLength <= 0 || + index > 1) + { + return; + } + + const ImVec2 cursor = ImGui::GetCursorScreenPos(); + if (index == 0) + { + bounds.left = cursor.x; + bounds.width = ImGui::GetContentRegionAvail().x; + bounds.first = cursor.y; + return; + } + + bounds.pitch = cursor.y - bounds.first; +} + +void DrawChecker(ImDrawList* list, const ImVec2& min, const ImVec2& max) +{ + list->AddRectFilled(min, max, IM_COL32(64, 64, 64, 255)); + const ImU32 dark = IM_COL32(48, 48, 48, 255); + int row = 0; + for (float y = min.y; y < max.y; y += checkerSize, row++) + { + int column = 0; + for (float x = min.x; x < max.x; x += checkerSize, column++) + { + if ((row & 1) == (column & 1)) + { + continue; + } + + list->AddRectFilled( + ImVec2(x, y), + ImVec2(std::min(x + checkerSize, max.x), std::min(y + checkerSize, max.y)), + dark); + } + } +} + +/* + * The picture under a pane's rows. Absolutely positioned over the table rather than submitted as a + * table row, because the rows a pane has and the rows the table has are different numbers: the + * queue column is usually the tallest, and the space this fills is the pane's share of what the + * queue is using. + */ +void DrawPaneImage(const DeviewScreen* screen, const DeviewPane& pane, const PaneImage& bounds, float bottom) +{ + if (pane.imagePathLength <= 0 || + pane.imageWidth <= 0 || + pane.imageHeight <= 0 || + bounds.width <= 0.0f || + bounds.first < 0.0f || + bounds.pitch <= 0.0f) + { + return; + } + + const float top = bounds.first + static_cast(pane.rowCount + 1) * bounds.pitch; + const float available = bottom - top; + if (available <= 0.0f) + { + return; + } + + const Texture2D* texture = Picture(Copy(screen, pane.imagePathOffset, pane.imagePathLength)); + if (texture == nullptr) + { + return; + } + + /* + * Fitted, and never enlarged past its own size: a snapshot is judged against the pixels it has, + * and an eight pixel icon stretched across a pane is an interpolation of them rather than a + * look at them. + * + * Scaled from the size the model carries rather than from the decoded texture, so all three + * heads place a picture identically even where their decoders would not agree. + */ + const float scale = std::min( + std::min( + bounds.width / static_cast(pane.imageWidth), + available / static_cast(pane.imageHeight)), + 1.0f); + const ImVec2 size( + std::max(1.0f, static_cast(pane.imageWidth) * scale), + std::max(1.0f, static_cast(pane.imageHeight) * scale)); + const ImVec2 min( + bounds.left + (bounds.width - size.x) * 0.5f, + top + (available - size.y) * 0.5f); + const ImVec2 max(min.x + size.x, min.y + size.y); + + ImDrawList* list = ImGui::GetWindowDrawList(); + DrawChecker(list, min, max); + list->AddImage(static_cast(texture->id), min, max); + /* An outline, so a picture whose edges are the colour of the pane still has visible extent. */ + list->AddRect( + ImVec2(min.x - 1.0f, min.y - 1.0f), + ImVec2(max.x + 1.0f, max.y + 1.0f), + IM_COL32(70, 70, 70, 255)); +} + void BuildFrame(const DeviewScreen* screen) { const ImGuiViewport* viewport = ImGui::GetMainViewport(); @@ -495,6 +743,11 @@ void BuildFrame(const DeviewScreen* screen) /* Where the border between the queue and the panes ended up, read back from the table rather * than recomputed, and -1 until a row has been laid out. */ float dividerX = -1.0f; + + /* Gathered from the table, and used after it closes. Both stay empty on the overwhelmingly + * common frame, where neither side is a picture. */ + PaneImage leftImage; + PaneImage rightImage; if (screen->paneCount >= 2 && ImGui::BeginTable("##panes", columns, ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchSame)) { @@ -596,14 +849,23 @@ void BuildFrame(const DeviewScreen* screen) dividerX = ImGui::GetCursorScreenPos().x - ImGui::GetStyle().CellPadding.x; } + RecordPaneImage(leftImage, left, index); DrawRow(screen, left, index, column); ImGui::TableSetColumnIndex(column + 1); + RecordPaneImage(rightImage, right, index); DrawRow(screen, right, index, column + 1); } ImGui::EndTable(); } + if (screen->paneCount >= 2) + { + const float bottom = bodyMin.y + bodyAvail.y; + DrawPaneImage(screen, screen->panes[0], leftImage, bottom); + DrawPaneImage(screen, screen->panes[1], rightImage, bottom); + } + /* * The drag, submitted after the table so it wins the overlap: within a window the last item to * claim a position is the one that hovers. Inert in a capture, which never feeds a mouse @@ -975,6 +1237,9 @@ void deview_shutdown(void) return; } + /* Before CloseWindow, which takes the GL context these live in with it. */ + UnloadPictures(); + if (state.context != nullptr) { ImGui::SetCurrentContext(state.context); diff --git a/native/swift/Sources/Deview/Frame.swift b/native/swift/Sources/Deview/Frame.swift index d12b7a43..adcdce5f 100644 --- a/native/swift/Sources/Deview/Frame.swift +++ b/native/swift/Sources/Deview/Frame.swift @@ -33,6 +33,13 @@ struct Frame { /// themselves cannot say. var scrollTop: Int32 = 0 var totalRows: Int32 = 0 + + /// The picture this side is, drawn under the rows. Empty for a text side, and for an image + /// side whose bytes could not be read or recognized — the managed side has already decided, + /// so the renderer never has to. The size is the file header's, not a decoder's. + var imagePath = "" + var imageWidth: Int32 = 0 + var imageHeight: Int32 = 0 } struct QueueItem { @@ -101,6 +108,9 @@ struct Frame { pane.header = string(screen, source.headerOffset, source.headerLength) pane.scrollTop = source.scrollTop pane.totalRows = source.totalRows + pane.imagePath = string(screen, source.imagePathOffset, source.imagePathLength) + pane.imageWidth = source.imageWidth + pane.imageHeight = source.imageHeight guard let rows = screen.rows else { return pane } diff --git a/native/swift/Sources/Deview/Palette.swift b/native/swift/Sources/Deview/Palette.swift index 0dae5db8..aa3fc482 100644 --- a/native/swift/Sources/Deview/Palette.swift +++ b/native/swift/Sources/Deview/Palette.swift @@ -40,6 +40,12 @@ enum Palette { static let buttonFace = grey(52) static let buttonDisabled = grey(34) + /// The two squares behind a picture. Dark enough not to compete with the image, and different + /// enough from each other that a transparent region is obviously transparent rather than just + /// flat. + static let checkerLight = grey(64) + static let checkerDark = grey(48) + static func foreground(_ kind: Int32) -> CGColor { switch kind { case DEVIEW_ROW_ADDED.value: diff --git a/native/swift/Sources/Deview/Renderer.swift b/native/swift/Sources/Deview/Renderer.swift index ed0120ad..7070e617 100644 --- a/native/swift/Sources/Deview/Renderer.swift +++ b/native/swift/Sources/Deview/Renderer.swift @@ -5,6 +5,7 @@ import CDeview import CoreGraphics import CoreText import Foundation +import ImageIO /// Draws a `Frame` with Core Text. Used both for the window and for the offscreen capture, so the /// baselines describe what a user sees rather than a second code path. @@ -34,6 +35,10 @@ final class Renderer { private static let padding: CGFloat = 6 private static let gap: CGFloat = 4 + /// The side of a checker square behind a picture, so an image with transparency reads as + /// transparent rather than as whatever colour the pane happens to be. + private static let checkerSize: CGFloat = 8 + /// Marker, space, four digit line number, two spaces. Matches AsciiRenderer's gutter, so a /// line lands in the same column in both. private static let gutterCells: CGFloat = 8 @@ -46,6 +51,20 @@ final class Renderer { /// view because this is what lays the rule out, and the drag has to land where it was drawn. private var queueWidth: CGFloat = 0 + /// Decoded pictures, keyed by the path the screen model handed over and invalidated by the + /// file's write time and length — the same freshness test the queue poller uses, so a re-run + /// that rewrites a received image refreshes the pane rather than leaving the previous one up. + /// + /// A nil `image` is a remembered failure, so something ImageIO cannot read is attempted once + /// rather than on every redraw, and AppKit redraws for a great many reasons. + private var pictures: [String: Picture] = [:] + + private struct Picture { + var image: CGImage? + var modified: Date + var length: UInt64 + } + /// One character cell. Measured from the font that was actually loaded, which is what the ABI /// reports back so the managed side can slice a pane to rows that fit. let cell: CGSize @@ -180,6 +199,13 @@ final class Renderer { } let bodyBottom = bodyTop + CGFloat(capacity) * line + + // Under the rows rather than instead of them. The rows are what every head draws — format, + // size and byte count, coloured against the other side — and this one can afford to also + // show the thing they describe. + image(frame.left, left: panesLeft, width: half, top: bodyTop, bottom: bodyBottom, line: line, in: context, size) + image(frame.right, left: panesLeft + half, width: panesWidth - half, top: bodyTop, bottom: bodyBottom, line: line, in: context, size) + if hasQueue { let ruleLeft = panesLeft - Renderer.gap / 2 columnRule(left: ruleLeft, top: bodyTop, bottom: bodyBottom, in: context, size) @@ -288,6 +314,120 @@ final class Renderer { context) } + /// The picture a pane is, one blank line under its rows — the same placement the other two + /// heads use. + /// + /// Fitted, and never enlarged past its own size: a snapshot is judged against the pixels it + /// has, and an eight point icon stretched across a pane is an interpolation of them rather than + /// a look at them. Scaled from the size the model carries rather than from the decoded image, + /// so all three heads place a picture identically. + private func image( + _ pane: Frame.Pane, + left: CGFloat, + width: CGFloat, + top: CGFloat, + bottom: CGFloat, + line: CGFloat, + in context: CGContext, + _ size: CGSize + ) { + guard !pane.imagePath.isEmpty, + pane.imageWidth > 0, + pane.imageHeight > 0, + let picture = self.picture(pane.imagePath) + else { + return + } + + let imageTop = top + CGFloat(pane.rows.count + 1) * line + let available = CGSize(width: width - Renderer.gap, height: bottom - imageTop) + guard available.width > 0, available.height > 0 else { + return + } + + let scale = min( + min( + available.width / CGFloat(pane.imageWidth), + available.height / CGFloat(pane.imageHeight)), + 1) + let drawn = CGSize( + width: max(1, (CGFloat(pane.imageWidth) * scale).rounded(.down)), + height: max(1, (CGFloat(pane.imageHeight) * scale).rounded(.down))) + let bounds = rect( + top: imageTop + ((available.height - drawn.height) / 2).rounded(.down), + left: left + ((available.width - drawn.width) / 2).rounded(.down), + width: drawn.width, + height: drawn.height, + size) + + checker(bounds, in: context) + + context.saveGState() + context.interpolationQuality = .high + context.draw(picture, in: bounds) + context.restoreGState() + + // An outline, so a picture whose edges are the colour of the pane still has visible extent. + context.setStrokeColor(Palette.rule) + context.setLineWidth(1) + context.stroke(bounds.insetBy(dx: -0.5, dy: -0.5)) + } + + private func checker(_ bounds: CGRect, in context: CGContext) { + context.setFillColor(Palette.checkerLight) + context.fill(bounds) + context.setFillColor(Palette.checkerDark) + + var row = 0 + var y = bounds.minY + while y < bounds.maxY { + var column = 0 + var x = bounds.minX + while x < bounds.maxX { + if row % 2 != column % 2 { + let square = CGRect(x: x, y: y, width: Renderer.checkerSize, height: Renderer.checkerSize) + context.fill(square.intersection(bounds)) + } + + x += Renderer.checkerSize + column += 1 + } + + y += Renderer.checkerSize + row += 1 + } + } + + private func picture(_ path: String) -> CGImage? { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let modified = attributes[.modificationDate] as? Date, + let length = attributes[.size] as? UInt64 + else { + pictures.removeValue(forKey: path) + return nil + } + + if let cached = pictures[path], cached.modified == modified, cached.length == length { + return cached.image + } + + let image = Renderer.decode(path) + pictures[path] = Picture(image: image, modified: modified, length: length) + return image + } + + /// ImageIO rather than NSImage, which would hand back a representation sized for a screen when + /// what this wants is the file's own pixels. It reads every format the viewer compares. + private static func decode(_ path: String) -> CGImage? { + guard let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), + CGImageSourceGetCount(source) > 0 + else { + return nil + } + + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } + /// Clipped to its own rect, so a long line stops at its column instead of running into the /// next one. private func text(_ string: String, in bounds: CGRect, _ colour: CGColor, _ context: CGContext) { diff --git a/native/swift/readme.md b/native/swift/readme.md index 48715709..c97a51f6 100644 --- a/native/swift/readme.md +++ b/native/swift/readme.md @@ -35,6 +35,14 @@ build. **Nothing is flipped.** Core Graphics has a bottom left origin; layout is written top down and converted once, which avoids having to fight the text matrix to keep glyphs upright. +**Pictures come from ImageIO**, not `NSImage`, which would hand back a representation sized for a +screen when what the pane wants is the file's own pixels. They are cached and invalidated by write +time and length, the same freshness test the managed queue poller uses, because AppKit redraws for +a great many reasons and decoding per redraw would make an idle window a busy one. A file ImageIO +cannot read is remembered as a failure and draws nothing; the property rows above it still carry +the comparison, which is why those rows are the description and the picture is an addition to it. +This head is the only one of the three whose decoder reads every format the viewer compares. + **A hidden start creates no window.** `NSWindow` may only be instantiated on the main thread, and a test host runs `[Before(Class)]` on whatever thread it likes, so `deview_init(hidden: 1)` builds only the renderer and defers the window until `deview_set_hidden(0)` asks for one. The app always diff --git a/src/DiffEngine.Tests/diffTools.include.md b/src/DiffEngine.Tests/diffTools.include.md index cf0cbff1..3f27245f 100644 --- a/src/DiffEngine.Tests/diffTools.include.md +++ b/src/DiffEngine.Tests/diffTools.include.md @@ -137,6 +137,7 @@ DiffTools.UseOrder(DiffTool.DeltaWalker); * Use shell execute: False * Create no window: True * Environment variable for custom install location: `DiffEngine_DiffEngineViewer` + * Supported binaries: .bmp, .gif, .ico, .jpeg, .jpg, .png, .webp #### Tool order: @@ -152,6 +153,8 @@ DiffTools.UseOrder(DiffTool.DiffEngineViewer); * Also available standalone as `DiffEngineViewer.Windows`, `.Mac` or `.Linux` * Renders natively per platform: WinForms on Windows, AppKit and Core Text on macOS, Dear ImGui through raylib on Linux + * Compares images by format, dimensions and content, and draws them, with + whichever formats each platform's own decoder reads #### Windows settings: diff --git a/src/DiffEngine/Implementation/DiffEngineViewer.cs b/src/DiffEngine/Implementation/DiffEngineViewer.cs index 564ec291..ac344921 100644 --- a/src/DiffEngine/Implementation/DiffEngineViewer.cs +++ b/src/DiffEngine/Implementation/DiffEngineViewer.cs @@ -13,7 +13,7 @@ public static Definition DiffEngineViewer() IsMdi: false, SupportsText: true, RequiresTarget: true, - BinaryExtensions: [], + BinaryExtensions: ImageExtensions.All, Cost: "Free", OsSupport: new( Windows: new( @@ -36,6 +36,8 @@ public static Definition DiffEngineViewer() * Also available standalone as `DiffEngineViewer.Windows`, `.Mac` or `.Linux` * Renders natively per platform: WinForms on Windows, AppKit and Core Text on macOS, Dear ImGui through raylib on Linux + * Compares images by format, dimensions and content, and draws them, with + whichever formats each platform's own decoder reads """); } diff --git a/src/DiffEngine/Viewer/ImageExtensions.cs b/src/DiffEngine/Viewer/ImageExtensions.cs new file mode 100644 index 00000000..2c75ee8d --- /dev/null +++ b/src/DiffEngine/Viewer/ImageExtensions.cs @@ -0,0 +1,33 @@ +/// +/// The image extensions DiffEngineViewer renders as pictures, and therefore the ones DiffEngine +/// offers it as a binary diff tool for. +/// +/// Lives in DiffEngine and is linked into the viewer rather than written on both sides. The viewer +/// cannot reference DiffEngine — DiffEngine publishes and embeds the viewer heads, so the reference +/// would be a cycle — and the two lists drifting apart means registering the tool for an extension +/// its renderer treats as text, which is a window full of mojibake. +/// +/// +/// Decided by extension rather than by sniffing the bytes, because the decision has to be made for +/// a file that does not exist yet: the expected side of a brand new image snapshot has nothing to +/// sniff, and it still has to render as a missing image rather than as empty text. +/// +/// +static class ImageExtensions +{ + public static readonly string[] All = + [ + ".bmp", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".png", + ".webp" + ]; + + static readonly HashSet lookup = new(All, StringComparer.OrdinalIgnoreCase); + + public static bool Is(string path) => + lookup.Contains(Path.GetExtension(path)); +} diff --git a/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so index b3fe3289..6eae9b0e 100644 Binary files a/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so and b/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so differ diff --git a/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so index 234d06fc..f5221c40 100644 Binary files a/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so and b/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so differ diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib index d21cb217..4cc2a5f3 100644 Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib differ diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib index d21cb217..4cc2a5f3 100644 Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib differ diff --git a/src/DiffEngineViewer.Tests/FileSideTests.cs b/src/DiffEngineViewer.Tests/FileSideTests.cs new file mode 100644 index 00000000..39bbee87 --- /dev/null +++ b/src/DiffEngineViewer.Tests/FileSideTests.cs @@ -0,0 +1,112 @@ +/// +/// The read seam: the one place that decides whether a path is text or a picture, and the only +/// part of image support that touches a disk. +/// +/// It runs on the queue poller, five times a second, over files a test run is rewriting underneath +/// it, so the rule it must not break is that nothing here throws. +/// +/// +public class FileSideTests +{ + [Test] + public async Task ReadsAnImage() + { + var file = Write("sample.png", Png()); + var side = FileSide.Read(file); + + await Assert.That(side.Text).IsEmpty(); + await Assert.That(side.Warning).IsNull(); + await Assert.That(side.Image!.Value.Header).IsEqualTo(new ImageHeader(ImageFormat.Png, 800, 600)); + await Assert.That(side.Image!.Value.Length).IsEqualTo(24); + await Assert.That(side.Image!.Value.Hash).IsNotNull(); + } + + /// + /// The extension decides, so bytes that are not a picture still read as an image side. The + /// alternative is a pane full of the mojibake that binary decoded as text produces. + /// + [Test] + public async Task AnImageNameOverBytesThatAreNot() + { + var file = Write("notreally.png", "the quick brown fox"u8.ToArray()); + var side = FileSide.Read(file); + + await Assert.That(side.Image).IsNotNull(); + await Assert.That(side.Image!.Value.Header).IsNull(); + await Assert.That(side.Text).IsEmpty(); + } + + [Test] + public async Task TextIsStillText() + { + var file = Write("sample.txt", "the quick brown fox"u8.ToArray()); + var side = FileSide.Read(file); + + await Assert.That(side.Image).IsNull(); + await Assert.That(side.Text).IsEqualTo("the quick brown fox"); + } + + /// + /// Normal for the expected side of a brand new snapshot. Absent is not unreadable: there is no + /// image and no warning, and the pane renders as the empty side it is. + /// + [Test] + public async Task MissingImage() + { + var side = FileSide.Read(Path.Combine(Directory(), "gone.png")); + + await Assert.That(side.Image).IsNull(); + await Assert.That(side.Warning).IsNull(); + await Assert.That(side.Stamp).IsNull(); + } + + /// + /// The rule the poller depends on: a read that cannot succeed comes back as a warning rather + /// than as an exception that closes the window. + /// + /// Provoked with an unrepresentable path rather than a locked file, because an exclusive lock + /// only refuses a reader on Windows and this has to fail the same way on all three platforms. + /// + /// + [Test] + public async Task UnreadableImageDegrades() + { + var side = FileSide.Read(Path.Combine(Directory(), "no\0such.png")); + + await Assert.That(side.Warning).IsNotNull(); + // Still an image side, so the pane names the picture it could not read. + await Assert.That(side.Image).IsNotNull(); + await Assert.That(side.Image!.Value.Hash).IsNull(); + } + + static string Write(string name, byte[] content) + { + var path = Path.Combine(Directory(), name); + File.WriteAllBytes(path, content); + return path; + } + + static string Directory() + { + var path = Path.Combine(Path.GetTempPath(), "deview-image-sides"); + System.IO.Directory.CreateDirectory(path); + return path; + } + + static byte[] Png() + { + var bytes = new byte[24]; + ReadOnlySpan signature = [0x89, (byte) 'P', (byte) 'N', (byte) 'G', 0x0D, 0x0A, 0x1A, 0x0A]; + signature.CopyTo(bytes); + "IHDR"u8.CopyTo(bytes.AsSpan(12)); + bytes[8] = 0; + bytes[9] = 0; + bytes[10] = 0; + bytes[11] = 13; + bytes[18] = 800 >> 8; + bytes[19] = 800 & 0xFF; + bytes[22] = 600 >> 8; + bytes[23] = 600 & 0xFF; + return bytes; + } +} diff --git a/src/DiffEngineViewer.Tests/Fixtures.cs b/src/DiffEngineViewer.Tests/Fixtures.cs index adfb9379..4633b84d 100644 --- a/src/DiffEngineViewer.Tests/Fixtures.cs +++ b/src/DiffEngineViewer.Tests/Fixtures.cs @@ -53,7 +53,11 @@ public static string Long(bool changed) public static SessionState File(string left = Received, string right = Expected) => ViewerSession.EnqueueFile( SessionState.Start(ViewerMode.File, Columns, Rows), - QueueEntry.ForFiles("Sample.received.txt", "Sample.verified.txt", left, right)); + QueueEntry.ForFiles( + "Sample.received.txt", + "Sample.verified.txt", + FileSide.OfText(left), + FileSide.OfText(right))); public static SessionState Inline(params InlinePatch[] patches) { @@ -125,8 +129,8 @@ public static QueueEntry Move( solution, "temp/sample.received.txt", "code/sample.verified.txt", - new(left, null, null), - new(right, null, null)); + FileSide.OfText(left), + FileSide.OfText(right)); public static QueueEntry Delete( string name = "extra.verified.txt", @@ -137,7 +141,7 @@ public static QueueEntry Delete( name, solution, $"code/{name}", - new(content, null, null)); + FileSide.OfText(content)); /// /// A state displaying someone else's queue, without a socket: what an attached viewer holds @@ -183,6 +187,35 @@ public static ViewerActions Copying(Action copy) => public static string Render(SessionState state) => AsciiRenderer.Render(ScreenBuilder.Build(state)); + /// + /// Two real images on disk, which is what an image pane needs: a head draws from the path the + /// model carries, so this is the one scene that cannot be built from numbers alone. + /// + /// Different sizes, so the fit is visible, and both fade to transparent on the right, so the + /// checkerboard behind them is too. Built byte by byte rather than encoded, so the byte counts + /// these panes print are the same numbers on every platform that captures them. + /// + /// + public static SessionState Images() + { + var left = WriteImage("sample.received.png", SamplePng.Build(120, 90, 198, 64, 64)); + var right = WriteImage("sample.verified.png", SamplePng.Build(80, 60, 64, 150, 198)); + return ViewerSession.EnqueueFile( + SessionState.Start(ViewerMode.File, Columns, Rows), + QueueEntry.ForFiles(left, right, FileSide.Read(left), FileSide.Read(right))); + } + + static string WriteImage(string name, byte[] content) + { + // A fixed directory and a fixed name: only the file name reaches a pane header, and a + // per-run temp path would put a different one there on every capture. + var directory = Path.Combine(Path.GetTempPath(), "deview-fixture-images"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, name); + System.IO.File.WriteAllBytes(path, content); + return path; + } + /// /// The one grouped-and-conflicted scene the pixel suites mirror: two solutions, a test /// sub-group, and the selection on a conflicted entry so the variant button is on screen. diff --git a/src/DiffEngineViewer.Tests/ImageHeaderTests.Formats.verified.txt b/src/DiffEngineViewer.Tests/ImageHeaderTests.Formats.verified.txt new file mode 100644 index 00000000..e2bceee1 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageHeaderTests.Formats.verified.txt @@ -0,0 +1,49 @@ +{ + Png: { + Width: 800, + Height: 600, + HasSize: true + }, + Jpeg: { + Format: Jpeg, + Width: 1920, + Height: 1080, + HasSize: true + }, + Gif: { + Format: Gif, + Width: 320, + Height: 240, + HasSize: true + }, + Bmp: { + Format: Bmp, + Width: 64, + Height: 48, + HasSize: true + }, + WebpLossy: { + Format: Webp, + Width: 500, + Height: 400, + HasSize: true + }, + WebpLossless: { + Format: Webp, + Width: 500, + Height: 400, + HasSize: true + }, + Ico: { + Format: Ico, + Width: 32, + Height: 32, + HasSize: true + }, + LargeIco: { + Format: Ico, + Width: 256, + Height: 256, + HasSize: true + } +} \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageHeaderTests.NotImages.verified.txt b/src/DiffEngineViewer.Tests/ImageHeaderTests.NotImages.verified.txt new file mode 100644 index 00000000..dce6cb6d --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageHeaderTests.NotImages.verified.txt @@ -0,0 +1,5 @@ +{ + Empty: not an image, + Text: not an image, + AlmostPng: not an image +} \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageHeaderTests.SizeUnknown.verified.txt b/src/DiffEngineViewer.Tests/ImageHeaderTests.SizeUnknown.verified.txt new file mode 100644 index 00000000..0c427fcc --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageHeaderTests.SizeUnknown.verified.txt @@ -0,0 +1,14 @@ +{ + TruncatedJpeg: { + Format: Jpeg, + HasSize: false + }, + UnknownWebpChunk: { + Format: Webp, + HasSize: false + }, + BmpMissingItsSize: { + Format: Bmp, + HasSize: false + } +} \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageHeaderTests.cs b/src/DiffEngineViewer.Tests/ImageHeaderTests.cs new file mode 100644 index 00000000..ee8f3b34 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageHeaderTests.cs @@ -0,0 +1,157 @@ +using System.Buffers.Binary; + +/// +/// What the sniffer makes of each format's leading bytes. +/// +/// The bytes are built here rather than committed as image files. The point of the sniffer is that +/// it reads fixed offsets instead of running a decoder, so a fixture holding real pixels would +/// exercise nothing extra and would hide the one thing under test — which byte says what — inside a +/// binary blob nobody can review. +/// +/// +public class ImageHeaderTests +{ + [Test] + public Task Formats() => + Verify( + new + { + Png = Read(Png(800, 600)), + Jpeg = Read(Jpeg(1920, 1080)), + Gif = Read(Gif(320, 240)), + // Stored top down, so the height on disk is negative and the size is not. + Bmp = Read(Bmp(64, 48)), + WebpLossy = Read(WebpLossy(500, 400)), + WebpLossless = Read(WebpLossless(500, 400)), + Ico = Read(Ico(32, 32)), + // Zero is how the field says 256, which is the size icons outgrew it at. + LargeIco = Read(Ico(256, 256)) + }); + + /// + /// Recognizing the format and reading the size are separate answers, and something that is + /// plainly an image has to keep the first when it cannot give the second. Otherwise a truncated + /// or unusual file falls all the way back to being rendered as text. + /// + [Test] + public Task SizeUnknown() => + Verify( + new + { + // The signature, and then nothing to walk to the frame header. + TruncatedJpeg = Read([0xFF, 0xD8, 0xFF, 0xE0]), + // A container holding a chunk this does not read. Still a WebP. + UnknownWebpChunk = Read(Webp("ANIM")), + BmpMissingItsSize = Read(Bmp(64, 48)[..22]) + }); + + [Test] + public Task NotImages() => + Verify( + new + { + Empty = Read([]), + Text = Read("the quick brown fox"u8.ToArray()), + // The right length and the wrong first byte, which is the case a length-only guard + // would wave through. + AlmostPng = Read(Broken()) + }); + + static object Read(byte[] bytes) => + ImageHeader.TryRead(bytes, out var header) ? header : "not an image"; + + static byte[] Broken() + { + var bytes = Png(800, 600); + bytes[0] = 0x88; + return bytes; + } + + static byte[] Png(int width, int height) + { + var bytes = new byte[24]; + ReadOnlySpan signature = [0x89, (byte) 'P', (byte) 'N', (byte) 'G', 0x0D, 0x0A, 0x1A, 0x0A]; + signature.CopyTo(bytes); + BinaryPrimitives.WriteInt32BigEndian(bytes.AsSpan(8), 13); + "IHDR"u8.CopyTo(bytes.AsSpan(12)); + BinaryPrimitives.WriteInt32BigEndian(bytes.AsSpan(16), width); + BinaryPrimitives.WriteInt32BigEndian(bytes.AsSpan(20), height); + return bytes; + } + + /// + /// With a metadata segment in front of the frame header, because stepping over one is the whole + /// reason this format is walked rather than read at an offset. + /// + static byte[] Jpeg(int width, int height) + { + List bytes = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]; + bytes.AddRange("JFIF\0"u8); + bytes.AddRange(new byte[9]); + bytes.AddRange([0xFF, 0xC0, 0x00, 0x11, 0x08]); + // Height before width, which is the pair this format stores the other way round. + bytes.AddRange([(byte) (height >> 8), (byte) height, (byte) (width >> 8), (byte) width]); + bytes.AddRange(new byte[6]); + return [.. bytes]; + } + + static byte[] Gif(int width, int height) + { + var bytes = new byte[13]; + "GIF89a"u8.CopyTo(bytes); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(6), (ushort) width); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(8), (ushort) height); + return bytes; + } + + static byte[] Bmp(int width, int height) + { + var bytes = new byte[54]; + "BM"u8.CopyTo(bytes); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(14), 40); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(18), width); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(22), -height); + return bytes; + } + + static byte[] Webp(string chunk) + { + var bytes = new byte[30]; + "RIFF"u8.CopyTo(bytes); + "WEBP"u8.CopyTo(bytes.AsSpan(8)); + Encoding.ASCII.GetBytes(chunk).CopyTo(bytes.AsSpan(12)); + return bytes; + } + + static byte[] WebpLossy(int width, int height) + { + var bytes = Webp("VP8 "); + bytes[23] = 0x9D; + bytes[24] = 0x01; + bytes[25] = 0x2A; + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(26), (ushort) width); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(28), (ushort) height); + return bytes; + } + + static byte[] WebpLossless(int width, int height) + { + var bytes = Webp("VP8L"); + bytes[20] = 0x2F; + // Two fourteen bit fields, each one short of the real size. + BinaryPrimitives.WriteUInt32LittleEndian( + bytes.AsSpan(21), + (uint) (width - 1) | ((uint) (height - 1) << 14)); + return bytes; + } + + static byte[] Ico(int width, int height) + { + var bytes = new byte[22]; + bytes[2] = 1; + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(4), 1); + bytes[6] = (byte) (width == 256 ? 0 : width); + bytes[7] = (byte) (height == 256 ? 0 : height); + return bytes; + } +} diff --git a/src/DiffEngineViewer.Tests/ImageScreenTests.DeleteInQueue.verified.txt b/src/DiffEngineViewer.Tests/ImageScreenTests.DeleteInQueue.verified.txt new file mode 100644 index 00000000..b22273e2 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageScreenTests.DeleteInQueue.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| extra.verified.png inline 1 of 1 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (1) | (deleted) | extra.verified.png | ++----------------------+-----------------------------------+-----------------------------------+ +| > extra.verified.png | | - 1 format PNG | +| | | - 2 dimensions 800 x 600 | +| | | - 3 bytes 12,384 | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept delete] [Discard] [Accept all] only extra.verified.png exists | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageScreenTests.Differs.verified.txt b/src/DiffEngineViewer.Tests/ImageScreenTests.Differs.verified.txt new file mode 100644 index 00000000..e0014aca --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageScreenTests.Differs.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| sample.received.png <> sample.verified.png diff | ++----------------------------------------------+-----------------------------------------------+ +| sample.received.png | sample.verified.png | ++----------------------------------------------+-----------------------------------------------+ +| 1 format PNG | 1 format PNG | +| ~ 2 dimensions 800 x 600 | ~ 2 dimensions 640 x 480 | +| ~ 3 bytes 12,384 | ~ 3 bytes 9,120 | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] images differ | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageScreenTests.FormatChanged.verified.txt b/src/DiffEngineViewer.Tests/ImageScreenTests.FormatChanged.verified.txt new file mode 100644 index 00000000..1b736a86 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageScreenTests.FormatChanged.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| sample.received.png <> sample.verified.png diff | ++----------------------------------------------+-----------------------------------------------+ +| sample.received.png | sample.verified.png | ++----------------------------------------------+-----------------------------------------------+ +| ~ 1 format PNG | ~ 1 format JPEG | +| 2 dimensions 800 x 600 | 2 dimensions 800 x 600 | +| ~ 3 bytes 12,384 | ~ 3 bytes 4,002 | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] images differ | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageScreenTests.Identical.verified.txt b/src/DiffEngineViewer.Tests/ImageScreenTests.Identical.verified.txt new file mode 100644 index 00000000..026f9157 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageScreenTests.Identical.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| sample.received.png <> sample.verified.png diff | ++----------------------------------------------+-----------------------------------------------+ +| sample.received.png | sample.verified.png | ++----------------------------------------------+-----------------------------------------------+ +| 1 format PNG | 1 format PNG | +| 2 dimensions 800 x 600 | 2 dimensions 800 x 600 | +| 3 bytes 12,384 | 3 bytes 12,384 | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] images are identical | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageScreenTests.MoveInQueue.verified.txt b/src/DiffEngineViewer.Tests/ImageScreenTests.MoveInQueue.verified.txt new file mode 100644 index 00000000..07eb6aa7 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageScreenTests.MoveInQueue.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| Sample.Test (png) inline 1 of 1 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (1) | sample.received.png | sample.verified.png | ++----------------------+-----------------------------------+-----------------------------------+ +| > Sample.Test (png) | 1 format PNG | 1 format PNG | +| | ~ 2 dimensions 800 x 600 | ~ 2 dimensions 640 x 480 | +| | ~ 3 bytes 12,384 | ~ 3 bytes 9,120 | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept move] [Discard] [Accept all] images differ | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageScreenTests.NewImage.verified.txt b/src/DiffEngineViewer.Tests/ImageScreenTests.NewImage.verified.txt new file mode 100644 index 00000000..d4c764d1 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageScreenTests.NewImage.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| sample.received.png <> sample.verified.png diff | ++----------------------------------------------+-----------------------------------------------+ +| sample.received.png | sample.verified.png | ++----------------------------------------------+-----------------------------------------------+ +| + 1 format PNG | | +| + 2 dimensions 800 x 600 | | +| + 3 bytes 12,384 | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] only sample.received.png exists | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageScreenTests.NotRecognized.verified.txt b/src/DiffEngineViewer.Tests/ImageScreenTests.NotRecognized.verified.txt new file mode 100644 index 00000000..4550f57a --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageScreenTests.NotRecognized.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| sample.received.png <> sample.verified.png diff | ++----------------------------------------------+-----------------------------------------------+ +| sample.received.png | sample.verified.png | ++----------------------------------------------+-----------------------------------------------+ +| ~ 1 format not recognized | ~ 1 format PNG | +| ~ 2 dimensions unknown | ~ 2 dimensions 800 x 600 | +| ~ 3 bytes 27 | ~ 3 bytes 12,384 | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] images differ | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageScreenTests.Unreadable.verified.txt b/src/DiffEngineViewer.Tests/ImageScreenTests.Unreadable.verified.txt new file mode 100644 index 00000000..4949702d --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageScreenTests.Unreadable.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| sample.received.png <> sample.verified.png diff | ++----------------------------------------------+-----------------------------------------------+ +| sample.received.png | sample.verified.png | ++----------------------------------------------+-----------------------------------------------+ +| ~ 1 format not recognized | ~ 1 format PNG | +| ~ 2 dimensions unknown | ~ 2 dimensions 800 x 600 | +| ~ 3 bytes unreadable | ~ 3 bytes 12,384 | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] images could not be compared | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/ImageScreenTests.cs b/src/DiffEngineViewer.Tests/ImageScreenTests.cs new file mode 100644 index 00000000..76b5f463 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ImageScreenTests.cs @@ -0,0 +1,155 @@ +/// +/// How an image comparison reads. +/// +/// Snapshotted through like every other screen, because the rows are +/// what all three heads draw: one line per property, coloured against the other side. A head with a +/// decoder also paints the picture underneath, but nothing about the comparison is only expressible +/// that way, which is the property these hold. +/// +/// +/// Whether the two are the same picture is the one thing the rows cannot say — it belongs to the +/// pair, not to either side — so it lives in the status line, and half of these exist to pin it. +/// +/// +public class ImageScreenTests +{ + [Test] + public Task Identical() => + Verify(Render(Received(), Expected())); + + /// + /// The ordinary failing snapshot: same format, different everything else. + /// + [Test] + public Task Differs() => + Verify(Render( + Received(), + Expected(width: 640, height: 480, length: 9_120, hash: "0B"))); + + /// + /// Re-encoded rather than redrawn: the same pixel size in a different format. The dimensions + /// row matches while the rest does not, which is the case a single verdict per side would lose. + /// + [Test] + public Task FormatChanged() => + Verify(Render( + Received(), + Expected(format: ImageFormat.Jpeg, length: 4_002, hash: "0B"))); + + /// + /// A brand new image snapshot: nothing committed to compare against yet. + /// + [Test] + public Task NewImage() => + Verify(Render(Received(), FileSide.OfText(""))); + + /// + /// Named as a picture, holding something that is not one. Still an image side — the extension + /// is what decides that — so it says what it found rather than rendering the bytes as text. + /// + [Test] + public Task NotRecognized() => + Verify(Render( + new("", null, null, new ImageFile("temp/sample.received.png", 27, null, "0C")), + Expected())); + + /// + /// Locked or otherwise unreadable. No hash means the two were never compared, which must not + /// be reported as them differing. + /// + [Test] + public Task Unreadable() => + Verify(Render( + new("", null, null, ImageFile.Unread("temp/sample.received.png")), + Expected())); + + [Test] + public Task MoveInQueue() => + Verify(Fixtures.Render(Fixtures.Attached( + Fixtures.Pending(), + QueueEntry.ForMove( + "move:temp/sample.received.png", + "Sample.Test (png)", + null, + "temp/sample.received.png", + "code/sample.verified.png", + Received(), + Expected(width: 640, height: 480, length: 9_120, hash: "0B"))))); + + /// + /// The file being deleted sits on the right, so a picture being deleted is the right side's, + /// and the left is the nothing that is left afterwards. + /// + [Test] + public Task DeleteInQueue() => + Verify(Fixtures.Render(Fixtures.Attached( + Fixtures.Pending(), + QueueEntry.ForDelete( + "delete:code/extra.verified.png", + "extra.verified.png", + null, + "code/extra.verified.png", + Expected())))); + + /// + /// The enrichment a head with a decoder draws under the rows. Offered only once the bytes were + /// read and recognized, so a renderer never has to decide whether a path is worth trying. + /// + [Test] + public async Task PanesCarryThePicture() + { + var screen = ScreenBuilder.Build(State(Received(), Expected())); + await Assert.That(screen.Left.Image).IsEqualTo(new ImagePane("temp/sample.received.png", 800, 600)); + await Assert.That(screen.Right.Image).IsEqualTo(new ImagePane("code/sample.verified.png", 800, 600)); + } + + [Test] + public async Task NoPictureWithoutARecognizedHeader() + { + var unread = new FileSide("", null, null, ImageFile.Unread("temp/sample.received.png")); + var screen = ScreenBuilder.Build(State(unread, FileSide.OfText(""))); + await Assert.That(screen.Left.Image).IsNull(); + await Assert.That(screen.Right.Image).IsNull(); + } + + /// + /// A text comparison must be untouched by any of this. + /// + [Test] + public async Task TextPanesCarryNoPicture() + { + var screen = ScreenBuilder.Build(Fixtures.File()); + await Assert.That(screen.Left.Image).IsNull(); + await Assert.That(screen.Right.Image).IsNull(); + } + + static FileSide Received( + ImageFormat format = ImageFormat.Png, + int width = 800, + int height = 600, + long length = 12_384, + string hash = "0A") => + Image("temp/sample.received.png", format, width, height, length, hash); + + static FileSide Expected( + ImageFormat format = ImageFormat.Png, + int width = 800, + int height = 600, + long length = 12_384, + string hash = "0A") => + Image("code/sample.verified.png", format, width, height, length, hash); + + // Forward slashes, and never a real file: an image side is a handful of numbers and a hash, so + // the screens are reachable without writing pictures to a disk that renders paths differently + // on each platform. + static FileSide Image(string path, ImageFormat format, int width, int height, long length, string hash) => + new("", null, null, new ImageFile(path, length, new ImageHeader(format, width, height), hash)); + + static string Render(FileSide left, FileSide right) => + Fixtures.Render(State(left, right)); + + static SessionState State(FileSide left, FileSide right) => + ViewerSession.EnqueueFile( + SessionState.Start(ViewerMode.File, Fixtures.Columns, Fixtures.Rows), + QueueEntry.ForFiles("temp/sample.received.png", "code/sample.verified.png", left, right)); +} diff --git a/src/DiffEngineViewer.Tests/PixelTests.Images.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.Images.Linux.verified.png new file mode 100644 index 00000000..d4582e19 Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.Images.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.Images.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.Images.OSX.verified.png new file mode 100644 index 00000000..4cb1e991 Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.Images.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.cs b/src/DiffEngineViewer.Tests/PixelTests.cs index 68a1608c..91d41804 100644 --- a/src/DiffEngineViewer.Tests/PixelTests.cs +++ b/src/DiffEngineViewer.Tests/PixelTests.cs @@ -105,6 +105,21 @@ public Task LongQueueLabel() => public Task GroupedConflictedQueue() => Capture(Fixtures.GroupedConflicted()); + /// + /// An image comparison: the pictures drawn under the rows every head draws. Mirrored in + /// WindowsPixelTests over the same fixture, which is what holds the three heads to one + /// placement rule — one blank line under the pane's own rows, fitted, never enlarged. + /// + /// PNG, which every head decodes. Where they differ is which other formats they can read at + /// all, and that difference is not visible in a picture: a format a head has no decoder for + /// draws nothing and the rows carry the comparison, which the ASCII screens already describe. + /// + /// + [Test] + [PixelTest] + public Task Images() => + Capture(Fixtures.Images()); + /// /// The context menu floated over the grouped queue, opened on the conflicted entry. /// diff --git a/src/DiffEngineViewer.Tests/SamplePng.cs b/src/DiffEngineViewer.Tests/SamplePng.cs new file mode 100644 index 00000000..0f58d8b2 --- /dev/null +++ b/src/DiffEngineViewer.Tests/SamplePng.cs @@ -0,0 +1,118 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; + +/// +/// A real, decodable PNG built byte by byte. +/// +/// Not a platform encoder, because these bytes reach committed pixel baselines on three platforms: +/// the pane prints the file's byte count, so an encoder that packs one byte differently on one of +/// them would move a number on screen and fail a baseline for a reason that has nothing to do with +/// the viewer. Stored deflate blocks and no filtering make the output a function of the arguments +/// alone. +/// +/// +/// PNG rather than a spread of formats, because every head has a decoder for it. The formats that +/// only some of them read are covered by the ASCII screens, which is where the difference is +/// visible: the rows are what all three draw. +/// +/// +static class SamplePng +{ + /// + /// A solid colour whose right hand half fades to fully transparent, so a capture shows both + /// the picture and the checkerboard that has to be visible through it. + /// + public static byte[] Build(int width, int height, byte red, byte green, byte blue) + { + var raw = new byte[height * (1 + width * 4)]; + var index = 0; + for (var y = 0; y < height; y++) + { + // Filter type 0: the row is stored as it is. The other four exist to help a compressor + // that is not being asked to compress anything here. + raw[index++] = 0; + for (var x = 0; x < width; x++) + { + raw[index++] = red; + raw[index++] = green; + raw[index++] = blue; + raw[index++] = Alpha(x, width); + } + } + + List bytes = [0x89, (byte) 'P', (byte) 'N', (byte) 'G', 0x0D, 0x0A, 0x1A, 0x0A]; + var header = new byte[13]; + BinaryPrimitives.WriteInt32BigEndian(header, width); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), height); + // Eight bits a channel, truecolour with alpha, and none of the three optional encodings. + header[8] = 8; + header[9] = 6; + Chunk(bytes, "IHDR", header); + Chunk(bytes, "IDAT", Compress(raw)); + Chunk(bytes, "IEND", []); + return [.. bytes]; + } + + static byte Alpha(int x, int width) + { + var half = width / 2; + if (x < half) + { + return 255; + } + + return (byte) (255 - 255 * (x - half) / Math.Max(1, width - half - 1)); + } + + /// + /// A zlib stream of stored blocks. Deterministic across runtimes in a way a real deflate is + /// not promised to be, which is the whole reason this exists rather than a call to an encoder. + /// + static byte[] Compress(byte[] raw) + { + using var output = new MemoryStream(); + using (var zlib = new ZLibStream(output, CompressionLevel.NoCompression, leaveOpen: true)) + { + zlib.Write(raw); + } + + return output.ToArray(); + } + + static void Chunk(List bytes, string name, byte[] data) + { + var length = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, data.Length); + bytes.AddRange(length); + + // The name and the data are one run for the checksum, which covers both and not the length. + var payload = new byte[4 + data.Length]; + Encoding.ASCII.GetBytes(name).CopyTo(payload, 0); + data.CopyTo(payload, 4); + bytes.AddRange(payload); + + var crc = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(crc, Crc(payload)); + bytes.AddRange(crc); + } + + /// + /// CRC-32 a bit at a time. A table would be faster and this runs over a few hundred bytes in a + /// test. + /// + static uint Crc(byte[] bytes) + { + var value = 0xFFFFFFFFu; + foreach (var current in bytes) + { + value ^= current; + for (var bit = 0; bit < 8; bit++) + { + value = (value & 1) == 0 ? value >> 1 : 0xEDB88320u ^ (value >> 1); + } + } + + return value ^ 0xFFFFFFFFu; + } +} diff --git a/src/DiffEngineViewer.Windows.Tests/DiffEngineViewer.Windows.Tests.csproj b/src/DiffEngineViewer.Windows.Tests/DiffEngineViewer.Windows.Tests.csproj index ab3e70a8..0ab7b67e 100644 --- a/src/DiffEngineViewer.Windows.Tests/DiffEngineViewer.Windows.Tests.csproj +++ b/src/DiffEngineViewer.Windows.Tests/DiffEngineViewer.Windows.Tests.csproj @@ -25,5 +25,6 @@ so the WinForms baselines describe the same screens the text ones do. --> + diff --git a/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs b/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs new file mode 100644 index 00000000..3e295243 --- /dev/null +++ b/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs @@ -0,0 +1,91 @@ +/// +/// The decode the WinForms head puts under an image pane's rows. +/// +/// OnPaint runs on every wheel notch and every resize, so the two properties worth holding +/// are that a picture is decoded once and that decoding it does not leave a handle on the file — +/// the received file is the one accepting is about to copy over. +/// +/// +public class ImageCacheTests +{ + [Test] + public async Task DecodesOnceAndKeepsIt() + { + var path = Write("decoded.png", SamplePng.Build(8, 6, 200, 40, 40)); + using var cache = new ImageCache(); + + var first = cache.Get(path); + await Assert.That(first).IsNotNull(); + await Assert.That(first!.Width).IsEqualTo(8); + await Assert.That(first.Height).IsEqualTo(6); + await Assert.That(ReferenceEquals(cache.Get(path), first)).IsTrue(); + } + + /// + /// The hazard this cache is written around. GDI+ holds the stream it was handed for as long as + /// the image lives, so decoding straight from the file would make the viewer the reason its own + /// accept fails. + /// + [Test] + public async Task LeavesNoHandleOnTheFile() + { + var path = Write("copied-over.png", SamplePng.Build(8, 6, 200, 40, 40)); + using var cache = new ImageCache(); + await Assert.That(cache.Get(path)).IsNotNull(); + + var replacement = Write("replacement.png", SamplePng.Build(4, 4, 40, 200, 40)); + File.Copy(replacement, path, true); + } + + /// + /// A re-run rewrites the received file underneath an open window, and the pane has to follow it + /// rather than keep showing what was there when it was first drawn. + /// + [Test] + public async Task RedecodesWhenTheFileChanges() + { + var path = Write("rewritten.png", SamplePng.Build(8, 6, 200, 40, 40)); + using var cache = new ImageCache(); + await Assert.That(cache.Get(path)!.Width).IsEqualTo(8); + + // A different size, so the change is visible whatever the file system's timestamp + // resolution turns out to be. + File.WriteAllBytes(path, SamplePng.Build(4, 4, 40, 200, 40)); + await Assert.That(cache.Get(path)!.Width).IsEqualTo(4); + } + + /// + /// Something named as a picture that this machine cannot decode draws as nothing, and is + /// attempted once rather than once per frame. The rows have already said what it is. + /// + [Test] + public async Task RemembersAFailure() + { + var path = Write("notreally.png", "the quick brown fox"u8.ToArray()); + using var cache = new ImageCache(); + + await Assert.That(cache.Get(path)).IsNull(); + await Assert.That(cache.Get(path)).IsNull(); + } + + [Test] + public async Task MissingFile() + { + using var cache = new ImageCache(); + await Assert.That(cache.Get(Path.Combine(Directory(), "gone.png"))).IsNull(); + } + + static string Write(string name, byte[] content) + { + var path = Path.Combine(Directory(), name); + File.WriteAllBytes(path, content); + return path; + } + + static string Directory() + { + var path = Path.Combine(Path.GetTempPath(), "deview-image-cache"); + System.IO.Directory.CreateDirectory(path); + return path; + } +} diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png new file mode 100644 index 00000000..be334206 Binary files /dev/null and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs index 31e09cc3..59710b67 100644 --- a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs +++ b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs @@ -98,6 +98,14 @@ public Task LongQueueLabel() => public Task GroupedConflictedQueue() => Capture(Fixtures.GroupedConflicted()); + /// + /// An image comparison: the pictures drawn under the rows every head draws. Mirrored in the + /// native suite, as ever, which is what holds the three heads to one placement rule. + /// + [Test] + public Task Images() => + Capture(Fixtures.Images()); + // No menu case here: this head shows a real popup, which is a top level window and so cannot // appear in a capture of the client area. ContextMenuTests renders the strip itself instead. diff --git a/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj b/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj index 03781b1d..6a1785b4 100644 --- a/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj +++ b/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj @@ -61,6 +61,8 @@ ones a using brings in, so unqualified Button is the model's and the control needs a name. --> + + diff --git a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs index 86eb68cd..72ce838b 100644 --- a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs +++ b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs @@ -123,7 +123,7 @@ public bool Capture(Screen screen, int width, int height, string pngPath) using var bitmap = new Bitmap(width, height); form.Surface.DrawToBitmap(bitmap, new(0, 0, width, height)); - bitmap.Save(pngPath, ImageFormat.Png); + bitmap.Save(pngPath, DrawingImageFormat.Png); } finally { diff --git a/src/DiffEngineViewer.Windows/ImageCache.cs b/src/DiffEngineViewer.Windows/ImageCache.cs new file mode 100644 index 00000000..62533991 --- /dev/null +++ b/src/DiffEngineViewer.Windows/ImageCache.cs @@ -0,0 +1,95 @@ +/// +/// Decoded pictures for the panes, keyed by the path the screen model handed over and invalidated +/// by the file's write time and length — the same freshness test the queue poller uses, so a re-run +/// that rewrites a received image refreshes the pane rather than leaving the old one up. +/// +/// A cache and not a convenience: OnPaint runs on every wheel notch and every resize, and +/// decoding a picture per frame is what turns a window that is merely showing something into one +/// that is busy. +/// +/// +sealed class ImageCache : IDisposable +{ + readonly Dictionary entries = new(StringComparer.OrdinalIgnoreCase); + + /// + /// A null is a remembered failure. Kept rather than dropped, so a file + /// this machine cannot decode is attempted once instead of once per frame. + /// + record Entry(long WriteTicksUtc, long Length, Image? Image); + + public Image? Get(string path) + { + long ticks; + long length; + try + { + var info = new FileInfo(path); + if (!info.Exists) + { + Forget(path); + return null; + } + + ticks = info.LastWriteTimeUtc.Ticks; + length = info.Length; + } + catch + { + // A file that cannot be stat'd cannot be drawn, and the rows have already said what + // the model made of it. + Forget(path); + return null; + } + + if (entries.TryGetValue(path, out var entry)) + { + if (entry.WriteTicksUtc == ticks && + entry.Length == length) + { + return entry.Image; + } + + Forget(path); + } + + var image = Load(path); + entries.Add(path, new(ticks, length, image)); + return image; + } + + static Image? Load(string path) + { + try + { + // Decoded from a copy of the bytes and then copied again. GDI+ holds on to the stream + // it was handed for as long as the image lives, and a viewer keeping a handle on the + // received file is one that blocks the accept it exists to perform. + using var stream = new MemoryStream(File.ReadAllBytes(path)); + using var decoded = new Bitmap(stream); + return new Bitmap(decoded); + } + catch + { + return null; + } + } + + void Forget(string path) + { + if (entries.Remove(path, out var entry)) + { + entry.Image?.Dispose(); + } + } + + public void Dispose() + { + foreach (var entry in entries.Values) + { + entry.Image?.Dispose(); + } + + entries.Clear(); + } +} diff --git a/src/DiffEngineViewer.Windows/Palette.cs b/src/DiffEngineViewer.Windows/Palette.cs index 9ac84aa5..7947106f 100644 --- a/src/DiffEngineViewer.Windows/Palette.cs +++ b/src/DiffEngineViewer.Windows/Palette.cs @@ -16,6 +16,15 @@ static class Palette public static readonly Color Rule = Color.FromArgb(70, 70, 70); + /// + /// The two squares behind a picture. Dark enough not to compete with the image, and different + /// enough from each other that a transparent region is obviously transparent rather than + /// just flat. + /// + public static readonly Color CheckerLight = Color.FromArgb(64, 64, 64); + + public static readonly Color CheckerDark = Color.FromArgb(48, 48, 48); + /// /// ImGui draws a selected Selectable as its accent at 31% over the window background. This is /// that composite, so the queue highlight matches without carrying an alpha channel around. diff --git a/src/DiffEngineViewer.Windows/ViewerCanvas.cs b/src/DiffEngineViewer.Windows/ViewerCanvas.cs index 63559b64..2b4e8742 100644 --- a/src/DiffEngineViewer.Windows/ViewerCanvas.cs +++ b/src/DiffEngineViewer.Windows/ViewerCanvas.cs @@ -44,10 +44,18 @@ sealed class ViewerCanvas : Control const int padding = 6; const int gap = 4; + /// + /// The side of a checker square behind a picture, so an image with transparency reads as + /// transparent rather than as whatever colour the pane happens to be. + /// + const int checker = 8; + readonly Font font = MonoFont.Create(); readonly QueueTips tips = new(); + readonly ImageCache images = new(); + Screen? screen; /// @@ -198,6 +206,13 @@ protected override void OnPaint(PaintEventArgs e) } var bodyBottom = bodyTop + capacity * lineHeight; + + // Under the rows rather than instead of them. The rows are what every head draws — format, + // size and byte count, coloured against the other side — and this head can afford to also + // show the thing they describe. + DrawImage(graphics, screen.Left, panesLeft, half, bodyTop, bodyBottom, lineHeight); + DrawImage(graphics, screen.Right, panesLeft + half, panesWidth - half, bodyTop, bodyBottom, lineHeight); + if (hasQueue) { DrawColumnRule(graphics, panesLeft - gap / 2, bodyTop, bodyBottom); @@ -206,6 +221,84 @@ protected override void OnPaint(PaintEventArgs e) DrawColumnRule(graphics, panesLeft + half - gap / 2, bodyTop, bodyBottom); } + void DrawImage(Graphics graphics, Pane pane, int left, int width, int bodyTop, int bodyBottom, int lineHeight) + { + if (pane.Image is not { } image) + { + return; + } + + var picture = images.Get(image.Path); + if (picture is null) + { + return; + } + + var top = bodyTop + pane.Rows.Count * lineHeight + lineHeight; + var available = new Rectangle(left, top, width - gap, bodyBottom - top); + if (available.Width <= 0 || + available.Height <= 0) + { + return; + } + + // Fitted, and never enlarged past its own size: a snapshot is judged against the pixels it + // has, and an eight pixel icon stretched across a pane is an interpolation of them rather + // than a look at them. + // + // Scaled from the size the model carries rather than from the decoded bitmap, so all three + // heads place a picture identically even where their decoders would not agree. + var scale = Math.Min( + Math.Min( + available.Width / (double) image.Width, + available.Height / (double) image.Height), + 1); + var drawn = new Size( + Math.Max(1, (int) (image.Width * scale)), + Math.Max(1, (int) (image.Height * scale))); + var bounds = new Rectangle( + available.X + (available.Width - drawn.Width) / 2, + available.Y + (available.Height - drawn.Height) / 2, + drawn.Width, + drawn.Height); + + DrawChecker(graphics, bounds); + + var interpolation = graphics.InterpolationMode; + var offset = graphics.PixelOffsetMode; + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + graphics.DrawImage(picture, bounds); + // Put back, because the text drawing this shares a Graphics with is set up once by Painter + // and would otherwise inherit whichever picture was drawn last. + graphics.InterpolationMode = interpolation; + graphics.PixelOffsetMode = offset; + + // An outline, so a picture whose edges are the colour of the pane still has visible extent. + using var pen = new Pen(Palette.Rule); + graphics.DrawRectangle(pen, bounds.X - 1, bounds.Y - 1, bounds.Width + 1, bounds.Height + 1); + } + + static void DrawChecker(Graphics graphics, Rectangle bounds) + { + graphics.FillRectangle(Painter.Brush(Palette.CheckerLight), bounds); + var dark = Painter.Brush(Palette.CheckerDark); + for (var y = bounds.Y; y < bounds.Bottom; y += checker) + { + for (var x = bounds.X; x < bounds.Right; x += checker) + { + if ((x - bounds.X) / checker % 2 == (y - bounds.Y) / checker % 2) + { + continue; + } + + graphics.FillRectangle( + dark, + Rectangle.Intersect(new(x, y, checker, checker), bounds)); + } + } + } + void DrawTitle(Graphics graphics, int lineHeight) { Painter.Draw(graphics, screen!.Title, font, Palette.Text, Cellular(padding, padding, Width - padding * 2, lineHeight)); @@ -424,6 +517,7 @@ protected override void Dispose(bool disposing) { font.Dispose(); tips.Dispose(); + images.Dispose(); } base.Dispose(disposing); diff --git a/src/DiffEngineViewer/DiffEngineViewer.csproj b/src/DiffEngineViewer/DiffEngineViewer.csproj index fb86251c..7022e34e 100644 --- a/src/DiffEngineViewer/DiffEngineViewer.csproj +++ b/src/DiffEngineViewer/DiffEngineViewer.csproj @@ -23,6 +23,11 @@ + + diff --git a/src/DiffEngineViewer/FileSide.cs b/src/DiffEngineViewer/FileSide.cs new file mode 100644 index 00000000..61719abc --- /dev/null +++ b/src/DiffEngineViewer/FileSide.cs @@ -0,0 +1,71 @@ +/// +/// Freshness token for a file a move or delete entry renders: enough to tell "unchanged since the +/// last pump" without reading the content again. +/// +readonly record struct FileStamp(long WriteTicksUtc, long Length); + +/// +/// One guarded read of a file a side of the diff points at, as whichever of text or picture its +/// extension says it is. Never throws: the poller that materializes these must survive a file +/// vanishing mid-pump — a throw there closes the window as "owner gone" — so a missing or locked +/// file degrades to an empty side plus a warning. +/// +readonly record struct FileSide(string Text, FileStamp? Stamp, string? Warning, ImageFile? Image) +{ + /// + /// Text that never came off a disk, which is what every test and every in-memory caller has. + /// + public static FileSide OfText(string text) => + new(text, null, null, null); + + public static FileSide Read(string path) + { + try + { + var info = new FileInfo(path); + if (!info.Exists) + { + // Normal for a move: a brand new snapshot has no verified file yet. An absent + // image is absent rather than unreadable, so it renders as the empty side it is. + return new("", null, null, null); + } + + var stamp = new FileStamp(info.LastWriteTimeUtc.Ticks, info.Length); + if (!ImageExtensions.Is(path)) + { + return new(File.ReadAllText(path), stamp, null, null); + } + + return new("", stamp, null, ImageFile.Read(path, File.ReadAllBytes(path))); + } + catch (Exception exception) + { + return new("", null, $"Could not read {path}. {exception.Message}", Unread(path)); + } + } + + /// + /// A locked or unreadable image is still an image, so the pane names the picture it could not + /// read rather than presenting it as text that happens to be empty. + /// + static ImageFile? Unread(string path) => + ImageExtensions.Is(path) ? ImageFile.Unread(path) : null; + + public static FileStamp? StampOf(string path) + { + try + { + var info = new FileInfo(path); + if (!info.Exists) + { + return null; + } + + return new FileStamp(info.LastWriteTimeUtc.Ticks, info.Length); + } + catch + { + return null; + } + } +} diff --git a/src/DiffEngineViewer/FileText.cs b/src/DiffEngineViewer/FileText.cs deleted file mode 100644 index e51151f9..00000000 --- a/src/DiffEngineViewer/FileText.cs +++ /dev/null @@ -1,53 +0,0 @@ -/// -/// Freshness token for a file a move or delete entry renders: enough to tell "unchanged since the -/// last pump" without reading the content again. -/// -readonly record struct FileStamp(long WriteTicksUtc, long Length); - -/// -/// One guarded read of a file a tracked move or delete points at. Never throws: the poller that -/// materializes these must survive a file vanishing mid-pump — a throw there closes the window as -/// "owner gone" — so a missing or locked file degrades to empty text plus a warning. -/// -readonly record struct FileText(string Text, FileStamp? Stamp, string? Warning) -{ - public static FileText Read(string path) - { - try - { - var info = new FileInfo(path); - if (!info.Exists) - { - // Normal for a move: a brand new snapshot has no verified file yet. - return new("", null, null); - } - - return new( - File.ReadAllText(path), - new FileStamp(info.LastWriteTimeUtc.Ticks, info.Length), - null); - } - catch (Exception exception) - { - return new("", null, $"Could not read {path}. {exception.Message}"); - } - } - - public static FileStamp? StampOf(string path) - { - try - { - var info = new FileInfo(path); - if (!info.Exists) - { - return null; - } - - return new FileStamp(info.LastWriteTimeUtc.Ticks, info.Length); - } - catch - { - return null; - } - } -} diff --git a/src/DiffEngineViewer/Images/ImageFile.cs b/src/DiffEngineViewer/Images/ImageFile.cs new file mode 100644 index 00000000..028dca85 --- /dev/null +++ b/src/DiffEngineViewer/Images/ImageFile.cs @@ -0,0 +1,29 @@ +/// +/// One side of a comparison that is a picture rather than text. +/// +/// Deliberately not the bytes. A head that draws pixels reads the file itself and caches what it +/// decoded, so holding a queue of images in the session would be megabytes kept alive to answer a +/// question a hash already answers. +/// +/// +/// Null when the bytes are not a format the viewer recognizes. +/// +/// Null when the content could not be read, which is the difference between "these differ" and +/// "these could not be compared". +/// +readonly record struct ImageFile(string Path, long Length, ImageHeader? Header, string? Hash) +{ + public static ImageFile Read(string path, byte[] bytes) => + new( + path, + bytes.Length, + ImageHeader.TryRead(bytes, out var header) ? header : null, + Convert.ToHexString(SHA256.HashData(bytes))); + + /// + /// A file that is an image by its name and nothing more: what is left when the bytes could not + /// be read at all. Still an image side, so the pane says so rather than showing empty text. + /// + public static ImageFile Unread(string path) => + new(path, 0, null, null); +} diff --git a/src/DiffEngineViewer/Images/ImageFormat.cs b/src/DiffEngineViewer/Images/ImageFormat.cs new file mode 100644 index 00000000..05e3a9a1 --- /dev/null +++ b/src/DiffEngineViewer/Images/ImageFormat.cs @@ -0,0 +1,14 @@ +/// +/// What an image side's bytes turned out to be. Not the same question as +/// , which answers whether a path is an image at all: a file named +/// .png holding something else is an image side with an unrecognized format, and says so. +/// +enum ImageFormat +{ + Png, + Jpeg, + Gif, + Bmp, + Webp, + Ico +} diff --git a/src/DiffEngineViewer/Images/ImageHeader.cs b/src/DiffEngineViewer/Images/ImageHeader.cs new file mode 100644 index 00000000..1cb83c6a --- /dev/null +++ b/src/DiffEngineViewer/Images/ImageHeader.cs @@ -0,0 +1,282 @@ +using System.Buffers.Binary; + +/// +/// The format and pixel size of an image, read from its leading bytes without decoding it. +/// +/// Hand rolled rather than System.Drawing, because this assembly is the renderer independent half +/// of the viewer and runs on macOS and Linux, where System.Drawing.Common does not. Only the +/// Windows head has a decoder, and the screen model may not describe an image differently +/// depending on which head is about to draw it. +/// +/// +/// A format may be recognized without its size being readable — a JPEG whose frame header sits +/// past the bytes handed in, for instance — so is a separate question from +/// whether this was recognized at all. +/// +/// +readonly record struct ImageHeader(ImageFormat Format, int Width, int Height) +{ + public bool HasSize => + Width > 0 && + Height > 0; + + public static bool TryRead(ReadOnlySpan bytes, out ImageHeader header) => + TryPng(bytes, out header) || + TryJpeg(bytes, out header) || + TryGif(bytes, out header) || + TryBmp(bytes, out header) || + TryWebp(bytes, out header) || + TryIco(bytes, out header); + + static bool TryPng(ReadOnlySpan bytes, out ImageHeader header) + { + header = default; + ReadOnlySpan signature = [0x89, (byte) 'P', (byte) 'N', (byte) 'G', 0x0D, 0x0A, 0x1A, 0x0A]; + if (bytes.Length < 24 || + !bytes[..8].SequenceEqual(signature)) + { + return false; + } + + // IHDR is required to be the first chunk, so the size is at a fixed offset rather than + // somewhere in a chunk walk. + header = new( + ImageFormat.Png, + BinaryPrimitives.ReadInt32BigEndian(bytes[16..]), + BinaryPrimitives.ReadInt32BigEndian(bytes[20..])); + return true; + } + + /// + /// The one format that has to be walked: the frame header giving the size follows however many + /// metadata segments the encoder chose to write, and Exif thumbnails routinely push it past the + /// first few hundred bytes. + /// + static bool TryJpeg(ReadOnlySpan bytes, out ImageHeader header) + { + header = default; + if (bytes.Length < 4 || + bytes[0] != 0xFF || + bytes[1] != 0xD8) + { + return false; + } + + // Recognized from the two byte signature, so a truncated or unwalkable file is still + // reported as a JPEG of unknown size rather than as not an image. + header = new(ImageFormat.Jpeg, 0, 0); + var index = 2; + while (index + 1 < bytes.Length) + { + if (bytes[index] != 0xFF) + { + return true; + } + + var marker = bytes[index + 1]; + index += 2; + + // Fill bytes before a marker, and the standalone markers, carry no length to skip. + if (marker == 0xFF) + { + index--; + continue; + } + + if (marker == 0x01 || + marker is >= 0xD0 and <= 0xD9) + { + continue; + } + + if (index + 1 >= bytes.Length) + { + return true; + } + + var length = BinaryPrimitives.ReadUInt16BigEndian(bytes[index..]); + if (IsStartOfFrame(marker)) + { + if (index + 6 >= bytes.Length) + { + return true; + } + + // Two bytes of length, one of sample precision, then height before width. + header = new( + ImageFormat.Jpeg, + BinaryPrimitives.ReadUInt16BigEndian(bytes[(index + 5)..]), + BinaryPrimitives.ReadUInt16BigEndian(bytes[(index + 3)..])); + return true; + } + + // A length shorter than the field itself would not advance, so the walk would not end. + if (length < 2) + { + return true; + } + + index += length; + } + + return true; + } + + /// + /// C0 through CF are the frame headers, apart from the huffman table, the arithmetic coding + /// table, and the reserved marker between them. + /// + static bool IsStartOfFrame(byte marker) => + marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC; + + static bool TryGif(ReadOnlySpan bytes, out ImageHeader header) + { + header = default; + if (bytes.Length < 10 || + !bytes[..4].SequenceEqual("GIF8"u8) || + bytes[4] is not ((byte) '7' or (byte) '9') || + bytes[5] != (byte) 'a') + { + return false; + } + + header = new( + ImageFormat.Gif, + BinaryPrimitives.ReadUInt16LittleEndian(bytes[6..]), + BinaryPrimitives.ReadUInt16LittleEndian(bytes[8..])); + return true; + } + + static bool TryBmp(ReadOnlySpan bytes, out ImageHeader header) + { + header = default; + if (bytes.Length < 22 || + bytes[0] != (byte) 'B' || + bytes[1] != (byte) 'M') + { + return false; + } + + // The original OS/2 header is the only one with sixteen bit dimensions. Every later one + // starts with the same thirty-two bit pair, whatever else it goes on to add. + if (BinaryPrimitives.ReadInt32LittleEndian(bytes[14..]) == 12) + { + header = new( + ImageFormat.Bmp, + BinaryPrimitives.ReadUInt16LittleEndian(bytes[18..]), + BinaryPrimitives.ReadUInt16LittleEndian(bytes[20..])); + return true; + } + + if (bytes.Length < 26) + { + header = new(ImageFormat.Bmp, 0, 0); + return true; + } + + // A negative height means the rows are stored top down, which is not something the size + // should report. + header = new( + ImageFormat.Bmp, + BinaryPrimitives.ReadInt32LittleEndian(bytes[18..]), + Math.Abs(BinaryPrimitives.ReadInt32LittleEndian(bytes[22..]))); + return true; + } + + static bool TryWebp(ReadOnlySpan bytes, out ImageHeader header) + { + header = default; + if (bytes.Length < 16 || + !bytes[..4].SequenceEqual("RIFF"u8) || + !bytes[8..12].SequenceEqual("WEBP"u8)) + { + return false; + } + + var chunk = bytes[12..16]; + + // Recognized from the container, so a chunk this does not read still reports a WebP of + // unknown size rather than falling through to "not an image". + header = new(ImageFormat.Webp, 0, 0); + + // Lossless: a signature byte, then two fourteen bit fields packed one short of their size. + if (chunk.SequenceEqual("VP8L"u8)) + { + if (bytes.Length >= 25 && + bytes[20] == 0x2F) + { + header = Packed(BinaryPrimitives.ReadUInt32LittleEndian(bytes[21..])); + } + + return true; + } + + // Extended: an animation, an alpha channel or metadata, whose canvas size stands in for the + // size of the frames inside it. + if (chunk.SequenceEqual("VP8X"u8)) + { + if (bytes.Length >= 30) + { + header = new( + ImageFormat.Webp, + ThreeByte(bytes[24..]) + 1, + ThreeByte(bytes[27..]) + 1); + } + + return true; + } + + if (!chunk.SequenceEqual("VP8 "u8)) + { + return true; + } + + // Lossy: the size follows the keyframe start code, and the top two bits of each field are + // a scaling hint rather than part of the number. + ReadOnlySpan startCode = [0x9D, 0x01, 0x2A]; + if (bytes.Length < 30 || + !bytes[23..26].SequenceEqual(startCode)) + { + return true; + } + + header = new( + ImageFormat.Webp, + BinaryPrimitives.ReadUInt16LittleEndian(bytes[26..]) & 0x3FFF, + BinaryPrimitives.ReadUInt16LittleEndian(bytes[28..]) & 0x3FFF); + return true; + } + + static ImageHeader Packed(uint bits) => + new( + ImageFormat.Webp, + (int) (bits & 0x3FFF) + 1, + (int) ((bits >> 14) & 0x3FFF) + 1); + + static int ThreeByte(ReadOnlySpan bytes) => + bytes[0] | (bytes[1] << 8) | (bytes[2] << 16); + + /// + /// An icon is a container, so this is the size of its first image. Zero in either byte means + /// 256, which is what the field could not hold once icons outgrew it. + /// + static bool TryIco(ReadOnlySpan bytes, out ImageHeader header) + { + header = default; + if (bytes.Length < 8 || + bytes[0] != 0 || + bytes[1] != 0 || + bytes[2] != 1 || + bytes[3] != 0 || + BinaryPrimitives.ReadUInt16LittleEndian(bytes[4..]) == 0) + { + return false; + } + + header = new( + ImageFormat.Ico, + bytes[6] == 0 ? 256 : bytes[6], + bytes[7] == 0 ? 256 : bytes[7]); + return true; + } +} diff --git a/src/DiffEngineViewer/Images/ImageRows.cs b/src/DiffEngineViewer/Images/ImageRows.cs new file mode 100644 index 00000000..47b819c0 --- /dev/null +++ b/src/DiffEngineViewer/Images/ImageRows.cs @@ -0,0 +1,123 @@ +using System.Globalization; + +/// +/// Turns two image sides into two equal length row lists, the same shape +/// produces for text, so an image comparison lays out through the machinery +/// every head already has rather than through anything new. +/// +/// One row per property, each carrying its own side's value and coloured by how it stands against +/// the other: a matching format reads as unchanged and a differing size as modified, which is the +/// same vocabulary a line of text gets. A side with no file at all is filler the whole way down, +/// the way an empty text side is. +/// +/// +static class ImageRows +{ + /// + /// Wide enough for the longest label plus a gap. + /// + const int labelWidth = 12; + + public static (IReadOnlyList Left, IReadOnlyList Right) Build(ImageFile? left, ImageFile? right) + { + var leftRows = new List(3); + var rightRows = new List(3); + Add(leftRows, rightRows, "format", Format(left), Format(right)); + Add(leftRows, rightRows, "dimensions", Dimensions(left), Dimensions(right)); + Add(leftRows, rightRows, "bytes", Bytes(left), Bytes(right)); + return (leftRows, rightRows); + } + + static void Add(List left, List right, string label, string? leftValue, string? rightValue) + { + // Numbered rather than left without a number. Filler is the only row the shim reads a + // missing number for; a numberless row of any other kind draws its gutter differently on + // each of the three heads, and these are rows, so numbering them costs nothing. + var number = left.Count + 1; + left.Add(Cell(number, label, leftValue, rightValue, RowKind.Added)); + right.Add(Cell(number, label, rightValue, leftValue, RowKind.Removed)); + } + + /// + /// What this side is when the other has no file: added on the received side, removed on the + /// expected one, matching which way round reads. + /// + static Row Cell(int number, string label, string? value, string? other, RowKind only) + { + if (value is null) + { + return new(null, RowKind.Filler, ""); + } + + RowKind kind; + if (other is null) + { + kind = only; + } + else + { + kind = value == other ? RowKind.Unchanged : RowKind.Modified; + } + + return new(number, kind, $"{label.PadRight(labelWidth)}{value}"); + } + + static string? Format(ImageFile? image) + { + if (image is not { } file) + { + return null; + } + + if (file.Header is not { } header) + { + return "not recognized"; + } + + return Name(header.Format); + } + + static string Name(ImageFormat format) => + format switch + { + ImageFormat.Png => "PNG", + ImageFormat.Jpeg => "JPEG", + ImageFormat.Gif => "GIF", + ImageFormat.Bmp => "BMP", + ImageFormat.Webp => "WebP", + _ => "ICO" + }; + + static string? Dimensions(ImageFile? image) + { + if (image is not { } file) + { + return null; + } + + if (file.Header is not { HasSize: true } header) + { + return "unknown"; + } + + return $"{header.Width} x {header.Height}"; + } + + static string? Bytes(ImageFile? image) + { + if (image is not { } file) + { + return null; + } + + // No hash means the bytes never arrived, so the length is zero because nothing was read + // rather than because the file is empty. + if (file.Hash is null) + { + return "unreadable"; + } + + // Invariant, so the snapshots do not depend on the machine's group separator. + return file.Length.ToString("N0", CultureInfo.InvariantCulture); + } +} diff --git a/src/DiffEngineViewer/Ipc/OwnerLink.cs b/src/DiffEngineViewer/Ipc/OwnerLink.cs index 32cbbe33..9ec9f817 100644 --- a/src/DiffEngineViewer/Ipc/OwnerLink.cs +++ b/src/DiffEngineViewer/Ipc/OwnerLink.cs @@ -172,8 +172,8 @@ List ReadChanges(ViewerResponse response) if (existing.TryGetValue(move.Key, out var entry) && entry.LeftFile == move.Temp && entry.TargetFile == move.Target && - entry.LeftStamp == FileText.StampOf(move.Temp) && - entry.RightStamp == FileText.StampOf(move.Target)) + entry.LeftStamp == FileSide.StampOf(move.Temp) && + entry.RightStamp == FileSide.StampOf(move.Target)) { changes.Add(entry); continue; @@ -185,15 +185,15 @@ List ReadChanges(ViewerResponse response) move.Group, move.Temp, move.Target, - FileText.Read(move.Temp), - FileText.Read(move.Target))); + FileSide.Read(move.Temp), + FileSide.Read(move.Target))); } foreach (var delete in response.Deletes) { if (existing.TryGetValue(delete.Key, out var entry) && entry.LeftFile == delete.File && - entry.LeftStamp == FileText.StampOf(delete.File)) + entry.LeftStamp == FileSide.StampOf(delete.File)) { changes.Add(entry); continue; @@ -204,7 +204,7 @@ List ReadChanges(ViewerResponse response) delete.Name, delete.Group, delete.File, - FileText.Read(delete.File))); + FileSide.Read(delete.File))); } return changes; diff --git a/src/DiffEngineViewer/Model/ImagePane.cs b/src/DiffEngineViewer/Model/ImagePane.cs new file mode 100644 index 00000000..65a2e68f --- /dev/null +++ b/src/DiffEngineViewer/Model/ImagePane.cs @@ -0,0 +1,16 @@ +/// +/// A picture for a head to draw under the pane's rows, and the model's whole statement about it: a +/// path on this machine and the size the file says it is. +/// +/// The rows are still the universal description — format, dimensions and byte count, coloured +/// against the other side — and every head draws those. This is an enrichment on top for a head +/// that has a decoder, so a head without one shows a comparison that is smaller rather than one +/// that is wrong. The heads therefore still consume one structure; they differ in how much of it +/// they can honour. +/// +/// +/// Null unless the bytes were read and recognized, so a head never has to decide whether a path is +/// worth trying. +/// +/// +record ImagePane(string Path, int Width, int Height); diff --git a/src/DiffEngineViewer/Model/Pane.cs b/src/DiffEngineViewer/Model/Pane.cs index d4404046..f63ccaf1 100644 --- a/src/DiffEngineViewer/Model/Pane.cs +++ b/src/DiffEngineViewer/Model/Pane.cs @@ -3,4 +3,8 @@ /// and describe where that slice sits /// so a scrollbar can be drawn. /// -record Pane(string Header, IReadOnlyList Rows, int ScrollTop, int TotalRows); +/// +/// The picture this side is, for a head that can draw one. Null for a text side, and for an image +/// side whose bytes could not be read or recognized. +/// +record Pane(string Header, IReadOnlyList Rows, int ScrollTop, int TotalRows, ImagePane? Image = null); diff --git a/src/DiffEngineViewer/Native/Deview.cs b/src/DiffEngineViewer/Native/Deview.cs index e81a3936..d080e270 100644 --- a/src/DiffEngineViewer/Native/Deview.cs +++ b/src/DiffEngineViewer/Native/Deview.cs @@ -10,7 +10,7 @@ static unsafe partial class Deview /// Must match DEVIEW_VERSION in native/include/deview.h. Bumped whenever the structs change, /// so a stale native library is reported rather than read as garbage. /// - public const int ExpectedVersion = 5; + public const int ExpectedVersion = 6; [LibraryImport(library, EntryPoint = "deview_version")] public static partial int Version(); diff --git a/src/DiffEngineViewer/Native/DeviewStructs.cs b/src/DiffEngineViewer/Native/DeviewStructs.cs index 86935d44..6d68e080 100644 --- a/src/DiffEngineViewer/Native/DeviewStructs.cs +++ b/src/DiffEngineViewer/Native/DeviewStructs.cs @@ -30,6 +30,17 @@ struct DeviewPane public int RowCount; public int ScrollTop; public int TotalRows; + + /// + /// , flattened. Zero length for a text side and for an image side whose + /// bytes could not be read or recognized, so a shim never decides whether a path is worth + /// trying. + /// + public int ImagePathOffset; + + public int ImagePathLength; + public int ImageWidth; + public int ImageHeight; } [StructLayout(LayoutKind.Sequential)] diff --git a/src/DiffEngineViewer/Native/ScreenPayload.cs b/src/DiffEngineViewer/Native/ScreenPayload.cs index 45affcc1..2752dac1 100644 --- a/src/DiffEngineViewer/Native/ScreenPayload.cs +++ b/src/DiffEngineViewer/Native/ScreenPayload.cs @@ -170,6 +170,9 @@ DeviewPane AddPane(Pane pane) }); } + // Empty rather than absent when the side is text or its picture was unreadable: the shim + // reads a length, and a zero one is what tells it there is nothing to draw. + var (imagePath, imagePathLength) = Add(pane.Image?.Path ?? ""); return new() { HeaderOffset = headerOffset, @@ -177,7 +180,11 @@ DeviewPane AddPane(Pane pane) RowOffset = rowOffset, RowCount = pane.Rows.Count, ScrollTop = pane.ScrollTop, - TotalRows = pane.TotalRows + TotalRows = pane.TotalRows, + ImagePathOffset = imagePath, + ImagePathLength = imagePathLength, + ImageWidth = pane.Image?.Width ?? 0, + ImageHeight = pane.Image?.Height ?? 0 }; } diff --git a/src/DiffEngineViewer/QueueEntry.cs b/src/DiffEngineViewer/QueueEntry.cs index f99be981..dca7f2f0 100644 --- a/src/DiffEngineViewer/QueueEntry.cs +++ b/src/DiffEngineViewer/QueueEntry.cs @@ -27,6 +27,10 @@ enum QueueEntryKind /// source file; file entries are accepted by copying left over right; move and delete entries /// belong to the tray and are accepted by forwarding their keys. /// +/// +/// Set when this side is a picture rather than text, which makes the whole entry an image +/// comparison. Null on the side of an image comparison that has no file yet. +/// record QueueEntry( string Key, string Name, @@ -45,17 +49,30 @@ record QueueEntry( IReadOnlyList Variants, int SelectedVariant, FileStamp? LeftStamp, - FileStamp? RightStamp) + FileStamp? RightStamp, + ImageFile? LeftImage = null, + ImageFile? RightImage = null) { - // Computed once, because the diff is a pure function of the two texts and a new entry only + // Computed once, because the diff is a pure function of the two sides and a new entry only // arrives on stdin or the socket. A `with` expression copies this field rather than - // recomputing, so change the text by building a fresh entry, never by `with`. - readonly (IReadOnlyList Left, IReadOnlyList Right) rows = DiffRows.Build(LeftText, RightText); + // recomputing, so change the content by building a fresh entry, never by `with`. + readonly (IReadOnlyList Left, IReadOnlyList Right) rows = + LeftImage is null && RightImage is null + ? DiffRows.Build(LeftText, RightText) + : ImageRows.Build(LeftImage, RightImage); public IReadOnlyList LeftRows => rows.Left; public IReadOnlyList RightRows => rows.Right; public int TotalRows => rows.Left.Count; + /// + /// A picture on either side makes the whole entry one, because the two sides of a comparison + /// are the same file under two names and cannot be a picture and a text file at once. + /// + public bool IsImage => + LeftImage is not null || + RightImage is not null; + public bool Conflicted => Variants.Count > 1; public static string KeyForInline(string sourceFile, int line) => @@ -90,26 +107,28 @@ public static QueueEntry ForInline(PendingInline pending, int selectedVariant = RightStamp: null); } - public static QueueEntry ForFiles(string leftFile, string rightFile, string leftText, string rightText) => + public static QueueEntry ForFiles(string leftFile, string rightFile, FileSide left, FileSide right) => new( Key: $"{leftFile.ToLowerInvariant()}|{rightFile.ToLowerInvariant()}", Name: $"{Path.GetFileName(leftFile)} <> {Path.GetFileName(rightFile)}", LeftHeader: Path.GetFileName(leftFile), RightHeader: Path.GetFileName(rightFile), - LeftText: CsStringLiteral.NormalizeNewlines(leftText), - RightText: CsStringLiteral.NormalizeNewlines(rightText), + LeftText: CsStringLiteral.NormalizeNewlines(left.Text), + RightText: CsStringLiteral.NormalizeNewlines(right.Text), Kind: QueueEntryKind.File, Patch: null, LeftFile: leftFile, TargetFile: rightFile, - Warning: null, + Warning: left.Warning ?? right.Warning, Status: null, Solution: null, TestName: null, Variants: [], SelectedVariant: 0, - LeftStamp: null, - RightStamp: null); + LeftStamp: left.Stamp, + RightStamp: right.Stamp, + LeftImage: left.Image, + RightImage: right.Image); public static QueueEntry ForMove( string key, @@ -117,8 +136,8 @@ public static QueueEntry ForMove( string? group, string temp, string target, - FileText tempText, - FileText targetText) => + FileSide tempSide, + FileSide targetSide) => new( Key: key, Name: name, @@ -126,27 +145,29 @@ public static QueueEntry ForMove( RightHeader: Path.GetFileName(target), // Left is what the test produced, right is what is committed — the same sides an // inline entry uses for received and expected. - LeftText: CsStringLiteral.NormalizeNewlines(tempText.Text), - RightText: CsStringLiteral.NormalizeNewlines(targetText.Text), + LeftText: CsStringLiteral.NormalizeNewlines(tempSide.Text), + RightText: CsStringLiteral.NormalizeNewlines(targetSide.Text), Kind: QueueEntryKind.Move, Patch: null, LeftFile: temp, TargetFile: target, - Warning: tempText.Warning ?? targetText.Warning, + Warning: tempSide.Warning ?? targetSide.Warning, Status: null, Solution: group, TestName: null, Variants: [], SelectedVariant: 0, - LeftStamp: tempText.Stamp, - RightStamp: targetText.Stamp); + LeftStamp: tempSide.Stamp, + RightStamp: targetSide.Stamp, + LeftImage: tempSide.Image, + RightImage: targetSide.Image); public static QueueEntry ForDelete( string key, string name, string? group, string file, - FileText current) => + FileSide current) => new( Key: key, Name: name, @@ -168,7 +189,10 @@ public static QueueEntry ForDelete( Variants: [], SelectedVariant: 0, LeftStamp: current.Stamp, - RightStamp: null); + RightStamp: null, + // The file on the right is the one that goes, so a picture being deleted is the right + // side's picture. Nothing is on the left, which is the point of the entry. + RightImage: current.Image); static (string header, string text, string? warning) Expected(InlinePatch patch) { diff --git a/src/DiffEngineViewer/ScreenBuilder.cs b/src/DiffEngineViewer/ScreenBuilder.cs index 17d39634..a77cb81f 100644 --- a/src/DiffEngineViewer/ScreenBuilder.cs +++ b/src/DiffEngineViewer/ScreenBuilder.cs @@ -20,12 +20,14 @@ public static Screen Build(SessionState state) current?.LeftHeader ?? "received", current?.LeftRows ?? [], state.ScrollTop, - body); + body, + current?.LeftImage); var right = BuildPane( current?.RightHeader ?? "expected", current?.RightRows ?? [], state.ScrollTop, - body); + body, + current?.RightImage); var queue = BuildQueue(state, body, out var top); return new( @@ -64,7 +66,12 @@ public static Screen Build(SessionState state) return new(anchor, menu.Items.Select(_ => _.Label).ToList()); } - static Pane BuildPane(string header, IReadOnlyList rows, int scrollTop, int body) + static Pane BuildPane( + string header, + IReadOnlyList rows, + int scrollTop, + int body, + ImageFile? image) { var end = Math.Min(scrollTop + body, rows.Count); var visible = new List(Math.Max(0, end - scrollTop)); @@ -73,7 +80,22 @@ static Pane BuildPane(string header, IReadOnlyList rows, int scrollTop, int visible.Add(rows[index]); } - return new(header, visible, scrollTop, rows.Count); + return new(header, visible, scrollTop, rows.Count, BuildImage(image)); + } + + /// + /// Offered to a head only once the bytes have been read and recognized. A file that could not + /// be read, or is not a format the viewer knows, has already said so in its rows, and asking a + /// head to try anyway would put the answer to that in each renderer rather than here. + /// + static ImagePane? BuildImage(ImageFile? image) + { + if (image is not { Header: { HasSize: true } header } file) + { + return null; + } + + return new(file.Path, header.Width, header.Height); } static IReadOnlyList BuildQueue(SessionState state, int body, out int top) @@ -162,9 +184,39 @@ static string BuildStatus(SessionState state, QueueEntry? current, int body) return current.Warning; } + // A line count says nothing about a picture, and whether the two are the same file is the + // one thing the rows cannot say: it belongs to the pair rather than to either side. + if (current.IsImage) + { + return ImageStatus(current); + } + var total = current.TotalRows; var from = total == 0 ? 0 : state.ScrollTop + 1; var to = Math.Min(state.ScrollTop + body, total); return $"lines {from}-{to} of {total}"; } + + static string ImageStatus(QueueEntry entry) + { + if (entry.LeftImage is not { } left) + { + return $"only {entry.RightHeader} exists"; + } + + if (entry.RightImage is not { } right) + { + return $"only {entry.LeftHeader} exists"; + } + + // A hash is missing when the bytes never arrived, which is not the same answer as "these + // are not the same picture" and must not be reported as one. + if (left.Hash is null || + right.Hash is null) + { + return "images could not be compared"; + } + + return left.Hash == right.Hash ? "images are identical" : "images differ"; + } } diff --git a/src/DiffEngineViewer/ViewerProgram.cs b/src/DiffEngineViewer/ViewerProgram.cs index 0e985c1e..f569c114 100644 --- a/src/DiffEngineViewer/ViewerProgram.cs +++ b/src/DiffEngineViewer/ViewerProgram.cs @@ -114,16 +114,13 @@ static int RunFile(ViewerRequest request, OpenWindow open) return 2; } - var entry = QueueEntry.ForFiles(left, right, Read(left), Read(right)); + // A missing target is normal: DiffEngine creates an empty one for tools that need it, and a + // brand new snapshot has nothing on the right yet. + var entry = QueueEntry.ForFiles(left, right, FileSide.Read(left), FileSide.Read(right)); var start = ViewerSession.EnqueueFile(SessionState.Start(ViewerMode.File), entry); return Run(new(start), null, null, open); } - // A missing target is normal: DiffEngine creates an empty one for tools that need it, and a - // brand new snapshot has nothing on the right yet. - static string Read(string path) => - File.Exists(path) ? File.ReadAllText(path) : ""; - /// /// A non null means this window is displaying someone else's queue, so /// commands that change it are forwarded rather than applied here.