Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/mdsource/viewer.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ A test run that fails several inline snapshots produces one window, not several.
binds the loopback port holds the queue; everything else hands its patch to that one. The window
lists everything pending and offers **Accept all**.

The list sits in a column on the left. Drag the divider beside it to widen the column when the file
names are longer than it is.

Closing the window discards the queue, unless [DiffEngineTray](/docs/tray.md) is running, in which
case the tray still has it and can reopen a window on it.

Expand Down
3 changes: 3 additions & 0 deletions docs/viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ A test run that fails several inline snapshots produces one window, not several.
binds the loopback port holds the queue; everything else hands its patch to that one. The window
lists everything pending and offers **Accept all**.

The list sits in a column on the left. Drag the divider beside it to widen the column when the file
names are longer than it is.

Closing the window discards the queue, unless [DiffEngineTray](/docs/tray.md) is running, in which
case the tray still has it and can reopen a window on it.

Expand Down
8 changes: 7 additions & 1 deletion native/include/deview.h
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ typedef struct DeviewInput {
* library is detected not crashed.
*
* 2: DeviewInput.columns and rows carry character cells rather than pixels.
* 3: deview_init's fontSize is an em size on both implementations. The ImGui one took it as a
* pixel height, so the same number rendered a quarter smaller there than on macOS.
*/
#define DEVIEW_VERSION 2
#define DEVIEW_VERSION 3

/*
* The Swift implementation imports this header for the struct layouts, because Swift does not
Expand All @@ -151,6 +153,10 @@ typedef struct DeviewInput {
/*
* Returns 1 on success. fontTtf may be NULL, in which case a built in font is used.
*
* fontSize is an em size, which is the unit Core Text and GDI+ take and therefore the one all
* three heads have to agree on. An implementation whose rasteriser scales by something else, as
* ImGui's does by pixel height, converts.
*
* hidden starts without a visible window, which the pixel snapshot tests rely on. An
* implementation may defer creating the window entirely until deview_set_hidden asks for one:
* capture does not need it, and on macOS a window may only be created on the main thread, which a
Expand Down
121 changes: 120 additions & 1 deletion native/src/deview.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include "raylib.h"
#include "rlgl.h"

#include <algorithm>
#include <cstring>
#include <string>

Expand All @@ -35,16 +36,71 @@ void ClearCloseFlag()
}
}

/*
* Queue column widths, counted in character cells rather than pixels so a scaled display gets a
* column that holds the same number of characters rather than a narrower one.
*/
constexpr float queueCells = 34.0f;
constexpr float minQueueCells = 8.0f;

/*
* What the drag leaves each of the two panes, so the splitter cannot be pushed far enough right to
* squeeze them out of existence.
*/
constexpr float minPaneCells = 12.0f;

/*
* How far either side of the divider counts as grabbing it. The border is a single pixel, which is
* not something a mouse can be asked to hit.
*/
constexpr float grabWidth = 4.0f;

/*
* deview_init's fontSize is an em size, which is what Core Text and GDI+ take and therefore what
* the other two heads render at. ImGui's stb_truetype loader scales by pixel height instead
* (stbtt_ScaleForPixelHeight in imgui_draw.cpp), so the same 15 came out as an em of about 11 and
* text a quarter smaller than the other heads, which is what left this head's queue column holding
* 34 characters in far fewer pixels.
*
* The correction is the font's own ascent plus descent over its em, and it is a constant because
* the only font that reaches here is the JetBrains Mono the managed side embeds: 1020 and 300 over
* 1000 units. Swapping that font means revisiting this number, hence naming it rather than folding
* it into the size.
*/
constexpr float emScale = 1.32f;

struct State
{
bool initialised = false;
bool windowOpen = false;
ImGuiContext* context = nullptr;
DeviewInput input{};

/*
* The queue column, owned here rather than by the table.
*
* ImGuiTableFlags_Resizable would give the drag for free, but it also hands the width to
* ImGui's own table state, which initialises once and then auto-fits or restores from saved
* settings. A column that is fixed and not resizable takes InitStretchWeightOrWidth on every
* frame instead, which is a width this side decides and can therefore reproduce: the pixel
* captures share one context and one table id and draw a single frame each, so anything
* carried between them shows up as a snapshot that depends on the test order.
*/
float queueWidth = 0.0f;

/* What was last handed to raylib, so an idle frame is not a window system call. */
int cursor = MOUSE_CURSOR_DEFAULT;
};

State state;

float ClampQueueWidth(float value, float available, float cell)
{
const float low = cell * minQueueCells;
const float high = std::max(low, available - cell * minPaneCells * 2.0f);
return std::min(std::max(value, low), high);
}

void ResetInput()
{
state.input.key = DEVIEW_KEY_NONE;
Expand Down Expand Up @@ -395,14 +451,29 @@ void BuildFrame(const DeviewScreen* screen)

const bool hasQueue = screen->queueCount > 0;
const int columns = hasQueue ? 3 : 2;
const float cell = ImGui::CalcTextSize("M").x;
if (state.queueWidth <= 0.0f)
{
state.queueWidth = cell * queueCells;
}

/* The body, measured before the table so the drag zone can span all of it rather than only the
* rows the table happens to have. */
const ImVec2 bodyMin = ImGui::GetCursorScreenPos();
const ImVec2 bodyAvail = ImGui::GetContentRegionAvail();
const float queueWidth = ClampQueueWidth(state.queueWidth, bodyAvail.x, cell);

/* 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;
if (screen->paneCount >= 2 &&
ImGui::BeginTable("##panes", columns, ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchSame))
{
const DeviewPane& left = screen->panes[0];
const DeviewPane& right = screen->panes[1];
if (hasQueue)
{
ImGui::TableSetupColumn("Pending", ImGuiTableColumnFlags_WidthFixed, 220.0f);
ImGui::TableSetupColumn("Pending", ImGuiTableColumnFlags_WidthFixed, queueWidth);
}

ImGui::TableSetupColumn(Copy(screen, left.headerOffset, left.headerLength).c_str());
Expand Down Expand Up @@ -447,6 +518,11 @@ void BuildFrame(const DeviewScreen* screen)
}

ImGui::TableSetColumnIndex(column);
if (hasQueue && dividerX < 0.0f)
{
dividerX = ImGui::GetCursorScreenPos().x - ImGui::GetStyle().CellPadding.x;
}

DrawRow(screen, left, index, column);
ImGui::TableSetColumnIndex(column + 1);
DrawRow(screen, right, index, column + 1);
Expand All @@ -455,6 +531,37 @@ void BuildFrame(const DeviewScreen* screen)
ImGui::EndTable();
}

/*
* 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
* button, so the width stays whatever this side decided.
*/
if (dividerX >= 0.0f)
{
const ImVec2 resume = ImGui::GetCursorScreenPos();
ImGui::SetCursorScreenPos(ImVec2(dividerX - grabWidth, bodyMin.y));
ImGui::InvisibleButton(
"##queue-splitter",
ImVec2(grabWidth * 2.0f + 1.0f, std::max(1.0f, bodyAvail.y)));
if (ImGui::IsItemHovered() || ImGui::IsItemActive())
{
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW);
}

if (ImGui::IsItemActive())
{
/* Moved by the distance between the cursor and the border it is dragging, rather than
* set from the cursor: a column's width is its inner width, and the border sits a
* padding and a spacing further right. A delta needs to know neither. */
state.queueWidth = ClampQueueWidth(
queueWidth + ImGui::GetIO().MousePos.x - dividerX,
bodyAvail.x,
cell);
}

ImGui::SetCursorScreenPos(resume);
}

ImGui::EndChild();
ImGui::Separator();

Expand Down Expand Up @@ -574,6 +681,7 @@ int32_t deview_init(
memcpy(copy, fontTtf, static_cast<size_t>(fontLength));
ImFontConfig config;
config.FontDataOwnedByAtlas = true;
config.ExtraSizeScale = emScale;
io.Fonts->AddFontFromMemoryTTF(copy, fontLength, fontSize <= 0.0f ? 15.0f : fontSize, &config);
}

Expand Down Expand Up @@ -605,6 +713,17 @@ int32_t deview_present(const DeviewScreen* screen)
BuildFrame(screen);
ImGui::Render();

/* ImGui only records the cursor it wants. Showing it is the backend's job, and the splitter is
* the one thing here that asks for anything but an arrow. */
const int cursor = ImGui::GetMouseCursor() == ImGuiMouseCursor_ResizeEW
? MOUSE_CURSOR_RESIZE_EW
: MOUSE_CURSOR_DEFAULT;
if (cursor != state.cursor)
{
state.cursor = cursor;
SetMouseCursor(cursor);
}

BeginDrawing();
ClearBackground(Color{24, 24, 24, 255});
RenderDrawData(ImGui::GetDrawData());
Expand Down
66 changes: 60 additions & 6 deletions native/swift/Sources/Deview/Renderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,19 @@ import Foundation
/// Nothing is flipped. Core Graphics puts the origin bottom left, and layout here is expressed top
/// down and converted once in `rect`, which avoids having to fight the text matrix.
final class Renderer {
private static let queueWidth: CGFloat = 220
/// Queue column widths, counted in character cells rather than points so a scaled display gets
/// a column that holds the same number of characters rather than a narrower one.
private static let defaultQueueCells: CGFloat = 34
private static let minQueueCells: CGFloat = 8

/// What the drag leaves each of the two panes, so the splitter cannot be pushed far enough
/// right to squeeze them out of existence.
private static let minPaneCells: CGFloat = 12

/// How far either side of the rule counts as grabbing it. The rule is a single point, which is
/// not something a mouse can be asked to hit.
private static let grab: CGFloat = 4

private static let padding: CGFloat = 6
private static let gap: CGFloat = 4

Expand All @@ -24,6 +36,10 @@ final class Renderer {
private let ascent: CGFloat
private let descent: CGFloat

/// Moved by dragging the rule between the queue and the panes. Kept here rather than in the
/// view because this is what lays the rule out, and the drag has to land where it was drawn.
private var queueWidth: CGFloat = 0

/// 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
Expand All @@ -33,6 +49,10 @@ final class Renderer {
struct Layout {
var buttons: [CGRect] = []
var queueItems: [CGRect] = []

/// The grab zone around the rule between the queue and the panes, empty when there is no
/// queue to divide off.
var splitter: CGRect = .zero
}

init(fontData: Data?, size: CGFloat) {
Expand All @@ -50,6 +70,22 @@ final class Renderer {
cell = CGSize(
width: max(1, advance.width.rounded()),
height: max(1, (ascent + descent + CTFontGetLeading(font)).rounded(.up)))
queueWidth = cell.width * Renderer.defaultQueueCells
}

/// Clamped on every use rather than only when dragged, so shrinking the window narrows the
/// column instead of leaving the panes with nothing.
private func clamp(_ value: CGFloat, _ width: CGFloat) -> CGFloat {
let low = cell.width * Renderer.minQueueCells
let high = max(
low,
width - Renderer.padding * 2 - Renderer.gap - cell.width * Renderer.minPaneCells * 2)
return min(max(value, low), high)
}

/// Puts the rule under the cursor. Called by the view while the splitter is being dragged.
func dragQueueWidth(to x: CGFloat, in width: CGFloat) {
queueWidth = clamp(x - Renderer.padding - Renderer.gap / 2, width)
}

private static func load(_ data: Data?, _ size: CGFloat) -> CTFont {
Expand Down Expand Up @@ -83,7 +119,8 @@ final class Renderer {

let line = cell.height
let hasQueue = !frame.queue.isEmpty
let panesLeft = hasQueue ? Renderer.padding + Renderer.queueWidth + Renderer.gap : Renderer.padding
let queue = hasQueue ? clamp(queueWidth, size.width) : 0
let panesLeft = hasQueue ? Renderer.padding + queue + Renderer.gap : Renderer.padding
let panesWidth = max(cell.width * 2, size.width - Renderer.padding - panesLeft)
let half = (panesWidth / 2).rounded(.down)

Expand All @@ -98,7 +135,7 @@ final class Renderer {

let headerTop = firstRule + Renderer.gap
if hasQueue {
text("Pending (\(frame.queue.count))", in: rect(top: headerTop, left: Renderer.padding, width: Renderer.queueWidth, height: line, size), Palette.text, context)
text("Pending (\(frame.queue.count))", in: rect(top: headerTop, left: Renderer.padding, width: queue, height: line, size), Palette.text, context)
}

text(frame.left.header, in: rect(top: headerTop, left: panesLeft, width: half, height: line, size), Palette.text, context)
Expand All @@ -113,7 +150,7 @@ final class Renderer {
for index in 0 ..< rows {
let top = bodyTop + CGFloat(index) * line
if hasQueue {
let bounds = rect(top: top, left: Renderer.padding, width: Renderer.queueWidth, height: line, size)
let bounds = rect(top: top, left: Renderer.padding, width: queue, height: line, size)
layout.queueItems.append(bounds)
queueItem(frame, index, bounds, context)
}
Expand All @@ -124,7 +161,14 @@ final class Renderer {

let bodyBottom = bodyTop + CGFloat(capacity) * line
if hasQueue {
columnRule(left: panesLeft - Renderer.gap / 2, top: bodyTop, bottom: bodyBottom, in: context, size)
let ruleLeft = panesLeft - Renderer.gap / 2
columnRule(left: ruleLeft, top: bodyTop, bottom: bodyBottom, in: context, size)
layout.splitter = rect(
top: bodyTop,
left: ruleLeft - Renderer.grab,
width: Renderer.grab * 2 + 1,
height: bodyBottom - bodyTop,
size)
}

columnRule(left: panesLeft + half - Renderer.gap / 2, top: bodyTop, bottom: bodyBottom, in: context, size)
Expand Down Expand Up @@ -173,7 +217,17 @@ final class Renderer {

let label = item.failed ? "\(item.label) !" : item.label
let colour = item.failed ? Palette.foreground(DEVIEW_ROW_REMOVED.value) : Palette.text
text(label, in: bounds.offsetBy(dx: cell.width, dy: 0), colour, context)
// Indented rather than offset: offsetBy keeps the width, which would let a long name clip
// one cell past the column instead of at it.
text(
label,
in: CGRect(
x: bounds.minX + cell.width,
y: bounds.minY,
width: bounds.width - cell.width,
height: bounds.height),
colour,
context)
}

private func row(_ pane: Frame.Pane, _ index: Int, _ bounds: CGRect, _ context: CGContext) {
Expand Down
Loading
Loading