v2 rebuild: GDI+ single-surface UI, self-calibrating progress, compact push-to-talk, GGML+Whisper integration
This commit is contained in:
@@ -0,0 +1,790 @@
|
||||
# Win Dictation — Crash Fixes & Modern UI (Build 2)
|
||||
|
||||
This document has two parts:
|
||||
|
||||
- **Part 1 — Critical fixes** (do these first): the Stop crash, resizing, model switching, thread count, model path.
|
||||
- **Part 2 — Modern UI**: replace the boxy Win32 look with a flat, rounded, dark "web-app" interface (GDI+ custom drawing).
|
||||
|
||||
Notes before you start:
|
||||
|
||||
- All snippets target your current `main.cpp` / `transcriber.cpp` / `transcriber.h`.
|
||||
- I can't compile on my side — integrate a piece at a time and build often. If a `DWMWA_*` or glyph constant is missing, see the `#define` block in §2.3.
|
||||
- Part 2's `LayoutControls()` (§2.12) is the final layout and supersedes any basic one from Part 1.
|
||||
|
||||
---
|
||||
|
||||
# Part 1 — Critical Fixes
|
||||
|
||||
## 1.1 The Stop crash (null Whisper context)
|
||||
|
||||
**Symptom:** record + VU work, then it crashes the moment you hit Stop.
|
||||
|
||||
**Root cause:** two defects combine.
|
||||
|
||||
1. In `wWinMain`, the preload thread sets `g_modelLoaded = true` *even when `preload()` returns false* (model file not found). So the UI shows "Ready" and lets you record with no model loaded.
|
||||
2. `transcribe_worker()` calls `whisper_reset_timings(m_ctx)` **before** the `if (m_ctx)` check. With `m_ctx == nullptr` that's an immediate access violation. Recording never touches Whisper (that's why capture + VU work) — the crash only fires when transcription starts.
|
||||
|
||||
The empty model dropdown in your screenshot confirms it: `RefreshModelList()` found no model files, so `preload()` failed silently.
|
||||
|
||||
### Fix A — make the worker null-safe (`transcriber.cpp`)
|
||||
|
||||
Replace the top of `transcribe_worker` and delete the `whisper_reset_timings` call entirely (it isn't needed for one-shot transcription):
|
||||
|
||||
```cpp
|
||||
void Transcriber::transcribe_worker(std::vector<float> audio) {
|
||||
// Hard guard: if the model failed to load, never touch whisper.
|
||||
if (!m_ctx) {
|
||||
m_busy = false;
|
||||
if (m_on_result) m_on_result(""); // UI will show "No speech / not loaded"
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_cfg.trim_silence) trim_silence(audio);
|
||||
|
||||
whisper_full_params wp = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
|
||||
wp.print_progress = false; wp.print_realtime = false; wp.print_timestamps = false;
|
||||
wp.no_timestamps = true; wp.translate = false;
|
||||
wp.language = m_cfg.language.c_str();
|
||||
wp.n_threads = m_cfg.n_threads;
|
||||
wp.no_context = true; wp.suppress_blank = true; wp.suppress_nst = true;
|
||||
wp.temperature = 0.0f;
|
||||
// (whisper_reset_timings removed — it was the crash site and is unnecessary)
|
||||
|
||||
std::string out;
|
||||
if (whisper_full(m_ctx, wp, audio.data(), (int)audio.size()) == 0) {
|
||||
int n = whisper_full_n_segments(m_ctx);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const char* t = whisper_full_get_segment_text(m_ctx, i);
|
||||
if (t) out += t;
|
||||
}
|
||||
out = clean_text(out);
|
||||
}
|
||||
m_busy = false;
|
||||
if (m_on_result) m_on_result(out);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix B — track load success honestly (`main.cpp`)
|
||||
|
||||
Add an atomic next to `g_modelLoaded`:
|
||||
|
||||
```cpp
|
||||
std::atomic<bool> g_modelLoaded{false};
|
||||
std::atomic<bool> g_modelOk{false};
|
||||
```
|
||||
|
||||
Fix the preload lambda so failure is recorded:
|
||||
|
||||
```cpp
|
||||
std::thread([] {
|
||||
bool ok = g_tx.preload(g_config); // false if the .bin isn't found
|
||||
g_modelOk = ok;
|
||||
g_modelLoaded = true;
|
||||
}).detach();
|
||||
```
|
||||
|
||||
### Fix C — refuse to record without a model (`main.cpp`, `WM_HOTKEY`)
|
||||
|
||||
This both prevents the crash path and tells you *why* nothing happens:
|
||||
|
||||
```cpp
|
||||
case WM_HOTKEY:
|
||||
if (wParam == HK_TOGGLE) {
|
||||
if (g_tx.is_busy()) break; // mid-transcription: ignore
|
||||
if (!g_tx.is_recording()) {
|
||||
if (!g_modelLoaded.load()) { SetStatus(hWnd, L"Loading model…"); break; }
|
||||
if (!g_modelOk.load()) {
|
||||
std::wstring m = L"Model not found:\n" + to_w(g_config.model_path)
|
||||
+ L"\n\nPut the .bin there and restart.";
|
||||
MessageBoxW(hWnd, m.c_str(), L"Dictation", MB_OK | MB_ICONWARNING);
|
||||
break;
|
||||
}
|
||||
g_prevForeground = GetForegroundWindow();
|
||||
ShowWindow(hWnd, SW_SHOWNA);
|
||||
g_recordingSecs = 0;
|
||||
if (g_tx.start_recording()) {
|
||||
SetDlgItemText(hWnd, ID_BTN_RECORD, L"■ Stop");
|
||||
SetStatus(hWnd, L"Recording…");
|
||||
} else SetStatus(hWnd, L"Microphone error");
|
||||
} else {
|
||||
g_tx.stop_and_transcribe();
|
||||
SetDlgItemText(hWnd, ID_BTN_RECORD, L"Record");
|
||||
SetStatus(hWnd, L"Transcribing…");
|
||||
}
|
||||
} else if (wParam == HK_HIDE) {
|
||||
if (g_tx.is_recording()) g_tx.cancel();
|
||||
ShowWindow(hWnd, SW_HIDE);
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
After this, a missing model shows **"Model not found: …\models\ggml-tiny.en.bin"** instead of crashing.
|
||||
|
||||
## 1.2 Verify the model file
|
||||
|
||||
The app now loads the model from a path relative to the **.exe**:
|
||||
`exe_dir() + "\\models\\ggml-tiny.en.bin"`. Confirm it's actually there:
|
||||
|
||||
```
|
||||
dir build\bin\Release\models
|
||||
```
|
||||
|
||||
You should see `ggml-tiny.en.bin`. If it's missing, re-run `build.ps1`, or drop the `.bin` into that `models\` folder manually. (Your CMake post-build step copies `models/` next to the exe — if the source `models/` had no `.bin` at build time, nothing got copied.)
|
||||
|
||||
## 1.3 Resizable window
|
||||
|
||||
Two reasons it's frozen: the window style has no sizing border, and there's no `WM_SIZE` handler so controls never reflow.
|
||||
|
||||
### Window style (`main.cpp`, `CreateWindowExW`)
|
||||
|
||||
> If you do Part 2 Option A (§2.3), keep a normal frame as shown here. If you do the borderless Option B (appendix), that section replaces this.
|
||||
|
||||
```cpp
|
||||
hMainWnd = CreateWindowExW(
|
||||
WS_EX_TOPMOST,
|
||||
L"WhisperDictationClass", L"Dictation",
|
||||
WS_OVERLAPPEDWINDOW, // caption + sysmenu + THICKFRAME + min/max = resizable
|
||||
CW_USEDEFAULT, CW_USEDEFAULT, 400, 340,
|
||||
nullptr, nullptr, hInstance, nullptr);
|
||||
```
|
||||
|
||||
### Handlers (`main.cpp`, `WndProc`)
|
||||
|
||||
```cpp
|
||||
case WM_SIZE:
|
||||
LayoutControls(hWnd, LOWORD(lParam), HIWORD(lParam)); // defined in §2.12
|
||||
return 0;
|
||||
|
||||
case WM_GETMINMAXINFO:
|
||||
((MINMAXINFO*)lParam)->ptMinTrackSize.x = 340;
|
||||
((MINMAXINFO*)lParam)->ptMinTrackSize.y = 280;
|
||||
return 0;
|
||||
```
|
||||
|
||||
Control positions in `InitializeUI` no longer matter — `LayoutControls` owns geometry. Add one explicit call right after `ShowWindow` so the first frame is laid out:
|
||||
|
||||
```cpp
|
||||
RECT rc; GetClientRect(hMainWnd, &rc);
|
||||
LayoutControls(hMainWnd, rc.right, rc.bottom);
|
||||
```
|
||||
|
||||
## 1.4 Model switching + correct thread count
|
||||
|
||||
Two real bugs you'll hit:
|
||||
|
||||
- The model dropdown only lists files that **exist**, so its selected index does **not** map to `kModelFiles[]`.
|
||||
- `preload()` early-returns when `m_ctx` is already set, so switching models **never reloads**.
|
||||
- The status bar shows `hardware_concurrency()` (4) instead of the threads actually used (2).
|
||||
|
||||
### transcriber.h — add a reload + a threads getter
|
||||
|
||||
```cpp
|
||||
bool reload(const WhisperConfig& cfg); // free + re-init with a new model
|
||||
int threads() const { return m_cfg.n_threads; }
|
||||
```
|
||||
|
||||
### transcriber.cpp — implement reload
|
||||
|
||||
```cpp
|
||||
bool Transcriber::reload(const WhisperConfig& cfg) {
|
||||
if (m_recording.load() || m_busy.load()) return false; // not mid-use
|
||||
if (m_worker.joinable()) m_worker.join();
|
||||
if (m_ctx) { whisper_free(m_ctx); m_ctx = nullptr; }
|
||||
return preload(cfg);
|
||||
}
|
||||
```
|
||||
|
||||
### main.cpp — track the real file paths the combo shows
|
||||
|
||||
Replace the static arrays + `RefreshModelList` + the `ID_COMBO_MODEL` handler:
|
||||
|
||||
```cpp
|
||||
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",
|
||||
};
|
||||
std::vector<std::string> g_modelComboPaths; // parallel to combo entries
|
||||
|
||||
void RefreshModelList(HWND hwnd) {
|
||||
HWND hCombo = GetDlgItem(hwnd, ID_COMBO_MODEL);
|
||||
SendMessage(hCombo, CB_RESETCONTENT, 0, 0);
|
||||
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) {
|
||||
SendMessage(hCombo, CB_ADDSTRING, 0, (LPARAM)kModelNames[i]);
|
||||
g_modelComboPaths.push_back(kModelFiles[i]); // remember the real file
|
||||
}
|
||||
}
|
||||
if (!g_modelComboPaths.empty()) SendMessage(hCombo, CB_SETCURSEL, 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
```cpp
|
||||
case ID_COMBO_MODEL:
|
||||
if (HIWORD(wParam) == CBN_SELCHANGE) {
|
||||
int idx = (int)SendMessage(GetDlgItem(hWnd, ID_COMBO_MODEL), CB_GETCURSEL, 0, 0);
|
||||
if (idx >= 0 && idx < (int)g_modelComboPaths.size()) {
|
||||
g_config.model_path = exe_dir() + "\\" + g_modelComboPaths[idx];
|
||||
g_modelLoaded = false; g_modelOk = false;
|
||||
SetStatus(hWnd, L"Loading model…");
|
||||
std::thread([] {
|
||||
bool ok = g_tx.reload(g_config); // <- reload, not preload
|
||||
g_modelOk = ok; g_modelLoaded = true;
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
### Correct thread count in the status (`UpdateStatus`)
|
||||
|
||||
```cpp
|
||||
} else if (g_modelLoaded.load()) {
|
||||
if (!g_modelOk.load()) { SetStatus(hwnd, L"Model not found — check models folder"); return; }
|
||||
swprintf_s(buf, L"Ready • %d threads", g_tx.threads());
|
||||
SetStatus(hwnd, buf);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part 2 — Modern UI (flat, rounded, dark)
|
||||
|
||||
## 2.1 Design language
|
||||
|
||||
The "boxy" feeling comes from three things: the gray 3-D system buttons, the chunky title bar, and zero breathing room. We fix all three.
|
||||
|
||||
- **Palette** — near-black canvas, one elevated surface tone, a single indigo accent, hairline borders. (Linear/Raycast register.)
|
||||
- **Shape** — everything rounded: 10px buttons/fields, 14px panels, rounded window corners.
|
||||
- **Type** — Segoe UI Variable / Segoe UI, a clear size hierarchy, no monospace for the transcript (sans reads more "app").
|
||||
- **Space** — 16px outer margin, 10–12px gaps, the transcript is the hero and grows with the window.
|
||||
- **Motion** — a soft pulse on the record button while recording; subtle hover/press on buttons.
|
||||
|
||||
| Token | Hex | Use |
|
||||
|---|---|---|
|
||||
| Bg | `#0F1115` | window canvas |
|
||||
| Surface | `#181B22` | fields, transcript panel |
|
||||
| SurfaceHi | `#20242D` | hover |
|
||||
| Border | `#262B36` | 1px hairlines |
|
||||
| Text | `#E7E9EE` | primary text |
|
||||
| TextDim | `#9AA0AB` | status, placeholders |
|
||||
| Accent | `#6E8BFF` | record idle, focus |
|
||||
| AccentHi | `#5B7BFF` | accent hover |
|
||||
| Danger | `#FF5C5C` | recording / stop |
|
||||
| Good | `#46D39A` | VU meter |
|
||||
|
||||
## 2.2 How it's built
|
||||
|
||||
- **GDI+** does the drawing — it anti-aliases rounded rectangles and supports alpha, so we get smooth corners without Direct2D.
|
||||
- **Owner-drawn child buttons** for Record / Copy / Paste / Pin (we paint them; Windows still gives us click + focus).
|
||||
- **Parent-painted** background, transcript panel frame, and the VU meter (in `WM_PAINT`).
|
||||
- **Native EDIT** stays for the transcript (so selection/scroll work) but flat-themed with padding; a STATIC shows placeholder text when empty.
|
||||
- **Owner-drawn comboboxes** for mic/model so they match the dark theme.
|
||||
- A tiny **button subclass** gives reliable hover.
|
||||
|
||||
This keeps real controls (accessibility, IME, scrolling) while looking custom.
|
||||
|
||||
## 2.3 Setup: GDI+ and a dark, rounded window (Option A — recommended)
|
||||
|
||||
Option A keeps the native frame but recolors it dark and rounds the corners — low-risk and modern. (Option B, fully borderless custom title bar, is in the appendix.)
|
||||
|
||||
At the top of `main.cpp`:
|
||||
|
||||
```cpp
|
||||
#include <gdiplus.h>
|
||||
#pragma comment(lib, "gdiplus.lib")
|
||||
using namespace Gdiplus;
|
||||
|
||||
// DWM attributes (define in case your SDK headers are older)
|
||||
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
||||
#endif
|
||||
#ifndef DWMWA_BORDER_COLOR
|
||||
#define DWMWA_BORDER_COLOR 34
|
||||
#endif
|
||||
#ifndef DWMWA_CAPTION_COLOR
|
||||
#define DWMWA_CAPTION_COLOR 35
|
||||
#endif
|
||||
#ifndef DWMWA_TEXT_COLOR
|
||||
#define DWMWA_TEXT_COLOR 36
|
||||
#endif
|
||||
#ifndef DWMWA_WINDOW_CORNER_PREFERENCE
|
||||
#define DWMWA_WINDOW_CORNER_PREFERENCE 33
|
||||
#endif
|
||||
#ifndef DWMWCP_ROUND
|
||||
#define DWMWCP_ROUND 2
|
||||
#endif
|
||||
```
|
||||
|
||||
Start/stop GDI+ in `wWinMain`:
|
||||
|
||||
```cpp
|
||||
ULONG_PTR g_gdipToken = 0;
|
||||
// near the top of wWinMain, before creating the window:
|
||||
GdiplusStartupInput gdipIn;
|
||||
GdiplusStartup(&g_gdipToken, &gdipIn, nullptr);
|
||||
// ... after the message loop, before return:
|
||||
GdiplusShutdown(g_gdipToken);
|
||||
```
|
||||
|
||||
After the window is created, theme the frame (Win11 honors all of these; Win10 ignores caption/border color but keeps dark mode):
|
||||
|
||||
```cpp
|
||||
BOOL dark = TRUE;
|
||||
DwmSetWindowAttribute(hMainWnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark));
|
||||
COLORREF cap = RGB(0x0F,0x11,0x15), bord = RGB(0x26,0x2B,0x36), txt = RGB(0xE7,0xE9,0xEE);
|
||||
DwmSetWindowAttribute(hMainWnd, DWMWA_CAPTION_COLOR, &cap, sizeof(cap));
|
||||
DwmSetWindowAttribute(hMainWnd, DWMWA_BORDER_COLOR, &bord, sizeof(bord));
|
||||
DwmSetWindowAttribute(hMainWnd, DWMWA_TEXT_COLOR, &txt, sizeof(txt));
|
||||
int corner = DWMWCP_ROUND;
|
||||
DwmSetWindowAttribute(hMainWnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));
|
||||
```
|
||||
|
||||
## 2.4 Theme constants + fonts (`main.cpp`)
|
||||
|
||||
```cpp
|
||||
// GDI+ colors
|
||||
static const Color C_BG (255,0x0F,0x11,0x15);
|
||||
static const Color C_SURFACE (255,0x18,0x1B,0x22);
|
||||
static const Color C_SURFACEHI(255,0x20,0x24,0x2D);
|
||||
static const Color C_BORDER (255,0x26,0x2B,0x36);
|
||||
static const Color C_TEXT (255,0xE7,0xE9,0xEE);
|
||||
static const Color C_TEXTDIM (255,0x9A,0xA0,0xAB);
|
||||
static const Color C_ACCENT (255,0x6E,0x8B,0xFF);
|
||||
static const Color C_ACCENTHI (255,0x5B,0x7B,0xFF);
|
||||
static const Color C_DANGER (255,0xFF,0x5C,0x5C);
|
||||
static const Color C_GOOD (255,0x46,0xD3,0x9A);
|
||||
// COLORREF mirrors for GDI (WM_CTLCOLOR*)
|
||||
#define CR_BG RGB(0x0F,0x11,0x15)
|
||||
#define CR_SURFACE RGB(0x18,0x1B,0x22)
|
||||
#define CR_TEXT RGB(0xE7,0xE9,0xEE)
|
||||
|
||||
HFONT g_fUI=nullptr, g_fUISemi=nullptr, g_fSmall=nullptr, g_fText=nullptr;
|
||||
HBRUSH g_brBg=nullptr, g_brSurface=nullptr;
|
||||
|
||||
static HFONT MakeFont(int px, int weight) {
|
||||
return CreateFontW(-px,0,0,0,weight,FALSE,FALSE,FALSE,DEFAULT_CHARSET,
|
||||
OUT_DEFAULT_PRECIS,CLIP_DEFAULT_PRECIS,CLEARTYPE_QUALITY,
|
||||
DEFAULT_PITCH|FF_DONTCARE,L"Segoe UI Variable Display");
|
||||
}
|
||||
// build once in wWinMain:
|
||||
// g_fUI = MakeFont(15, FW_NORMAL); g_fUISemi = MakeFont(15, FW_SEMIBOLD);
|
||||
// g_fSmall = MakeFont(12, FW_NORMAL); g_fText = MakeFont(16, FW_NORMAL);
|
||||
// g_brBg = CreateSolidBrush(CR_BG); g_brSurface = CreateSolidBrush(CR_SURFACE);
|
||||
// (If "Segoe UI Variable Display" is unavailable it falls back to Segoe UI.)
|
||||
```
|
||||
|
||||
## 2.5 GDI+ helpers
|
||||
|
||||
```cpp
|
||||
static void RoundPath(GraphicsPath& p, const Rect& r, int rad) {
|
||||
int d = rad * 2;
|
||||
p.AddArc(r.X, r.Y, d, d, 180, 90);
|
||||
p.AddArc(r.GetRight()-d, r.Y, d, d, 270, 90);
|
||||
p.AddArc(r.GetRight()-d, r.GetBottom()-d, d, d, 0, 90);
|
||||
p.AddArc(r.X, r.GetBottom()-d, d, d, 90, 90);
|
||||
p.CloseFigure();
|
||||
}
|
||||
static void FillRound(Graphics& g, const Color& c, const Rect& r, int rad) {
|
||||
GraphicsPath p; RoundPath(p, r, rad); SolidBrush b(c); g.FillPath(&b, &p);
|
||||
}
|
||||
static void StrokeRound(Graphics& g, const Color& c, const Rect& r, int rad, REAL w=1.0f) {
|
||||
GraphicsPath p; RoundPath(p, r, rad); Pen pen(c, w); g.DrawPath(&pen, &p);
|
||||
}
|
||||
static void DrawTextC(Graphics& g, const wchar_t* s, const Font& f, const Color& c,
|
||||
const RectF& box, StringAlignment h, StringAlignment v) {
|
||||
StringFormat sf; sf.SetAlignment(h); sf.SetLineAlignment(v);
|
||||
sf.SetTrimming(StringTrimmingEllipsisCharacter);
|
||||
SolidBrush b(c); g.DrawString(s, -1, &f, box, &sf, &b);
|
||||
}
|
||||
```
|
||||
|
||||
## 2.6 Reliable hover (button subclass)
|
||||
|
||||
Owner-draw buttons don't reliably get hover. This subclass tracks it and stores a 0/1 hover flag in the window's user data, repainting on enter/leave.
|
||||
|
||||
```cpp
|
||||
LRESULT CALLBACK BtnProc(HWND h, UINT m, WPARAM w, LPARAM l, UINT_PTR, DWORD_PTR) {
|
||||
if (m == WM_MOUSEMOVE) {
|
||||
if (!GetWindowLongPtr(h, GWLP_USERDATA)) {
|
||||
SetWindowLongPtr(h, GWLP_USERDATA, 1);
|
||||
TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, h, 0 };
|
||||
TrackMouseEvent(&t);
|
||||
InvalidateRect(h, nullptr, FALSE);
|
||||
}
|
||||
} else if (m == WM_MOUSELEAVE) {
|
||||
SetWindowLongPtr(h, GWLP_USERDATA, 0);
|
||||
InvalidateRect(h, nullptr, FALSE);
|
||||
}
|
||||
return DefSubclassProc(h, m, w, l); // needs <commctrl.h> (already included)
|
||||
}
|
||||
// after creating each owner-draw button:
|
||||
// SetWindowSubclass(hBtn, BtnProc, 1, 0);
|
||||
```
|
||||
|
||||
(Add `#pragma comment(lib, "comctl32.lib")` is already present; `SetWindowSubclass` lives in `<commctrl.h>`.)
|
||||
|
||||
## 2.7 The record button (hero, pulsing)
|
||||
|
||||
`ID_BTN_RECORD` is an owner-draw button drawn as a rounded pill: accent "● Record" when idle, danger "■ Stop" with a soft brightness pulse while recording. The dot/square are drawn shapes (no icon-font dependency).
|
||||
|
||||
```cpp
|
||||
void DrawRecordButton(LPDRAWITEMSTRUCT d) {
|
||||
Graphics g(d->hDC); g.SetSmoothingMode(SmoothingModeAntiAlias);
|
||||
Rect rc(d->rcItem.left, d->rcItem.top,
|
||||
d->rcItem.right-d->rcItem.left, d->rcItem.bottom-d->rcItem.top);
|
||||
// paint the parent canvas in the corners first
|
||||
SolidBrush bg(C_BG); g.FillRectangle(&bg, rc);
|
||||
|
||||
bool rec = g_tx.is_recording();
|
||||
bool hover = GetWindowLongPtr(d->hwndItem, GWLP_USERDATA) != 0;
|
||||
bool pressed = (d->itemState & ODS_SELECTED) != 0;
|
||||
|
||||
Color fill = rec ? C_DANGER : (hover ? C_ACCENTHI : C_ACCENT);
|
||||
if (rec) { // gentle pulse
|
||||
double ph = (GetTickCount() % 1400) / 1400.0;
|
||||
int add = (int)(18 * (0.5 + 0.5 * sin(ph * 6.2831853)));
|
||||
fill = Color(255, min(255,0xFF), min(255,0x5C+add), min(255,0x5C+add));
|
||||
}
|
||||
if (pressed) fill = Color(255, GetRValue(0)+ (BYTE)(fill.GetR()*0.85),
|
||||
(BYTE)(fill.GetG()*0.85), (BYTE)(fill.GetB()*0.85));
|
||||
|
||||
Rect pill = rc; pill.Inflate(-1,-1);
|
||||
FillRound(g, fill, pill, pill.Height/2); // full pill
|
||||
|
||||
// icon: filled circle (idle) or rounded square (recording)
|
||||
int cx = pill.X + 22, cy = pill.Y + pill.Height/2;
|
||||
SolidBrush white(Color(255,255,255,255));
|
||||
if (rec) { Rect sq(cx-7, cy-7, 14, 14); FillRound(g, Color(255,255,255,255), sq, 3); }
|
||||
else { g.FillEllipse(&white, cx-7, cy-7, 14, 14); }
|
||||
|
||||
Font f(d->hDC, g_fUISemi);
|
||||
RectF tb((REAL)(pill.X+36), (REAL)pill.Y, (REAL)(pill.Width-44), (REAL)pill.Height);
|
||||
DrawTextC(g, rec ? L"Stop" : L"Record", f, Color(255,255,255,255),
|
||||
tb, StringAlignmentNear, StringAlignmentCenter);
|
||||
}
|
||||
```
|
||||
|
||||
While recording, repaint it each tick so the pulse animates — in `WM_TIMER`:
|
||||
|
||||
```cpp
|
||||
if (g_tx.is_recording()) InvalidateRect(GetDlgItem(hWnd, ID_BTN_RECORD), nullptr, FALSE);
|
||||
```
|
||||
|
||||
## 2.8 Flat buttons (Copy / Paste / Pin)
|
||||
|
||||
Generic owner-draw for secondary buttons — a flat surface chip that lifts on hover, accent text. Pin shows a filled dot when active.
|
||||
|
||||
```cpp
|
||||
void DrawFlatButton(LPDRAWITEMSTRUCT d, const wchar_t* label, bool active=false) {
|
||||
Graphics g(d->hDC); g.SetSmoothingMode(SmoothingModeAntiAlias);
|
||||
Rect rc(d->rcItem.left, d->rcItem.top,
|
||||
d->rcItem.right-d->rcItem.left, d->rcItem.bottom-d->rcItem.top);
|
||||
SolidBrush bg(C_BG); g.FillRectangle(&bg, rc);
|
||||
|
||||
bool hover = GetWindowLongPtr(d->hwndItem, GWLP_USERDATA) != 0;
|
||||
bool pressed = (d->itemState & ODS_SELECTED) != 0;
|
||||
Rect chip = rc; chip.Inflate(-1,-1);
|
||||
FillRound(g, pressed ? C_SURFACE : (hover ? C_SURFACEHI : C_SURFACE), chip, 10);
|
||||
StrokeRound(g, C_BORDER, chip, 10, 1.0f);
|
||||
|
||||
Font f(d->hDC, g_fUI);
|
||||
Color tc = active ? C_ACCENT : C_TEXT;
|
||||
RectF tb((REAL)chip.X, (REAL)chip.Y, (REAL)chip.Width, (REAL)chip.Height);
|
||||
DrawTextC(g, label, f, tc, tb, StringAlignmentCenter, StringAlignmentCenter);
|
||||
}
|
||||
```
|
||||
|
||||
In `WM_DRAWITEM`, route each control:
|
||||
|
||||
```cpp
|
||||
case WM_DRAWITEM: {
|
||||
LPDRAWITEMSTRUCT d = (LPDRAWITEMSTRUCT)lParam;
|
||||
switch (d->CtlID) {
|
||||
case ID_BTN_RECORD: DrawRecordButton(d); return TRUE;
|
||||
case ID_BTN_COPY: DrawFlatButton(d, L"Copy"); return TRUE;
|
||||
case ID_BTN_PASTE: DrawFlatButton(d, L"Paste"); return TRUE;
|
||||
case ID_BTN_PIN: DrawFlatButton(d, g_pinned ? L"Pinned" : L"Pin", g_pinned); return TRUE;
|
||||
case ID_COMBO_AUDIO:
|
||||
case ID_COMBO_MODEL: DrawCombo(d); return TRUE; // §2.9
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
Make Copy/Paste/Pin owner-draw too (add `BS_OWNERDRAW`) and subclass them for hover:
|
||||
|
||||
```cpp
|
||||
// in InitializeUI, create with BS_OWNERDRAW, then:
|
||||
SetWindowSubclass(hCopy, BtnProc, 1, 0);
|
||||
SetWindowSubclass(hPaste, BtnProc, 1, 0);
|
||||
SetWindowSubclass(hPin, BtnProc, 1, 0);
|
||||
SetWindowSubclass(GetDlgItem(hwnd, ID_BTN_RECORD), BtnProc, 1, 0);
|
||||
```
|
||||
|
||||
## 2.9 Mic & model selectors (dark combos)
|
||||
|
||||
Make both combos `CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS`. Set row height once, then draw field + list items dark with a drawn chevron.
|
||||
|
||||
```cpp
|
||||
case WM_MEASUREITEM: {
|
||||
auto* mi = (LPMEASUREITEMSTRUCT)lParam;
|
||||
if (mi->CtlType == ODT_COMBOBOX) mi->itemHeight = 26;
|
||||
return TRUE;
|
||||
}
|
||||
```
|
||||
|
||||
```cpp
|
||||
void DrawCombo(LPDRAWITEMSTRUCT d) {
|
||||
Graphics g(d->hDC); g.SetSmoothingMode(SmoothingModeAntiAlias);
|
||||
Rect rc(d->rcItem.left, d->rcItem.top,
|
||||
d->rcItem.right-d->rcItem.left, d->rcItem.bottom-d->rcItem.top);
|
||||
bool inField = (d->itemState & ODS_COMBOBOXEDIT) != 0; // the closed field
|
||||
bool sel = (d->itemState & ODS_SELECTED) != 0;
|
||||
|
||||
SolidBrush bg(inField ? C_SURFACE : (sel ? C_SURFACEHI : C_SURFACE));
|
||||
g.FillRectangle(&bg, rc);
|
||||
if (inField) { Rect b=rc; b.Inflate(-1,-1); StrokeRound(g, C_BORDER, b, 9); }
|
||||
|
||||
wchar_t txt[256] = L"";
|
||||
if ((int)d->itemID >= 0)
|
||||
SendMessageW(d->hwndItem, CB_GETLBTEXT, d->itemID, (LPARAM)txt);
|
||||
|
||||
Font f(d->hDC, g_fUI);
|
||||
RectF tb((REAL)rc.X+10, (REAL)rc.Y, (REAL)(rc.Width-28), (REAL)rc.Height);
|
||||
DrawTextC(g, txt, f, C_TEXT, tb, StringAlignmentNear, StringAlignmentCenter);
|
||||
|
||||
if (inField) { // chevron
|
||||
int cx = rc.X + rc.Width - 16, cy = rc.Y + rc.Height/2;
|
||||
Pen pen(C_TEXTDIM, 1.6f);
|
||||
g.DrawLine(&pen, cx-4, cy-2, cx, cy+2);
|
||||
g.DrawLine(&pen, cx, cy+2, cx+4, cy-2);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep your existing `WM_CTLCOLORLISTBOX` returning the surface brush so the dropdown list background is dark too.
|
||||
|
||||
> Minimal-by-default option: hide both combos and reveal them only when a small "settings" toggle in the header is clicked — so the resting UI is just record + transcript. Skip if you'd rather keep them always visible.
|
||||
|
||||
## 2.10 Transcript field (flat panel + placeholder)
|
||||
|
||||
Keep the native multiline EDIT but make it flat: no client edge, surface background, light text, inner padding. Draw a rounded surface panel behind it in `WM_PAINT`; inset the EDIT a few px inside that panel so the rounded corners show.
|
||||
|
||||
Create it without `WS_EX_CLIENTEDGE` (flat) and give it inner margins:
|
||||
|
||||
```cpp
|
||||
HWND hEdit = CreateWindowExW(0, L"EDIT", L"",
|
||||
WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY,
|
||||
0,0,0,0, hwnd, (HMENU)ID_EDIT_TEXT, hInst, nullptr);
|
||||
SendMessage(hEdit, WM_SETFONT, (WPARAM)g_fText, TRUE);
|
||||
SendMessage(hEdit, EM_SETMARGINS, EC_LEFTMARGIN|EC_RIGHTMARGIN, MAKELONG(10,10));
|
||||
```
|
||||
|
||||
Theme it (you already have `WM_CTLCOLOREDIT` — point it at the surface):
|
||||
|
||||
```cpp
|
||||
case WM_CTLCOLOREDIT: {
|
||||
HDC hdc=(HDC)wParam; SetTextColor(hdc, CR_TEXT); SetBkColor(hdc, CR_SURFACE);
|
||||
return (LRESULT)g_brSurface;
|
||||
}
|
||||
```
|
||||
|
||||
Placeholder via a STATIC shown only when empty (`ID_STATIC_PLACEHOLDER`), created after the edit so it sits on top:
|
||||
|
||||
```cpp
|
||||
CreateWindowW(L"STATIC", L"Your transcription will appear here…",
|
||||
WS_CHILD | WS_VISIBLE | SS_LEFT, 0,0,0,0, hwnd,
|
||||
(HMENU)ID_STATIC_PLACEHOLDER, hInst, nullptr);
|
||||
// font g_fText; color via WM_CTLCOLORSTATIC -> C_TEXTDIM on C_SURFACE
|
||||
// toggle it:
|
||||
void UpdatePlaceholder(HWND hwnd) {
|
||||
bool empty = GetWindowTextLengthW(GetDlgItem(hwnd, ID_EDIT_TEXT)) == 0;
|
||||
ShowWindow(GetDlgItem(hwnd, ID_STATIC_PLACEHOLDER), empty ? SW_SHOW : SW_HIDE);
|
||||
}
|
||||
// call after setting/clearing transcript text (WM_APP_RESULT, Clear).
|
||||
```
|
||||
|
||||
## 2.11 Custom VU meter
|
||||
|
||||
Drop the `PROGRESS_CLASS` control; draw a row of rounded segments in `WM_PAINT` that light up green with the input level. Store its rect in a global from `LayoutControls`.
|
||||
|
||||
```cpp
|
||||
RECT g_vuRect = {0,0,0,0};
|
||||
float g_energy = 0.0f; // updated in WM_TIMER from g_tx.get_audio_energy()
|
||||
|
||||
void DrawVU(Graphics& g, const RECT& r, float level) {
|
||||
const int N = 14, gap = 3;
|
||||
int w = (r.right-r.left); int segW = (w - gap*(N-1)) / N;
|
||||
int h = r.bottom - r.top;
|
||||
int lit = (int)(level * N + 0.5f);
|
||||
for (int i = 0; i < N; ++i) {
|
||||
int x = r.left + i*(segW+gap);
|
||||
Rect seg(x, r.top, segW, h);
|
||||
Color c = (i < lit) ? C_GOOD : C_SURFACEHI;
|
||||
FillRound(g, c, seg, 2);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `WM_TIMER`, refresh the level and invalidate just the VU rect:
|
||||
|
||||
```cpp
|
||||
g_energy = g_tx.is_recording() ? g_tx.get_audio_energy() : 0.0f;
|
||||
InvalidateRect(hWnd, &g_vuRect, FALSE);
|
||||
```
|
||||
|
||||
## 2.12 Responsive layout (final)
|
||||
|
||||
This is the single source of truth for geometry — referenced by §1.3.
|
||||
|
||||
```cpp
|
||||
void LayoutControls(HWND h, int W, int H) {
|
||||
const int M = 16, row = 36, gap = 10;
|
||||
int x = M, y = M, innerW = W - 2*M;
|
||||
|
||||
// Row 1: Record (left, prominent) + Pin (right)
|
||||
int pinW = 78, recW = innerW - pinW - gap;
|
||||
MoveWindow(GetDlgItem(h, ID_BTN_RECORD), x, y, recW, row, TRUE);
|
||||
MoveWindow(GetDlgItem(h, ID_BTN_PIN), x + recW + gap, y, pinW, row, TRUE);
|
||||
|
||||
// Row 2: VU meter (custom-painted) — reserve a slim strip
|
||||
y += row + gap;
|
||||
g_vuRect = { x, y, x + innerW, y + 8 };
|
||||
|
||||
// Row 3: status line
|
||||
y += 8 + gap;
|
||||
MoveWindow(GetDlgItem(h, ID_STATIC_STATUS), x, y, innerW, 18, TRUE);
|
||||
|
||||
// Row 4: transcript panel (hero) — fills the middle
|
||||
y += 18 + gap;
|
||||
int bottom = (row + gap) * 2; // selectors row + copy/paste row
|
||||
int panelTop = y;
|
||||
int textH = H - y - M - bottom;
|
||||
if (textH < 70) textH = 70;
|
||||
// EDIT inset 10px inside the rounded panel drawn in WM_PAINT
|
||||
MoveWindow(GetDlgItem(h, ID_EDIT_TEXT), x+10, panelTop+10, innerW-20, textH-20, TRUE);
|
||||
MoveWindow(GetDlgItem(h, ID_STATIC_PLACEHOLDER), x+16, panelTop+16, innerW-32, 22, TRUE);
|
||||
|
||||
// Row 5: mic + model selectors
|
||||
y += textH + gap;
|
||||
int halfW = (innerW - gap) / 2;
|
||||
MoveWindow(GetDlgItem(h, ID_COMBO_AUDIO), x, y, halfW, row, TRUE);
|
||||
MoveWindow(GetDlgItem(h, ID_COMBO_MODEL), x + halfW + gap, y, halfW, row, TRUE);
|
||||
|
||||
// Row 6: Copy + Paste
|
||||
y += row + gap;
|
||||
MoveWindow(GetDlgItem(h, ID_BTN_COPY), x, y, halfW, row, TRUE);
|
||||
MoveWindow(GetDlgItem(h, ID_BTN_PASTE), x + halfW + gap, y, halfW, row, TRUE);
|
||||
|
||||
// remember the transcript panel rect for WM_PAINT
|
||||
extern RECT g_panelRect; g_panelRect = { x, panelTop, x+innerW, panelTop+textH };
|
||||
InvalidateRect(h, nullptr, FALSE);
|
||||
}
|
||||
```
|
||||
|
||||
## 2.13 Putting it together (paint + flicker-free)
|
||||
|
||||
Flicker-free background and the painted bits (canvas, transcript panel frame, VU):
|
||||
|
||||
```cpp
|
||||
RECT g_panelRect = {0,0,0,0};
|
||||
|
||||
case WM_ERASEBKGND: return 1; // we paint everything in WM_PAINT
|
||||
|
||||
case WM_PAINT: {
|
||||
PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps);
|
||||
RECT rc; GetClientRect(hWnd, &rc);
|
||||
// double buffer
|
||||
HDC mem = CreateCompatibleDC(hdc);
|
||||
HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom);
|
||||
HBITMAP old = (HBITMAP)SelectObject(mem, bmp);
|
||||
{
|
||||
Graphics g(mem); g.SetSmoothingMode(SmoothingModeAntiAlias);
|
||||
SolidBrush bg(C_BG); g.FillRectangle(&bg, 0,0,rc.right,rc.bottom);
|
||||
// transcript panel
|
||||
Rect panel(g_panelRect.left, g_panelRect.top,
|
||||
g_panelRect.right-g_panelRect.left, g_panelRect.bottom-g_panelRect.top);
|
||||
FillRound(g, C_SURFACE, panel, 14);
|
||||
StrokeRound(g, C_BORDER, panel, 14, 1.0f);
|
||||
// VU
|
||||
DrawVU(g, g_vuRect, g_energy);
|
||||
}
|
||||
BitBlt(hdc, 0,0, rc.right, rc.bottom, mem, 0,0, SRCCOPY);
|
||||
SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem);
|
||||
EndPaint(hWnd, &ps);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
> Child controls (EDIT, combos, buttons) paint themselves on top of this — they're opaque where they sit, so the panel fill shows only in its 10px inset border. Because `WM_ERASEBKGND` returns 1 and we double-buffer, there's no flicker on resize.
|
||||
|
||||
`WM_CTLCOLORSTATIC` should return the bg brush for the status line and the surface brush for the placeholder:
|
||||
|
||||
```cpp
|
||||
case WM_CTLCOLORSTATIC: {
|
||||
HDC hdc=(HDC)wParam;
|
||||
if (GetDlgCtrlID((HWND)lParam) == ID_STATIC_PLACEHOLDER) {
|
||||
SetTextColor(hdc, RGB(0x9A,0xA0,0xAB)); SetBkColor(hdc, CR_SURFACE);
|
||||
return (LRESULT)g_brSurface;
|
||||
}
|
||||
SetTextColor(hdc, CR_TEXT); SetBkColor(hdc, CR_BG);
|
||||
return (LRESULT)g_brBg;
|
||||
}
|
||||
```
|
||||
|
||||
Define `#define ID_STATIC_PLACEHOLDER 1014` with your other IDs, and remember to call `UpdatePlaceholder(hWnd)` after `WM_APP_RESULT` sets text and after Clear.
|
||||
|
||||
---
|
||||
|
||||
# Part 3 — Build & checklist
|
||||
|
||||
- **Link GDI+:** the `#pragma comment(lib,"gdiplus.lib")` covers MSVC; no CMake change needed. (`comctl32` for `SetWindowSubclass` is already linked.)
|
||||
- **Build:** `cmake --build build --config Release` as before.
|
||||
- **Verify, in order:**
|
||||
- [ ] Launch → window has rounded corners + dark title bar, no gray 3-D buttons.
|
||||
- [ ] Record pill fills accent; hover lightens it; recording turns it red and it pulses; VU segments track your voice.
|
||||
- [ ] Stop → transcript appears in the flat panel; placeholder hides; "Copied/Pasted".
|
||||
- [ ] **No crash on Stop.** If you see "Model not found", fix the path (§1.2).
|
||||
- [ ] Drag the window edges → it resizes and the transcript grows; min size respected.
|
||||
- [ ] Switch model in the dropdown → status shows "Loading…" then "Ready • 2 threads"; next transcription uses it.
|
||||
- **OS notes:** caption color / rounded corners need Windows 11 (build 22000+). On Windows 10 you still get dark mode (immersive) but square corners and the default caption color — everything else is identical since it's all custom-drawn.
|
||||
|
||||
---
|
||||
|
||||
# Appendix — Option B: fully borderless custom title bar (advanced)
|
||||
|
||||
If you want **zero** system chrome (no native title bar at all), use the "extend client over the caption" technique instead of Option A's recoloring. Keep `WS_OVERLAPPEDWINDOW` (so resize/snap/shadow/rounding stay native) and reclaim the caption area:
|
||||
|
||||
```cpp
|
||||
case WM_NCCALCSIZE:
|
||||
if (wParam) {
|
||||
NCCALCSIZE_PARAMS* p = (NCCALCSIZE_PARAMS*)lParam;
|
||||
int top = p->rgrc[0].top;
|
||||
LRESULT r = DefWindowProc(hWnd, message, wParam, lParam); // default frame calc
|
||||
p->rgrc[0].top = top; // give the caption height back to the client area
|
||||
return r;
|
||||
}
|
||||
break;
|
||||
|
||||
case WM_NCHITTEST: {
|
||||
LRESULT ht = DefWindowProc(hWnd, message, wParam, lParam); // handles resize edges
|
||||
if (ht != HTCLIENT) return ht;
|
||||
POINT pt{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
|
||||
ScreenToClient(hWnd, &pt);
|
||||
const int HEADER = 40;
|
||||
if (pt.y < HEADER) {
|
||||
// exclude your custom min/close/pin hit-rects here, else:
|
||||
return HTCAPTION; // drag region
|
||||
}
|
||||
return HTCLIENT;
|
||||
}
|
||||
```
|
||||
|
||||
Then draw your own header (title text on the left, custom min/close buttons on the right) in `WM_PAINT`, and shift `LayoutControls`' starting `y` down by `HEADER`. This is more finicky across DPI/OS — only take it on if Option A's dark caption isn't minimal enough for you.
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user