Release v0.6.54: adjustable pencil smoothing and unsaved settings state

This commit is contained in:
agrynco committed 2026-11-06 14:46:53 +02:00
1 parent 727dc96908
commit 63b7b879a3
14 files changed
+318 -18

No files matched your search

+76 -4
View File
@@ -178,6 +178,8 @@ struct Capture
Mark textDefaults{};
ScreenshotShape shape=ScreenshotShape::None;
LineStyle lineStyle=LineStyle::Solid;
int smoothing=25;
bool smoothingDrag=false;
LineEnds arrowEnds=LineEnds::End, freehandEnds=LineEnds::None;
COLORREF currentColor=RGB(225,42,50);
COLORREF customColors[16]{};
@@ -630,6 +632,13 @@ bool selectedLine(const Capture &c)
}
void syncLineOptions(HWND w,const Capture &c)
{
bool pencil=c.selected>=0 && c.marks[c.selected].freehand;
int amount=pencil?c.marks[c.selected].smoothing:c.smoothing;
SendDlgItemMessageW(w,41,TBM_SETPOS,TRUE,amount);
auto smoothingText=L"Smoothing: "+std::to_wstring(amount)+L"%";
SetDlgItemTextW(w,42,smoothingText.c_str());
SetDlgItemTextW(w,43,pencil?L"Selected stroke":L"New pencil strokes");
EnableWindow(GetDlgItem(w,44),std::any_of(c.marks.begin(),c.marks.end(),[](const Mark &m){return m.freehand;}));
EnableWindow(GetDlgItem(w,34),c.textTool || (c.selected>=0 && (c.marks[c.selected].isText() || !c.marks[c.selected].label.empty())));
EnableWindow(GetDlgItem(w,35),c.selected>=0 && c.marks[c.selected].isLine());
bool selected=selectedLine(c),enabled=selected || c.arrow || c.freehand;
@@ -689,8 +698,12 @@ void layout(HWND w, Capture &c)
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
MoveWindow(GetDlgItem(w,28),MulDiv(648,dpi,96),MulDiv(6,dpi,96),
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
c.panelHeight=c.unit+MulDiv(40,dpi,96);
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
c.panelHeight=c.unit+MulDiv(80,dpi,96);
for(int id:{30,31,32,33,34,35,41,42,43,44}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
MoveWindow(GetDlgItem(w,42),MulDiv(12,dpi,96),c.unit+MulDiv(44,dpi,96),MulDiv(128,dpi,96),MulDiv(24,dpi,96),TRUE);
MoveWindow(GetDlgItem(w,41),MulDiv(142,dpi,96),c.unit+MulDiv(40,dpi,96),MulDiv(200,dpi,96),MulDiv(30,dpi,96),TRUE);
MoveWindow(GetDlgItem(w,43),MulDiv(350,dpi,96),c.unit+MulDiv(44,dpi,96),MulDiv(150,dpi,96),MulDiv(24,dpi,96),TRUE);
MoveWindow(GetDlgItem(w,44),MulDiv(516,dpi,96),c.unit+MulDiv(40,dpi,96),MulDiv(210,dpi,96),MulDiv(30,dpi,96),TRUE);
MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE);
MoveWindow(GetDlgItem(w,30),MulDiv(80,dpi,96),c.unit+MulDiv(2,dpi,96),MulDiv(150,dpi,96),MulDiv(180,dpi,96),TRUE);
MoveWindow(GetDlgItem(w,33),MulDiv(248,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(40,dpi,96),MulDiv(24,dpi,96),TRUE);
@@ -1072,6 +1085,14 @@ void edit(HWND w, Capture &c)
SetWindowSubclass(labelButton,toolHover,1,0);
fontTip.uId=(UINT_PTR)labelButton; fontTip.lpszText=const_cast<wchar_t *>(L"Arrow label (F2 / double-click); drag label to reposition");
SendMessageW(tips,TTM_ADDTOOLW,0,(LPARAM)&fontTip);
INITCOMMONCONTROLSEX sliders{sizeof(sliders),ICC_BAR_CLASSES}; InitCommonControlsEx(&sliders);
CreateWindowW(TRACKBAR_CLASSW,L"Pencil smoothing",WS_CHILD|WS_VISIBLE|WS_TABSTOP|TBS_NOTICKS,
0,0,0,0,w,(HMENU)41,c.app.instance,nullptr);
SendDlgItemMessageW(w,41,TBM_SETRANGE,TRUE,MAKELPARAM(0,100));
SendDlgItemMessageW(w,41,TBM_SETPAGESIZE,0,10);
for(int id:{42,43}) CreateWindowW(L"STATIC",L"",WS_CHILD|WS_VISIBLE,0,0,0,0,w,(HMENU)(INT_PTR)id,c.app.instance,nullptr);
CreateWindowW(L"BUTTON",L"Apply to all strokes",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
0,0,0,0,w,(HMENU)44,c.app.instance,nullptr);
c.syncHistory();
auto work = monitor.rcWork;
SetWindowPos(w,HWND_NOTOPMOST,work.left+20,work.top+20,
@@ -1140,6 +1161,21 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
case WM_SETCURSOR:
if ((HWND)wp==w && LOWORD(lp)==HTCLIENT) { captureCursor(w,*c); return TRUE; }
break;
case WM_HSCROLL:
if((HWND)lp==GetDlgItem(w,41) && !c->dragging) {
int amount=int(SendDlgItemMessageW(w,41,TBM_GETPOS,0,0));
bool pencil=c->selected>=0 && c->marks[c->selected].freehand;
if(pencil && c->marks[c->selected].smoothing!=amount) {
if(!c->smoothingDrag) c->remember(c->marks);
setScreenshotSmoothing(c->marks[c->selected],amount);
resolveScreenshotLinks(c->marks);
c->smoothingDrag=LOWORD(wp)==TB_THUMBTRACK;
} else if(!pencil) c->smoothing=amount;
if(LOWORD(wp)!=TB_THUMBTRACK) c->smoothingDrag=false;
syncLineOptions(w,*c); InvalidateRect(w,nullptr,FALSE);
return 0;
}
break;
case WM_COMMAND:
if(LOWORD(wp)==40) {
if(c->inlineText && HIWORD(wp)==EN_CHANGE) { readInlineText(*c); positionInlineText(*c); }
@@ -1147,6 +1183,17 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return 0;
}
if (c->dragging) return 0;
if(LOWORD(wp)==44) {
int amount=int(SendDlgItemMessageW(w,41,TBM_GETPOS,0,0));
c->smoothing=amount;
if(std::any_of(c->marks.begin(),c->marks.end(),[&](const Mark &m){return m.freehand && m.smoothing!=amount;})) {
c->remember(c->marks);
for(auto &mark:c->marks) if(mark.freehand) setScreenshotSmoothing(mark,amount);
resolveScreenshotLinks(c->marks);
}
c->smoothingDrag=false; syncLineOptions(w,*c); InvalidateRect(w,nullptr,FALSE);
return 0;
}
if(LOWORD(wp)==35) {
finishInlineText(w,*c,true);
if(c->selected>=0 && c->marks[c->selected].isLine()) beginMarkText(w,*c,c->selected);
@@ -1543,6 +1590,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
c->remember(c->marks); c->marks.push_back(std::move(c->stroke));
if(c->freehand) {
auto &mark=c->marks.back();
mark.rawPoints=mark.points;
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
resolveScreenshotLinks(c->marks);
@@ -1553,10 +1601,18 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
ScreenshotShape recognized{};
bool straightLine=false;
auto improved=improveScreenshotStroke(c->marks.back().points,&recognized,&straightLine);
if(recognized==ScreenshotShape::None && !straightLine) {
improved=smoothScreenshotStroke(c->marks.back().rawPoints,c->smoothing);
const auto &raw=c->marks.back().rawPoints;
if(improved.size()==raw.size() && std::equal(improved.begin(),improved.end(),raw.begin(),
[](POINT a,POINT b){return a.x==b.x && a.y==b.y;})) improved.clear();
}
if(!improved.empty()) {
c->remember(c->marks);
auto &mark=c->marks.back();
mark.points=std::move(improved);
if(recognized==ScreenshotShape::None && !straightLine) {
setScreenshotSmoothing(mark,c->smoothing);
} else { mark.points=std::move(improved); mark.rawPoints.clear(); }
mark.from=mark.points.front(); mark.to=mark.points.back();
if(straightLine) { mark.points.clear(); mark.freehand=false; }
if(recognized!=ScreenshotShape::None) {
@@ -1914,7 +1970,14 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
POINT start=from?from->from:m.from, end=to?to->from:m.to;
if(from) m.from=rim(*from,end,true);
if(to) m.to=rim(*to,start,false);
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; }
if(m.freehand && !m.points.empty()) {
m.points.front()=m.from; m.points.back()=m.to;
if(!m.rawPoints.empty() && (m.rawPoints.front().x!=m.from.x || m.rawPoints.front().y!=m.from.y ||
m.rawPoints.back().x!=m.to.x || m.rawPoints.back().y!=m.to.y)) {
m.rawPoints.front()=m.from; m.rawPoints.back()=m.to;
setScreenshotSmoothing(m,m.smoothing);
}
}
}
state[index]=2;
};
@@ -2068,10 +2131,15 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
return low<=high?std::clamp(delta,low,high):0L;
};
auto bounds=markBounds(m);
for(auto p:m.rawPoints) {
bounds.left=std::min(bounds.left,p.x); bounds.right=std::max(bounds.right,p.x);
bounds.top=std::min(bounds.top,p.y); bounds.bottom=std::max(bounds.bottom,p.y);
}
LONG dx=shift(position.x-origin.x,bounds.left,bounds.right,width);
LONG dy=shift(position.y-origin.y,bounds.top,bounds.bottom,height);
m.from.x+=dx; m.to.x+=dx; m.from.y+=dy; m.to.y+=dy;
for(auto &point:m.points) { point.x+=dx; point.y+=dy; }
for(auto &point:m.rawPoints) { point.x+=dx; point.y+=dy; }
}
else if(part==MarkPart::Start || part==MarkPart::End)
{
@@ -2096,6 +2164,10 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
}
}
if(m.freehand && !m.points.empty()) (part==MarkPart::Start?m.points.front():m.points.back())=endpoint;
if(m.freehand && !m.rawPoints.empty()) {
(part==MarkPart::Start?m.rawPoints.front():m.rawPoints.back())=endpoint;
setScreenshotSmoothing(m,m.smoothing);
}
if(m.shape!=ScreenshotShape::None && m.rotation!=0) {
auto fixedBefore=rotateShapePoint(original,part==MarkPart::Start?original.to:original.from);
auto fixedAfter=rotateShapePoint(m,part==MarkPart::Start?m.to:m.from);
+2
View File
@@ -39,6 +39,8 @@ struct ScreenshotMark {
bool labelRotationManual=false;
unsigned long long id{};
ScreenshotLink fromLink, toLink;
std::vector<POINT> rawPoints; // Original pencil path for reversible smoothing.
int smoothing=0; // 0..100; applies only to freehand strokes.
bool isText() const { return !text.empty(); }
bool isHighlight() const { return !points.empty() && !freehand && shape==ScreenshotShape::None; }
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
+36
View File
@@ -38,6 +38,42 @@ inline double cross(Point a, Point b, Point c) { return (b.x-a.x)*(c.y-a.y)-(b.y
}
namespace ks {
// Filter by distance along the path, not mouse-event count. Always start from
// the original path so lowering the amount restores detail without drift.
inline std::vector<POINT> smoothScreenshotStroke(const std::vector<POINT> &input, int amount) {
amount=std::clamp(amount,0,100);
if(!amount || input.size()<5) return input;
using namespace stroke_detail;
std::vector<Point> raw;
for(auto p:input) if(raw.empty() || distance(raw.back(),{double(p.x),double(p.y)})>0)
raw.push_back({double(p.x),double(p.y)});
double total=length(raw);
if(raw.size()<5 || total<16) return input;
auto points=sample(raw,size_t(std::clamp(std::ceil(total/2)+1,3.0,4096.0)));
double step=total/(points.size()-1),radius=0.5+32*std::pow(amount/100.0,2);
int reach=std::max(1,int(std::ceil(radius/step)));
std::vector<POINT> result;
for(size_t i=0;i<points.size();++i) {
Point sum{}; double weights=0;
for(int j=std::max(0,int(i)-reach);j<=std::min(int(points.size())-1,int(i)+reach);++j) {
double d=(j-double(i))*step,weight=std::exp(-2*d*d/(radius*radius));
sum.x+=points[j].x*weight; sum.y+=points[j].y*weight; weights+=weight;
}
double blend=std::min(1.0,std::min(i,points.size()-1-i)*step/radius);
POINT p{LONG(std::lround(points[i].x+blend*(sum.x/weights-points[i].x))),
LONG(std::lround(points[i].y+blend*(sum.y/weights-points[i].y)))};
if(result.empty() || p.x!=result.back().x || p.y!=result.back().y) result.push_back(p);
}
result.front()=input.front(); result.back()=input.back();
return result;
}
inline void setScreenshotSmoothing(ScreenshotMark &mark,int amount) {
if(!mark.freehand || mark.shape!=ScreenshotShape::None || mark.points.empty()) return;
if(mark.rawPoints.empty()) mark.rawPoints=mark.points;
mark.smoothing=std::clamp(amount,0,100);
mark.points=smoothScreenshotStroke(mark.rawPoints,mark.smoothing);
mark.from=mark.points.front(); mark.to=mark.points.back();
}
// Returns a replacement path; an empty result means no visible improvement.
// Report recognized geometry separately from a smoothed freehand path.
inline std::vector<POINT> improveScreenshotStroke(const std::vector<POINT> &input, ScreenshotShape *shape=nullptr,
+40 -1
View File
@@ -968,7 +968,9 @@ struct SettingsView
int selection = -1;
bool filling{};
std::vector<std::vector<HWND>> pages;
bool ready{}, refreshing{};
};
void updateSettingsState(HWND w, SettingsView *v);
void selectSettingsPage(HWND w, SettingsView *v)
{
int selected = TabCtrl_GetCurSel(GetDlgItem(w, 260));
@@ -1169,6 +1171,7 @@ void fillSettings(HWND w, SettingsView *v)
v->selection = 0;
fillLayouts(w, v);
v->filling = false;
updateSettingsState(w, v);
}
int integer(HWND w, int id)
{
@@ -1298,7 +1301,10 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
auto words = parseCommaList(text);
std::sort(words.begin(), words.end());
words.erase(std::unique(words.begin(), words.end()), words.end());
draft.autoWords[language] = std::move(words);
// Opening and accepting an untouched empty dictionary must not
// introduce a new entry and mark Settings as changed.
if (!words.empty() || draft.autoWords.contains(language))
draft.autoWords[language] = std::move(words);
}
draft.autoExcluded = parseCommaList(value(w, 404));
draft.autoPrefixChars = value(w, 408);
@@ -1535,6 +1541,27 @@ bool settingsChanged(HWND w, SettingsView *v)
return true;
}
}
void updateSettingsState(HWND w, SettingsView *v)
{
if (!v || !v->ready || v->filling || v->refreshing) return;
v->refreshing = true;
bool changed = settingsChanged(w, v);
EnableWindow(GetDlgItem(w, IDOK), changed);
const wchar_t *text = changed ? L"Unsaved changes — click Save to apply." : L"No unsaved changes.";
if (value(w, 261) != text) value(w, 261, text);
v->refreshing = false;
}
// Refresh after commands have updated controls/drafts, including early returns.
// Saving can destroy Settings, so retrieve its state only if it still exists.
struct SettingsStateRefresh
{
HWND window;
~SettingsStateRefresh()
{
if (IsWindow(window))
updateSettingsState(window, reinterpret_cast<SettingsView *>(GetWindowLongPtrW(window, GWLP_USERDATA)));
}
};
void closeSettings(HWND w, SettingsView *v)
{
if (settingsChanged(w, v))
@@ -1583,6 +1610,12 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
switch (msg)
{
case WM_ENABLE:
if (wp) updateSettingsState(w, v);
break;
case WM_ACTIVATE:
if (LOWORD(wp) != WA_INACTIVE) updateSettingsState(w, v);
break;
case WM_NOTIFY:
if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE)
{
@@ -1592,6 +1625,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break;
case WM_COMMAND:
{
SettingsStateRefresh refresh{w};
int id = LOWORD(wp);
if (id == 220 && HIWORD(wp) == LBN_SELCHANGE && !v->filling)
{
@@ -1627,6 +1661,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
if (id == IDOK)
{
if (!settingsChanged(w, v)) return 0;
if (v->app->busy)
throw std::runtime_error("Wait for the current text operation to finish");
v->app->applySettings(collectSettings(w, v));
@@ -1846,6 +1881,7 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *parent = (SettingsView *)GetWindowLongPtrW(v->app->settingsWindow, GWLP_USERDATA);
parent->draft.snippets = s.snippets;
updateSettingsState(v->app->settingsWindow, parent);
}
DestroyWindow(w);
return 0;
@@ -2069,11 +2105,14 @@ void showSettings(App &app, bool visible)
checkbox(w, L"Write lo&g", 208, 24, 378, 210);
page();
control(w, L"STATIC", L"No unsaved changes.", 0, 261, 18, 430, 684, 24);
button(w, L"Impo&rt...", 250, 18, 466, 125);
button(w, L"&Export...", 251, 155, 466, 125);
button(w, L"&Save", IDOK, 490, 466, 100);
button(w, L"&Cancel", IDCANCEL, 602, 466, 100);
fillSettings(w, v);
v->ready = true;
updateSettingsState(w, v);
TabCtrl_SetCurSel(tabs, 0);
selectSettingsPage(w, v);
if (visible)
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.53";
inline constexpr wchar_t AppVersion[] = L"0.6.54";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;