3387 lines
162 KiB
C++
3387 lines
162 KiB
C++
#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, ¢ered, &titleBrush);
|
||
g.DrawString(L"Drag the mouse to capture", -1, &subtitle, subArea, ¢ered, &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, ¢ered, &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);
|
||
}
|