diff --git a/src/downloader.h b/src/downloader.h new file mode 100644 index 0000000..63d82fd --- /dev/null +++ b/src/downloader.h @@ -0,0 +1,92 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#pragma comment(lib, "winhttp.lib") + +#define WM_APP_DLPROGRESS (WM_USER + 7) + +struct Downloader { + std::atomic active{false}; + std::atomic cancel{false}; + int itemIndex = -1; + std::thread th; + + void start(HWND notify, int index, std::wstring url, std::wstring dest); + void requestCancel() { cancel = true; } + void join() { if (th.joinable()) th.join(); } +}; + +static void DlThread(HWND notify, int index, std::wstring url, std::wstring dest, Downloader* dl) { + std::wstring tmp = dest + L".part"; + int result = -1; + HINTERNET hSes = nullptr, hCon = nullptr, hReq = nullptr; + FILE* f = nullptr; + do { + URL_COMPONENTS uc{}; uc.dwStructSize = sizeof(uc); + wchar_t host[256] = {0}, path[2048] = {0}; + uc.lpszHostName = host; uc.dwHostNameLength = _countof(host); + uc.lpszUrlPath = path; uc.dwUrlPathLength = _countof(path); + if (!WinHttpCrackUrl(url.c_str(), 0, 0, &uc)) break; + hSes = WinHttpOpen(L"win-dictation/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSes) break; + hCon = WinHttpConnect(hSes, host, uc.nPort, 0); + if (!hCon) break; + hReq = WinHttpOpenRequest(hCon, L"GET", path, nullptr, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + (uc.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0); + if (!hReq) break; + if (!WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, 0)) break; + if (!WinHttpReceiveResponse(hReq, nullptr)) break; + DWORD status = 0, sz = sizeof(status); + WinHttpQueryHeaders(hReq, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status, &sz, WINHTTP_NO_HEADER_INDEX); + if (status != 200) break; + ULONGLONG total = 0; + { + wchar_t cl[40]; DWORD cls = sizeof(cl); + if (WinHttpQueryHeaders(hReq, WINHTTP_QUERY_CONTENT_LENGTH, + WINHTTP_HEADER_NAME_BY_INDEX, cl, &cls, WINHTTP_NO_HEADER_INDEX)) + total = (ULONGLONG)_wtoi64(cl); + } + if (_wfopen_s(&f, tmp.c_str(), L"wb") != 0 || !f) break; + std::vector buf(64 * 1024); + ULONGLONG got = 0; int lastPct = -1; bool ioOk = true; + for (;;) { + if (dl->cancel.load()) { result = -2; ioOk = false; break; } + DWORD avail = 0; + if (!WinHttpQueryDataAvailable(hReq, &avail)) { ioOk = false; break; } + if (avail == 0) break; + DWORD toRead = std::min(avail, (DWORD)buf.size()), rd = 0; + if (!WinHttpReadData(hReq, buf.data(), toRead, &rd) || rd == 0) { ioOk = false; break; } + if (fwrite(buf.data(), 1, rd, f) != rd) { ioOk = false; break; } + got += rd; + int pct = total ? (int)(got * 100 / total) : 0; + if (pct != lastPct) { lastPct = pct; PostMessage(notify, WM_APP_DLPROGRESS, index, pct); } + } + fclose(f); f = nullptr; + if (ioOk && (total == 0 || got == total)) + if (MoveFileExW(tmp.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING)) + result = 101; + } while (false); + if (f) fclose(f); + if (result != 101) DeleteFileW(tmp.c_str()); + if (hReq) WinHttpCloseHandle(hReq); + if (hCon) WinHttpCloseHandle(hCon); + if (hSes) WinHttpCloseHandle(hSes); + PostMessage(notify, WM_APP_DLPROGRESS, index, result); +} + +inline void Downloader::start(HWND notify, int index, std::wstring url, std::wstring dest) { + if (active.exchange(true)) return; + cancel = false; itemIndex = index; + if (th.joinable()) th.join(); + th = std::thread(DlThread, notify, index, std::move(url), std::move(dest), this); +} diff --git a/src/history.h b/src/history.h new file mode 100644 index 0000000..d50cd20 --- /dev/null +++ b/src/history.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#include +#include + +struct HistoryEntry { std::wstring path; std::wstring label; }; + +inline std::wstring HistoryDir() { + wchar_t buf[MAX_PATH]; GetModuleFileNameW(nullptr, buf, MAX_PATH); + std::wstring p(buf); p = p.substr(0, p.find_last_of(L"\\/")); + return p + L"\\history"; +} + +inline bool WriteFileUtf8(const std::wstring& path, const std::wstring& text) { + int n = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), nullptr, 0, nullptr, nullptr); + std::string u8(n, '\0'); + if (n) WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), &u8[0], n, nullptr, nullptr); + HANDLE h = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return false; + DWORD wr; const unsigned char bom[3] = {0xEF,0xBB,0xBF}; + WriteFile(h, bom, 3, &wr, nullptr); + WriteFile(h, u8.data(), (DWORD)u8.size(), &wr, nullptr); + CloseHandle(h); + return true; +} + +inline std::wstring ReadFileUtf8(const std::wstring& path) { + HANDLE h = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); + if (h == INVALID_HANDLE_VALUE) return L""; + DWORD size = GetFileSize(h, nullptr), rd = 0; + std::string u8(size, '\0'); + if (size) ReadFile(h, &u8[0], size, &rd, nullptr); + CloseHandle(h); + size_t off = (u8.size() >= 3 && (unsigned char)u8[0]==0xEF && (unsigned char)u8[1]==0xBB && (unsigned char)u8[2]==0xBF) ? 3 : 0; + int n = MultiByteToWideChar(CP_UTF8, 0, u8.data()+off, (int)(u8.size()-off), nullptr, 0); + std::wstring w(n, L'\0'); + if (n) MultiByteToWideChar(CP_UTF8, 0, u8.data()+off, (int)(u8.size()-off), &w[0], n); + return w; +} + +inline void PruneHistory(int keep) { + std::vector files; + WIN32_FIND_DATAW fd; + HANDLE h = FindFirstFileW((HistoryDir() + L"\\*.txt").c_str(), &fd); + if (h == INVALID_HANDLE_VALUE) return; + do { files.push_back(HistoryDir() + L"\\" + fd.cFileName); } while (FindNextFileW(h, &fd)); + FindClose(h); + std::sort(files.begin(), files.end()); + for (int i = 0; i < (int)files.size() - keep; ++i) DeleteFileW(files[i].c_str()); +} + +inline std::wstring ArchiveSession(const std::wstring& text) { + bool any = false; for (wchar_t c : text) if (!iswspace(c)) { any = true; break; } + if (!any) return L""; + CreateDirectoryW(HistoryDir().c_str(), nullptr); + SYSTEMTIME t; GetLocalTime(&t); + wchar_t name[64]; + swprintf_s(name, L"%04d-%02d-%02d_%02d%02d%02d.txt", t.wYear, t.wMonth, t.wDay, t.wHour, t.wMinute, t.wSecond); + std::wstring path = HistoryDir() + L"\\" + name; + WriteFileUtf8(path, text); + PruneHistory(100); + return path; +} + +inline std::vector LoadHistoryIndex() { + std::vector out; + WIN32_FIND_DATAW fd; + HANDLE h = FindFirstFileW((HistoryDir() + L"\\*.txt").c_str(), &fd); + if (h == INVALID_HANDLE_VALUE) return out; + do { + HistoryEntry e; + e.path = HistoryDir() + L"\\" + fd.cFileName; + std::wstring stem(fd.cFileName); + stem = stem.substr(0, stem.find(L'.')); + std::wstring preview = ReadFileUtf8(e.path).substr(0, 28); + for (wchar_t& c : preview) if (c == L'\r' || c == L'\n') c = L' '; + e.label = stem.substr(0, 10) + L" " + stem.substr(11, 2) + L":" + stem.substr(13, 2) + + L" \u2014 " + preview + L"\u2026"; + out.push_back(e); + } while (FindNextFileW(h, &fd)); + FindClose(h); + std::sort(out.begin(), out.end(), [](auto& a, auto& b){ return a.path > b.path; }); + return out; +} diff --git a/src/main.cpp b/src/main.cpp index 55a00be..6cd670f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -28,6 +28,9 @@ using namespace Gdiplus; #include "settings.h" #include "logging.h" #include "timing.h" +#include "stats.h" +#include "history.h" +#include "downloader.h" #ifndef DWMWA_USE_IMMERSIVE_DARK_MODE #define DWMWA_USE_IMMERSIVE_DARK_MODE 20 @@ -135,6 +138,24 @@ float g_dpiScale = 1.0f; std::wstring g_statusOverride; DWORD g_statusOverrideUntil = 0; AppSettings g_set; +UsageStats g_stats; +bool g_editDirty = false; +DWORD g_insStart = 0, g_insEnd = 0; +REAL g_statsTopY = 0; +std::vector g_history; +std::wstring g_lastLoadedText; +#define ID_SEL_HISTORY 1019 +enum class View { Main, Settings }; +View g_view = View::Main; +std::string g_pendingModelPath; +int g_setScroll = 0, g_setScrollMax = 0; +RectF g_sBack, g_sSave, g_sCancel; +RECT g_sContent = {0,0,0,0}; +struct CatRowRect { RectF row, btn; }; +int g_setHot = -1; +Downloader g_dl; +enum class DlState { NotInstalled, Downloading, Installed }; +struct CatState { DlState state = DlState::NotInstalled; int pct = 0; }; static const float kMaxRecordSeconds = 600.0f; void PersistNow() { @@ -161,12 +182,25 @@ Gdiplus::Font* g_gpUI = nullptr; Gdiplus::Font* g_gpUISemi = nullptr; Gdiplus::Font* g_gpSmall = nullptr; Gdiplus::Font* g_gpText = nullptr; +Gdiplus::Font* g_gpIcon = nullptr; -static const wchar_t* kModelNames[] = { L"tiny.en", L"tiny.en-q8_0", L"base.en-q5_1", L"base.en" }; -static const char* kModelFiles[] = { - "models\\ggml-tiny.en.bin", "models\\ggml-tiny.en-q8_0.bin", - "models\\ggml-base.en-q5_1.bin", "models\\ggml-base.en.bin", +struct ModelInfo { + const wchar_t* display; + const char* fileName; + const wchar_t* sizeLabel; + const wchar_t* hint; }; +static const ModelInfo kCatalog[] = { + { L"tiny.en", "ggml-tiny.en.bin", L"~75 MB", L"fastest" }, + { L"tiny.en-q8_0", "ggml-tiny.en-q8_0.bin", L"~42 MB", L"fastest, smaller file" }, + { L"base.en-q5_1", "ggml-base.en-q5_1.bin", L"~59 MB", L"good balance" }, + { L"base.en", "ggml-base.en.bin", L"~142 MB", L"more accurate" }, + { L"small.en-q5_1", "ggml-small.en-q5_1.bin", L"~182 MB", L"accurate — slow on this CPU" }, + { L"small.en", "ggml-small.en.bin", L"~466 MB", L"most accurate — slowest" }, +}; +static const int kCatalogCount = (int)std::size(kCatalog); +CatRowRect g_catRect[kCatalogCount]; +CatState g_cat[kCatalogCount]; std::vector g_modelComboPaths; std::vector g_audioItems; int g_audioSel = 0; std::vector g_modelItems; int g_modelSel = 0; @@ -175,7 +209,7 @@ bool g_initializing = true; struct PopupState { std::vector items; int sel; int hot; HWND owner; int ctrlId; }; static PopupState g_pop; -enum class WK { RecordHero, Pin, Copy, Paste, Clear, SelAudio, SelModel, Transcript }; +enum class WK { RecordHero, Pin, SettingsCog, Copy, Paste, Clear, SelAudio, History, Transcript }; struct Widget { WK kind; RectF r; @@ -226,7 +260,6 @@ void ShowContextMenu(HWND, POINT); void RefreshAudioDevices(HWND); void RefreshModelList(HWND); void SetStatus(HWND, const wchar_t*); -void UpdateStatus(HWND); void UpdatePlaceholder(HWND); void LayoutControls(HWND, int, int); void LayoutWidgets(int W, int H); @@ -236,19 +269,28 @@ void OnClick(HWND hwnd, WK kind); void DrawHero(Graphics& g, const Widget& w); void DrawGhost(Graphics& g, const Widget& w, const wchar_t* label, bool active); void DrawPinSurface(Graphics& g, const Widget& w); +void DrawIconChip(Graphics& g, const Widget& w, const wchar_t* glyph, const wchar_t* fallback, bool active); void DrawSelectSurface(Graphics& g, const Widget& w, const std::wstring& text); void DrawStatusStrip(Graphics& g, const RectF& strip); void DrawCard(Graphics& g, const Rect& cardRect); void DrawVU(Graphics&, const RECT&, float); void DrawProgress(Graphics&, const RECT&, float); -void ShowSelectPopup(HWND owner, int ctrlId, const std::vector& items, int sel); +void ShowSelectPopup(HWND owner, int ctrlId, const std::vector& items, int sel, const RectF& anchor); +void SwitchView(HWND hwnd, View v); +void LayoutSettings(int W, int H); +void PaintSettings(Graphics& g, HWND hwnd, int W, int H); +int SettingsHitTest(POINT p); +void OnSettingsClick(HWND hwnd, POINT p); +void ApplySettings(HWND hwnd); std::string exe_dir(); std::wstring to_w(const std::string&); +std::wstring GetEditText(HWND hwnd); bool SetClipboardTextUtf8(HWND, const std::string&); void send_ctrl_v(); void PasteIntoWindow(HWND); bool DetectGPUAvailability(); std::string SelectOptimalModel(bool); +std::vector BuildStatsLines(); static HFONT MakeFont(int px, int weight, float scale = 1.0f) { return CreateFontW(-(int)(px * scale + 0.5f), 0, 0, 0, weight, FALSE, FALSE, FALSE, DEFAULT_CHARSET, @@ -279,11 +321,13 @@ static Gdiplus::Font* GdipFontFromHFont(HFONT hf) { } static void RebuildGdipFonts() { - delete g_gpUI; delete g_gpUISemi; delete g_gpSmall; delete g_gpText; + delete g_gpUI; delete g_gpUISemi; delete g_gpSmall; delete g_gpText; delete g_gpIcon; g_gpUI = GdipFontFromHFont(g_fUI); g_gpUISemi = GdipFontFromHFont(g_fUISemi); g_gpSmall = GdipFontFromHFont(g_fSmall); g_gpText = GdipFontFromHFont(g_fText); + g_gpIcon = new Gdiplus::Font(L"Segoe MDL2 Assets", 15.0f * g_dpiScale, FontStyleRegular, UnitPixel); + if (g_gpIcon->GetLastStatus() != Ok) { delete g_gpIcon; g_gpIcon = nullptr; } if (!g_gpUI) g_gpUI = new Gdiplus::Font(L"Segoe UI", 15.0f * g_dpiScale, FontStyleRegular, UnitPixel); if (!g_gpUISemi) g_gpUISemi = new Gdiplus::Font(L"Segoe UI", 15.0f * g_dpiScale, FontStyleBold, UnitPixel); if (!g_gpSmall) g_gpSmall = new Gdiplus::Font(L"Segoe UI", 12.0f * g_dpiScale, FontStyleRegular, UnitPixel); @@ -394,7 +438,7 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { 0, 0, 0, 0, hMainWnd, (HMENU)ID_STATIC_STATUS, hInst, nullptr); HWND hEdit = CreateWindowExW(0, L"EDIT", L"", - WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY, + WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL, 0, 0, 0, 0, hMainWnd, (HMENU)ID_EDIT_TEXT, hInst, nullptr); SetWindowTheme(hEdit, L"DarkMode_Explorer", nullptr); SendMessage(hEdit, WM_SETFONT, (WPARAM)g_fText, TRUE); @@ -447,6 +491,16 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { RefreshAudioDevices(hMainWnd); RefreshModelList(hMainWnd); + CreateDirectoryA((exe_dir() + "\\models").c_str(), nullptr); + { + WIN32_FIND_DATAA fd; + HANDLE h = FindFirstFileA((exe_dir() + "\\models\\*.part").c_str(), &fd); + if (h != INVALID_HANDLE_VALUE) { + do { DeleteFileA((exe_dir() + "\\models\\" + fd.cFileName).c_str()); } while (FindNextFileA(h, &fd)); + FindClose(h); + } + } + bool has_gpu = DetectGPUAvailability(); std::string model = SelectOptimalModel(has_gpu); @@ -475,6 +529,8 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { SeedDefaults(g_timing, g_config.model_path); LoadTiming(g_timing, g_config.model_path); + LoadStats(g_stats); + g_history = LoadHistoryIndex(); { char log[256]; @@ -532,12 +588,14 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { UnregisterHotKey(hMainWnd, HK_TOGGLE); UnregisterHotKey(hMainWnd, HK_HIDE); g_tx.cancel(); + g_dl.requestCancel(); + g_dl.join(); Shell_NotifyIcon(NIM_DELETE, &nid); SDL_Quit(); ReleaseMutex(g_hMutex); CloseHandle(g_hMutex); - delete g_gpUI; delete g_gpUISemi; delete g_gpSmall; delete g_gpText; - g_gpUI = g_gpUISemi = g_gpSmall = g_gpText = nullptr; + delete g_gpUI; delete g_gpUISemi; delete g_gpSmall; delete g_gpText; delete g_gpIcon; + g_gpUI = g_gpUISemi = g_gpSmall = g_gpText = g_gpIcon = nullptr; GdiplusShutdown(g_gdipToken); DeleteObject(g_fUI); DeleteObject(g_fUISemi); @@ -566,8 +624,16 @@ void DrawProgress(Graphics& g, const RECT& r, float frac) { Rect track(r.left, r.top, r.right - r.left, r.bottom - r.top); FillRound(g, C_SURFACEHI, track, 4); frac = frac < 0.0f ? 0.0f : (frac > 1.0f ? 1.0f : frac); - int w = (int)((r.right - r.left) * frac); + int fullW = r.right - r.left; + int w = (int)(fullW * frac); if (w > 0) { Rect fill(r.left, r.top, w, r.bottom - r.top); FillRound(g, C_ACCENT, fill, 4); } + + if (frac >= 0.949f && frac < 1.0f) { + double ph = (GetTickCount() % 1100) / 1100.0; + BYTE a = (BYTE)(70 + 150 * (0.5 + 0.5 * sin(ph * 6.2831853))); + Rect tail(r.left + w, r.top, fullW - w, r.bottom - r.top); + FillRound(g, Color(a, 0x6E, 0x8B, 0xFF), tail, 4); + } } LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l) { @@ -614,7 +680,7 @@ LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l) { return DefWindowProc(h, m, w, l); } -void ShowSelectPopup(HWND owner, int ctrlId, const std::vector& items, int sel) { +void ShowSelectPopup(HWND owner, int ctrlId, const std::vector& items, int sel, const RectF& anchor) { static bool reg = false; if (!reg) { WNDCLASSEXW wc{ sizeof(wc) }; @@ -627,10 +693,11 @@ void ShowSelectPopup(HWND owner, int ctrlId, const std::vector& it reg = true; } g_pop = { items, sel, -1, owner, ctrlId }; - RECT rc; GetWindowRect(GetDlgItem(owner, ctrlId), &rc); - int h = (int)items.size() * 30 + 6, wdt = rc.right - rc.left; + POINT tl{ (LONG)anchor.X, (LONG)(anchor.Y + anchor.Height) }; + ClientToScreen(owner, &tl); + int h = (int)items.size() * 30 + 6, wdt = (int)anchor.Width; HWND p = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_TOPMOST, L"DictPopup", L"", - WS_POPUP, rc.left, rc.bottom + 2, wdt, h, owner, nullptr, hInst, nullptr); + WS_POPUP, tl.x, tl.y + 2, wdt, h, owner, nullptr, hInst, nullptr); int corner = DWMWCP_ROUND; DwmSetWindowAttribute(p, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner)); ShowWindow(p, SW_SHOWNA); SetFocus(p); } @@ -684,9 +751,11 @@ void LayoutWidgets(int W, int H) { const REAL M = 16 * s, row = 36 * s, gap = 10 * s; REAL x = M, y = M, innerW = W - 2 * M; - REAL pinW = 78 * s, recW = innerW - pinW - gap; - g_w[(int)WK::RecordHero].r = RectF(x, y, recW, row); - g_w[(int)WK::Pin].r = RectF(x + recW + gap, y, pinW, row); + REAL iconW = 44 * s; + REAL recW = innerW - 2 * (iconW + gap); + g_w[(int)WK::RecordHero].r = RectF(x, y, recW, row); + g_w[(int)WK::Pin].r = RectF(x + recW + gap, y, iconW, row); + g_w[(int)WK::SettingsCog].r = RectF(x + recW + gap + iconW + gap, y, iconW, row); y += row + gap; REAL vuH = 8 * s; @@ -708,7 +777,7 @@ void LayoutWidgets(int W, int H) { y += textH + gap; REAL halfW = (innerW - gap) / 2; g_w[(int)WK::SelAudio].r = RectF(x, y, halfW, row); - g_w[(int)WK::SelModel].r = RectF(x + halfW + gap, y, halfW, row); + g_w[(int)WK::History].r = RectF(x + halfW + gap, y, halfW, row); y += row + gap; REAL thirdW = (innerW - gap * 2) / 3; @@ -803,6 +872,22 @@ void DrawPinSurface(Graphics& g, const Widget& w) { DrawTextC(g, g_pinned ? L"Pinned" : L"Pin", *g_gpUI, tc, tb, StringAlignmentCenter, StringAlignmentCenter); } +void DrawIconChip(Graphics& g, const Widget& w, const wchar_t* glyph, + const wchar_t* fallback, bool active) { + Rect chip((int)w.r.X, (int)w.r.Y, (int)w.r.Width, (int)w.r.Height); + chip.Inflate(-1, -1); + float a = w.anim; + if (a > 0.001f) { + BYTE al = (BYTE)std::min(255, (int)(255 * a)); + FillRound(g, Color(al, T_CARD_HI.GetR(), T_CARD_HI.GetG(), T_CARD_HI.GetB()), + chip, (int)(10 * g_dpiScale)); + } + Color tc = active ? T_ACCENT : (a > 0.01f ? T_TEXT : T_FAINT); + RectF tb((REAL)chip.X, (REAL)chip.Y, (REAL)chip.Width, (REAL)chip.Height); + if (g_gpIcon) DrawTextC(g, glyph, *g_gpIcon, tc, tb, StringAlignmentCenter, StringAlignmentCenter); + else DrawTextC(g, fallback, *g_gpUI, tc, tb, StringAlignmentCenter, StringAlignmentCenter); +} + void DrawSelectSurface(Graphics& g, const Widget& w, const std::wstring& text) { Rect rc((int)w.r.X, (int)w.r.Y, (int)w.r.Width, (int)w.r.Height); Rect field = rc; field.Inflate(-1, -1); @@ -855,6 +940,9 @@ void PaintSurface(HWND hwnd) { SolidBrush bg(T_BG); g.FillRectangle(&bg, 0, 0, W, H); + if (g_view == View::Settings) { PaintSettings(g, hwnd, W, H); } + else { + Rect panel(g_panelRect.left, g_panelRect.top, g_panelRect.right - g_panelRect.left, g_panelRect.bottom - g_panelRect.top); @@ -866,9 +954,10 @@ void PaintSurface(HWND hwnd) { case WK::Copy: DrawGhost(g, w, L"Copy", false); break; case WK::Paste: DrawGhost(g, w, L"Paste", false); break; case WK::Clear: DrawGhost(g, w, L"Clear", false); break; - case WK::Pin: DrawPinSurface(g, w); break; + case WK::Pin: DrawIconChip(g, w, L"\uE718", L"P", g_pinned); break; + case WK::SettingsCog: DrawIconChip(g, w, L"\uE713", L"\u2699", false); break; + case WK::History: DrawSelectSurface(g, w, L"History"); break; case WK::SelAudio: DrawSelectSurface(g, w, g_audioItems.empty() ? L"No devices" : g_audioItems[g_audioSel]); break; - case WK::SelModel: DrawSelectSurface(g, w, g_modelItems.empty() ? L"\u2014" : g_modelItems[g_modelSel]); break; case WK::Transcript: break; } } @@ -887,7 +976,10 @@ void PaintSurface(HWND hwnd) { int mm = (int)total / 60; int ss = (int)total % 60; int rem = (int)(g_progressRemain + 0.5f); - swprintf_s(statusBuf, L"Transcribing %d:%02d \u2022 %d%% \u2022 %ds left", mm, ss, pct, rem); + if (g_progressFrac >= 0.949f) + swprintf_s(statusBuf, L"Transcribing %d:%02d \u2022 95%% \u2022 finishing\u2026", mm, ss); + else + swprintf_s(statusBuf, L"Transcribing %d:%02d \u2022 %d%% \u2022 %ds left", mm, ss, pct, rem); } else if (g_modelLoaded.load()) { if (!g_statusOverride.empty() && GetTickCount() < g_statusOverrideUntil) { wcscpy_s(statusBuf, g_statusOverride.c_str()); @@ -913,6 +1005,7 @@ void PaintSurface(HWND hwnd) { DrawTextC(g, L"Your transcription will appear here\u2026", *g_gpText, T_DIM, placeholderBox, StringAlignmentNear, StringAlignmentNear); } + } // else (view == Main) } BitBlt(hdc, 0, 0, W, H, mem, 0, 0, SRCCOPY); SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem); @@ -953,10 +1046,18 @@ void OnClick(HWND hWnd, WK kind) { PasteIntoWindow(target); } break; - case WK::Clear: + case WK::Clear: { + std::wstring cur = GetEditText(hWnd); + if (!cur.empty() && cur != g_lastLoadedText) + ArchiveSession(cur); + g_history = LoadHistoryIndex(); + g_lastLoadedText.clear(); + g_editDirty = false; SetDlgItemText(hWnd, ID_EDIT_TEXT, L""); UpdatePlaceholder(hWnd); + SetStatus(hWnd, L"Saved to history"); break; + } case WK::Pin: g_pinned = !g_pinned; SetWindowPos(hWnd, g_pinned ? HWND_TOPMOST : HWND_NOTOPMOST, @@ -965,15 +1066,207 @@ void OnClick(HWND hWnd, WK kind) { PersistNow(); break; case WK::SelAudio: - ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel); + ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r); break; - case WK::SelModel: - ShowSelectPopup(hWnd, ID_SEL_MODEL, g_modelItems, g_modelSel); + case WK::History: { + std::vector items; + for (auto& e : g_history) items.push_back(e.label); + if (items.empty()) items.push_back(L"No history yet"); + ShowSelectPopup(hWnd, ID_SEL_HISTORY, items, -1, g_w[(int)WK::History].r); break; + } + case WK::SettingsCog: SwitchView(hWnd, View::Settings); break; default: break; } } +void RefreshCatalogStates() { + std::string dir = exe_dir(); + for (int i = 0; i < kCatalogCount; ++i) { + if (g_dl.active.load() && g_dl.itemIndex == i) { g_cat[i].state = DlState::Downloading; continue; } + std::string full = dir + "\\models\\" + kCatalog[i].fileName; + g_cat[i].state = (GetFileAttributesA(full.c_str()) != INVALID_FILE_ATTRIBUTES) + ? DlState::Installed : DlState::NotInstalled; + g_cat[i].pct = 0; + } +} + +void SwitchView(HWND hwnd, View v) { + g_view = v; + ShowWindow(GetDlgItem(hwnd, ID_EDIT_TEXT), v == View::Main ? SW_SHOW : SW_HIDE); + if (v == View::Settings) { + g_pendingModelPath = g_config.model_path; + g_setScroll = 0; + RefreshCatalogStates(); + } + RECT rc; GetClientRect(hwnd, &rc); + LayoutWidgets(rc.right, rc.bottom); + if (v == View::Settings) LayoutSettings(rc.right, rc.bottom); + g_setHot = -1; + InvalidateRect(hwnd, nullptr, FALSE); +} + +void LayoutSettings(int W, int H) { + float s = g_dpiScale; + const REAL M = 16 * s, row = 36 * s, gap = 10 * s; + g_sBack = RectF(M, M, 90 * s, row); + g_sSave = RectF(M, H - M - row, (W - 2*M - gap) / 2, row); + g_sCancel = RectF(M + (W - 2*M - gap)/2 + gap, H - M - row, (W - 2*M - gap)/2, row); + g_sContent = { (int)M, (int)(M + row + gap), (int)(W - M), (int)(H - M - row - gap) }; + + REAL cy = (REAL)g_sContent.top - g_setScroll; + REAL cw = (REAL)(g_sContent.right - g_sContent.left); + cy += 22 * s; + for (int i = 0; i < kCatalogCount; ++i) { + g_catRect[i].row = RectF(M, cy, cw, 34 * s); + REAL bw = 112 * s; + g_catRect[i].btn = RectF(M + cw - bw, cy + 3 * s, bw, 28 * s); + cy += 40 * s; + } + cy += 26 * s; + REAL statsTop = cy; + cy += (REAL)BuildStatsLines().size() * 20 * s; + g_statsTopY = statsTop; + int contentH = (int)(cy + g_setScroll) - g_sContent.top + (int)(8 * s); + int viewH = g_sContent.bottom - g_sContent.top; + g_setScrollMax = std::max(0, contentH - viewH); + if (g_setScroll > g_setScrollMax) g_setScroll = g_setScrollMax; +} + +void DrawCatalogButton(Graphics& g, int i, bool installed) { + const RectF& b = g_catRect[i].btn; + Rect br((int)b.X, (int)b.Y, (int)b.Width, (int)b.Height); + bool hov = (g_setHot == 100 + i); + wchar_t label[32]; Color tc = T_DIM; + switch (g_cat[i].state) { + case DlState::Installed: + wcscpy_s(label, L"Installed"); tc = T_GOOD; break; + case DlState::Downloading: + swprintf_s(label, 32, L"%d%% \u2715", g_cat[i].pct); tc = T_ACCENT; break; + default: + wcscpy_s(label, L"Download"); tc = hov ? T_TEXT : T_DIM; break; + } + if (hov && g_cat[i].state != DlState::Installed) + FillRound(g, T_CARD_HI, br, (int)(8 * g_dpiScale)); + DrawTextC(g, label, *g_gpSmall, tc, b, StringAlignmentCenter, StringAlignmentCenter); +} + +void OnCatalogButton(HWND hwnd, int i) { + switch (g_cat[i].state) { + case DlState::Installed: { + std::string full = exe_dir() + "\\models\\" + kCatalog[i].fileName; + g_pendingModelPath = full; + break; + } + case DlState::Downloading: + g_dl.requestCancel(); + break; + default: { + if (g_dl.active.load()) { SetStatus(hwnd, L"One download at a time"); return; } + std::wstring url = L"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/" + + to_w(kCatalog[i].fileName); + std::wstring dest = to_w(exe_dir()) + L"\\models\\" + to_w(kCatalog[i].fileName); + g_cat[i].state = DlState::Downloading; g_cat[i].pct = 0; + g_dl.start(hwnd, i, url, dest); + break; + } + } + InvalidateRect(hwnd, nullptr, FALSE); +} + +void PaintSettings(Graphics& g, HWND hwnd, int W, int H) { + float s = g_dpiScale; + bool hb = (g_setHot == 200); + if (hb) FillRound(g, T_CARD_HI, Rect((int)g_sBack.X,(int)g_sBack.Y,(int)g_sBack.Width,(int)g_sBack.Height), (int)(10*s)); + DrawTextC(g, L"\u2190 Back", *g_gpUI, hb ? T_TEXT : T_DIM, g_sBack, StringAlignmentCenter, StringAlignmentCenter); + RectF title((REAL)g_sContent.left, g_sBack.Y, (REAL)(g_sContent.right - g_sContent.left), g_sBack.Height); + DrawTextC(g, L"Settings", *g_gpUISemi, T_TEXT, title, StringAlignmentCenter, StringAlignmentCenter); + + g.SetClip(Rect(g_sContent.left, g_sContent.top, + g_sContent.right - g_sContent.left, g_sContent.bottom - g_sContent.top)); + RectF cap(g_catRect[0].row.X, g_catRect[0].row.Y - 20*s, 300*s, 18*s); + DrawTextC(g, L"MODEL", *g_gpSmall, T_FAINT, cap, StringAlignmentNear, StringAlignmentNear); + std::string dir = exe_dir(); + for (int i = 0; i < kCatalogCount; ++i) { + const RectF& r = g_catRect[i].row; + std::string rel = std::string("models\\") + kCatalog[i].fileName; + bool installed = GetFileAttributesA((dir + "\\" + rel).c_str()) != INVALID_FILE_ATTRIBUTES; + bool selected = (g_pendingModelPath == dir + "\\" + rel); + if (g_setHot == i && installed) + FillRound(g, T_CARD_HI, Rect((int)r.X,(int)r.Y,(int)r.Width,(int)r.Height), (int)(9*s)); + int rx = (int)(r.X + 12*s), ry = (int)(r.Y + r.Height/2); + Pen ring(installed ? T_DIM : T_FAINT, 1.4f); + g.DrawEllipse(&ring, rx-6, ry-6, 12, 12); + if (selected) { SolidBrush dot(T_ACCENT); g.FillEllipse(&dot, rx-3, ry-3, 6, 6); } + RectF nameBox(r.X + 30*s, r.Y, 150*s, r.Height); + DrawTextC(g, kCatalog[i].display, *g_gpUI, installed ? T_TEXT : T_DIM, + nameBox, StringAlignmentNear, StringAlignmentCenter); + wchar_t meta[96]; swprintf_s(meta, L"%s \u00b7 %s", kCatalog[i].sizeLabel, kCatalog[i].hint); + RectF metaBox(r.X + 30*s + 150*s, r.Y, r.Width - 30*s - 150*s - 120*s, r.Height); + DrawTextC(g, meta, *g_gpSmall, T_FAINT, metaBox, StringAlignmentNear, StringAlignmentCenter); + DrawCatalogButton(g, i, installed); + } + RectF scap(g_catRect[0].row.X, g_statsTopY - 20*s, 300*s, 18*s); + DrawTextC(g, L"STATISTICS", *g_gpSmall, T_FAINT, scap, StringAlignmentNear, StringAlignmentNear); + auto lines = BuildStatsLines(); + for (size_t i = 0; i < lines.size(); ++i) { + RectF lr(g_catRect[0].row.X, g_statsTopY + (REAL)i * 20*s, (REAL)(g_sContent.right - g_sContent.left), 18*s); + DrawTextC(g, lines[i].c_str(), *g_gpUI, T_DIM, lr, StringAlignmentNear, StringAlignmentNear); + } + g.ResetClip(); + + Rect sv((int)g_sSave.X,(int)g_sSave.Y,(int)g_sSave.Width,(int)g_sSave.Height); + FillRound(g, g_setHot == 201 ? T_ACCENT_HI : T_ACCENT, sv, (int)(10*s)); + DrawTextC(g, L"Save", *g_gpUISemi, Color(255,255,255,255), g_sSave, StringAlignmentCenter, StringAlignmentCenter); + if (g_setHot == 202) + FillRound(g, T_CARD_HI, Rect((int)g_sCancel.X,(int)g_sCancel.Y,(int)g_sCancel.Width,(int)g_sCancel.Height), (int)(10*s)); + DrawTextC(g, L"Cancel", *g_gpUI, g_setHot == 202 ? T_TEXT : T_DIM, g_sCancel, StringAlignmentCenter, StringAlignmentCenter); +} + +static bool PtIn(const RectF& r, POINT p) { return r.Contains((REAL)p.x, (REAL)p.y); } + +int SettingsHitTest(POINT p) { + if (PtIn(g_sBack, p)) return 200; + if (PtIn(g_sSave, p)) return 201; + if (PtIn(g_sCancel, p)) return 202; + if (!PtInRect(&g_sContent, p)) return -1; + for (int i = 0; i < kCatalogCount; ++i) { + if (PtIn(g_catRect[i].btn, p)) return 100 + i; + if (PtIn(g_catRect[i].row, p)) return i; + } + return -1; +} + +void OnSettingsClick(HWND hwnd, POINT p) { + int hit = SettingsHitTest(p); + if (hit == 200 || hit == 202) { SwitchView(hwnd, View::Main); return; } + if (hit == 201) { ApplySettings(hwnd); return; } + if (hit >= 100 && hit < 100 + kCatalogCount) { OnCatalogButton(hwnd, hit - 100); return; } + if (hit >= 0 && hit < kCatalogCount) { + std::string full = exe_dir() + "\\models\\" + kCatalog[hit].fileName; + if (GetFileAttributesA(full.c_str()) != INVALID_FILE_ATTRIBUTES) { + g_pendingModelPath = full; + InvalidateRect(hwnd, nullptr, FALSE); + } + } +} + +void ApplySettings(HWND hwnd) { + if (!g_pendingModelPath.empty() && g_pendingModelPath != g_config.model_path) { + g_config.model_path = g_pendingModelPath; + SeedDefaults(g_timing, g_config.model_path); + LoadTiming(g_timing, g_config.model_path); + g_modelLoaded = false; g_modelOk = false; + std::thread([] { + bool ok = g_tx.reload(g_config); + g_modelOk = ok; g_modelLoaded = true; + }).detach(); + PersistNow(); + SetStatus(hwnd, L"Settings saved \u2014 loading model\u2026"); + } + SwitchView(hwnd, View::Main); +} + void UpdatePlaceholder(HWND hwnd) { bool empty = GetWindowTextLengthW(GetDlgItem(hwnd, ID_EDIT_TEXT)) == 0; ShowWindow(GetDlgItem(hwnd, ID_STATIC_PLACEHOLDER), empty ? SW_SHOW : SW_HIDE); @@ -1001,11 +1294,11 @@ void RefreshModelList(HWND hwnd) { g_modelItems.clear(); g_modelComboPaths.clear(); std::string dir = exe_dir(); - for (int i = 0; i < 4; ++i) { - std::string full = dir + "\\" + kModelFiles[i]; - if (GetFileAttributesA(full.c_str()) != INVALID_FILE_ATTRIBUTES) { - g_modelItems.push_back(kModelNames[i]); - g_modelComboPaths.push_back(kModelFiles[i]); + for (int i = 0; i < kCatalogCount; ++i) { + std::string rel = std::string("models\\") + kCatalog[i].fileName; + if (GetFileAttributesA((dir + "\\" + rel).c_str()) != INVALID_FILE_ATTRIBUTES) { + g_modelItems.push_back(kCatalog[i].display); + g_modelComboPaths.push_back(rel); } } g_modelSel = 0; @@ -1018,31 +1311,6 @@ void SetStatus(HWND hwnd, const wchar_t* text) { InvalidateRect(hwnd, nullptr, FALSE); } -void UpdateStatus(HWND hwnd) { - wchar_t buf[256]; - if (g_tx.is_recording()) { - int secs = g_recordingSecs.load(); - swprintf_s(buf, L"Recording %d:%02d", secs / 60, secs % 60); - SetStatus(hwnd, buf); - } else if (g_tx.is_busy()) { - int pct = (int)(g_progressFrac*100.0f + 0.5f); - float total = g_tx.audio_seconds(); - int mm = (int)total / 60; - int ss = (int)total % 60; - int rem = (int)(g_progressRemain + 0.5f); - swprintf_s(buf, L"Transcribing %d:%02d \u2022 %d%% \u2022 %ds left", mm, ss, pct, rem); - SetStatus(hwnd, buf); - } else if (g_modelLoaded.load()) { - if (!g_modelOk.load()) { - SetStatus(hwnd, L"Model not found \u2014 check models folder"); - return; - } - swprintf_s(buf, L"Ready \u2022 %d threads", g_tx.threads()); - SetStatus(hwnd, buf); - } else { - SetStatus(hwnd, L"Loading model\u2026"); - } -} LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { @@ -1074,6 +1342,13 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) return 0; case WM_MOUSEMOVE: { + if (g_view == View::Settings) { + POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; + int hot = SettingsHitTest(p); + if (hot != g_setHot) { g_setHot = hot; InvalidateRect(hWnd, nullptr, FALSE); } + TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, hWnd, 0 }; TrackMouseEvent(&t); + return 0; + } POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; int hot = HitTest(p); if (hot != g_hot) { @@ -1089,10 +1364,16 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) if (g_hot >= 0) { g_w[g_hot].hover = false; g_hot = -1; EnsureAnimating(hWnd); } return 0; case WM_LBUTTONDOWN: + if (g_view == View::Settings) return 0; g_active = g_hot; if (g_active >= 0) { g_w[g_active].pressed = true; SetCapture(hWnd); EnsureAnimating(hWnd); } return 0; case WM_LBUTTONUP: { + if (g_view == View::Settings) { + POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; + OnSettingsClick(hWnd, p); + return 0; + } ReleaseCapture(); POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; if (g_active >= 0 && HitTest(p) == g_active) OnClick(hWnd, g_w[g_active].kind); @@ -1101,6 +1382,16 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) return 0; } + case WM_MOUSEWHEEL: + if (g_view == View::Settings) { + g_setScroll -= GET_WHEEL_DELTA_WPARAM(wParam) / 2; + g_setScroll = std::max(0, std::min(g_setScroll, g_setScrollMax)); + RECT rc; GetClientRect(hWnd, &rc); + LayoutSettings(rc.right, rc.bottom); + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + case WM_DPICHANGED: { g_dpiScale = LOWORD(wParam) / 96.0f; if (g_dpiScale <= 0.0f) g_dpiScale = 1.0f; @@ -1113,6 +1404,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) if (hEdit) SendMessage(hEdit, WM_SETFONT, (WPARAM)g_fText, TRUE); LayoutControls(hWnd, sug->right - sug->left, sug->bottom - sug->top); LayoutWidgets(sug->right - sug->left, sug->bottom - sug->top); + if (g_view == View::Settings) LayoutSettings(sug->right - sug->left, sug->bottom - sug->top); InvalidateRect(hWnd, nullptr, TRUE); return 0; } @@ -1120,6 +1412,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) case WM_SIZE: LayoutControls(hWnd, LOWORD(lParam), HIWORD(lParam)); LayoutWidgets(LOWORD(lParam), HIWORD(lParam)); + if (g_view == View::Settings) LayoutSettings(LOWORD(lParam), HIWORD(lParam)); return 0; case WM_EXITSIZEMOVE: @@ -1169,10 +1462,9 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) float dt = (now - g_lastTick) / 1000.0f; g_lastTick = now; if (dt <= 0.0f) dt = 0.05f; g_est.tick(dt, g_progressFrac, g_progressRemain); - InvalidateRect(hWnd, &g_vuRect, FALSE); + InvalidateRect(hWnd, nullptr, FALSE); } } - UpdateStatus(hWnd); } break; @@ -1188,6 +1480,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) break; } if (!g_tx.is_recording()) { + if (g_view == View::Settings) SwitchView(hWnd, View::Main); if (!g_modelLoaded.load()) { SetStatus(hWnd, L"Loading model\u2026"); break; } if (!g_modelOk.load()) { std::wstring m = L"Model not found:\n" + to_w(g_config.model_path) @@ -1198,6 +1491,11 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) g_prevForeground = GetForegroundWindow(); ShowWindow(hWnd, SW_SHOWNA); g_recordingSecs = 0; + { + DWORD s = 0, e = 0; + SendMessageW(GetDlgItem(hWnd, ID_EDIT_TEXT), EM_GETSEL, (WPARAM)&s, (LPARAM)&e); + g_insStart = s; g_insEnd = e; + } if (g_tx.start_recording()) { SetStatus(hWnd, L"Recording\u2026"); } else { @@ -1236,17 +1534,21 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) std::string* res = (std::string*)wParam; if (res && !res->empty()) { + RecordUsage(g_stats, g_lastAudioLen, actual, to_w(*res)); char log[128]; float secs = g_tx.audio_seconds(); snprintf(log, sizeof(log), "Transcribed %.1fs %d chars", secs, (int)res->size()); LogLine(log); HWND hEdit = GetDlgItem(hWnd, ID_EDIT_TEXT); - int len = GetWindowTextLengthW(hEdit); - std::wstring cur; - if (len > 0) { cur.resize(len + 1); int g = GetWindowTextW(hEdit, &cur[0], len + 1); cur.resize(g); } - std::wstring combined = append_transcript(cur, to_w(*res)); - SetWindowTextW(hEdit, combined.c_str()); - SendMessageW(hEdit, EM_SETSEL, (WPARAM)combined.size(), (LPARAM)combined.size()); + std::wstring full = GetEditText(hWnd); + DWORD s = std::min(g_insStart, (DWORD)full.size()); + DWORD e = std::min(g_insEnd, (DWORD)full.size()); + std::wstring add = to_w(*res); + bool needLead = (s > 0) && !iswspace(full[s - 1]); + bool needTrail = (e < full.size()) && !iswspace(full[e]); + std::wstring ins = (needLead ? L" " : L"") + add + (needTrail ? L" " : L""); + SendMessageW(hEdit, EM_SETSEL, s, e); + SendMessageW(hEdit, EM_REPLACESEL, TRUE, (LPARAM)ins.c_str()); SendMessageW(hEdit, EM_SCROLLCARET, 0, 0); UpdatePlaceholder(hWnd); @@ -1276,6 +1578,26 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) return 0; } + case WM_APP_DLPROGRESS: { + int idx = (int)wParam, code = (int)lParam; + if (idx >= 0 && idx < kCatalogCount) { + if (code >= 0 && code <= 100) { + g_cat[idx].state = DlState::Downloading; g_cat[idx].pct = code; + } else { + g_dl.join(); g_dl.active = false; + if (code == 101) { + RefreshModelList(hWnd); + SetStatus(hWnd, L"Model downloaded"); + } else { + SetStatus(hWnd, code == -2 ? L"Download cancelled" : L"Download failed"); + } + RefreshCatalogStates(); + } + } + InvalidateRect(hWnd, nullptr, FALSE); + return 0; + } + case WM_APP_SELECT: { int ctrlId = (int)wParam, idx = (int)lParam; if (ctrlId == ID_SEL_AUDIO) { @@ -1296,11 +1618,26 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) }).detach(); InvalidateRect(GetDlgItem(hWnd, ID_SEL_MODEL), nullptr, FALSE); PersistNow(); + } else if (ctrlId == ID_SEL_HISTORY && idx >= 0 && idx < (int)g_history.size()) { + std::wstring cur = GetEditText(hWnd); + if (!cur.empty() && cur != g_lastLoadedText) + ArchiveSession(cur); + std::wstring text = ReadFileUtf8(g_history[idx].path); + SetWindowTextW(GetDlgItem(hWnd, ID_EDIT_TEXT), text.c_str()); + g_lastLoadedText = text; + g_editDirty = false; + g_history = LoadHistoryIndex(); + UpdatePlaceholder(hWnd); + SetStatus(hWnd, L"Loaded from history"); } return 0; } case WM_COMMAND: + if (LOWORD(wParam) == ID_EDIT_TEXT && HIWORD(wParam) == EN_CHANGE) { + g_editDirty = true; + break; + } switch (LOWORD(wParam)) { case ID_TRAY_EXIT: DestroyWindow(hWnd); @@ -1327,10 +1664,18 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) case ID_BTN_RECORD: PostMessage(hWnd, WM_HOTKEY, HK_TOGGLE, 0); break; - case ID_BTN_CLEAR: + case ID_BTN_CLEAR: { + std::wstring cur = GetEditText(hWnd); + if (!cur.empty() && cur != g_lastLoadedText) + ArchiveSession(cur); + g_history = LoadHistoryIndex(); + g_lastLoadedText.clear(); + g_editDirty = false; SetDlgItemText(hWnd, ID_EDIT_TEXT, L""); UpdatePlaceholder(hWnd); + SetStatus(hWnd, L"Saved to history"); break; + } case ID_BTN_PIN: g_pinned = !g_pinned; SetWindowPos(hWnd, g_pinned ? HWND_TOPMOST : HWND_NOTOPMOST, @@ -1362,10 +1707,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) } break; case ID_SEL_AUDIO: - ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel); - break; - case ID_SEL_MODEL: - ShowSelectPopup(hWnd, ID_SEL_MODEL, g_modelItems, g_modelSel); + ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r); break; } break; @@ -1384,10 +1726,13 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) ShowWindow(hWnd, SW_HIDE); return 0; - case WM_DESTROY: + case WM_DESTROY: { + std::wstring cur = GetEditText(hWnd); + if (!cur.empty() && cur != g_lastLoadedText) ArchiveSession(cur); PersistNow(); PostQuitMessage(0); break; + } default: return DefWindowProc(hWnd, message, wParam, lParam); @@ -1424,6 +1769,37 @@ std::wstring to_w(const std::string& s) { return w; } +std::wstring GetEditText(HWND hwnd) { + HWND e = GetDlgItem(hwnd, ID_EDIT_TEXT); + int len = GetWindowTextLengthW(e); + std::wstring s; + if (len > 0) { s.resize(len + 1); int got = GetWindowTextW(e, &s[0], len + 1); s.resize(got); } + return s; +} + +std::vector BuildStatsLines() { + std::vector out; + wchar_t b[160]; + swprintf_s(b, L"Dictated %s of audio across %d clips", + FormatHMS(g_stats.totalAudioSec).c_str(), (int)g_stats.totalClips); + out.push_back(b); + double mins = g_stats.totalAudioSec / 60.0; + swprintf_s(b, L"Words %d (%d wpm speaking)", + (int)g_stats.totalWords, mins > 0.05 ? (int)(g_stats.totalWords / mins + 0.5) : 0); + out.push_back(b); + swprintf_s(b, L"Processing %s total (%.1fx real-time on this machine)", + FormatHMS(g_stats.totalProcSec).c_str(), + g_stats.totalProcSec > 0.5 ? g_stats.totalAudioSec / g_stats.totalProcSec : 0.0); + out.push_back(b); + swprintf_s(b, L"Longest clip %s", FormatHMS(g_stats.longestClipSec).c_str()); + out.push_back(b); + double typingSec = (g_stats.totalWords / 40.0) * 60.0; + double savedSec = typingSec - g_stats.totalAudioSec; + if (savedSec > 60) + { swprintf_s(b, L"Time saved ~%s vs typing at 40 wpm", FormatHMS(savedSec).c_str()); out.push_back(b); } + return out; +} + bool SetClipboardTextUtf8(HWND owner, const std::string& utf8) { std::wstring w = to_w(utf8); if (!OpenClipboard(owner)) return false; diff --git a/src/stats.h b/src/stats.h new file mode 100644 index 0000000..4563bc6 --- /dev/null +++ b/src/stats.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include +#include + +struct UsageStats { + double totalAudioSec = 0; + double totalProcSec = 0; + double totalWords = 0; + double totalClips = 0; + double longestClipSec = 0; +}; + +inline std::wstring StatsIniPath() { + wchar_t buf[MAX_PATH]; GetModuleFileNameW(nullptr, buf, MAX_PATH); + std::wstring p(buf); p = p.substr(0, p.find_last_of(L"\\/")); + return p + L"\\win-dictation.ini"; +} +inline void StPut(const wchar_t* k, double v) { + wchar_t b[64]; swprintf_s(b, L"%.3f", v); + WritePrivateProfileStringW(L"stats", k, b, StatsIniPath().c_str()); +} +inline double StGet(const wchar_t* k) { + wchar_t out[64]; + GetPrivateProfileStringW(L"stats", k, L"0", out, 64, StatsIniPath().c_str()); + return wcstod(out, nullptr); +} +inline void LoadStats(UsageStats& s) { + s.totalAudioSec = StGet(L"audioSec"); s.totalProcSec = StGet(L"procSec"); + s.totalWords = StGet(L"words"); s.totalClips = StGet(L"clips"); + s.longestClipSec= StGet(L"longest"); +} +inline void SaveStats(const UsageStats& s) { + StPut(L"audioSec", s.totalAudioSec); StPut(L"procSec", s.totalProcSec); + StPut(L"words", s.totalWords); StPut(L"clips", s.totalClips); + StPut(L"longest", s.longestClipSec); +} + +inline int CountWords(const std::wstring& s) { + int n = 0; bool in = false; + for (wchar_t c : s) { bool w = !iswspace(c); if (w && !in) ++n; in = w; } + return n; +} +inline void RecordUsage(UsageStats& st, double audioSec, double procSec, const std::wstring& text) { + st.totalAudioSec += audioSec; + st.totalProcSec += procSec; + st.totalWords += CountWords(text); + st.totalClips += 1; + if (audioSec > st.longestClipSec) st.longestClipSec = audioSec; + SaveStats(st); +} + +inline std::wstring FormatHMS(double sec) { + int s = (int)(sec + 0.5), h = s / 3600, m = (s % 3600) / 60; s %= 60; + wchar_t b[64]; + if (h) swprintf_s(b, L"%dh %dm", h, m); + else if (m) swprintf_s(b, L"%dm %ds", m, s); + else swprintf_s(b, L"%ds", s); + return b; +}