Files
NightShot/src/overlay.cpp
T

3387 lines
162 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "overlay.hpp"
#include "app_messages.hpp"
#include "color_picker_math.hpp"
#include "gif_options_window.hpp"
#include "gif_recorder.hpp"
#include "image_io.hpp"
#include "settings.hpp"
#include "theme.hpp"
#include "uploader.hpp"
#include <objidl.h>
#include <gdiplus.h>
#include <windowsx.h>
#include <shellscalingapi.h>
#include <algorithm>
#include <array>
#include <cstdint>
#include <cmath>
#include <climits>
#include <cstdlib>
#include <cwctype>
#include <iomanip>
#include <memory>
#include <initializer_list>
#include <sstream>
#include <string>
#include <utility>
#include <unordered_set>
#include <vector>
using namespace Gdiplus;
namespace {
constexpr wchar_t kOverlayClass[] = L"NightShotOverlayWindow";
enum class Tool { Drag, Pen, Line, Arrow, Rectangle, Marker, Text };
enum class Action { Drag, Pen, Line, Arrow, Rectangle, Marker, Text, Color, Undo,
Upload, Share, Search, Gif, Print, Copy, Save, Close };
enum class ColorField { None, Hex, Red, Green, Blue, Alpha, Hue, Saturation, Value };
constexpr std::size_t kStrokeChunkSegments = 32;
struct Annotation {
Tool tool = Tool::Pen;
std::vector<PointF> points;
// Pen/marker segments are grouped spatially so repainting a tiny dirty
// rectangle does not scan a complete multi-thousand-point stroke.
std::vector<RECT> strokeChunkBounds;
RECT strokeBounds{};
bool hasStrokeBounds = false;
Color color = Color(255, 59, 130, 246);
float width = 3.0f;
float textSize = 22.0f; // physical pixels; captured when text editing begins
std::wstring text;
};
RECT unionRawRects(RECT a, RECT b) {
if (a.right <= a.left || a.bottom <= a.top) return b;
if (b.right <= b.left || b.bottom <= b.top) return a;
return RECT{std::min(a.left, b.left), std::min(a.top, b.top),
std::max(a.right, b.right), std::max(a.bottom, b.bottom)};
}
void appendStrokeChunkBounds(Annotation& annotation, PointF first, PointF second) {
if (annotation.tool != Tool::Pen && annotation.tool != Tool::Marker) return;
if (annotation.points.size() < 2) return;
const int padding = static_cast<int>(std::ceil(annotation.width * 0.5f)) + 3;
RECT segment{
static_cast<LONG>(std::floor(std::min(first.X, second.X))) - padding,
static_cast<LONG>(std::floor(std::min(first.Y, second.Y))) - padding,
static_cast<LONG>(std::ceil(std::max(first.X, second.X))) + padding + 1,
static_cast<LONG>(std::ceil(std::max(first.Y, second.Y))) + padding + 1};
const std::size_t segmentIndex = annotation.points.size() - 2;
const std::size_t chunkIndex = segmentIndex / kStrokeChunkSegments;
if (annotation.strokeChunkBounds.size() <= chunkIndex) {
annotation.strokeChunkBounds.push_back(segment);
} else {
annotation.strokeChunkBounds[chunkIndex] = unionRawRects(annotation.strokeChunkBounds[chunkIndex], segment);
}
annotation.strokeBounds = annotation.hasStrokeBounds ? unionRawRects(annotation.strokeBounds, segment) : segment;
annotation.hasStrokeBounds = true;
}
struct Button {
RECT rect{};
Action action = Action::Pen;
bool enabled = true;
};
RECT normalizeRect(POINT a, POINT b) {
return RECT{std::min(a.x, b.x), std::min(a.y, b.y), std::max(a.x, b.x), std::max(a.y, b.y)};
}
bool pointInRect(const RECT& r, POINT p) {
return p.x >= r.left && p.x < r.right && p.y >= r.top && p.y < r.bottom;
}
int rectWidth(const RECT& r) { return r.right - r.left; }
int rectHeight(const RECT& r) { return r.bottom - r.top; }
Color toGdiColor(COLORREF c, BYTE alpha = 255) {
return Color(alpha, GetRValue(c), GetGValue(c), GetBValue(c));
}
std::wstring actionName(Action action) {
switch (action) {
case Action::Drag: return L"Move selection (D)";
case Action::Pen: return L"Pencil (P)";
case Action::Line: return L"Line (L)";
case Action::Arrow: return L"Arrow (A)";
case Action::Rectangle: return L"Rectangle (R)";
case Action::Marker: return L"Marker (M)";
case Action::Text: return L"Text (T)";
case Action::Color: return L"Color picker (C)";
case Action::Undo: return L"Undo (Ctrl+Z)";
case Action::Upload: return L"Upload";
case Action::Share: return L"Share";
case Action::Search: return L"Find similar";
case Action::Gif: return L"Record GIF (Ctrl+PrintScreen)";
case Action::Print: return L"Print";
case Action::Copy: return L"Copy (Ctrl+C)";
case Action::Save: return L"Save (Ctrl+S)";
case Action::Close: return L"Close (Esc)";
}
return L"";
}
bool isToolAction(Action action) {
return action >= Action::Drag && action <= Action::Text;
}
Tool toolFromAction(Action action) {
switch (action) {
case Action::Drag: return Tool::Drag;
case Action::Line: return Tool::Line;
case Action::Arrow: return Tool::Arrow;
case Action::Rectangle: return Tool::Rectangle;
case Action::Marker: return Tool::Marker;
case Action::Text: return Tool::Text;
default: return Tool::Pen;
}
}
void drawArrow(Graphics& g, const Pen& pen, const Color& color, PointF start, PointF end) {
const float dx = end.X - start.X;
const float dy = end.Y - start.Y;
const float length = std::sqrt(dx * dx + dy * dy);
if (length < 1.0f) return;
const float ux = dx / length;
const float uy = dy / length;
const float nx = -uy;
const float ny = ux;
const float stroke = std::max(1.0f, pen.GetWidth());
const float headLength = std::clamp(stroke * 4.6f + 7.0f, 11.0f, 27.0f);
const float halfHeadWidth = std::clamp(headLength * 0.48f, 5.0f, 14.0f);
// Lightshot-style arrow: one clean rounded shaft with a filled triangular
// head. The shaft runs under the head slightly so antialiasing never leaves
// a visible seam between the two shapes.
const float shaftInset = std::min(headLength * 0.58f, length * 0.45f);
PointF shaftEnd(end.X - ux * shaftInset, end.Y - uy * shaftInset);
g.DrawLine(&pen, start, shaftEnd);
PointF baseCenter(end.X - ux * headLength, end.Y - uy * headLength);
PointF triangle[3]{
end,
PointF(baseCenter.X + nx * halfHeadWidth, baseCenter.Y + ny * halfHeadWidth),
PointF(baseCenter.X - nx * halfHeadWidth, baseCenter.Y - ny * halfHeadWidth)};
SolidBrush headBrush(color);
g.FillPolygon(&headBrush, triangle, 3);
}
void drawAnnotation(Graphics& g, const Annotation& a, float offsetX, float offsetY) {
if (a.points.empty()) return;
Pen pen(a.color, a.width);
pen.SetLineJoin(LineJoinRound);
pen.SetStartCap(LineCapRound);
pen.SetEndCap(LineCapRound);
auto shifted = [&](PointF p) { return PointF(p.X + offsetX, p.Y + offsetY); };
switch (a.tool) {
case Tool::Drag:
break;
case Tool::Pen:
case Tool::Marker: {
if (a.points.size() == 1) {
PointF p = shifted(a.points.front());
g.DrawEllipse(&pen, p.X, p.Y, 1.0f, 1.0f);
break;
}
std::vector<PointF> pts;
pts.reserve(a.points.size());
for (auto p : a.points) pts.push_back(shifted(p));
g.DrawLines(&pen, pts.data(), static_cast<INT>(pts.size()));
break;
}
case Tool::Line:
if (a.points.size() >= 2) g.DrawLine(&pen, shifted(a.points[0]), shifted(a.points[1]));
break;
case Tool::Arrow:
if (a.points.size() >= 2) drawArrow(g, pen, a.color, shifted(a.points[0]), shifted(a.points[1]));
break;
case Tool::Rectangle:
if (a.points.size() >= 2) {
const PointF p1 = shifted(a.points[0]);
const PointF p2 = shifted(a.points[1]);
const REAL x = std::min(p1.X, p2.X), y = std::min(p1.Y, p2.Y);
g.DrawRectangle(&pen, x, y, std::abs(p2.X - p1.X), std::abs(p2.Y - p1.Y));
}
break;
case Tool::Text: {
if (a.text.empty()) break;
const GraphicsState state = g.Save();
g.SetTextRenderingHint(TextRenderingHintAntiAliasGridFit);
g.SetTextContrast(0);
g.SetPixelOffsetMode(PixelOffsetModeDefault);
FontFamily family(L"Segoe UI");
const REAL size = std::max<REAL>(14.0f, std::round(a.textSize));
Font font(&family, size, FontStyleRegular, UnitPixel);
SolidBrush brush(a.color);
PointF p = shifted(a.points[0]);
p.X = std::round(p.X);
p.Y = std::round(p.Y);
StringFormat format;
format.SetFormatFlags(StringFormatFlagsNoWrap | StringFormatFlagsMeasureTrailingSpaces);
g.DrawString(a.text.c_str(), -1, &font, p, &format, &brush);
g.Restore(state);
break;
}
}
}
void drawAnnotationClipped(Graphics& g, const Annotation& annotation, const RECT& clip,
float offsetX = 0.0f, float offsetY = 0.0f) {
if (annotation.points.empty()) return;
if (annotation.tool != Tool::Pen && annotation.tool != Tool::Marker) {
float minX = annotation.points.front().X + offsetX;
float minY = annotation.points.front().Y + offsetY;
float maxX = minX;
float maxY = minY;
for (const PointF& point : annotation.points) {
minX = std::min(minX, point.X + offsetX);
minY = std::min(minY, point.Y + offsetY);
maxX = std::max(maxX, point.X + offsetX);
maxY = std::max(maxY, point.Y + offsetY);
}
float padding = annotation.width * 0.5f + 5.0f;
if (annotation.tool == Tool::Arrow) padding += 30.0f;
if (annotation.tool == Tool::Text) {
const float size = std::max(14.0f, annotation.textSize);
maxX += std::max(size * 2.0f, static_cast<float>(annotation.text.size()) * size * 0.72f);
maxY += size * 1.45f;
}
if (minX - padding >= clip.right || maxX + padding <= clip.left ||
minY - padding >= clip.bottom || maxY + padding <= clip.top) return;
drawAnnotation(g, annotation, offsetX, offsetY);
return;
}
Pen pen(annotation.color, annotation.width);
pen.SetLineJoin(LineJoinRound);
pen.SetStartCap(LineCapRound);
pen.SetEndCap(LineCapRound);
const float padding = annotation.width * 0.5f + 2.0f;
auto shifted = [&](PointF point) { return PointF(point.X + offsetX, point.Y + offsetY); };
if (annotation.points.size() == 1) {
const PointF point = shifted(annotation.points.front());
if (point.X + padding >= clip.left && point.X - padding <= clip.right &&
point.Y + padding >= clip.top && point.Y - padding <= clip.bottom) {
g.DrawEllipse(&pen, point.X, point.Y, 1.0f, 1.0f);
}
return;
}
auto drawSegmentRange = [&](std::size_t firstPointIndex, std::size_t endPointIndex) {
endPointIndex = std::min(endPointIndex, annotation.points.size());
for (std::size_t i = firstPointIndex; i < endPointIndex; ++i) {
const PointF first = shifted(annotation.points[i - 1]);
const PointF second = shifted(annotation.points[i]);
const float left = std::min(first.X, second.X) - padding;
const float top = std::min(first.Y, second.Y) - padding;
const float right = std::max(first.X, second.X) + padding;
const float bottom = std::max(first.Y, second.Y) + padding;
if (left < clip.right && right > clip.left && top < clip.bottom && bottom > clip.top) {
g.DrawLine(&pen, first, second);
}
}
};
if (annotation.hasStrokeBounds && offsetX == 0.0f && offsetY == 0.0f &&
(annotation.strokeBounds.left >= clip.right || annotation.strokeBounds.right <= clip.left ||
annotation.strokeBounds.top >= clip.bottom || annotation.strokeBounds.bottom <= clip.top)) return;
if (!annotation.strokeChunkBounds.empty() && offsetX == 0.0f && offsetY == 0.0f) {
for (std::size_t chunk = 0; chunk < annotation.strokeChunkBounds.size(); ++chunk) {
const RECT& bounds = annotation.strokeChunkBounds[chunk];
if (bounds.left >= clip.right || bounds.right <= clip.left ||
bounds.top >= clip.bottom || bounds.bottom <= clip.top) continue;
const std::size_t firstPointIndex = chunk * kStrokeChunkSegments + 1;
drawSegmentRange(firstPointIndex, firstPointIndex + kStrokeChunkSegments);
}
} else {
drawSegmentRange(1, annotation.points.size());
}
}
void addRoundedRect(GraphicsPath& path, const RectF& rect, float radius) {
const float d = radius * 2.0f;
path.AddArc(rect.X, rect.Y, d, d, 180, 90);
path.AddArc(rect.GetRight() - d, rect.Y, d, d, 270, 90);
path.AddArc(rect.GetRight() - d, rect.GetBottom() - d, d, d, 0, 90);
path.AddArc(rect.X, rect.GetBottom() - d, d, d, 90, 90);
path.CloseFigure();
}
void fillRoundedRect(Graphics& g, const RectF& rect, float radius, const Brush& brush) {
GraphicsPath path;
addRoundedRect(path, rect, radius);
g.FillPath(&brush, &path);
}
void strokeRoundedRect(Graphics& g, const RectF& rect, float radius, const Pen& pen) {
GraphicsPath path;
addRoundedRect(path, rect, radius);
g.DrawPath(&pen, &path);
}
void fillRoundedGradient(Graphics& g, const RectF& rect, float radius,
const Color& start, const Color& end,
LinearGradientMode mode = LinearGradientModeVertical) {
GraphicsPath path;
addRoundedRect(path, rect, radius);
LinearGradientBrush brush(rect, start, end, mode);
g.FillPath(&brush, &path);
}
enum class UiIcon {
Move, PenTool, Line, ArrowUpRight, Square, Marker, Type, Colors, Undo,
Upload, Share, Search, Film, Printer, Copy, Save, Close
};
class IconCanvas {
public:
IconCanvas(Graphics& graphics, const RectF& bounds, const Color& color)
: g_(graphics), scale_(std::min(bounds.Width, bounds.Height) / 24.0f),
ox_(std::floor(bounds.X + (bounds.Width - 24.0f * scale_) * 0.5f) + 0.5f),
oy_(std::floor(bounds.Y + (bounds.Height - 24.0f * scale_) * 0.5f) + 0.5f),
pen_(color, std::max(1.30f, 1.78f * scale_)), brush_(color) {
pen_.SetStartCap(LineCapRound);
pen_.SetEndCap(LineCapRound);
pen_.SetLineJoin(LineJoinRound);
}
PointF point(float x, float y) const { return PointF(ox_ + x * scale_, oy_ + y * scale_); }
RectF rect(float x, float y, float w, float h) const {
return RectF(ox_ + x * scale_, oy_ + y * scale_, w * scale_, h * scale_);
}
void line(float x1, float y1, float x2, float y2) { g_.DrawLine(&pen_, point(x1, y1), point(x2, y2)); }
void polyline(std::initializer_list<std::pair<float, float>> points, bool closed = false) {
std::vector<PointF> mapped;
mapped.reserve(points.size());
for (const auto& [x, y] : points) mapped.push_back(point(x, y));
if (mapped.size() < 2) return;
GraphicsPath path;
path.AddLines(mapped.data(), static_cast<INT>(mapped.size()));
if (closed) path.CloseFigure();
g_.DrawPath(&pen_, &path);
}
void ellipse(float x, float y, float w, float h) {
const RectF bounds = rect(x, y, w, h);
g_.DrawEllipse(&pen_, bounds);
}
void fillEllipse(float x, float y, float w, float h) {
const RectF bounds = rect(x, y, w, h);
g_.FillEllipse(&brush_, bounds);
}
void roundedRect(float x, float y, float w, float h, float radius) {
const RectF bounds = rect(x, y, w, h);
strokeRoundedRect(g_, bounds, radius * scale_, pen_);
}
void arc(float x, float y, float w, float h, float start, float sweep) {
const RectF bounds = rect(x, y, w, h);
g_.DrawArc(&pen_, bounds, start, sweep);
}
Graphics& graphics() { return g_; }
Pen& pen() { return pen_; }
private:
Graphics& g_;
float scale_;
float ox_;
float oy_;
Pen pen_;
SolidBrush brush_;
};
void drawUiIcon(Graphics& g, UiIcon icon, const RectF& bounds, const Color& color, COLORREF selectedColor) {
(void)selectedColor;
const GraphicsState state = g.Save();
g.SetSmoothingMode(SmoothingModeAntiAlias);
g.SetPixelOffsetMode(PixelOffsetModeHalf);
g.SetCompositingQuality(CompositingQualityHighQuality);
IconCanvas c(g, bounds, color);
switch (icon) {
case UiIcon::Move:
c.line(12.0f, 3.0f, 12.0f, 21.0f);
c.line(3.0f, 12.0f, 21.0f, 12.0f);
c.polyline({{8.0f, 7.0f}, {12.0f, 3.0f}, {16.0f, 7.0f}});
c.polyline({{8.0f, 17.0f}, {12.0f, 21.0f}, {16.0f, 17.0f}});
c.polyline({{7.0f, 8.0f}, {3.0f, 12.0f}, {7.0f, 16.0f}});
c.polyline({{17.0f, 8.0f}, {21.0f, 12.0f}, {17.0f, 16.0f}});
break;
case UiIcon::PenTool:
c.polyline({{3.5f, 20.5f}, {5.0f, 15.5f}, {15.8f, 4.7f}, {19.3f, 8.2f}, {8.5f, 19.0f}, {3.5f, 20.5f}}, true);
c.line(5.0f, 15.5f, 8.5f, 19.0f);
c.line(14.4f, 6.1f, 17.9f, 9.6f);
break;
case UiIcon::Line:
c.line(5.0f, 19.0f, 19.0f, 5.0f);
break;
case UiIcon::ArrowUpRight:
c.line(5.0f, 19.0f, 19.0f, 5.0f);
c.line(10.5f, 5.0f, 19.0f, 5.0f);
c.line(19.0f, 5.0f, 19.0f, 13.5f);
break;
case UiIcon::Square:
c.roundedRect(4.0f, 4.0f, 16.0f, 16.0f, 2.2f);
break;
case UiIcon::Marker:
c.polyline({{5.0f, 16.0f}, {14.2f, 6.8f}, {17.2f, 9.8f}, {8.0f, 19.0f}, {5.0f, 19.0f}, {5.0f, 16.0f}}, true);
c.line(14.2f, 6.8f, 16.4f, 4.6f);
c.line(16.4f, 4.6f, 19.4f, 7.6f);
c.line(19.4f, 7.6f, 17.2f, 9.8f);
c.line(4.0f, 21.0f, 12.0f, 21.0f);
break;
case UiIcon::Type:
c.line(4.0f, 6.0f, 4.0f, 4.0f);
c.line(4.0f, 4.0f, 20.0f, 4.0f);
c.line(20.0f, 4.0f, 20.0f, 6.0f);
c.line(12.0f, 4.0f, 12.0f, 20.0f);
c.line(8.5f, 20.0f, 15.5f, 20.0f);
break;
case UiIcon::Colors: {
// Compact eyedropper + sampled-color well, matching the picker itself.
c.polyline({{14.2f, 4.0f}, {20.0f, 9.8f}, {17.2f, 12.6f}, {11.4f, 6.8f}, {14.2f, 4.0f}}, true);
c.line(13.0f, 8.4f, 6.2f, 15.2f);
c.line(6.2f, 15.2f, 5.0f, 19.0f);
c.line(5.0f, 19.0f, 8.8f, 17.8f);
c.line(8.8f, 17.8f, 15.6f, 11.0f);
c.ellipse(3.5f, 16.0f, 5.0f, 5.0f);
break;
}
case UiIcon::Undo:
c.arc(4.0f, 5.0f, 16.0f, 14.0f, 205.0f, 285.0f);
c.line(5.3f, 9.0f, 5.3f, 4.8f);
c.line(5.3f, 9.0f, 9.5f, 9.0f);
break;
case UiIcon::Upload:
c.line(12.0f, 15.0f, 12.0f, 4.0f);
c.line(7.8f, 8.2f, 12.0f, 4.0f);
c.line(16.2f, 8.2f, 12.0f, 4.0f);
c.polyline({{5.0f, 14.0f}, {5.0f, 18.0f}, {6.0f, 19.0f}, {18.0f, 19.0f}, {19.0f, 18.0f}, {19.0f, 14.0f}});
break;
case UiIcon::Share:
c.ellipse(3.5f, 9.5f, 4.0f, 4.0f);
c.ellipse(16.5f, 3.5f, 4.0f, 4.0f);
c.ellipse(16.5f, 16.5f, 4.0f, 4.0f);
c.line(7.1f, 10.7f, 16.8f, 6.3f);
c.line(7.1f, 12.3f, 16.8f, 17.7f);
break;
case UiIcon::Search:
c.ellipse(4.0f, 4.0f, 13.0f, 13.0f);
c.line(15.0f, 15.0f, 21.0f, 21.0f);
break;
case UiIcon::Film:
c.roundedRect(3.0f, 5.0f, 18.0f, 14.0f, 2.0f);
c.line(7.0f, 5.0f, 7.0f, 19.0f);
c.line(17.0f, 5.0f, 17.0f, 19.0f);
c.line(3.0f, 9.0f, 7.0f, 9.0f);
c.line(3.0f, 15.0f, 7.0f, 15.0f);
c.line(17.0f, 9.0f, 21.0f, 9.0f);
c.line(17.0f, 15.0f, 21.0f, 15.0f);
c.polyline({{10.0f, 9.0f}, {15.0f, 12.0f}, {10.0f, 15.0f}}, true);
break;
case UiIcon::Printer:
c.roundedRect(3.0f, 8.0f, 18.0f, 10.0f, 2.0f);
c.polyline({{7.0f, 8.0f}, {7.0f, 3.0f}, {17.0f, 3.0f}, {17.0f, 8.0f}});
c.roundedRect(7.0f, 14.0f, 10.0f, 7.0f, 1.2f);
c.fillEllipse(17.2f, 10.7f, 1.8f, 1.8f);
break;
case UiIcon::Copy:
c.roundedRect(7.0f, 7.0f, 13.0f, 13.0f, 2.0f);
c.polyline({{16.0f, 7.0f}, {16.0f, 5.0f}, {15.0f, 4.0f}, {5.0f, 4.0f}, {4.0f, 5.0f}, {4.0f, 15.0f}, {5.0f, 16.0f}, {7.0f, 16.0f}});
break;
case UiIcon::Save:
c.polyline({{4.0f, 3.0f}, {17.0f, 3.0f}, {21.0f, 7.0f}, {21.0f, 21.0f}, {3.0f, 21.0f}, {3.0f, 4.0f}, {4.0f, 3.0f}}, true);
c.roundedRect(7.0f, 13.0f, 10.0f, 8.0f, 1.2f);
c.polyline({{7.0f, 3.0f}, {7.0f, 8.0f}, {16.0f, 8.0f}, {16.0f, 3.0f}});
break;
case UiIcon::Close:
c.line(5.0f, 5.0f, 19.0f, 19.0f);
c.line(19.0f, 5.0f, 5.0f, 19.0f);
break;
}
g.Restore(state);
}
UiIcon iconForAction(Action action) {
switch (action) {
case Action::Drag: return UiIcon::Move;
case Action::Pen: return UiIcon::PenTool;
case Action::Line: return UiIcon::Line;
case Action::Arrow: return UiIcon::ArrowUpRight;
case Action::Rectangle: return UiIcon::Square;
case Action::Marker: return UiIcon::Marker;
case Action::Text: return UiIcon::Type;
case Action::Color: return UiIcon::Colors;
case Action::Undo: return UiIcon::Undo;
case Action::Upload: return UiIcon::Upload;
case Action::Share: return UiIcon::Share;
case Action::Search: return UiIcon::Search;
case Action::Gif: return UiIcon::Film;
case Action::Print: return UiIcon::Printer;
case Action::Copy: return UiIcon::Copy;
case Action::Save: return UiIcon::Save;
case Action::Close: return UiIcon::Close;
}
return UiIcon::Close;
}
} // namespace
struct OverlayWindow::Impl {
struct MonitorArea {
HMONITOR handle = nullptr;
RECT bounds{};
RECT workBounds{};
UINT dpi = 96;
};
struct UiMetrics {
// Roughly 20% tighter than v0.11. The glyph itself is slightly larger,
// so the panel loses empty air without making the icon harder to read.
int button = 28;
int gap = 1;
int radius = 6;
int panelPadding = 2;
int icon = 19;
int handle = 7;
int margin = 6;
int paletteCell = 25;
int paletteSwatch = 18;
int pickerPadding = 9;
int pickerFieldHeight = 27;
int tooltipDelayMs = 420;
};
static constexpr UINT_PTR kHoverTimer = 1;
static constexpr int kPaintTileWidth = 1024;
static constexpr int kPaintTileHeight = 512;
Impl(ScreenCapture&& screen, HWND target, OverlayMode initialMode)
: notificationTarget(target), capture(std::move(screen)), mode(initialMode) {
enumerateMonitors();
const settings::AppSettings stored = settings::load();
penWidth = static_cast<float>(stored.penWidth);
lineWidth = static_cast<float>(stored.lineWidth);
arrowWidth = static_cast<float>(stored.arrowWidth);
rectangleWidth = static_cast<float>(stored.rectangleWidth);
markerWidth = static_cast<float>(stored.markerWidth);
savedPalette = stored.paletteArgb;
savedPaletteOccupied = stored.paletteOccupied;
color = color_picker_math::colorFromArgb(stored.selectedColorArgb);
colorAlpha = color_picker_math::alphaFromArgb(stored.selectedColorArgb);
pickerHsv = color_picker_math::rgbToHsv(GetRValue(color), GetGValue(color), GetBValue(color));
POINT cursor{};
if (GetCursorPos(&cursor)) {
cursor.x -= capture.x;
cursor.y -= capture.y;
hintMonitorIndex = monitorIndexForPoint(cursor);
cursorPoint = cursor;
}
}
~Impl() {
releaseScratchBuffer();
}
HWND hwnd = nullptr;
HWND notificationTarget = nullptr;
ScreenCapture capture;
OverlayMode mode = OverlayMode::Screenshot;
// selection is the visible part inside the virtual desktop.
// logicalSelection keeps the full rectangle while it is temporarily clipped
// by a desktop edge, so dragging it back restores the original dimensions.
RECT selection{};
RECT logicalSelection{};
RECT originalSelection{};
RECT originalLogicalSelection{};
POINT dragStart{};
POINT lastPoint{};
bool hasSelection = false;
bool selecting = false;
bool drawing = false;
bool draggingSelection = false;
int resizeHandle = -1;
Tool tool = Tool::Drag;
COLORREF color = theme::kAccent;
std::vector<Annotation> annotations;
std::vector<Button> verticalButtons;
std::vector<Button> horizontalButtons;
std::array<RECT, 8> handleRects{};
std::vector<MonitorArea> monitors;
UiMetrics ui{};
int uiMonitorIndex = 0;
int hintMonitorIndex = 0;
RECT verticalPanelBounds{};
RECT horizontalPanelBounds{};
RECT palettePanelBounds{};
RECT dimensionBadgeRect{};
int hoveredButton = -1; // combined index: vertical then horizontal
int tooltipButton = -1;
bool trackingMouseLeave = false;
bool paletteOpen = false;
BYTE colorAlpha = 255;
color_picker_math::Hsv pickerHsv{};
bool eyedropperActive = false;
bool pickerDraggingSv = false;
bool pickerDraggingHue = false;
bool pickerDraggingAlpha = false;
ColorField activeColorField = ColorField::None;
std::wstring colorFieldBuffer;
bool colorFieldReplaceOnType = false;
RECT pickerPreviewRect{};
RECT pickerEyedropperRect{};
RECT pickerSvRect{};
RECT pickerHueRect{};
RECT pickerAlphaRect{};
std::array<RECT, 8> pickerFieldRects{};
int pickerPaletteCell = 25;
int pickerPaletteSwatch = 18;
std::array<std::uint32_t, settings::kSavedColorCount> savedPalette{};
std::array<bool, settings::kSavedColorCount> savedPaletteOccupied{};
std::array<RECT, settings::kSavedColorCount> paletteRects{};
float penWidth = 3.0f;
float lineWidth = 3.0f;
float arrowWidth = 3.0f;
float rectangleWidth = 3.0f;
float markerWidth = 15.0f;
POINT cursorPoint{};
RECT brushPreviewRect{};
bool textEditing = false;
POINT textAnchor{};
RECT textEditorRect{};
float textEditSize = 22.0f;
std::wstring textEditBuffer;
HDC scratchDc = nullptr;
HBITMAP scratchBitmap = nullptr;
HGDIOBJ oldScratchBitmap = nullptr;
std::uint8_t* scratchPixels = nullptr;
int scratchWidth = 0;
int scratchHeight = 0;
int scratchStride = 0;
static BOOL CALLBACK enumMonitorProc(HMONITOR monitor, HDC, LPRECT, LPARAM value) {
auto* self = reinterpret_cast<Impl*>(value);
MONITORINFO info{};
info.cbSize = sizeof(info);
if (!GetMonitorInfoW(monitor, &info)) return TRUE;
UINT dpiX = 96;
UINT dpiY = 96;
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &dpiX, &dpiY) == S_OK) {
dpiX = (dpiX + dpiY) / 2;
} else {
dpiX = 96;
}
RECT local{
info.rcMonitor.left - self->capture.x,
info.rcMonitor.top - self->capture.y,
info.rcMonitor.right - self->capture.x,
info.rcMonitor.bottom - self->capture.y};
RECT localWork{
info.rcWork.left - self->capture.x,
info.rcWork.top - self->capture.y,
info.rcWork.right - self->capture.x,
info.rcWork.bottom - self->capture.y};
self->monitors.push_back(MonitorArea{monitor, local, localWork, dpiX});
return TRUE;
}
void enumerateMonitors() {
monitors.clear();
EnumDisplayMonitors(nullptr, nullptr, &Impl::enumMonitorProc, reinterpret_cast<LPARAM>(this));
if (monitors.empty()) {
const RECT fallback{0, 0, capture.width, capture.height};
monitors.push_back(MonitorArea{nullptr, fallback, fallback, 96});
}
}
static long long distanceSquaredToRect(const RECT& r, POINT p) {
const long long dx = p.x < r.left ? static_cast<long long>(r.left) - p.x
: p.x >= r.right ? static_cast<long long>(p.x) - (r.right - 1) : 0;
const long long dy = p.y < r.top ? static_cast<long long>(r.top) - p.y
: p.y >= r.bottom ? static_cast<long long>(p.y) - (r.bottom - 1) : 0;
return dx * dx + dy * dy;
}
int monitorIndexForPoint(POINT p) const {
for (size_t i = 0; i < monitors.size(); ++i) {
if (pointInRect(monitors[i].bounds, p)) return static_cast<int>(i);
}
int best = 0;
long long bestDistance = distanceSquaredToRect(monitors[0].bounds, p);
for (size_t i = 1; i < monitors.size(); ++i) {
const long long distance = distanceSquaredToRect(monitors[i].bounds, p);
if (distance < bestDistance) {
best = static_cast<int>(i);
bestDistance = distance;
}
}
return best;
}
static RECT intersectRects(RECT a, RECT b) {
RECT result{std::max(a.left, b.left), std::max(a.top, b.top),
std::min(a.right, b.right), std::min(a.bottom, b.bottom)};
if (result.right <= result.left || result.bottom <= result.top) return RECT{0, 0, 0, 0};
return result;
}
static bool rectsIntersect(RECT a, RECT b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
UiMetrics metricsForMonitor(int index) const {
const float scale = std::clamp(static_cast<float>(monitors[index].dpi) / 96.0f, 0.85f, 2.25f);
auto px = [scale](float value) { return std::max(1, static_cast<int>(std::lround(value * scale))); };
UiMetrics result;
result.button = px(28.0f);
result.gap = px(1.0f);
result.radius = px(6.0f);
result.panelPadding = px(2.0f);
result.icon = px(19.0f);
result.handle = std::max(7, px(7.0f));
result.margin = px(6.0f);
result.paletteCell = px(25.0f);
result.paletteSwatch = px(18.0f);
result.pickerPadding = px(9.0f);
result.pickerFieldHeight = px(27.0f);
result.tooltipDelayMs = 420;
return result;
}
RECT safeMonitorBounds(int index) const {
RECT result = monitors[index].workBounds;
InflateRect(&result, -ui.margin, -ui.margin);
return result;
}
static long long intersectionArea(RECT first, RECT second) {
const LONG width = std::max(0L, std::min(first.right, second.right) - std::max(first.left, second.left));
const LONG height = std::max(0L, std::min(first.bottom, second.bottom) - std::max(first.top, second.top));
return static_cast<long long>(width) * static_cast<long long>(height);
}
static bool isDrawingTool(Tool value) {
return value == Tool::Pen || value == Tool::Line || value == Tool::Arrow ||
value == Tool::Rectangle || value == Tool::Marker;
}
float currentStrokeWidth() const {
switch (tool) {
case Tool::Pen: return penWidth;
case Tool::Line: return lineWidth;
case Tool::Arrow: return arrowWidth;
case Tool::Rectangle: return rectangleWidth;
case Tool::Marker: return markerWidth;
default: return 0.0f;
}
}
float* currentStrokeWidthPointer() {
switch (tool) {
case Tool::Pen: return &penWidth;
case Tool::Line: return &lineWidth;
case Tool::Arrow: return &arrowWidth;
case Tool::Rectangle: return &rectangleWidth;
case Tool::Marker: return &markerWidth;
default: return nullptr;
}
}
void persistEditorPreferences() const {
settings::AppSettings value = settings::load();
value.penWidth = static_cast<int>(std::lround(penWidth));
value.lineWidth = static_cast<int>(std::lround(lineWidth));
value.arrowWidth = static_cast<int>(std::lround(arrowWidth));
value.rectangleWidth = static_cast<int>(std::lround(rectangleWidth));
value.markerWidth = static_cast<int>(std::lround(markerWidth));
value.selectedColorArgb = currentArgb();
value.paletteArgb = savedPalette;
value.paletteOccupied = savedPaletteOccupied;
const bool saved = settings::save(value);
(void)saved;
}
RECT calculateDimensionBadgeBounds() const {
if ((!hasSelection && !selecting) || rectWidth(selection) <= 0 || rectHeight(selection) <= 0)
return RECT{0, 0, 0, 0};
const POINT anchor{selection.left, selection.top};
const int monitorIndex = monitorIndexForPoint(anchor);
const float scale = std::clamp(static_cast<float>(monitors[monitorIndex].dpi) / 96.0f, 0.85f, 2.25f);
const UiMetrics metrics = metricsForMonitor(monitorIndex);
RECT safe = monitors[monitorIndex].workBounds;
InflateRect(&safe, -metrics.margin, -metrics.margin);
const std::wstring dimensions = std::to_wstring(std::max(0, rectWidth(selection))) + L" × " +
std::to_wstring(std::max(0, rectHeight(selection)));
const LONG width = static_cast<LONG>(std::ceil(dimensions.size() * 7.15f * scale)) +
static_cast<LONG>(std::max(13, metrics.icon - 3));
const LONG height = static_cast<LONG>(std::max(22, static_cast<int>(std::lround(24.0f * scale))));
const LONG gap = static_cast<LONG>(std::max(4, metrics.gap + 3));
LONG x = std::clamp(selection.left, safe.left, std::max(safe.left, safe.right - width));
LONG y = selection.top - height - gap;
if (y < safe.top) y = selection.top + gap;
y = std::clamp(y, safe.top, std::max(safe.top, safe.bottom - height));
return RECT{x, y, x + width, y + height};
}
void updateDimensionBadgeBounds() {
dimensionBadgeRect = calculateDimensionBadgeBounds();
}
void syncPickerFromRgb() {
pickerHsv = color_picker_math::rgbToHsv(GetRValue(color), GetGValue(color), GetBValue(color));
}
void setCurrentColor(COLORREF nextColor, BYTE nextAlpha, bool syncHsv = true) {
color = nextColor;
colorAlpha = nextAlpha;
if (syncHsv) syncPickerFromRgb();
persistEditorPreferences();
}
std::uint32_t currentArgb() const {
return color_picker_math::toArgb(color, colorAlpha);
}
static ColorField fieldForIndex(int index) {
constexpr std::array<ColorField, 8> fields{
ColorField::Hex, ColorField::Red, ColorField::Green, ColorField::Blue,
ColorField::Alpha, ColorField::Hue, ColorField::Saturation, ColorField::Value};
return index >= 0 && index < static_cast<int>(fields.size()) ? fields[index] : ColorField::None;
}
static const wchar_t* colorFieldLabel(ColorField field) {
switch (field) {
case ColorField::Hex: return L"HEX";
case ColorField::Red: return L"R";
case ColorField::Green: return L"G";
case ColorField::Blue: return L"B";
case ColorField::Alpha: return L"A";
case ColorField::Hue: return L"H";
case ColorField::Saturation: return L"S";
case ColorField::Value: return L"V";
default: return L"";
}
}
std::wstring colorFieldValue(ColorField field) const {
switch (field) {
case ColorField::Hex: return color_picker_math::hexRgba(color, colorAlpha);
case ColorField::Red: return std::to_wstring(GetRValue(color));
case ColorField::Green: return std::to_wstring(GetGValue(color));
case ColorField::Blue: return std::to_wstring(GetBValue(color));
case ColorField::Alpha: return std::to_wstring(colorAlpha);
case ColorField::Hue: return std::to_wstring(static_cast<int>(std::lround(pickerHsv.h)) % 360);
case ColorField::Saturation: return std::to_wstring(static_cast<int>(std::lround(pickerHsv.s * 100.0f)));
case ColorField::Value: return std::to_wstring(static_cast<int>(std::lround(pickerHsv.v * 100.0f)));
default: return L"";
}
}
static bool parseStrictInt(const std::wstring& text, int& result) {
if (text.empty()) return false;
wchar_t* end = nullptr;
const long value = std::wcstol(text.c_str(), &end, 10);
if (!end || *end != L'\0') return false;
result = static_cast<int>(value);
return true;
}
void startColorFieldEdit(ColorField field) {
activeColorField = field;
colorFieldBuffer = colorFieldValue(field);
colorFieldReplaceOnType = true;
invalidateRectPadded(palettePanelBounds, 3);
}
void cancelColorFieldEdit() {
if (activeColorField == ColorField::None) return;
activeColorField = ColorField::None;
colorFieldBuffer.clear();
colorFieldReplaceOnType = false;
invalidateRectPadded(palettePanelBounds, 3);
}
void commitColorFieldEdit() {
if (activeColorField == ColorField::None) return;
bool changed = false;
if (activeColorField == ColorField::Hex) {
COLORREF parsedColor = color;
BYTE parsedAlpha = colorAlpha;
if (color_picker_math::parseHexRgba(colorFieldBuffer, parsedColor, parsedAlpha)) {
setCurrentColor(parsedColor, parsedAlpha);
changed = true;
}
} else {
int value = 0;
if (parseStrictInt(colorFieldBuffer, value)) {
switch (activeColorField) {
case ColorField::Red:
setCurrentColor(RGB(std::clamp(value, 0, 255), GetGValue(color), GetBValue(color)), colorAlpha);
changed = true;
break;
case ColorField::Green:
setCurrentColor(RGB(GetRValue(color), std::clamp(value, 0, 255), GetBValue(color)), colorAlpha);
changed = true;
break;
case ColorField::Blue:
setCurrentColor(RGB(GetRValue(color), GetGValue(color), std::clamp(value, 0, 255)), colorAlpha);
changed = true;
break;
case ColorField::Alpha:
setCurrentColor(color, static_cast<BYTE>(std::clamp(value, 0, 255)), false);
changed = true;
break;
case ColorField::Hue:
pickerHsv.h = static_cast<float>((value % 360 + 360) % 360);
setCurrentColor(color_picker_math::hsvToRgb(pickerHsv.h, pickerHsv.s, pickerHsv.v), colorAlpha, false);
changed = true;
break;
case ColorField::Saturation:
pickerHsv.s = static_cast<float>(std::clamp(value, 0, 100)) / 100.0f;
setCurrentColor(color_picker_math::hsvToRgb(pickerHsv.h, pickerHsv.s, pickerHsv.v), colorAlpha, false);
changed = true;
break;
case ColorField::Value:
pickerHsv.v = static_cast<float>(std::clamp(value, 0, 100)) / 100.0f;
setCurrentColor(color_picker_math::hsvToRgb(pickerHsv.h, pickerHsv.s, pickerHsv.v), colorAlpha, false);
changed = true;
break;
default: break;
}
}
}
activeColorField = ColorField::None;
colorFieldBuffer.clear();
colorFieldReplaceOnType = false;
if (changed) invalidateButton(7);
invalidateRectPadded(palettePanelBounds, 3);
}
void appendColorFieldCharacter(wchar_t character) {
if (activeColorField == ColorField::None) return;
if (character == L'\b') {
if (colorFieldReplaceOnType) colorFieldBuffer.clear();
else if (!colorFieldBuffer.empty()) colorFieldBuffer.pop_back();
colorFieldReplaceOnType = false;
} else if (activeColorField == ColorField::Hex) {
if (colorFieldReplaceOnType) { colorFieldBuffer.clear(); colorFieldReplaceOnType = false; }
if (character == L'#' && colorFieldBuffer.empty()) colorFieldBuffer.push_back(character);
else if (std::iswxdigit(static_cast<wint_t>(character)) && colorFieldBuffer.size() < 9)
colorFieldBuffer.push_back(static_cast<wchar_t>(std::towupper(character)));
else return;
} else {
if (colorFieldReplaceOnType) { colorFieldBuffer.clear(); colorFieldReplaceOnType = false; }
if (std::iswdigit(static_cast<wint_t>(character)) && colorFieldBuffer.size() < 3)
colorFieldBuffer.push_back(character);
else return;
}
invalidateRectPadded(palettePanelBounds, 3);
}
void pasteColorFieldFromClipboard() {
if (activeColorField == ColorField::None || !OpenClipboard(hwnd)) return;
HANDLE data = GetClipboardData(CF_UNICODETEXT);
if (data) {
const auto* text = static_cast<const wchar_t*>(GlobalLock(data));
if (text) {
colorFieldBuffer.clear();
colorFieldReplaceOnType = false;
for (const wchar_t* cursor = text; *cursor; ++cursor) {
const wchar_t character = *cursor;
if (activeColorField == ColorField::Hex) {
if (character == L'#' && colorFieldBuffer.empty()) colorFieldBuffer.push_back(character);
else if (std::iswxdigit(static_cast<wint_t>(character)) && colorFieldBuffer.size() < 9)
colorFieldBuffer.push_back(static_cast<wchar_t>(std::towupper(character)));
} else if (std::iswdigit(static_cast<wint_t>(character)) && colorFieldBuffer.size() < 3) {
colorFieldBuffer.push_back(character);
}
}
GlobalUnlock(data);
invalidateRectPadded(palettePanelBounds, 3);
}
}
CloseClipboard();
}
void saveCurrentColorToSlot(std::size_t index) {
if (index >= settings::kSavedColorCount) return;
savedPalette[index] = currentArgb();
savedPaletteOccupied[index] = true;
persistEditorPreferences();
invalidateRectPadded(palettePanelBounds, 3);
}
void clearSavedColorSlot(std::size_t index) {
if (index < 8 || index >= settings::kSavedColorCount) return;
savedPaletteOccupied[index] = false;
savedPalette[index] = 0;
persistEditorPreferences();
invalidateRectPadded(palettePanelBounds, 3);
}
void updatePickerFromSvPoint(POINT point) {
if (rectWidth(pickerSvRect) <= 1 || rectHeight(pickerSvRect) <= 1) return;
pickerHsv.s = std::clamp(static_cast<float>(point.x - pickerSvRect.left) /
static_cast<float>(rectWidth(pickerSvRect) - 1), 0.0f, 1.0f);
pickerHsv.v = 1.0f - std::clamp(static_cast<float>(point.y - pickerSvRect.top) /
static_cast<float>(rectHeight(pickerSvRect) - 1), 0.0f, 1.0f);
color = color_picker_math::hsvToRgb(pickerHsv.h, pickerHsv.s, pickerHsv.v);
invalidateButton(7);
invalidateRectPadded(palettePanelBounds, 3);
}
void updatePickerFromHuePoint(POINT point) {
if (rectHeight(pickerHueRect) <= 1) return;
const float ratio = std::clamp(static_cast<float>(point.y - pickerHueRect.top) /
static_cast<float>(rectHeight(pickerHueRect) - 1), 0.0f, 1.0f);
pickerHsv.h = ratio * 359.999f;
color = color_picker_math::hsvToRgb(pickerHsv.h, pickerHsv.s, pickerHsv.v);
invalidateButton(7);
invalidateRectPadded(palettePanelBounds, 3);
}
void updatePickerFromAlphaPoint(POINT point) {
if (rectHeight(pickerAlphaRect) <= 1) return;
const float ratio = std::clamp(static_cast<float>(point.y - pickerAlphaRect.top) /
static_cast<float>(rectHeight(pickerAlphaRect) - 1), 0.0f, 1.0f);
colorAlpha = static_cast<BYTE>(std::lround((1.0f - ratio) * 255.0f));
invalidateButton(7);
invalidateRectPadded(palettePanelBounds, 3);
}
bool sampleCapturedColor(POINT point) {
if (point.x < 0 || point.y < 0 ||
point.x >= capture.width || point.y >= capture.height) return false;
COLORREF sampled{};
if (!capture.samplePixel(point.x, point.y, sampled)) return false;
setCurrentColor(sampled, 255);
return true;
}
RECT brushPreviewBounds(POINT point) const {
if (!hasSelection || !isDrawingTool(tool) || point.x < 0 || point.y < 0 ||
point.x >= capture.width || point.y >= capture.height) return RECT{0, 0, 0, 0};
const int diameter = std::max(1, static_cast<int>(std::ceil(currentStrokeWidth())));
const int radius = (diameter + 1) / 2 + 2;
return clampToClient(RECT{point.x - radius, point.y - radius,
point.x + radius + 1, point.y + radius + 1});
}
void updateBrushPreview(POINT point) {
const RECT previous = brushPreviewRect;
cursorPoint = point;
brushPreviewRect = brushPreviewBounds(point);
invalidateRectPadded(previous, 2);
invalidateRectPadded(brushPreviewRect, 2);
}
void adjustCurrentStrokeWidth(int wheelDelta) {
float* width = currentStrokeWidthPointer();
if (!width || wheelDelta == 0) return;
const float step = tool == Tool::Marker ? 2.0f : 1.0f;
const float maximum = tool == Tool::Marker ? 96.0f : 64.0f;
const RECT previous = brushPreviewRect;
*width = std::clamp(*width + (wheelDelta > 0 ? -step : step), 1.0f, maximum);
persistEditorPreferences();
brushPreviewRect = brushPreviewBounds(cursorPoint);
invalidateRectPadded(previous, 3);
invalidateRectPadded(brushPreviewRect, 3);
}
void releaseScratchBuffer() {
if (scratchDc && oldScratchBitmap && oldScratchBitmap != HGDI_ERROR) SelectObject(scratchDc, oldScratchBitmap);
oldScratchBitmap = nullptr;
if (scratchBitmap) DeleteObject(scratchBitmap);
scratchBitmap = nullptr;
if (scratchDc) DeleteDC(scratchDc);
scratchDc = nullptr;
scratchPixels = nullptr;
scratchWidth = 0;
scratchHeight = 0;
scratchStride = 0;
}
bool ensureScratchBuffer(HDC target, int width, int height) {
if (width <= 0 || height <= 0) return false;
if (scratchDc && scratchBitmap && scratchWidth >= width && scratchHeight >= height) return true;
releaseScratchBuffer();
scratchWidth = ((width + 127) / 128) * 128;
scratchHeight = ((height + 63) / 64) * 64;
scratchStride = scratchWidth * 4;
scratchDc = CreateCompatibleDC(target);
if (!scratchDc) return false;
BITMAPINFO info{};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = scratchWidth;
info.bmiHeader.biHeight = -scratchHeight;
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* pixels = nullptr;
scratchBitmap = CreateDIBSection(target, &info, DIB_RGB_COLORS, &pixels, nullptr, 0);
if (!scratchBitmap) {
releaseScratchBuffer();
return false;
}
scratchPixels = static_cast<std::uint8_t*>(pixels);
oldScratchBitmap = SelectObject(scratchDc, scratchBitmap);
if (!oldScratchBitmap || oldScratchBitmap == HGDI_ERROR) {
oldScratchBitmap = nullptr;
releaseScratchBuffer();
return false;
}
return true;
}
RECT clampToClient(RECT r) const {
r.left = std::clamp(r.left, 0L, static_cast<LONG>(capture.width));
r.top = std::clamp(r.top, 0L, static_cast<LONG>(capture.height));
r.right = std::clamp(r.right, 0L, static_cast<LONG>(capture.width));
r.bottom = std::clamp(r.bottom, 0L, static_cast<LONG>(capture.height));
return r;
}
RECT visibleSelectionFromLogical(RECT logical) const {
const RECT client{0, 0, static_cast<LONG>(capture.width), static_cast<LONG>(capture.height)};
return intersectRects(logical, client);
}
RECT keepLogicalSelectionReachable(RECT logical) const {
// Let the full selection move beyond the virtual desktop, but always keep
// a small draggable strip visible. The hidden part is retained in
// logicalSelection and returns as soon as the user drags back.
constexpr LONG minVisible = 8L;
if (logical.right < minVisible) OffsetRect(&logical, minVisible - logical.right, 0);
if (logical.left > static_cast<LONG>(capture.width) - minVisible)
OffsetRect(&logical, static_cast<LONG>(capture.width) - minVisible - logical.left, 0);
if (logical.bottom < minVisible) OffsetRect(&logical, 0, minVisible - logical.bottom);
if (logical.top > static_cast<LONG>(capture.height) - minVisible)
OffsetRect(&logical, 0, static_cast<LONG>(capture.height) - minVisible - logical.top);
return logical;
}
void invalidateRectPadded(RECT r, int padding = 0) {
if (!hwnd) return;
InflateRect(&r, padding, padding);
r = clampToClient(r);
if (r.right > r.left && r.bottom > r.top) InvalidateRect(hwnd, &r, FALSE);
}
RECT toolbarBounds(const std::vector<Button>& buttons) const {
if (buttons.empty()) return RECT{0, 0, 0, 0};
RECT bounds = buttons.front().rect;
for (const Button& button : buttons) {
bounds.left = std::min(bounds.left, button.rect.left);
bounds.top = std::min(bounds.top, button.rect.top);
bounds.right = std::max(bounds.right, button.rect.right);
bounds.bottom = std::max(bounds.bottom, button.rect.bottom);
}
InflateRect(&bounds, ui.panelPadding + 3, ui.panelPadding + 4);
return clampToClient(bounds);
}
RECT popupBoundsNearButton(int combinedIndex, int width, int height) const {
const Button* button = nullptr;
bool vertical = false;
if (combinedIndex >= 0 && combinedIndex < static_cast<int>(verticalButtons.size())) {
button = &verticalButtons[combinedIndex];
vertical = true;
} else {
const int horizontalIndex = combinedIndex - static_cast<int>(verticalButtons.size());
if (horizontalIndex >= 0 && horizontalIndex < static_cast<int>(horizontalButtons.size()))
button = &horizontalButtons[horizontalIndex];
}
if (!button) return RECT{0, 0, 0, 0};
POINT center{(button->rect.left + button->rect.right) / 2,
(button->rect.top + button->rect.bottom) / 2};
const RECT safe = safeMonitorBounds(monitorIndexForPoint(center));
const int gap = std::max(5, ui.gap + 4);
std::array<RECT, 4> candidates{};
if (vertical) {
candidates = {{
{button->rect.left - gap - width, button->rect.top + (rectHeight(button->rect) - height) / 2,
button->rect.left - gap, button->rect.top + (rectHeight(button->rect) - height) / 2 + height},
{button->rect.right + gap, button->rect.top + (rectHeight(button->rect) - height) / 2,
button->rect.right + gap + width, button->rect.top + (rectHeight(button->rect) - height) / 2 + height},
{button->rect.left + (rectWidth(button->rect) - width) / 2, button->rect.top - gap - height,
button->rect.left + (rectWidth(button->rect) - width) / 2 + width, button->rect.top - gap},
{button->rect.left + (rectWidth(button->rect) - width) / 2, button->rect.bottom + gap,
button->rect.left + (rectWidth(button->rect) - width) / 2 + width, button->rect.bottom + gap + height}}};
} else {
candidates = {{
{button->rect.left + (rectWidth(button->rect) - width) / 2, button->rect.top - gap - height,
button->rect.left + (rectWidth(button->rect) - width) / 2 + width, button->rect.top - gap},
{button->rect.left + (rectWidth(button->rect) - width) / 2, button->rect.bottom + gap,
button->rect.left + (rectWidth(button->rect) - width) / 2 + width, button->rect.bottom + gap + height},
{button->rect.left - gap - width, button->rect.top + (rectHeight(button->rect) - height) / 2,
button->rect.left - gap, button->rect.top + (rectHeight(button->rect) - height) / 2 + height},
{button->rect.right + gap, button->rect.top + (rectHeight(button->rect) - height) / 2,
button->rect.right + gap + width, button->rect.top + (rectHeight(button->rect) - height) / 2 + height}}};
}
auto fits = [&](const RECT& r) {
return r.left >= safe.left && r.top >= safe.top && r.right <= safe.right && r.bottom <= safe.bottom &&
!rectsIntersect(r, dimensionBadgeRect);
};
for (const RECT& candidate : candidates) if (fits(candidate)) return candidate;
RECT result = candidates.front();
if (result.left < safe.left) OffsetRect(&result, safe.left - result.left, 0);
if (result.right > safe.right) OffsetRect(&result, safe.right - result.right, 0);
if (result.top < safe.top) OffsetRect(&result, 0, safe.top - result.top);
if (result.bottom > safe.bottom) OffsetRect(&result, 0, safe.bottom - result.bottom);
return clampToClient(result);
}
RECT tooltipBoundsApprox(int combinedIndex) const {
if (combinedIndex < 0) return RECT{0, 0, 0, 0};
const Button* button = nullptr;
if (combinedIndex < static_cast<int>(verticalButtons.size())) button = &verticalButtons[combinedIndex];
else {
const int index = combinedIndex - static_cast<int>(verticalButtons.size());
if (index >= 0 && index < static_cast<int>(horizontalButtons.size())) button = &horizontalButtons[index];
}
if (!button) return RECT{0, 0, 0, 0};
const std::wstring label = actionName(button->action);
const float scale = std::max(0.85f, static_cast<float>(ui.icon) / 17.0f);
const int width = std::max(44, static_cast<int>(std::lround(label.size() * 6.4f * scale)) + 16);
const int height = std::max(24, static_cast<int>(std::lround(25.0f * scale)));
return popupBoundsNearButton(combinedIndex, width, height);
}
RECT hintRectForMonitor(int index) const {
index = std::clamp(index, 0, static_cast<int>(monitors.size()) - 1);
const RECT safe = monitors[index].workBounds;
const float scale = std::clamp(static_cast<float>(monitors[index].dpi) / 96.0f, 0.85f, 2.25f);
const int width = static_cast<int>(std::lround(248.0f * scale));
const int height = static_cast<int>(std::lround(76.0f * scale));
const int cx = (safe.left + safe.right) / 2;
const int cy = (safe.top + safe.bottom) / 2;
return RECT{cx - width / 2, cy - height / 2, cx - width / 2 + width, cy - height / 2 + height};
}
RECT uiBounds() const {
RECT bounds{0, 0, 0, 0};
bool any = false;
auto include = [&](RECT r) {
if (r.right <= r.left || r.bottom <= r.top) return;
if (!any) { bounds = r; any = true; return; }
bounds = unionRects(bounds, r);
};
include(toolbarBounds(verticalButtons));
include(toolbarBounds(horizontalButtons));
if (paletteOpen) include(palettePanelBounds);
if (tooltipButton >= 0) include(tooltipBoundsApprox(tooltipButton));
if (textEditing) include(textEditorRect);
return any ? clampToClient(bounds) : RECT{0, 0, 0, 0};
}
void invalidate() { if (hwnd) InvalidateRect(hwnd, nullptr, FALSE); }
void invalidateUi() { invalidateRectPadded(uiBounds(), 3); }
void invalidateUiTransition(RECT oldUiBounds) {
invalidateRectPadded(oldUiBounds, 3);
invalidateRectPadded(uiBounds(), 3);
}
void invalidateOutline(RECT r, int thickness) {
if (r.right <= r.left || r.bottom <= r.top) return;
invalidateRectPadded(RECT{r.left, r.top, r.right, std::min(r.bottom, r.top + thickness)}, thickness);
invalidateRectPadded(RECT{r.left, std::max(r.top, r.bottom - thickness), r.right, r.bottom}, thickness);
invalidateRectPadded(RECT{r.left, r.top, std::min(r.right, r.left + thickness), r.bottom}, thickness);
invalidateRectPadded(RECT{std::max(r.left, r.right - thickness), r.top, r.right, r.bottom}, thickness);
}
void invalidateSelectionDifference(RECT oldSelection, RECT newSelection) {
if (!hwnd) return;
HRGN oldRegion = CreateRectRgn(oldSelection.left, oldSelection.top, oldSelection.right, oldSelection.bottom);
HRGN newRegion = CreateRectRgn(newSelection.left, newSelection.top, newSelection.right, newSelection.bottom);
HRGN difference = CreateRectRgn(0, 0, 0, 0);
if (oldRegion && newRegion && difference) {
CombineRgn(difference, oldRegion, newRegion, RGN_XOR);
InvalidateRgn(hwnd, difference, FALSE);
}
if (difference) DeleteObject(difference);
if (newRegion) DeleteObject(newRegion);
if (oldRegion) DeleteObject(oldRegion);
}
void invalidateSelection() {
if (!hasSelection && !selecting) { invalidate(); return; }
invalidateOutline(selection, std::max(8, ui.handle + 4));
invalidateRectPadded(uiBounds(), 3);
invalidateRectPadded(dimensionBadgeRect, 5);
invalidateRectPadded(brushPreviewRect, 3);
}
RECT annotationBounds(const Annotation& annotation) const {
if (annotation.points.empty()) return RECT{0, 0, 0, 0};
if ((annotation.tool == Tool::Pen || annotation.tool == Tool::Marker) && annotation.hasStrokeBounds) {
return clampToClient(annotation.strokeBounds);
}
float minX = annotation.points.front().X;
float minY = annotation.points.front().Y;
float maxX = minX;
float maxY = minY;
for (const PointF& point : annotation.points) {
minX = std::min(minX, point.X);
minY = std::min(minY, point.Y);
maxX = std::max(maxX, point.X);
maxY = std::max(maxY, point.Y);
}
int padding = static_cast<int>(std::ceil(annotation.width * 0.5f)) + 5;
if (annotation.tool == Tool::Arrow) padding += 30;
if (annotation.tool == Tool::Text) {
const float size = std::max(14.0f, annotation.textSize);
maxX += std::max(size * 2.0f, static_cast<float>(annotation.text.size()) * size * 0.72f);
maxY += size * 1.45f;
padding = std::max(5, static_cast<int>(std::ceil(size * 0.18f)));
}
RECT result{
static_cast<LONG>(std::floor(minX)) - padding,
static_cast<LONG>(std::floor(minY)) - padding,
static_cast<LONG>(std::ceil(maxX)) + padding + 1,
static_cast<LONG>(std::ceil(maxY)) + padding + 1};
return clampToClient(result);
}
static RECT unionRects(RECT a, RECT b) {
if (a.right <= a.left || a.bottom <= a.top) return b;
if (b.right <= b.left || b.bottom <= b.top) return a;
return RECT{std::min(a.left, b.left), std::min(a.top, b.top),
std::max(a.right, b.right), std::max(a.bottom, b.bottom)};
}
void invalidateSelectionTransition(RECT oldSelection, RECT oldUiBounds, RECT oldBadgeBounds) {
invalidateSelectionDifference(oldSelection, selection);
invalidateOutline(oldSelection, std::max(9, ui.handle + 5));
invalidateOutline(selection, std::max(9, ui.handle + 5));
invalidateRectPadded(oldUiBounds, 3);
invalidateRectPadded(uiBounds(), 3);
// Invalidate the exact previous badge rectangle. The old broad estimate
// missed the below-selection placement used for tiny edge selections and
// left a GIF-like trail behind.
invalidateRectPadded(oldBadgeBounds, 5);
invalidateRectPadded(dimensionBadgeRect, 5);
}
static RECT clampPanelTo(RECT panel, RECT safe) {
if (panel.left < safe.left) OffsetRect(&panel, safe.left - panel.left, 0);
if (panel.right > safe.right) OffsetRect(&panel, safe.right - panel.right, 0);
if (panel.top < safe.top) OffsetRect(&panel, 0, safe.top - panel.top);
if (panel.bottom > safe.bottom) OffsetRect(&panel, 0, safe.bottom - panel.bottom);
return panel;
}
void updateLayout() {
verticalButtons.clear();
horizontalButtons.clear();
verticalPanelBounds = RECT{0, 0, 0, 0};
horizontalPanelBounds = RECT{0, 0, 0, 0};
palettePanelBounds = RECT{0, 0, 0, 0};
pickerPreviewRect = RECT{0, 0, 0, 0};
pickerEyedropperRect = RECT{0, 0, 0, 0};
pickerSvRect = RECT{0, 0, 0, 0};
pickerHueRect = RECT{0, 0, 0, 0};
pickerAlphaRect = RECT{0, 0, 0, 0};
pickerFieldRects.fill(RECT{0, 0, 0, 0});
paletteRects.fill(RECT{0, 0, 0, 0});
updateDimensionBadgeBounds();
if (!hasSelection) return;
const POINT uiAnchor{
selection.right > selection.left ? selection.right - 1 : selection.left,
selection.bottom > selection.top ? selection.bottom - 1 : selection.top};
uiMonitorIndex = monitorIndexForPoint(uiAnchor);
ui = metricsForMonitor(uiMonitorIndex);
const RECT safe = safeMonitorBounds(uiMonitorIndex);
updateDimensionBadgeBounds();
RECT badgeObstacle = dimensionBadgeRect;
if (badgeObstacle.right > badgeObstacle.left && badgeObstacle.bottom > badgeObstacle.top) {
const int clearance = std::max(3, ui.panelPadding + 2);
InflateRect(&badgeObstacle, clearance, clearance);
}
RECT visibleSelection = intersectRects(selection, monitors[uiMonitorIndex].bounds);
if (visibleSelection.right <= visibleSelection.left || visibleSelection.bottom <= visibleSelection.top) {
visibleSelection = RECT{
std::clamp(selection.left, safe.left, safe.right - 1),
std::clamp(selection.top, safe.top, safe.bottom - 1),
std::clamp(selection.left + 1, safe.left + 1, safe.right),
std::clamp(selection.top + 1, safe.top + 1, safe.bottom)};
}
auto fits = [&](const RECT& rect) {
return rect.left >= safe.left && rect.top >= safe.top &&
rect.right <= safe.right && rect.bottom <= safe.bottom;
};
auto chooseCandidate = [&](const auto& candidates, std::initializer_list<RECT> obstacles) {
RECT best = clampPanelTo(candidates.front(), safe);
long long bestScore = LLONG_MAX;
for (const RECT& raw : candidates) {
RECT candidate = raw;
const bool fullyFits = fits(candidate);
if (!fullyFits) candidate = clampPanelTo(candidate, safe);
long long overlap = 0;
for (const RECT& obstacle : obstacles) overlap += intersectionArea(candidate, obstacle);
const long long displacement = std::llabs(static_cast<long long>(candidate.left) - raw.left) +
std::llabs(static_cast<long long>(candidate.top) - raw.top);
const long long score = overlap * 1000LL + displacement * 10LL + (fullyFits ? 0LL : 100LL);
if (score < bestScore) {
best = candidate;
bestScore = score;
if (score == 0) break;
}
}
return best;
};
const std::array<Action, 9> verticalActions{
Action::Drag, Action::Pen, Action::Line, Action::Arrow, Action::Rectangle,
Action::Marker, Action::Text, Action::Color, Action::Undo};
const int verticalContentHeight = static_cast<int>(verticalActions.size()) * ui.button +
(static_cast<int>(verticalActions.size()) - 1) * ui.gap;
const int verticalPanelWidth = ui.button + ui.panelPadding * 2;
const int verticalPanelHeight = verticalContentHeight + ui.panelPadding * 2;
const int outsideGap = std::max(ui.gap + ui.panelPadding + 2, ui.margin);
const std::array<RECT, 6> verticalCandidates{{
{visibleSelection.right + outsideGap, visibleSelection.top,
visibleSelection.right + outsideGap + verticalPanelWidth, visibleSelection.top + verticalPanelHeight},
{visibleSelection.left - outsideGap - verticalPanelWidth, visibleSelection.top,
visibleSelection.left - outsideGap, visibleSelection.top + verticalPanelHeight},
{visibleSelection.right + outsideGap, visibleSelection.bottom - verticalPanelHeight,
visibleSelection.right + outsideGap + verticalPanelWidth, visibleSelection.bottom},
{visibleSelection.left - outsideGap - verticalPanelWidth, visibleSelection.bottom - verticalPanelHeight,
visibleSelection.left - outsideGap, visibleSelection.bottom},
{visibleSelection.right - verticalPanelWidth - ui.margin, visibleSelection.top + ui.margin,
visibleSelection.right - ui.margin, visibleSelection.top + ui.margin + verticalPanelHeight},
{visibleSelection.left + ui.margin, visibleSelection.top + ui.margin,
visibleSelection.left + ui.margin + verticalPanelWidth, visibleSelection.top + ui.margin + verticalPanelHeight}}};
verticalPanelBounds = chooseCandidate(verticalCandidates, {badgeObstacle});
for (std::size_t i = 0; i < verticalActions.size(); ++i) {
const int x = verticalPanelBounds.left + ui.panelPadding;
const int y = verticalPanelBounds.top + ui.panelPadding + static_cast<int>(i) * (ui.button + ui.gap);
verticalButtons.push_back(Button{RECT{x, y, x + ui.button, y + ui.button}, verticalActions[i], true});
}
const std::array<Action, 8> horizontalActions{
Action::Upload, Action::Share, Action::Search, Action::Gif,
Action::Print, Action::Copy, Action::Save, Action::Close};
const int horizontalContentWidth = static_cast<int>(horizontalActions.size()) * ui.button +
(static_cast<int>(horizontalActions.size()) - 1) * ui.gap;
const int horizontalPanelWidth = horizontalContentWidth + ui.panelPadding * 2;
const int horizontalPanelHeight = ui.button + ui.panelPadding * 2;
const int preferredLeft = std::clamp(visibleSelection.right - horizontalPanelWidth,
safe.left, std::max(safe.left, safe.right - horizontalPanelWidth));
const int leftAligned = std::clamp(visibleSelection.left,
safe.left, std::max(safe.left, safe.right - horizontalPanelWidth));
const std::array<RECT, 6> horizontalCandidates{{
{preferredLeft, visibleSelection.bottom + outsideGap,
preferredLeft + horizontalPanelWidth, visibleSelection.bottom + outsideGap + horizontalPanelHeight},
{preferredLeft, visibleSelection.top - outsideGap - horizontalPanelHeight,
preferredLeft + horizontalPanelWidth, visibleSelection.top - outsideGap},
{leftAligned, visibleSelection.bottom + outsideGap,
leftAligned + horizontalPanelWidth, visibleSelection.bottom + outsideGap + horizontalPanelHeight},
{leftAligned, visibleSelection.top - outsideGap - horizontalPanelHeight,
leftAligned + horizontalPanelWidth, visibleSelection.top - outsideGap},
{preferredLeft, visibleSelection.bottom - horizontalPanelHeight - ui.margin,
preferredLeft + horizontalPanelWidth, visibleSelection.bottom - ui.margin},
{preferredLeft, visibleSelection.top + ui.margin,
preferredLeft + horizontalPanelWidth, visibleSelection.top + ui.margin + horizontalPanelHeight}}};
horizontalPanelBounds = chooseCandidate(horizontalCandidates, {verticalPanelBounds, badgeObstacle});
for (std::size_t i = 0; i < horizontalActions.size(); ++i) {
const int x = horizontalPanelBounds.left + ui.panelPadding + static_cast<int>(i) * (ui.button + ui.gap);
const int y = horizontalPanelBounds.top + ui.panelPadding;
horizontalButtons.push_back(Button{RECT{x, y, x + ui.button, y + ui.button}, horizontalActions[i], true});
}
const int hs = ui.handle;
const int cx = (selection.left + selection.right) / 2;
const int cy = (selection.top + selection.bottom) / 2;
const std::array<POINT, 8> points{{
{selection.left, selection.top}, {cx, selection.top}, {selection.right, selection.top},
{selection.right, cy}, {selection.right, selection.bottom}, {cx, selection.bottom},
{selection.left, selection.bottom}, {selection.left, cy}}};
for (std::size_t i = 0; i < points.size(); ++i) {
RECT handle{points[i].x - hs / 2, points[i].y - hs / 2,
points[i].x - hs / 2 + hs, points[i].y - hs / 2 + hs};
if (handle.left < 0) OffsetRect(&handle, -handle.left, 0);
if (handle.top < 0) OffsetRect(&handle, 0, -handle.top);
if (handle.right > capture.width) OffsetRect(&handle, capture.width - handle.right, 0);
if (handle.bottom > capture.height) OffsetRect(&handle, 0, capture.height - handle.bottom);
handleRects[i] = handle;
}
if (!verticalButtons.empty()) {
const RECT anchor = verticalButtons[7].rect;
const float dpiScale = std::clamp(static_cast<float>(monitors[uiMonitorIndex].dpi) / 96.0f, 0.85f, 2.25f);
const float widthScale = static_cast<float>(std::max(1, rectWidth(safe))) / 318.0f;
const float heightScale = static_cast<float>(std::max(1, rectHeight(safe))) / 394.0f;
const float pickerScale = std::clamp(std::min({dpiScale, widthScale, heightScale}), 0.72f, dpiScale);
const auto pickerPx = [pickerScale](float value) {
return std::max(1, static_cast<int>(std::lround(value * pickerScale)));
};
const int pad = pickerPx(9.0f);
const int gap = pickerPx(6.0f);
const int topHeight = pickerPx(30.0f);
const int sliderWidth = pickerPx(15.0f);
const int svHeight = pickerPx(112.0f);
const int fieldHeight = pickerPx(27.0f);
pickerPaletteCell = pickerPx(25.0f);
pickerPaletteSwatch = pickerPx(18.0f);
const int gridWidth = pickerPaletteCell * 8;
const int gridHeight = pickerPaletteCell * 6;
const int panelWidth = std::max(pickerPx(304.0f), gridWidth + pad * 2);
const int numericHeight = fieldHeight * 2 + gap;
const int panelHeight = pad * 2 + topHeight + gap + svHeight + gap + numericHeight + gap + gridHeight;
const int pickerGap = std::max(ui.margin, pickerPx(7.0f));
const std::array<RECT, 4> pickerCandidates{{
{verticalPanelBounds.left - pickerGap - panelWidth,
std::clamp(anchor.top - topHeight, safe.top, std::max(safe.top, safe.bottom - panelHeight)),
verticalPanelBounds.left - pickerGap,
std::clamp(anchor.top - topHeight, safe.top, std::max(safe.top, safe.bottom - panelHeight)) + panelHeight},
{verticalPanelBounds.right + pickerGap,
std::clamp(anchor.top - topHeight, safe.top, std::max(safe.top, safe.bottom - panelHeight)),
verticalPanelBounds.right + pickerGap + panelWidth,
std::clamp(anchor.top - topHeight, safe.top, std::max(safe.top, safe.bottom - panelHeight)) + panelHeight},
{std::clamp(anchor.left - panelWidth / 2, safe.left, std::max(safe.left, safe.right - panelWidth)),
verticalPanelBounds.bottom + pickerGap,
std::clamp(anchor.left - panelWidth / 2, safe.left, std::max(safe.left, safe.right - panelWidth)) + panelWidth,
verticalPanelBounds.bottom + pickerGap + panelHeight},
{std::clamp(anchor.left - panelWidth / 2, safe.left, std::max(safe.left, safe.right - panelWidth)),
verticalPanelBounds.top - pickerGap - panelHeight,
std::clamp(anchor.left - panelWidth / 2, safe.left, std::max(safe.left, safe.right - panelWidth)) + panelWidth,
verticalPanelBounds.top - pickerGap}}};
palettePanelBounds = chooseCandidate(
pickerCandidates, {verticalPanelBounds, horizontalPanelBounds, badgeObstacle});
int y = palettePanelBounds.top + pad;
pickerPreviewRect = RECT{palettePanelBounds.left + pad, y,
palettePanelBounds.left + pad + topHeight, y + topHeight};
pickerEyedropperRect = RECT{pickerPreviewRect.right + gap, y,
pickerPreviewRect.right + gap + topHeight, y + topHeight};
pickerFieldRects[0] = RECT{pickerEyedropperRect.right + gap, y,
palettePanelBounds.right - pad, y + topHeight};
y += topHeight + gap;
const int svWidth = std::max(pickerPx(100.0f), panelWidth - pad * 2 - sliderWidth * 2 - gap * 2);
pickerSvRect = RECT{palettePanelBounds.left + pad, y,
palettePanelBounds.left + pad + svWidth, y + svHeight};
pickerHueRect = RECT{pickerSvRect.right + gap, y,
pickerSvRect.right + gap + sliderWidth, y + svHeight};
pickerAlphaRect = RECT{pickerHueRect.right + gap, y,
pickerHueRect.right + gap + sliderWidth, y + svHeight};
y += svHeight + gap;
const int fieldGap = gap;
const int fieldWidth = (panelWidth - pad * 2 - fieldGap * 3) / 4;
for (int i = 1; i < 8; ++i) {
const int numericIndex = i - 1;
const int row = numericIndex / 4;
const int col = numericIndex % 4;
const int left = palettePanelBounds.left + pad + col * (fieldWidth + fieldGap);
const int top = y + row * (fieldHeight + fieldGap);
pickerFieldRects[i] = RECT{left, top, left + fieldWidth, top + fieldHeight};
}
y += numericHeight + gap;
const int gridLeft = palettePanelBounds.left + (panelWidth - gridWidth) / 2;
for (std::size_t i = 0; i < settings::kSavedColorCount; ++i) {
const int row = static_cast<int>(i) / 8;
const int col = static_cast<int>(i) % 8;
const int cellLeft = gridLeft + col * pickerPaletteCell;
const int cellTop = y + row * pickerPaletteCell;
const int inset = std::max(1, (pickerPaletteCell - pickerPaletteSwatch) / 2);
paletteRects[i] = RECT{cellLeft + inset, cellTop + inset,
cellLeft + inset + pickerPaletteSwatch,
cellTop + inset + pickerPaletteSwatch};
}
}
}
bool buttonEnabled(Action action) const {
return action != Action::Undo || !annotations.empty();
}
int hitButton(POINT p) const {
for (size_t i = 0; i < verticalButtons.size(); ++i) {
if (buttonEnabled(verticalButtons[i].action) && pointInRect(verticalButtons[i].rect, p))
return static_cast<int>(i);
}
for (size_t i = 0; i < horizontalButtons.size(); ++i) {
if (buttonEnabled(horizontalButtons[i].action) && pointInRect(horizontalButtons[i].rect, p))
return static_cast<int>(verticalButtons.size() + i);
}
return -1;
}
Button* buttonAtCombinedIndex(int index) {
if (index < 0) return nullptr;
if (index < static_cast<int>(verticalButtons.size())) return &verticalButtons[index];
index -= static_cast<int>(verticalButtons.size());
if (index < static_cast<int>(horizontalButtons.size())) return &horizontalButtons[index];
return nullptr;
}
const Button* buttonAtCombinedIndex(int index) const {
if (index < 0) return nullptr;
if (index < static_cast<int>(verticalButtons.size())) return &verticalButtons[index];
index -= static_cast<int>(verticalButtons.size());
if (index < static_cast<int>(horizontalButtons.size())) return &horizontalButtons[index];
return nullptr;
}
bool hitPalette(POINT p) const {
return paletteOpen && pointInRect(palettePanelBounds, p);
}
bool handleColorPickerLeftDown(POINT point) {
if (!paletteOpen) return false;
if (pointInRect(pickerEyedropperRect, point)) {
const RECT previousUi = uiBounds();
cancelColorFieldEdit();
paletteOpen = false;
eyedropperActive = true;
invalidateUiTransition(previousUi);
updateCursor(point);
return true;
}
for (int i = 0; i < 8; ++i) {
if (pointInRect(pickerFieldRects[i], point)) {
if (activeColorField != ColorField::None) commitColorFieldEdit();
startColorFieldEdit(fieldForIndex(i));
return true;
}
}
if (pointInRect(pickerSvRect, point)) {
commitColorFieldEdit();
pickerDraggingSv = true;
updatePickerFromSvPoint(point);
SetCapture(hwnd);
return true;
}
if (pointInRect(pickerHueRect, point)) {
commitColorFieldEdit();
pickerDraggingHue = true;
updatePickerFromHuePoint(point);
SetCapture(hwnd);
return true;
}
if (pointInRect(pickerAlphaRect, point)) {
commitColorFieldEdit();
pickerDraggingAlpha = true;
updatePickerFromAlphaPoint(point);
SetCapture(hwnd);
return true;
}
for (std::size_t i = 0; i < settings::kSavedColorCount; ++i) {
if (!pointInRect(paletteRects[i], point)) continue;
commitColorFieldEdit();
if (savedPaletteOccupied[i]) {
setCurrentColor(color_picker_math::colorFromArgb(savedPalette[i]),
color_picker_math::alphaFromArgb(savedPalette[i]));
invalidateButton(7);
invalidateRectPadded(palettePanelBounds, 3);
} else {
saveCurrentColorToSlot(i);
}
return true;
}
if (pointInRect(palettePanelBounds, point)) {
commitColorFieldEdit();
return true;
}
return false;
}
bool handleColorPickerRightDown(POINT point) {
if (!paletteOpen) return false;
for (std::size_t i = 0; i < settings::kSavedColorCount; ++i) {
if (!pointInRect(paletteRects[i], point)) continue;
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
if (shift) clearSavedColorSlot(i);
else saveCurrentColorToSlot(i);
return true;
}
return pointInRect(palettePanelBounds, point);
}
bool pickerDragging() const {
return pickerDraggingSv || pickerDraggingHue || pickerDraggingAlpha;
}
void updatePickerDrag(POINT point) {
if (pickerDraggingSv) updatePickerFromSvPoint(point);
else if (pickerDraggingHue) updatePickerFromHuePoint(point);
else if (pickerDraggingAlpha) updatePickerFromAlphaPoint(point);
}
void finishPickerDrag() {
if (!pickerDragging()) return;
pickerDraggingSv = false;
pickerDraggingHue = false;
pickerDraggingAlpha = false;
persistEditorPreferences();
ReleaseCapture();
}
bool hitToolbarPanel(POINT p) const {
return pointInRect(verticalPanelBounds, p) || pointInRect(horizontalPanelBounds, p);
}
void invalidateButton(int index) {
if (const Button* button = buttonAtCombinedIndex(index)) invalidateRectPadded(button->rect, ui.panelPadding + 3);
}
void clearHover() {
if (hoveredButton < 0 && tooltipButton < 0) return;
const int oldHover = hoveredButton;
const int oldTooltip = tooltipButton;
hoveredButton = -1;
tooltipButton = -1;
KillTimer(hwnd, kHoverTimer);
invalidateButton(oldHover);
invalidateRectPadded(tooltipBoundsApprox(oldTooltip), 3);
}
void setHoveredButton(int next) {
if (next == hoveredButton) return;
const int oldHover = hoveredButton;
const int oldTooltip = tooltipButton;
KillTimer(hwnd, kHoverTimer);
hoveredButton = next;
tooltipButton = -1;
invalidateButton(oldHover);
invalidateButton(hoveredButton);
invalidateRectPadded(tooltipBoundsApprox(oldTooltip), 3);
if (hoveredButton >= 0) SetTimer(hwnd, kHoverTimer, static_cast<UINT>(ui.tooltipDelayMs), nullptr);
}
void armMouseLeaveTracking() {
if (trackingMouseLeave) return;
TRACKMOUSEEVENT event{};
event.cbSize = sizeof(event);
event.dwFlags = TME_LEAVE;
event.hwndTrack = hwnd;
trackingMouseLeave = TrackMouseEvent(&event) != FALSE;
}
int hitHandle(POINT p) const {
for (int i = 0; i < 8; ++i) if (pointInRect(handleRects[i], p)) return i;
return -1;
}
bool hitSelectionBorder(POINT p) const {
constexpr int e = 5;
RECT outer{selection.left - e, selection.top - e, selection.right + e, selection.bottom + e};
RECT inner{selection.left + e, selection.top + e, selection.right - e, selection.bottom - e};
return pointInRect(outer, p) && !pointInRect(inner, p);
}
void setTool(Tool value) {
const RECT previousUi = uiBounds();
const RECT previousPreview = brushPreviewRect;
cancelColorFieldEdit();
eyedropperActive = false;
tool = value;
paletteOpen = false;
brushPreviewRect = brushPreviewBounds(cursorPoint);
invalidateUiTransition(previousUi);
invalidateRectPadded(previousPreview, 3);
invalidateRectPadded(brushPreviewRect, 3);
if (hwnd) {
POINT cursor{};
if (GetCursorPos(&cursor) && ScreenToClient(hwnd, &cursor)) updateCursor(cursor);
}
}
Color annotationColor(bool marker = false) const {
const BYTE alpha = marker ? static_cast<BYTE>(std::min<int>(colorAlpha, 96)) : colorAlpha;
return toGdiColor(color, alpha);
}
std::unique_ptr<Bitmap> renderSelectionBitmap() {
if (!hasSelection || rectWidth(selection) <= 0 || rectHeight(selection) <= 0) return {};
// Screenshots are opaque. RGB avoids the zero-alpha semantics of GDI
// writes into an ARGB GDI+ bitmap while the sparse monitor capture is
// copied into this temporary crop.
auto output = std::make_unique<Bitmap>(rectWidth(selection), rectHeight(selection), PixelFormat32bppRGB);
Graphics g(output.get());
g.Clear(Color(255, 0, 0, 0));
HDC outputDc = g.GetHDC();
if (!outputDc) return {};
const bool copied = capture.blitTo(outputDc, 0, 0, selection);
g.ReleaseHDC(outputDc);
if (!copied) return {};
g.SetSmoothingMode(SmoothingModeAntiAlias);
g.SetTextRenderingHint(TextRenderingHintAntiAliasGridFit);
const Rect destination(0, 0, rectWidth(selection), rectHeight(selection));
g.SetClip(destination);
// Annotations live in virtual-desktop coordinates. Selection is only a
// crop rectangle, so moving/resizing it never mutates drawing data.
for (const auto& annotation : annotations)
drawAnnotation(g, annotation, static_cast<float>(-selection.left), static_cast<float>(-selection.top));
return output;
}
void doCopy() {
commitTextEdit();
auto image = renderSelectionBitmap();
if (!image || !copyBitmapToClipboard(hwnd, *image)) {
MessageBoxW(hwnd, L"Could not copy the screenshot to the clipboard.", L"NightShot", MB_ICONERROR);
return;
}
if (notificationTarget && IsWindow(notificationTarget)) {
PostMessageW(notificationTarget, WM_NIGHTSHOT_SCREENSHOT_COPIED, 0, 0);
}
DestroyWindow(hwnd);
}
void doUpload() {
commitTextEdit();
const settings::AppSettings configuration = settings::load();
if (configuration.uploadUrl.empty()) {
MessageBoxW(hwnd,
L"Сначала укажи URL API-сервера: ПКМ по иконке NightShot в трее → Настройки.",
L"NightShot — загрузка", MB_OK | MB_ICONINFORMATION);
return;
}
auto image = renderSelectionBitmap();
if (!image) return;
std::vector<BYTE> png;
if (!encodeBitmapAsPng(*image, png)) {
MessageBoxW(hwnd, L"Не удалось подготовить PNG для загрузки.",
L"NightShot — загрузка", MB_OK | MB_ICONERROR);
return;
}
if (!uploader::start(notificationTarget, std::move(png), configuration)) {
MessageBoxW(hwnd, L"Не удалось запустить фоновую загрузку.",
L"NightShot — загрузка", MB_OK | MB_ICONERROR);
return;
}
if (configuration.closeEditorAfterUpload) DestroyWindow(hwnd);
}
void beginGifRecording(const gif_recorder::Options& options) {
commitTextEdit();
if (!hasSelection || !notificationTarget || !IsWindow(notificationTarget)) return;
auto* request = new gif_recorder::StartRequest();
request->screenRect = RECT{
capture.x + selection.left, capture.y + selection.top,
capture.x + selection.right, capture.y + selection.bottom};
request->options = options;
request->notificationTarget = notificationTarget;
if (!PostMessageW(notificationTarget, WM_NIGHTSHOT_BEGIN_GIF, 0,
reinterpret_cast<LPARAM>(request))) {
delete request;
MessageBoxW(hwnd, L"Не удалось передать область в GIF-рекордер.",
L"NightShot — GIF", MB_OK | MB_ICONERROR);
return;
}
DestroyWindow(hwnd);
}
void doGif() {
const settings::AppSettings configuration = settings::load();
gif_recorder::Options options;
if (!gif_options_window::show(hwnd, configuration, options)) return;
beginGifRecording(options);
}
void doQuickGif() {
const settings::AppSettings configuration = settings::load();
gif_recorder::Options options;
options.fps = configuration.gifFps;
options.quality = configuration.gifQuality;
options.durationSeconds = 0;
options.hardLimitSeconds = configuration.gifMaxDurationSeconds;
options.captureCursor = configuration.gifCaptureCursor;
beginGifRecording(options);
}
void doSave() {
commitTextEdit();
auto image = renderSelectionBitmap();
if (!image) return;
const settings::AppSettings configuration = settings::load();
std::wstring path;
if (!saveScreenshotIncremented(*image, configuration, path)) {
MessageBoxW(hwnd, L"Не удалось сохранить снимок в выбранную папку.",
L"NightShot — сохранение", MB_OK | MB_ICONERROR);
return;
}
if (notificationTarget && IsWindow(notificationTarget)) {
auto* savedPath = new std::wstring(std::move(path));
if (!PostMessageW(notificationTarget, WM_NIGHTSHOT_SCREENSHOT_SAVED, 0,
reinterpret_cast<LPARAM>(savedPath))) {
delete savedPath;
}
}
DestroyWindow(hwnd);
}
void doPrint() {
commitTextEdit();
auto image = renderSelectionBitmap();
if (!image) return;
const PrintBitmapResult result = printBitmap(hwnd, *image);
if (result == PrintBitmapResult::Printed) DestroyWindow(hwnd);
else if (result == PrintBitmapResult::Failed)
MessageBoxW(hwnd, L"Could not print the screenshot.", L"NightShot", MB_ICONERROR);
}
void execute(Action action) {
if (isToolAction(action)) {
setTool(toolFromAction(action));
return;
}
switch (action) {
case Action::Color: {
commitTextEdit();
const RECT previousUi = uiBounds();
const RECT previousPreview = brushPreviewRect;
cancelColorFieldEdit();
eyedropperActive = false;
syncPickerFromRgb();
paletteOpen = !paletteOpen;
updateLayout();
brushPreviewRect = brushPreviewBounds(cursorPoint);
invalidateUiTransition(previousUi);
invalidateRectPadded(previousPreview, 3);
invalidateRectPadded(brushPreviewRect, 3);
break;
}
case Action::Undo:
commitTextEdit();
if (!annotations.empty()) {
const RECT dirty = annotationBounds(annotations.back());
annotations.pop_back();
invalidateRectPadded(dirty, 2);
if (annotations.empty()) invalidateButton(8);
}
break;
case Action::Upload: doUpload(); break;
case Action::Share:
MessageBoxW(hwnd, L"Share requires an uploaded screenshot URL. Connect an uploader first.",
L"Share endpoint not configured", MB_OK | MB_ICONINFORMATION);
break;
case Action::Search:
MessageBoxW(hwnd, L"Reverse image search requires an upload/search provider. The UI hook is ready.",
L"Search provider not configured", MB_OK | MB_ICONINFORMATION);
break;
case Action::Gif: doGif(); break;
case Action::Print: doPrint(); break;
case Action::Copy: doCopy(); break;
case Action::Save: doSave(); break;
case Action::Close: DestroyWindow(hwnd); break;
default: break;
}
}
void drawToolbarContainer(Graphics& g, const std::vector<Button>& buttons) {
if (buttons.empty()) return;
RECT bounds = buttons.front().rect;
for (const Button& button : buttons) {
bounds.left = std::min(bounds.left, button.rect.left);
bounds.top = std::min(bounds.top, button.rect.top);
bounds.right = std::max(bounds.right, button.rect.right);
bounds.bottom = std::max(bounds.bottom, button.rect.bottom);
}
const float pad = static_cast<float>(ui.panelPadding);
RectF shadow(static_cast<REAL>(bounds.left) - pad + 1.0f,
static_cast<REAL>(bounds.top) - pad + 2.0f,
static_cast<REAL>(rectWidth(bounds)) + pad * 2.0f,
static_cast<REAL>(rectHeight(bounds)) + pad * 2.0f);
SolidBrush shadowBrush(Color(58, 0, 0, 0));
fillRoundedRect(g, shadow, static_cast<float>(ui.radius + 3), shadowBrush);
RectF panel(static_cast<REAL>(bounds.left) - pad,
static_cast<REAL>(bounds.top) - pad,
static_cast<REAL>(rectWidth(bounds)) + pad * 2.0f,
static_cast<REAL>(rectHeight(bounds)) + pad * 2.0f);
fillRoundedGradient(g, panel, static_cast<float>(ui.radius + 3),
toGdiColor(theme::kPanelTop, 248),
toGdiColor(theme::kPanelBottom, 248));
Pen border(toGdiColor(theme::kBorder, 225), 1.0f);
strokeRoundedRect(g, panel, static_cast<float>(ui.radius + 3), border);
}
void drawButton(Graphics& g, const Button& button, bool hovered) {
const bool enabled = buttonEnabled(button.action);
const bool active = enabled && ((button.action == Action::Drag && tool == Tool::Drag) ||
(button.action == Action::Pen && tool == Tool::Pen) ||
(button.action == Action::Line && tool == Tool::Line) ||
(button.action == Action::Arrow && tool == Tool::Arrow) ||
(button.action == Action::Rectangle && tool == Tool::Rectangle) ||
(button.action == Action::Marker && tool == Tool::Marker) ||
(button.action == Action::Text && tool == Tool::Text) ||
(button.action == Action::Color && paletteOpen));
const RectF r(static_cast<REAL>(button.rect.left), static_cast<REAL>(button.rect.top),
static_cast<REAL>(rectWidth(button.rect)), static_cast<REAL>(rectHeight(button.rect)));
if (active) {
fillRoundedGradient(g, r, static_cast<float>(ui.radius),
toGdiColor(theme::kAccent),
toGdiColor(theme::kAccentEnd),
LinearGradientModeForwardDiagonal);
Pen activeBorder(Color(125, 255, 255, 255), 1.0f);
strokeRoundedRect(g, r, static_cast<float>(ui.radius), activeBorder);
} else if (hovered && enabled) {
RectF hoverRect(r.X + 1.0f, r.Y + 1.0f, r.Width - 2.0f, r.Height - 2.0f);
const COLORREF hoverColor = button.action == Action::Close ? RGB(103, 42, 54) : theme::kPanelHover;
SolidBrush hoverBrush(toGdiColor(hoverColor, 205));
fillRoundedRect(g, hoverRect, static_cast<float>(std::max(4, ui.radius - 1)), hoverBrush);
Pen hoverBorder(toGdiColor(button.action == Action::Close ? RGB(154, 68, 82) : theme::kBorder, 205), 1.0f);
strokeRoundedRect(g, hoverRect, static_cast<float>(std::max(4, ui.radius - 1)), hoverBorder);
}
const Color iconColor = !enabled ? toGdiColor(theme::kMuted, 105)
: active ? Color(255, 255, 255, 255)
: toGdiColor(theme::kText, 238);
const float iconSize = static_cast<float>(ui.icon);
const RectF iconRect(
r.X + (r.Width - iconSize) * 0.5f,
r.Y + (r.Height - iconSize) * 0.5f,
iconSize,
iconSize);
drawUiIcon(g, iconForAction(button.action), iconRect, iconColor, color);
if (button.action == Action::Color) {
const float indicatorWidth = std::max(8.0f, iconSize * 0.58f);
const float indicatorHeight = std::max(2.0f, std::round(iconSize * 0.14f));
RectF indicator(r.X + (r.Width - indicatorWidth) * 0.5f,
r.GetBottom() - indicatorHeight - std::max(3.0f, r.Height * 0.09f),
indicatorWidth, indicatorHeight);
SolidBrush selectedBrush(toGdiColor(color));
fillRoundedRect(g, indicator, indicatorHeight * 0.5f, selectedBrush);
if (GetRValue(color) < 45 && GetGValue(color) < 45 && GetBValue(color) < 45) {
Pen indicatorBorder(Color(160, 255, 255, 255), 1.0f);
strokeRoundedRect(g, indicator, indicatorHeight * 0.5f, indicatorBorder);
}
}
}
void drawCheckerboard(Graphics& g, const RECT& rectangle, int cell) const {
if (rectangle.right <= rectangle.left || rectangle.bottom <= rectangle.top) return;
// RECT coordinates are LONG in WinAPI. Keep all checkerboard geometry in
// the same type so MinGW does not have to deduce std::min(int, LONG).
const LONG cellSize = std::max<LONG>(2L, static_cast<LONG>(cell));
SolidBrush first(Color(255, 224, 224, 232));
SolidBrush second(Color(255, 172, 172, 184));
for (LONG y = rectangle.top; y < rectangle.bottom; y += cellSize) {
for (LONG x = rectangle.left; x < rectangle.right; x += cellSize) {
const LONG row = (y - rectangle.top) / cellSize;
const LONG column = (x - rectangle.left) / cellSize;
const LONG blockWidth = std::min<LONG>(cellSize, rectangle.right - x);
const LONG blockHeight = std::min<LONG>(cellSize, rectangle.bottom - y);
const RectF block(static_cast<REAL>(x), static_cast<REAL>(y),
static_cast<REAL>(blockWidth),
static_cast<REAL>(blockHeight));
g.FillRectangle(((row + column) & 1L) ? &first : &second, block);
}
}
}
void drawPickerField(Graphics& g, const RECT& rectangle, ColorField field,
const Font& labelFont, const Font& valueFont) const {
if (rectangle.right <= rectangle.left || rectangle.bottom <= rectangle.top) return;
const bool active = field == activeColorField;
const RectF bounds(static_cast<REAL>(rectangle.left), static_cast<REAL>(rectangle.top),
static_cast<REAL>(rectWidth(rectangle)), static_cast<REAL>(rectHeight(rectangle)));
SolidBrush fill(toGdiColor(active ? RGB(42, 49, 72) : RGB(27, 27, 38), 248));
fillRoundedRect(g, bounds, std::max(4.0f, static_cast<float>(ui.radius) * 0.75f), fill);
Pen border(toGdiColor(active ? theme::kAccentHover : theme::kBorder, active ? 245 : 210), 1.0f);
strokeRoundedRect(g, bounds, std::max(4.0f, static_cast<float>(ui.radius) * 0.75f), border);
const float labelWidth = field == ColorField::Hex ? std::max(30.0f, bounds.Width * 0.19f)
: std::max(16.0f, bounds.Width * 0.25f);
RectF labelArea(bounds.X + 5.0f, bounds.Y, labelWidth, bounds.Height);
RectF valueArea(bounds.X + labelWidth, bounds.Y,
std::max(1.0f, bounds.Width - labelWidth - 5.0f), bounds.Height);
SolidBrush labelBrush(toGdiColor(theme::kMuted));
SolidBrush valueBrush(toGdiColor(theme::kText));
StringFormat labelFormat;
labelFormat.SetAlignment(StringAlignmentNear);
labelFormat.SetLineAlignment(StringAlignmentCenter);
StringFormat valueFormat;
valueFormat.SetAlignment(field == ColorField::Hex ? StringAlignmentNear : StringAlignmentFar);
valueFormat.SetLineAlignment(StringAlignmentCenter);
const std::wstring value = active ? colorFieldBuffer : colorFieldValue(field);
g.DrawString(colorFieldLabel(field), -1, &labelFont, labelArea, &labelFormat, &labelBrush);
g.DrawString(value.c_str(), -1, &valueFont, valueArea, &valueFormat, &valueBrush);
if (active) {
RectF measured{};
g.MeasureString(value.c_str(), -1, &valueFont, valueArea, &valueFormat, &measured);
const REAL caretX = std::clamp(measured.GetRight() + 1.0f,
valueArea.X + 1.0f, valueArea.GetRight() - 2.0f);
Pen caret(toGdiColor(theme::kText), 1.0f);
g.DrawLine(&caret, caretX, bounds.Y + 6.0f, caretX, bounds.GetBottom() - 6.0f);
}
}
void drawPalette(Graphics& g) {
if (!paletteOpen) return;
const RECT panelRect = palettePanelBounds;
const int monitorIndex = monitorIndexForPoint(POINT{panelRect.left, panelRect.top});
const float scale = std::clamp(static_cast<float>(monitors[monitorIndex].dpi) / 96.0f, 0.72f, 2.25f);
const GraphicsState state = g.Save();
g.SetSmoothingMode(SmoothingModeAntiAlias);
g.SetPixelOffsetMode(PixelOffsetModeHalf);
g.SetTextRenderingHint(TextRenderingHintClearTypeGridFit);
RectF shadow(static_cast<REAL>(panelRect.left + 1), static_cast<REAL>(panelRect.top + 2),
static_cast<REAL>(rectWidth(panelRect)), static_cast<REAL>(rectHeight(panelRect)));
SolidBrush shadowBrush(Color(72, 0, 0, 0));
fillRoundedRect(g, shadow, static_cast<float>(ui.radius + 4), shadowBrush);
RectF panel(static_cast<REAL>(panelRect.left), static_cast<REAL>(panelRect.top),
static_cast<REAL>(rectWidth(panelRect)), static_cast<REAL>(rectHeight(panelRect)));
fillRoundedGradient(g, panel, static_cast<float>(ui.radius + 4),
toGdiColor(theme::kPanelTop, 252),
toGdiColor(theme::kPanelBottom, 252));
Pen panelBorder(toGdiColor(theme::kBorder, 235), 1.0f);
strokeRoundedRect(g, panel, static_cast<float>(ui.radius + 4), panelBorder);
// Current RGBA preview.
drawCheckerboard(g, pickerPreviewRect, std::max(3, static_cast<int>(std::lround(5.0f * scale))));
RectF preview(static_cast<REAL>(pickerPreviewRect.left), static_cast<REAL>(pickerPreviewRect.top),
static_cast<REAL>(rectWidth(pickerPreviewRect)), static_cast<REAL>(rectHeight(pickerPreviewRect)));
SolidBrush previewColor(Color(colorAlpha, GetRValue(color), GetGValue(color), GetBValue(color)));
g.FillRectangle(&previewColor, preview);
Pen previewBorder(Color(180, 255, 255, 255), 1.0f);
strokeRoundedRect(g, preview, std::max(4.0f, 5.0f * scale), previewBorder);
// Eyedropper button.
RectF eyedropper(static_cast<REAL>(pickerEyedropperRect.left), static_cast<REAL>(pickerEyedropperRect.top),
static_cast<REAL>(rectWidth(pickerEyedropperRect)), static_cast<REAL>(rectHeight(pickerEyedropperRect)));
SolidBrush eyedropperFill(toGdiColor(eyedropperActive ? theme::kPanelActive : RGB(30, 30, 42), 250));
fillRoundedRect(g, eyedropper, std::max(4.0f, 5.0f * scale), eyedropperFill);
Pen eyedropperBorder(toGdiColor(eyedropperActive ? theme::kAccentHover : theme::kBorder, 230), 1.0f);
strokeRoundedRect(g, eyedropper, std::max(4.0f, 5.0f * scale), eyedropperBorder);
const float dropperSize = std::min(eyedropper.Width, eyedropper.Height) * 0.64f;
drawUiIcon(g, UiIcon::Colors,
RectF(eyedropper.X + (eyedropper.Width - dropperSize) * 0.5f,
eyedropper.Y + (eyedropper.Height - dropperSize) * 0.5f,
dropperSize, dropperSize),
toGdiColor(theme::kText), color);
// Saturation/value square.
const RectF sv(static_cast<REAL>(pickerSvRect.left), static_cast<REAL>(pickerSvRect.top),
static_cast<REAL>(rectWidth(pickerSvRect)), static_cast<REAL>(rectHeight(pickerSvRect)));
SolidBrush hueBase(toGdiColor(color_picker_math::hsvToRgb(pickerHsv.h, 1.0f, 1.0f)));
g.FillRectangle(&hueBase, sv);
LinearGradientBrush whiteOverlay(sv, Color(255, 255, 255, 255), Color(0, 255, 255, 255),
LinearGradientModeHorizontal);
g.FillRectangle(&whiteOverlay, sv);
LinearGradientBrush blackOverlay(sv, Color(0, 0, 0, 0), Color(255, 0, 0, 0),
LinearGradientModeVertical);
g.FillRectangle(&blackOverlay, sv);
Pen areaBorder(Color(190, 255, 255, 255), 1.0f);
g.DrawRectangle(&areaBorder, sv);
const REAL svX = sv.X + pickerHsv.s * std::max(1.0f, sv.Width - 1.0f);
const REAL svY = sv.Y + (1.0f - pickerHsv.v) * std::max(1.0f, sv.Height - 1.0f);
Pen markerShadow(Color(210, 0, 0, 0), std::max(3.0f, 3.0f * scale));
Pen markerLight(Color(255, 255, 255, 255), std::max(1.0f, 1.2f * scale));
g.DrawEllipse(&markerShadow, svX - 5.0f * scale, svY - 5.0f * scale, 10.0f * scale, 10.0f * scale);
g.DrawEllipse(&markerLight, svX - 5.0f * scale, svY - 5.0f * scale, 10.0f * scale, 10.0f * scale);
// Hue strip.
constexpr std::array<float, 7> hueStops{0.0f, 60.0f, 120.0f, 180.0f, 240.0f, 300.0f, 360.0f};
for (std::size_t i = 0; i + 1 < hueStops.size(); ++i) {
const REAL top = static_cast<REAL>(pickerHueRect.top) +
static_cast<REAL>(rectHeight(pickerHueRect)) * static_cast<REAL>(i) / 6.0f;
const REAL bottom = static_cast<REAL>(pickerHueRect.top) +
static_cast<REAL>(rectHeight(pickerHueRect)) * static_cast<REAL>(i + 1) / 6.0f;
RectF segment(static_cast<REAL>(pickerHueRect.left), top,
static_cast<REAL>(rectWidth(pickerHueRect)), std::max(1.0f, bottom - top));
LinearGradientBrush hueBrush(segment,
toGdiColor(color_picker_math::hsvToRgb(hueStops[i], 1.0f, 1.0f)),
toGdiColor(color_picker_math::hsvToRgb(std::fmod(hueStops[i + 1], 360.0f), 1.0f, 1.0f)),
LinearGradientModeVertical);
g.FillRectangle(&hueBrush, segment);
}
const RectF hueBounds(static_cast<REAL>(pickerHueRect.left), static_cast<REAL>(pickerHueRect.top),
static_cast<REAL>(rectWidth(pickerHueRect)), static_cast<REAL>(rectHeight(pickerHueRect)));
g.DrawRectangle(&areaBorder, hueBounds);
const REAL hueY = hueBounds.Y + (pickerHsv.h / 360.0f) * std::max(1.0f, hueBounds.Height - 1.0f);
Pen sliderShadow(Color(220, 0, 0, 0), std::max(3.0f, 3.0f * scale));
Pen sliderLight(Color(255, 255, 255, 255), std::max(1.0f, 1.0f * scale));
g.DrawLine(&sliderShadow, hueBounds.X - 2.0f, hueY, hueBounds.GetRight() + 2.0f, hueY);
g.DrawLine(&sliderLight, hueBounds.X - 2.0f, hueY, hueBounds.GetRight() + 2.0f, hueY);
// Alpha strip.
drawCheckerboard(g, pickerAlphaRect, std::max(2, static_cast<int>(std::lround(4.0f * scale))));
const RectF alphaBounds(static_cast<REAL>(pickerAlphaRect.left), static_cast<REAL>(pickerAlphaRect.top),
static_cast<REAL>(rectWidth(pickerAlphaRect)), static_cast<REAL>(rectHeight(pickerAlphaRect)));
LinearGradientBrush alphaBrush(alphaBounds,
Color(255, GetRValue(color), GetGValue(color), GetBValue(color)),
Color(0, GetRValue(color), GetGValue(color), GetBValue(color)),
LinearGradientModeVertical);
g.FillRectangle(&alphaBrush, alphaBounds);
g.DrawRectangle(&areaBorder, alphaBounds);
const REAL alphaY = alphaBounds.Y + (1.0f - static_cast<float>(colorAlpha) / 255.0f) *
std::max(1.0f, alphaBounds.Height - 1.0f);
g.DrawLine(&sliderShadow, alphaBounds.X - 2.0f, alphaY, alphaBounds.GetRight() + 2.0f, alphaY);
g.DrawLine(&sliderLight, alphaBounds.X - 2.0f, alphaY, alphaBounds.GetRight() + 2.0f, alphaY);
FontFamily family(L"Segoe UI");
Font labelFont(&family, static_cast<REAL>(std::max(9, static_cast<int>(std::lround(9.5f * scale)))),
FontStyleRegular, UnitPixel);
Font valueFont(&family, static_cast<REAL>(std::max(10, static_cast<int>(std::lround(11.0f * scale)))),
FontStyleRegular, UnitPixel);
for (int i = 0; i < 8; ++i) {
drawPickerField(g, pickerFieldRects[i], fieldForIndex(i), labelFont, valueFont);
}
// 8x6 persistent saved-color grid. Empty cells have a subtle plus.
for (std::size_t i = 0; i < settings::kSavedColorCount; ++i) {
const RECT& rectangle = paletteRects[i];
if (rectangle.right <= rectangle.left || rectangle.bottom <= rectangle.top) continue;
const RectF swatch(static_cast<REAL>(rectangle.left), static_cast<REAL>(rectangle.top),
static_cast<REAL>(rectWidth(rectangle)), static_cast<REAL>(rectHeight(rectangle)));
if (savedPaletteOccupied[i]) {
const COLORREF slotColor = color_picker_math::colorFromArgb(savedPalette[i]);
const BYTE slotAlpha = color_picker_math::alphaFromArgb(savedPalette[i]);
drawCheckerboard(g, rectangle, std::max(2, pickerPaletteSwatch / 4));
SolidBrush slotBrush(Color(slotAlpha, GetRValue(slotColor), GetGValue(slotColor), GetBValue(slotColor)));
fillRoundedRect(g, swatch, std::max(3.0f, static_cast<float>(pickerPaletteSwatch) * 0.22f), slotBrush);
const bool selected = savedPalette[i] == currentArgb();
Pen slotBorder(selected ? toGdiColor(theme::kAccentHover) : Color(145, 255, 255, 255),
selected ? std::max(2.0f, 1.5f * scale) : 1.0f);
strokeRoundedRect(g, swatch, std::max(3.0f, static_cast<float>(pickerPaletteSwatch) * 0.22f), slotBorder);
} else {
SolidBrush emptyFill(toGdiColor(RGB(31, 31, 43), 220));
fillRoundedRect(g, swatch, std::max(3.0f, static_cast<float>(pickerPaletteSwatch) * 0.22f), emptyFill);
Pen emptyBorder(toGdiColor(theme::kBorder, 165), 1.0f);
strokeRoundedRect(g, swatch, std::max(3.0f, static_cast<float>(pickerPaletteSwatch) * 0.22f), emptyBorder);
Pen plus(toGdiColor(theme::kMuted, 175), 1.0f);
const REAL centerX = swatch.X + swatch.Width * 0.5f;
const REAL centerY = swatch.Y + swatch.Height * 0.5f;
const REAL arm = std::max(2.0f, swatch.Width * 0.18f);
g.DrawLine(&plus, centerX - arm, centerY, centerX + arm, centerY);
g.DrawLine(&plus, centerX, centerY - arm, centerX, centerY + arm);
}
}
g.Restore(state);
}
std::vector<RECT> collectUpdateRects() const {
std::vector<RECT> result;
HRGN region = CreateRectRgn(0, 0, 0, 0);
if (!region) return result;
const int type = GetUpdateRgn(hwnd, region, FALSE);
if (type == NULLREGION || type == ERROR) {
DeleteObject(region);
return result;
}
const DWORD bytes = GetRegionData(region, 0, nullptr);
if (bytes >= sizeof(RGNDATAHEADER)) {
std::vector<std::uint8_t> storage(bytes);
auto* data = reinterpret_cast<RGNDATA*>(storage.data());
if (GetRegionData(region, bytes, data) == bytes) {
const auto* rects = reinterpret_cast<const RECT*>(data->Buffer);
result.assign(rects, rects + data->rdh.nCount);
}
}
DeleteObject(region);
if (result.size() > 64) {
// RECT coordinates are LONG. Keep the whole grid calculation LONG
// as well; mixing LONG with int makes std::max/std::min ambiguous
// under MinGW-w64 (where LONG is long, not int).
constexpr LONG grid = 128L;
const LONG clientWidth = static_cast<LONG>(capture.width);
const LONG clientHeight = static_cast<LONG>(capture.height);
std::unordered_set<std::uint64_t> occupied;
std::vector<RECT> coarse;
for (RECT rect : result) {
rect = clampToClient(rect);
if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
const LONG firstX = rect.left / grid;
const LONG lastX = std::max(firstX, (rect.right - 1L) / grid);
const LONG firstY = rect.top / grid;
const LONG lastY = std::max(firstY, (rect.bottom - 1L) / grid);
for (LONG y = firstY; y <= lastY; ++y) {
for (LONG x = firstX; x <= lastX; ++x) {
const std::uint64_t key = (static_cast<std::uint64_t>(static_cast<std::uint32_t>(y)) << 32) |
static_cast<std::uint32_t>(x);
if (!occupied.insert(key).second) continue;
coarse.push_back(clampToClient(RECT{
x * grid,
y * grid,
std::min(clientWidth, (x + 1L) * grid),
std::min(clientHeight, (y + 1L) * grid)}));
}
}
}
if (!coarse.empty() && coarse.size() <= 512) result = std::move(coarse);
}
return result;
}
static std::uint8_t dimChannel(std::uint8_t source, int tint) {
// Equivalent to alpha-blending the old dark navy veil once, but done
// directly in the dirty scratch tile instead of asking GDI+ to touch
// the complete virtual desktop for every mouse move.
return static_cast<std::uint8_t>((static_cast<int>(source) * 105 + tint * 150 + 127) / 255);
}
void dimScratchRect(const RECT& globalRect) {
const int width = rectWidth(globalRect);
const int height = rectHeight(globalRect);
if (!scratchPixels || width <= 0 || height <= 0) return;
RECT clear{0, 0, 0, 0};
if ((hasSelection || selecting) && rectWidth(selection) > 0 && rectHeight(selection) > 0) {
clear = intersectRects(globalRect, selection);
if (clear.right > clear.left && clear.bottom > clear.top) {
OffsetRect(&clear, -globalRect.left, -globalRect.top);
}
}
auto dimRange = [&](std::uint8_t* row, int from, int to) {
from = std::clamp(from, 0, width);
to = std::clamp(to, 0, width);
for (int x = from; x < to; ++x) {
std::uint8_t* pixel = row + x * 4;
pixel[0] = dimChannel(pixel[0], 14); // B
pixel[1] = dimChannel(pixel[1], 8); // G
pixel[2] = dimChannel(pixel[2], 8); // R
pixel[3] = 255;
}
};
for (int y = 0; y < height; ++y) {
std::uint8_t* row = scratchPixels + y * scratchStride;
if (clear.right > clear.left && y >= clear.top && y < clear.bottom) {
dimRange(row, 0, clear.left);
dimRange(row, clear.right, width);
} else {
dimRange(row, 0, width);
}
}
}
static float estimatedTextWidth(const std::wstring& text, float size) {
if (text.empty()) return std::max(12.0f, size * 0.35f);
return std::max(size * 0.6f, static_cast<float>(text.size()) * size * 0.62f);
}
void drawTextEditor(Graphics& g, RECT dirty) {
if (!textEditing || !rectsIntersect(textEditorRect, dirty)) return;
const int monitorIndex = monitorIndexForPoint(textAnchor);
const float scale = std::clamp(static_cast<float>(monitors[monitorIndex].dpi) / 96.0f, 0.85f, 2.25f);
RectF box(static_cast<REAL>(textEditorRect.left), static_cast<REAL>(textEditorRect.top),
static_cast<REAL>(rectWidth(textEditorRect)), static_cast<REAL>(rectHeight(textEditorRect)));
RectF shadow(box.X + 1.0f, box.Y + std::max(2.0f, 2.0f * scale), box.Width, box.Height);
SolidBrush shadowBrush(Color(72, 0, 0, 0));
fillRoundedRect(g, shadow, std::max(6.0f, 7.0f * scale), shadowBrush);
fillRoundedGradient(g, box, std::max(6.0f, 7.0f * scale),
toGdiColor(theme::kPanelTop, 252),
toGdiColor(theme::kPanelBottom, 252));
Pen border(toGdiColor(theme::kAccentHover, 235), std::max(1.0f, scale));
strokeRoundedRect(g, box, std::max(6.0f, 7.0f * scale), border);
const GraphicsState state = g.Save();
g.SetTextRenderingHint(TextRenderingHintAntiAliasGridFit);
g.SetTextContrast(0);
g.SetPixelOffsetMode(PixelOffsetModeDefault);
const int padX = std::max(7, static_cast<int>(std::lround(8.0f * scale)));
const REAL originX = static_cast<REAL>(textEditorRect.left + padX);
const REAL originY = static_cast<REAL>(textEditorRect.top + std::max(4, static_cast<int>(std::lround(5.0f * scale))));
RectF textClip(originX, static_cast<REAL>(textEditorRect.top + 2),
static_cast<REAL>(std::max(1, rectWidth(textEditorRect) - padX * 2)),
static_cast<REAL>(std::max(1, rectHeight(textEditorRect) - 4)));
g.SetClip(textClip, CombineModeIntersect);
FontFamily family(L"Segoe UI");
Font font(&family, std::round(textEditSize), FontStyleRegular, UnitPixel);
SolidBrush brush(toGdiColor(color));
StringFormat format;
format.SetFormatFlags(StringFormatFlagsNoWrap | StringFormatFlagsMeasureTrailingSpaces);
g.DrawString(textEditBuffer.c_str(), -1, &font, PointF(originX, originY), &format, &brush);
RectF measured{};
g.MeasureString(textEditBuffer.c_str(), -1, &font, PointF(originX, originY), &format, &measured);
const REAL caretX = std::min<REAL>(static_cast<REAL>(textEditorRect.right - padX),
std::max(originX, measured.GetRight() + 1.0f));
Pen caret(toGdiColor(theme::kText), std::max(1.0f, scale));
const REAL caretTop = originY + std::max(1.0f, textEditSize * 0.08f);
const REAL caretBottom = std::min<REAL>(static_cast<REAL>(textEditorRect.bottom - 5),
originY + textEditSize * 1.18f);
g.DrawLine(&caret, caretX, caretTop, caretX, caretBottom);
g.Restore(state);
}
void drawBrushPreview(Graphics& g, RECT dirty) {
if (!hasSelection || selecting || draggingSelection || resizeHandle >= 0 ||
paletteOpen || eyedropperActive || !isDrawingTool(tool) ||
!rectsIntersect(brushPreviewRect, dirty)) return;
const float diameter = std::max(1.0f, currentStrokeWidth());
const float x = static_cast<float>(cursorPoint.x) - diameter * 0.5f;
const float y = static_cast<float>(cursorPoint.y) - diameter * 0.5f;
const GraphicsState state = g.Save();
g.SetSmoothingMode(SmoothingModeAntiAlias);
g.SetPixelOffsetMode(PixelOffsetModeHalf);
// Preview is deliberately hollow: it represents the exact physical
// brush diameter without covering the pixels the user is trying to aim
// at. Keep the border at one physical pixel on every monitor.
Pen border(toGdiColor(color, static_cast<BYTE>(std::max<int>(colorAlpha, 160))), 1.0f);
g.DrawEllipse(&border, x, y, diameter, diameter);
g.Restore(state);
}
void drawScene(Graphics& g, RECT dirty) {
// Drawing is a virtual-desktop layer, independent from the crop. It is
// intentionally visible even outside the selected rectangle and keeps
// absolute coordinates while the selection moves or resizes.
for (const auto& annotation : annotations) {
drawAnnotationClipped(g, annotation, dirty);
}
if (hasSelection || selecting) {
const RECT r = selection;
const int borderPadding = std::max(10, ui.handle + 6);
const std::array<RECT, 4> borderBands{{
{r.left - borderPadding, r.top - borderPadding, r.right + borderPadding, r.top + borderPadding},
{r.left - borderPadding, r.bottom - borderPadding, r.right + borderPadding, r.bottom + borderPadding},
{r.left - borderPadding, r.top, r.left + borderPadding, r.bottom},
{r.right - borderPadding, r.top, r.right + borderPadding, r.bottom}}};
bool borderDirty = false;
for (const RECT& band : borderBands) borderDirty = borderDirty || rectsIntersect(band, dirty);
if (borderDirty) {
Pen border(toGdiColor(theme::kSelectionBorder), 1.25f);
REAL pattern[] = {4.0f, 4.0f};
border.SetDashPattern(pattern, 2);
g.DrawRectangle(&border, static_cast<REAL>(r.left), static_cast<REAL>(r.top),
static_cast<REAL>(std::max(0, rectWidth(r) - 1)),
static_cast<REAL>(std::max(0, rectHeight(r) - 1)));
if (hasSelection && !selecting && tool == Tool::Drag) {
SolidBrush handleFill(toGdiColor(theme::kAccentHover));
Pen handleBorder(Color(210, 255, 255, 255), 1.0f);
for (const RECT& handle : handleRects) {
if (!rectsIntersect(handle, dirty)) continue;
RectF handleRect(static_cast<REAL>(handle.left), static_cast<REAL>(handle.top),
static_cast<REAL>(rectWidth(handle)), static_cast<REAL>(rectHeight(handle)));
fillRoundedRect(g, handleRect, std::max(2.0f, static_cast<float>(ui.handle) * 0.28f), handleFill);
strokeRoundedRect(g, handleRect, std::max(2.0f, static_cast<float>(ui.handle) * 0.28f), handleBorder);
}
}
}
if (rectsIntersect(dimensionBadgeRect, dirty)) {
const std::wstring dimensions = std::to_wstring(std::max(0, rectWidth(r))) + L" × " +
std::to_wstring(std::max(0, rectHeight(r)));
const int badgeMonitor = monitorIndexForPoint(POINT{r.left, r.top});
const float scale = std::clamp(static_cast<float>(monitors[badgeMonitor].dpi) / 96.0f, 0.85f, 2.25f);
FontFamily family(L"Segoe UI");
Font font(&family, static_cast<REAL>(std::lround(12.5f * scale)), FontStyleRegular, UnitPixel);
RectF badgeRect(static_cast<REAL>(dimensionBadgeRect.left), static_cast<REAL>(dimensionBadgeRect.top),
static_cast<REAL>(rectWidth(dimensionBadgeRect)), static_cast<REAL>(rectHeight(dimensionBadgeRect)));
fillRoundedGradient(g, badgeRect, std::max(5.0f, 6.0f * scale),
toGdiColor(theme::kPanelTop, 242),
toGdiColor(theme::kPanelBottom, 242));
Pen badgeBorder(toGdiColor(theme::kBorder, 225), 1.0f);
strokeRoundedRect(g, badgeRect, std::max(5.0f, 6.0f * scale), badgeBorder);
SolidBrush textBrush(toGdiColor(theme::kText));
StringFormat format;
format.SetAlignment(StringAlignmentCenter);
format.SetLineAlignment(StringAlignmentCenter);
g.DrawString(dimensions.c_str(), -1, &font, badgeRect, &format, &textBrush);
}
} else {
const int monitorIndex = std::clamp(hintMonitorIndex, 0, static_cast<int>(monitors.size()) - 1);
const RECT cardBounds = hintRectForMonitor(monitorIndex);
if (rectsIntersect(cardBounds, dirty)) {
const float scale = std::clamp(static_cast<float>(monitors[monitorIndex].dpi) / 96.0f, 0.85f, 2.25f);
FontFamily family(L"Segoe UI");
Font title(&family, static_cast<REAL>(std::lround(18.0f * scale)), FontStyleBold, UnitPixel);
Font subtitle(&family, static_cast<REAL>(std::lround(12.5f * scale)), FontStyleRegular, UnitPixel);
RectF card(static_cast<REAL>(cardBounds.left), static_cast<REAL>(cardBounds.top),
static_cast<REAL>(rectWidth(cardBounds)), static_cast<REAL>(rectHeight(cardBounds)));
RectF shadow(card.X + 1.0f, card.Y + 3.0f, card.Width, card.Height);
SolidBrush shadowBrush(Color(68, 0, 0, 0));
fillRoundedRect(g, shadow, 15.0f * scale, shadowBrush);
fillRoundedGradient(g, card, 15.0f * scale,
toGdiColor(theme::kPanelTop, 244),
toGdiColor(theme::kPanelBottom, 244));
Pen cardBorder(toGdiColor(theme::kBorder, 230), 1.0f);
strokeRoundedRect(g, card, 15.0f * scale, cardBorder);
StringFormat centered;
centered.SetAlignment(StringAlignmentCenter);
centered.SetLineAlignment(StringAlignmentCenter);
SolidBrush titleBrush(toGdiColor(theme::kText));
SolidBrush subBrush(toGdiColor(theme::kMuted));
RectF titleArea(card.X, card.Y + 9.0f * scale, card.Width, 30.0f * scale);
RectF subArea(card.X, card.Y + 39.0f * scale, card.Width, 22.0f * scale);
g.DrawString(L"Select an area", -1, &title, titleArea, &centered, &titleBrush);
g.DrawString(L"Drag the mouse to capture", -1, &subtitle, subArea, &centered, &subBrush);
}
}
drawTextEditor(g, dirty);
drawBrushPreview(g, dirty);
if (hasSelection && !selecting && rectsIntersect(uiBounds(), dirty)) {
if (rectsIntersect(toolbarBounds(verticalButtons), dirty)) {
drawToolbarContainer(g, verticalButtons);
for (size_t i = 0; i < verticalButtons.size(); ++i) {
RECT buttonPaint = verticalButtons[i].rect;
InflateRect(&buttonPaint, 2, 2);
if (rectsIntersect(buttonPaint, dirty))
drawButton(g, verticalButtons[i], hoveredButton == static_cast<int>(i));
}
}
if (rectsIntersect(toolbarBounds(horizontalButtons), dirty)) {
drawToolbarContainer(g, horizontalButtons);
for (size_t i = 0; i < horizontalButtons.size(); ++i) {
RECT buttonPaint = horizontalButtons[i].rect;
InflateRect(&buttonPaint, 2, 2);
if (rectsIntersect(buttonPaint, dirty))
drawButton(g, horizontalButtons[i], hoveredButton == static_cast<int>(verticalButtons.size() + i));
}
}
if (paletteOpen && rectsIntersect(palettePanelBounds, dirty)) drawPalette(g);
if (tooltipButton >= 0) {
const Button* hovered = buttonAtCombinedIndex(tooltipButton);
if (hovered) {
const RECT tipBounds = tooltipBoundsApprox(tooltipButton);
if (rectsIntersect(tipBounds, dirty)) {
const std::wstring label = actionName(hovered->action);
POINT center{(hovered->rect.left + hovered->rect.right) / 2,
(hovered->rect.top + hovered->rect.bottom) / 2};
const int monitorIndex = monitorIndexForPoint(center);
const float scale = std::clamp(static_cast<float>(monitors[monitorIndex].dpi) / 96.0f, 0.85f, 2.25f);
FontFamily family(L"Segoe UI");
Font font(&family, static_cast<REAL>(std::lround(12.0f * scale)), FontStyleRegular, UnitPixel);
RectF tip(static_cast<REAL>(tipBounds.left), static_cast<REAL>(tipBounds.top),
static_cast<REAL>(rectWidth(tipBounds)), static_cast<REAL>(rectHeight(tipBounds)));
fillRoundedGradient(g, tip, 6.0f * scale,
toGdiColor(theme::kPanelTop, 246),
toGdiColor(theme::kPanelBottom, 246));
Pen tipBorder(toGdiColor(theme::kBorder, 225), 1.0f);
strokeRoundedRect(g, tip, 6.0f * scale, tipBorder);
SolidBrush foreground(toGdiColor(theme::kText));
StringFormat centered;
centered.SetAlignment(StringAlignmentCenter);
centered.SetLineAlignment(StringAlignmentCenter);
g.DrawString(label.c_str(), -1, &font, tip, &centered, &foreground);
}
}
}
}
}
void paintTile(HDC target, RECT tile) {
tile = clampToClient(tile);
const int width = rectWidth(tile);
const int height = rectHeight(tile);
if (width <= 0 || height <= 0 || !ensureScratchBuffer(target, width, height)) return;
static_cast<void>(capture.blitTo(scratchDc, 0, 0, tile));
dimScratchRect(tile);
Graphics graphics(scratchDc);
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
graphics.SetPixelOffsetMode(PixelOffsetModeHalf);
graphics.SetCompositingQuality(CompositingQualityHighSpeed);
graphics.SetInterpolationMode(InterpolationModeLowQuality);
graphics.SetTextRenderingHint(TextRenderingHintClearTypeGridFit);
graphics.SetTextContrast(0);
graphics.SetClip(Rect(0, 0, width, height), CombineModeReplace);
graphics.TranslateTransform(static_cast<REAL>(-tile.left), static_cast<REAL>(-tile.top));
drawScene(graphics, tile);
BitBlt(target, tile.left, tile.top, width, height, scratchDc, 0, 0, SRCCOPY);
}
void paint() {
std::vector<RECT> dirtyRects = collectUpdateRects();
PAINTSTRUCT paintStruct{};
HDC target = BeginPaint(hwnd, &paintStruct);
if (dirtyRects.empty() && paintStruct.rcPaint.right > paintStruct.rcPaint.left &&
paintStruct.rcPaint.bottom > paintStruct.rcPaint.top) {
dirtyRects.push_back(paintStruct.rcPaint);
}
for (RECT dirty : dirtyRects) {
dirty = clampToClient(dirty);
for (int top = dirty.top; top < dirty.bottom; top += kPaintTileHeight) {
for (int left = dirty.left; left < dirty.right; left += kPaintTileWidth) {
RECT tile{left, top,
std::min(static_cast<int>(dirty.right), left + kPaintTileWidth),
std::min(static_cast<int>(dirty.bottom), top + kPaintTileHeight)};
paintTile(target, tile);
}
}
}
EndPaint(hwnd, &paintStruct);
}
void invalidateSegmentTiles(PointF start, PointF end, int padding) {
const float dx = end.X - start.X;
const float dy = end.Y - start.Y;
const float length = std::sqrt(dx * dx + dy * dy);
const int step = std::max(24, 48 - padding * 2);
const int count = std::max(1, static_cast<int>(std::ceil(length / static_cast<float>(step))));
const int radius = std::max(18, padding + 18);
for (int i = 0; i <= count; ++i) {
const float t = static_cast<float>(i) / static_cast<float>(count);
const int x = static_cast<int>(std::lround(start.X + dx * t));
const int y = static_cast<int>(std::lround(start.Y + dy * t));
invalidateRectPadded(RECT{x - radius, y - radius, x + radius + 1, y + radius + 1}, 1);
}
}
void invalidateRectangleOutline(PointF first, PointF second, int padding) {
RECT rectangle{
static_cast<LONG>(std::floor(std::min(first.X, second.X))),
static_cast<LONG>(std::floor(std::min(first.Y, second.Y))),
static_cast<LONG>(std::ceil(std::max(first.X, second.X))) + 1,
static_cast<LONG>(std::ceil(std::max(first.Y, second.Y))) + 1};
invalidateOutline(rectangle, std::max(4, padding));
}
void invalidateAnnotationShape(const Annotation& annotation) {
if (annotation.points.empty()) return;
const int padding = static_cast<int>(std::ceil(annotation.width * 0.5f)) + 4;
if ((annotation.tool == Tool::Line || annotation.tool == Tool::Arrow) && annotation.points.size() >= 2) {
invalidateSegmentTiles(annotation.points[0], annotation.points[1],
padding + (annotation.tool == Tool::Arrow ? 30 : 0));
if (annotation.tool == Tool::Arrow) {
const PointF end = annotation.points[1];
invalidateRectPadded(RECT{static_cast<LONG>(end.X) - 36, static_cast<LONG>(end.Y) - 36,
static_cast<LONG>(end.X) + 37, static_cast<LONG>(end.Y) + 37}, 1);
}
} else if (annotation.tool == Tool::Rectangle && annotation.points.size() >= 2) {
invalidateRectangleOutline(annotation.points[0], annotation.points[1], padding);
} else {
invalidateRectPadded(annotationBounds(annotation), 2);
}
}
void beginAnnotation(POINT p) {
if (!hasSelection || tool == Tool::Drag) return;
p.x = std::clamp(p.x, 0L, static_cast<LONG>(capture.width - 1));
p.y = std::clamp(p.y, 0L, static_cast<LONG>(capture.height - 1));
if (tool == Tool::Text) {
beginTextEdit(p);
return;
}
const bool undoWasDisabled = annotations.empty();
Annotation annotation;
annotation.tool = tool;
annotation.color = annotationColor(tool == Tool::Marker);
annotation.width = currentStrokeWidth();
annotation.points.push_back(PointF(static_cast<REAL>(p.x), static_cast<REAL>(p.y)));
if (tool == Tool::Line || tool == Tool::Arrow || tool == Tool::Rectangle)
annotation.points.push_back(annotation.points.front());
annotations.push_back(std::move(annotation));
drawing = true;
if (undoWasDisabled) invalidateButton(8);
SetCapture(hwnd);
}
void updateAnnotation(POINT p) {
if (!drawing || annotations.empty()) return;
p.x = std::clamp(p.x, 0L, static_cast<LONG>(capture.width - 1));
p.y = std::clamp(p.y, 0L, static_cast<LONG>(capture.height - 1));
Annotation& annotation = annotations.back();
if (annotation.tool == Tool::Pen || annotation.tool == Tool::Marker) {
const PointF previous = annotation.points.back();
const PointF next(static_cast<REAL>(p.x), static_cast<REAL>(p.y));
const float dx = previous.X - next.X;
const float dy = previous.Y - next.Y;
// Sampling every ~2 physical pixels keeps curves smooth while avoiding
// thousands of nearly identical points on high-polling-rate mice.
if (dx * dx + dy * dy < 4.0f) return;
annotation.points.push_back(next);
appendStrokeChunkBounds(annotation, previous, next);
const int padding = static_cast<int>(std::ceil(annotation.width * 0.5f)) + 4;
RECT segment{
static_cast<LONG>(std::floor(std::min(previous.X, next.X))) - padding,
static_cast<LONG>(std::floor(std::min(previous.Y, next.Y))) - padding,
static_cast<LONG>(std::ceil(std::max(previous.X, next.X))) + padding + 1,
static_cast<LONG>(std::ceil(std::max(previous.Y, next.Y))) + padding + 1};
invalidateRectPadded(segment, 1);
return;
}
if (annotation.points.size() < 2) return;
const PointF next(static_cast<REAL>(p.x), static_cast<REAL>(p.y));
if (annotation.points[1].X == next.X && annotation.points[1].Y == next.Y) return;
Annotation previousShape = annotation;
annotation.points[1] = next;
invalidateAnnotationShape(previousShape);
invalidateAnnotationShape(annotation);
}
void finishAnnotation() {
if (!drawing) return;
drawing = false;
ReleaseCapture();
if (!annotations.empty()) invalidateAnnotationShape(annotations.back());
}
void updateTextEditorRect() {
if (!textEditing) return;
const int monitorIndex = monitorIndexForPoint(textAnchor);
const UiMetrics metrics = metricsForMonitor(monitorIndex);
RECT safe = monitors[monitorIndex].workBounds;
RECT insetSafe = safe;
InflateRect(&insetSafe, -metrics.margin, -metrics.margin);
if (rectWidth(insetSafe) >= 40 && rectHeight(insetSafe) >= 24) safe = insetSafe;
if (rectWidth(safe) < 40 || rectHeight(safe) < 24) {
textEditing = false;
textEditorRect = RECT{0, 0, 0, 0};
return;
}
const float scale = std::clamp(static_cast<float>(monitors[monitorIndex].dpi) / 96.0f, 0.85f, 2.25f);
const int padX = std::max(7, static_cast<int>(std::lround(8.0f * scale)));
const int padY = std::max(5, static_cast<int>(std::lround(6.0f * scale)));
const int minimumWidth = static_cast<int>(std::lround(140.0f * scale));
const int desiredWidth = static_cast<int>(std::lround(320.0f * scale));
const int contentWidth = static_cast<int>(std::ceil(estimatedTextWidth(textEditBuffer, textEditSize))) + padX * 2 + 12;
int width = std::clamp(std::max(minimumWidth, contentWidth), minimumWidth, desiredWidth);
const int height = std::max(static_cast<int>(std::lround(40.0f * scale)),
static_cast<int>(std::ceil(textEditSize * 1.35f)) + padY * 2);
int left = textAnchor.x - padX;
int top = textAnchor.y - padY;
if (left + width > safe.right) left = safe.right - width;
if (top + height > safe.bottom) top = safe.bottom - height;
left = std::max(left, static_cast<int>(safe.left));
top = std::max(top, static_cast<int>(safe.top));
width = std::max(40, std::min(width, static_cast<int>(safe.right) - left));
const int finalHeight = std::max(24, std::min(height, static_cast<int>(safe.bottom) - top));
textEditorRect = RECT{left, top, left + width, top + finalHeight};
}
void beginTextEdit(POINT p) {
commitTextEdit();
const int monitorIndex = monitorIndexForPoint(p);
const UiMetrics metrics = metricsForMonitor(monitorIndex);
RECT available = monitors[monitorIndex].workBounds;
InflateRect(&available, -metrics.margin, -metrics.margin);
if (rectWidth(available) < 50 || rectHeight(available) < 32) return;
const float scale = std::clamp(static_cast<float>(monitors[monitorIndex].dpi) / 96.0f, 0.85f, 2.25f);
textEditSize = static_cast<float>(std::max(18, static_cast<int>(std::lround(22.0f * scale))));
textAnchor = POINT{std::clamp(p.x, available.left, available.right - 1),
std::clamp(p.y, available.top, available.bottom - 1)};
textEditBuffer.clear();
textEditing = true;
updateTextEditorRect();
invalidateRectPadded(textEditorRect, 4);
SetFocus(hwnd);
}
void closeTextEdit(bool commit) {
if (!textEditing) return;
const RECT previousEditor = textEditorRect;
bool addedAnnotation = false;
RECT addedBounds{0, 0, 0, 0};
const bool undoWasDisabled = annotations.empty();
if (commit && !textEditBuffer.empty()) {
Annotation annotation;
annotation.tool = Tool::Text;
annotation.color = annotationColor();
annotation.width = 1.0f;
annotation.textSize = textEditSize;
const int monitorIndex = monitorIndexForPoint(textAnchor);
const float scale = std::clamp(static_cast<float>(monitors[monitorIndex].dpi) / 96.0f, 0.85f, 2.25f);
const int padX = std::max(7, static_cast<int>(std::lround(8.0f * scale)));
const int padY = std::max(5, static_cast<int>(std::lround(6.0f * scale)));
annotation.points.emplace_back(static_cast<REAL>(textEditorRect.left + padX),
static_cast<REAL>(textEditorRect.top + padY));
annotation.text = textEditBuffer;
annotations.push_back(std::move(annotation));
addedBounds = annotationBounds(annotations.back());
addedAnnotation = true;
}
textEditing = false;
textEditBuffer.clear();
textEditorRect = RECT{0, 0, 0, 0};
invalidateRectPadded(previousEditor, 5);
if (addedAnnotation) invalidateRectPadded(addedBounds, 3);
if (addedAnnotation && undoWasDisabled) invalidateButton(8);
SetFocus(hwnd);
}
void commitTextEdit() { closeTextEdit(true); }
void cancelTextEdit() { closeTextEdit(false); }
void appendTextCharacter(wchar_t ch) {
if (!textEditing) return;
const RECT previous = textEditorRect;
if (ch == L'\b') {
if (!textEditBuffer.empty()) textEditBuffer.pop_back();
} else if (ch >= 0x20 && ch != 0x7F) {
textEditBuffer.push_back(ch);
} else {
return;
}
updateTextEditorRect();
invalidateRectPadded(unionRects(previous, textEditorRect), 5);
}
void pasteTextFromClipboard() {
if (!textEditing || !OpenClipboard(hwnd)) return;
HANDLE data = GetClipboardData(CF_UNICODETEXT);
if (data) {
const auto* text = static_cast<const wchar_t*>(GlobalLock(data));
if (text) {
const RECT previous = textEditorRect;
for (const wchar_t* p = text; *p; ++p) {
if (*p == L'\r' || *p == L'\n' || *p == L'\t') textEditBuffer.push_back(L' ');
else if (*p >= 0x20) textEditBuffer.push_back(*p);
}
GlobalUnlock(data);
updateTextEditorRect();
invalidateRectPadded(unionRects(previous, textEditorRect), 5);
}
}
CloseClipboard();
}
void resizeSelectionTo(POINT p) {
const RECT previousSelection = selection;
const RECT previousUi = uiBounds();
const RECT previousBadge = dimensionBadgeRect;
const RECT previousPreview = brushPreviewRect;
RECT r = originalSelection;
const int minSize = 4;
switch (resizeHandle) {
case 0: r.left = p.x; r.top = p.y; break;
case 1: r.top = p.y; break;
case 2: r.right = p.x; r.top = p.y; break;
case 3: r.right = p.x; break;
case 4: r.right = p.x; r.bottom = p.y; break;
case 5: r.bottom = p.y; break;
case 6: r.left = p.x; r.bottom = p.y; break;
case 7: r.left = p.x; break;
default: break;
}
if (r.left > r.right) std::swap(r.left, r.right);
if (r.top > r.bottom) std::swap(r.top, r.bottom);
r.left = static_cast<LONG>(std::clamp(static_cast<int>(r.left), 0, capture.width - minSize));
r.top = static_cast<LONG>(std::clamp(static_cast<int>(r.top), 0, capture.height - minSize));
r.right = static_cast<LONG>(std::clamp(static_cast<int>(r.right), static_cast<int>(r.left) + minSize, capture.width));
r.bottom = static_cast<LONG>(std::clamp(static_cast<int>(r.bottom), static_cast<int>(r.top) + minSize, capture.height));
selection = r;
logicalSelection = selection;
updateLayout();
brushPreviewRect = brushPreviewBounds(cursorPoint);
invalidateSelectionTransition(previousSelection, previousUi, previousBadge);
invalidateRectPadded(previousPreview, 3);
invalidateRectPadded(brushPreviewRect, 3);
}
void moveSelectionTo(POINT p) {
const RECT previousSelection = selection;
const RECT previousUi = uiBounds();
const RECT previousBadge = dimensionBadgeRect;
const RECT previousPreview = brushPreviewRect;
const LONG requestedDx = p.x - dragStart.x;
const LONG requestedDy = p.y - dragStart.y;
RECT nextLogical = originalLogicalSelection;
OffsetRect(&nextLogical, requestedDx, requestedDy);
nextLogical = keepLogicalSelectionReachable(nextLogical);
logicalSelection = nextLogical;
selection = visibleSelectionFromLogical(logicalSelection);
// Annotations deliberately stay in absolute virtual-desktop
// coordinates. Selection is only the crop rectangle.
updateLayout();
brushPreviewRect = brushPreviewBounds(cursorPoint);
invalidateSelectionTransition(previousSelection, previousUi, previousBadge);
invalidateRectPadded(previousPreview, 3);
invalidateRectPadded(brushPreviewRect, 3);
}
void updateCursor(POINT p) {
if (eyedropperActive) { SetCursor(LoadCursorW(nullptr, IDC_CROSS)); return; }
if (textEditing && pointInRect(textEditorRect, p)) { SetCursor(LoadCursorW(nullptr, IDC_IBEAM)); return; }
if (paletteOpen) {
for (const RECT& field : pickerFieldRects) {
if (pointInRect(field, p)) { SetCursor(LoadCursorW(nullptr, IDC_IBEAM)); return; }
}
if (pointInRect(pickerSvRect, p) || pointInRect(pickerHueRect, p) ||
pointInRect(pickerAlphaRect, p)) {
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
return;
}
if (pointInRect(pickerEyedropperRect, p)) { SetCursor(LoadCursorW(nullptr, IDC_HAND)); return; }
}
if (!hasSelection) { SetCursor(LoadCursorW(nullptr, IDC_CROSS)); return; }
if (hitButton(p) >= 0 || hitPalette(p)) { SetCursor(LoadCursorW(nullptr, IDC_HAND)); return; }
if (hitToolbarPanel(p)) { SetCursor(LoadCursorW(nullptr, IDC_ARROW)); return; }
// The selection is interactive only in Drag mode. Drawing tools never
// accidentally resize/move the crop when a stroke starts near its edge.
if (tool == Tool::Drag) {
const int handle = hitHandle(p);
switch (handle) {
case 0: case 4: SetCursor(LoadCursorW(nullptr, IDC_SIZENWSE)); return;
case 2: case 6: SetCursor(LoadCursorW(nullptr, IDC_SIZENESW)); return;
case 1: case 5: SetCursor(LoadCursorW(nullptr, IDC_SIZENS)); return;
case 3: case 7: SetCursor(LoadCursorW(nullptr, IDC_SIZEWE)); return;
default: break;
}
if (hitSelectionBorder(p) || pointInRect(selection, p)) {
SetCursor(LoadCursorW(nullptr, IDC_SIZEALL));
return;
}
SetCursor(LoadCursorW(nullptr, IDC_ARROW));
return;
}
if (tool == Tool::Text) {
SetCursor(LoadCursorW(nullptr, IDC_IBEAM));
return;
}
if (isDrawingTool(tool)) {
// The hollow brush preview becomes the cursor over the entire
// virtual desktop, not just over the crop.
SetCursor(nullptr);
return;
}
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
}
};
OverlayWindow::OverlayWindow(ScreenCapture&& capture, HWND notificationTarget, OverlayMode mode)
: impl_(new Impl(std::move(capture), notificationTarget, mode)) {}
OverlayWindow::~OverlayWindow() { delete impl_; }
HWND OverlayWindow::create(HINSTANCE instance, ScreenCapture&& capture, HWND notificationTarget,
OverlayMode mode) {
static bool registered = false;
if (!registered) {
WNDCLASSEXW wc{};
wc.cbSize = sizeof(wc);
wc.style = 0;
wc.lpfnWndProc = &OverlayWindow::windowProc;
wc.hInstance = instance;
wc.hCursor = LoadCursorW(nullptr, IDC_CROSS);
wc.hbrBackground = nullptr;
wc.lpszClassName = kOverlayClass;
if (!RegisterClassExW(&wc) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) return nullptr;
registered = true;
}
auto* self = new OverlayWindow(std::move(capture), notificationTarget, mode);
HWND hwnd = CreateWindowExW(WS_EX_TOPMOST | WS_EX_TOOLWINDOW,
kOverlayClass, L"NightShot Capture", WS_POPUP,
self->impl_->capture.x, self->impl_->capture.y,
self->impl_->capture.width, self->impl_->capture.height,
nullptr, nullptr, instance, self);
if (!hwnd) {
delete self;
return nullptr;
}
ShowWindow(hwnd, SW_SHOW);
SetWindowPos(hwnd, HWND_TOPMOST, self->impl_->capture.x, self->impl_->capture.y,
self->impl_->capture.width, self->impl_->capture.height,
SWP_SHOWWINDOW | SWP_NOOWNERZORDER);
SetForegroundWindow(hwnd);
SetFocus(hwnd);
return hwnd;
}
LRESULT CALLBACK OverlayWindow::windowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
auto* self = reinterpret_cast<OverlayWindow*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
if (message == WM_NCCREATE) {
auto* create = reinterpret_cast<CREATESTRUCTW*>(lParam);
self = static_cast<OverlayWindow*>(create->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
self->impl_->hwnd = hwnd;
}
if (!self) return DefWindowProcW(hwnd, message, wParam, lParam);
++self->dispatchDepth_;
const LRESULT result = self->handleMessage(message, wParam, lParam);
if (message == WM_NCDESTROY) {
SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0);
self->deletePending_ = true;
}
--self->dispatchDepth_;
if (self->deletePending_ && self->dispatchDepth_ == 0) delete self;
return result;
}
LRESULT OverlayWindow::handleMessage(UINT message, WPARAM wParam, LPARAM lParam) {
Impl& s = *impl_;
switch (message) {
case WM_ERASEBKGND: return 1;
case WM_PAINT: s.paint(); return 0;
case WM_SETCURSOR: {
POINT p{}; GetCursorPos(&p); ScreenToClient(s.hwnd, &p); s.updateCursor(p); return TRUE;
}
case WM_LBUTTONDOWN: {
POINT p{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
s.cursorPoint = p;
s.clearHover();
if (s.eyedropperActive) {
const RECT previousUi = s.uiBounds();
if (s.sampleCapturedColor(p)) {
s.eyedropperActive = false;
s.paletteOpen = true;
s.updateLayout();
s.invalidateUiTransition(previousUi);
s.invalidateButton(7);
}
s.updateCursor(p);
return 0;
}
if (s.paletteOpen) {
if (s.handleColorPickerLeftDown(p)) return 0;
const int clicked = s.hitButton(p);
if (clicked >= 0) {
const Button* button = s.buttonAtCombinedIndex(clicked);
if (button && button->action == Action::Color) {
s.execute(Action::Color);
return 0;
}
}
const RECT previousUi = s.uiBounds();
s.commitColorFieldEdit();
s.paletteOpen = false;
s.invalidateUiTransition(previousUi);
}
s.commitTextEdit();
if (s.hasSelection) {
const int button = s.hitButton(p);
if (button >= 0) {
if (Button* b = s.buttonAtCombinedIndex(button)) s.execute(b->action);
return 0;
}
// Panel padding/gaps are UI, not transparent drawing holes.
if (s.hitToolbarPanel(p)) return 0;
if (s.tool != Tool::Drag) {
// Drawing/text tools own the entire virtual desktop. The
// selection border and resize handles are intentionally
// inert until the user explicitly switches back to Drag.
s.beginAnnotation(p);
s.updateBrushPreview(p);
return 0;
}
const int handle = s.hitHandle(p);
if (handle >= 0) {
// A manual resize commits the currently visible rectangle as
// the new real size and discards any hidden edge overflow.
s.logicalSelection = s.selection;
s.resizeHandle = handle;
s.originalSelection = s.selection;
s.originalLogicalSelection = s.logicalSelection;
s.dragStart = p;
SetCapture(s.hwnd);
return 0;
}
if (s.hitSelectionBorder(p) || pointInRect(s.selection, p)) {
s.draggingSelection = true;
s.originalSelection = s.selection;
s.originalLogicalSelection = s.logicalSelection;
s.dragStart = p;
SetCapture(s.hwnd);
return 0;
}
// Drag mode + click outside the crop starts a new crop. Global
// annotations are deliberately preserved.
const RECT abandonedSelection = s.selection;
const RECT abandonedUi = s.uiBounds();
const RECT abandonedBadge = s.dimensionBadgeRect;
const RECT abandonedPreview = s.brushPreviewRect;
s.hasSelection = false;
s.selecting = true;
s.dragStart = p;
s.selection = RECT{p.x, p.y, p.x, p.y};
s.logicalSelection = s.selection;
s.brushPreviewRect = RECT{0, 0, 0, 0};
s.updateDimensionBadgeBounds();
s.updateLayout();
SetCapture(s.hwnd);
s.invalidateSelectionDifference(abandonedSelection, s.selection);
s.invalidateOutline(abandonedSelection, std::max(9, s.ui.handle + 5));
s.invalidateRectPadded(abandonedUi, 4);
s.invalidateRectPadded(abandonedBadge, 5);
s.invalidateRectPadded(abandonedPreview, 3);
return 0;
}
s.selecting = true;
s.dragStart = p;
s.selection = RECT{p.x, p.y, p.x, p.y};
s.logicalSelection = s.selection;
s.updateDimensionBadgeBounds();
s.updateLayout();
SetCapture(s.hwnd);
// The desktop is already dimmed. Only the hint card disappears here;
// the selected pixels are revealed incrementally by WM_MOUSEMOVE.
s.invalidateRectPadded(s.hintRectForMonitor(s.hintMonitorIndex), 5);
return 0;
}
case WM_MOUSEMOVE: {
POINT p{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
s.cursorPoint = p;
s.armMouseLeaveTracking();
if (s.pickerDragging()) {
s.updatePickerDrag(p);
s.updateCursor(p);
} else if (s.eyedropperActive) {
s.updateCursor(p);
} else if (s.selecting) {
const RECT previousSelection = s.selection;
const RECT previousBadge = s.dimensionBadgeRect;
POINT clamped{static_cast<LONG>(std::clamp(static_cast<int>(p.x), 0, s.capture.width)),
static_cast<LONG>(std::clamp(static_cast<int>(p.y), 0, s.capture.height))};
s.selection = normalizeRect(s.dragStart, clamped);
s.logicalSelection = s.selection;
s.updateDimensionBadgeBounds();
s.invalidateSelectionTransition(previousSelection, RECT{0, 0, 0, 0}, previousBadge);
} else if (s.resizeHandle >= 0) {
s.resizeSelectionTo(p);
} else if (s.draggingSelection) {
s.moveSelectionTo(p);
} else if (s.drawing) {
s.updateAnnotation(p);
s.updateBrushPreview(p);
} else {
if (!s.hasSelection) {
const int nextMonitor = s.monitorIndexForPoint(p);
if (nextMonitor != s.hintMonitorIndex) {
const RECT oldHint = s.hintRectForMonitor(s.hintMonitorIndex);
s.hintMonitorIndex = nextMonitor;
s.invalidateRectPadded(oldHint, 5);
s.invalidateRectPadded(s.hintRectForMonitor(s.hintMonitorIndex), 5);
}
}
s.setHoveredButton(s.hitButton(p));
s.updateBrushPreview(p);
s.updateCursor(p);
}
return 0;
}
case WM_LBUTTONUP: {
const POINT p{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
s.cursorPoint = p;
if (s.pickerDragging()) {
s.finishPickerDrag();
} else if (s.selecting) {
s.selecting = false;
ReleaseCapture();
if (rectWidth(s.selection) >= 4 && rectHeight(s.selection) >= 4) {
s.hasSelection = true;
s.logicalSelection = s.selection;
s.updateLayout();
} else {
s.hasSelection = false;
s.dimensionBadgeRect = RECT{0, 0, 0, 0};
}
s.brushPreviewRect = s.brushPreviewBounds(p);
s.invalidateSelection();
s.invalidateUi();
if (s.hasSelection && s.mode == OverlayMode::GifQuick) {
s.doQuickGif();
return 0;
}
} else if (s.resizeHandle >= 0) {
s.logicalSelection = s.selection;
s.resizeHandle = -1;
ReleaseCapture();
s.updateLayout();
s.brushPreviewRect = s.brushPreviewBounds(p);
s.invalidateSelection();
s.invalidateUi();
} else if (s.draggingSelection) {
s.draggingSelection = false;
ReleaseCapture();
s.updateLayout();
s.brushPreviewRect = s.brushPreviewBounds(p);
s.invalidateSelection();
s.invalidateUi();
} else if (s.drawing) {
s.finishAnnotation();
s.updateBrushPreview(p);
}
s.updateCursor(p);
return 0;
}
case WM_RBUTTONDOWN: {
const POINT point{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
if (s.eyedropperActive) {
const RECT previousUi = s.uiBounds();
s.eyedropperActive = false;
s.paletteOpen = true;
s.updateLayout();
s.invalidateUiTransition(previousUi);
s.updateCursor(point);
return 0;
}
if (s.paletteOpen) {
if (s.handleColorPickerRightDown(point)) return 0;
const RECT previousUi = s.uiBounds();
s.commitColorFieldEdit();
s.paletteOpen = false;
s.invalidateUiTransition(previousUi);
s.updateCursor(point);
return 0;
}
if (s.drawing && !s.annotations.empty()) {
const RECT dirty = s.annotationBounds(s.annotations.back());
s.annotations.pop_back();
s.drawing = false;
ReleaseCapture();
s.invalidateRectPadded(dirty, 2);
if (s.annotations.empty()) s.invalidateButton(8);
s.updateBrushPreview(point);
} else {
DestroyWindow(s.hwnd);
}
return 0;
}
case WM_MOUSEWHEEL: {
POINT point{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
ScreenToClient(s.hwnd, &point);
s.cursorPoint = point;
if (!s.paletteOpen && !s.eyedropperActive && s.hasSelection &&
Impl::isDrawingTool(s.tool)) {
s.adjustCurrentStrokeWidth(GET_WHEEL_DELTA_WPARAM(wParam));
s.updateCursor(point);
return 0;
}
break;
}
case WM_MOUSELEAVE:
s.trackingMouseLeave = false;
if (!s.selecting && !s.drawing && !s.draggingSelection && s.resizeHandle < 0) s.clearHover();
return 0;
case WM_TIMER:
if (wParam == Impl::kHoverTimer) {
KillTimer(s.hwnd, Impl::kHoverTimer);
POINT cursor{};
GetCursorPos(&cursor);
ScreenToClient(s.hwnd, &cursor);
if (s.hoveredButton >= 0 && s.hitButton(cursor) == s.hoveredButton) {
const int previousTooltip = s.tooltipButton;
s.tooltipButton = s.hoveredButton;
s.invalidateRectPadded(s.tooltipBoundsApprox(previousTooltip), 3);
s.invalidateRectPadded(s.tooltipBoundsApprox(s.tooltipButton), 3);
}
return 0;
}
break;
case WM_CHAR:
if (s.activeColorField != ColorField::None) {
if (wParam == L'\r' || wParam == L'\n') { s.commitColorFieldEdit(); return 0; }
if (wParam == 0x1B) { s.cancelColorFieldEdit(); return 0; }
s.appendColorFieldCharacter(static_cast<wchar_t>(wParam));
return 0;
}
if (s.textEditing) {
if (wParam == L'\r' || wParam == L'\n') { s.commitTextEdit(); return 0; }
if (wParam == 0x1B) { s.cancelTextEdit(); return 0; }
s.appendTextCharacter(static_cast<wchar_t>(wParam));
return 0;
}
break;
case WM_KEYDOWN: {
const bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
if (s.activeColorField != ColorField::None) {
if (wParam == VK_ESCAPE) { s.cancelColorFieldEdit(); return 0; }
if (wParam == VK_RETURN) { s.commitColorFieldEdit(); return 0; }
if (ctrl && wParam == 'V') { s.pasteColorFieldFromClipboard(); return 0; }
return 0;
}
if (s.eyedropperActive && wParam == VK_ESCAPE) {
const RECT previousUi = s.uiBounds();
s.eyedropperActive = false;
s.paletteOpen = true;
s.updateLayout();
s.invalidateUiTransition(previousUi);
return 0;
}
if (s.paletteOpen && wParam == VK_ESCAPE) {
const RECT previousUi = s.uiBounds();
s.paletteOpen = false;
s.invalidateUiTransition(previousUi);
return 0;
}
if (s.textEditing) {
if (wParam == VK_ESCAPE) { s.cancelTextEdit(); return 0; }
if (wParam == VK_RETURN) { s.commitTextEdit(); return 0; }
if (ctrl && wParam == 'V') { s.pasteTextFromClipboard(); return 0; }
}
if (wParam == VK_ESCAPE) { DestroyWindow(s.hwnd); return 0; }
if (ctrl && wParam == 'Z') { s.execute(Action::Undo); return 0; }
if (ctrl && wParam == 'C' && s.hasSelection) { s.doCopy(); return 0; }
if (ctrl && wParam == 'S' && s.hasSelection) { s.doSave(); return 0; }
if (!ctrl) {
switch (wParam) {
case 'D': s.setTool(Tool::Drag); return 0;
case 'P': s.setTool(Tool::Pen); return 0;
case 'L': s.setTool(Tool::Line); return 0;
case 'A': s.setTool(Tool::Arrow); return 0;
case 'R': s.setTool(Tool::Rectangle); return 0;
case 'M': s.setTool(Tool::Marker); return 0;
case 'T': s.setTool(Tool::Text); return 0;
case 'C': s.execute(Action::Color); return 0;
}
}
break;
}
case WM_KILLFOCUS:
// Keep the overlay alive while native save dialogs/message boxes are open.
return 0;
case WM_CLOSE: DestroyWindow(s.hwnd); return 0;
case WM_DESTROY:
KillTimer(s.hwnd, Impl::kHoverTimer);
return 0;
case WM_NCDESTROY: return DefWindowProcW(s.hwnd, message, wParam, lParam);
default: break;
}
return DefWindowProcW(s.hwnd, message, wParam, lParam);
}