diff --git a/README.md b/README.md index 88bbde6..9e5a66d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ and labels are tested on Windows with an NVIDIA GPU). The `.mcap` files come from the **Import** window, which takes any folder of images (PNG, JPEG, HEIC, AVIF, WebP, GIF, BMP, TIFF, camera RAW), videos (MP4, MOV, MKV, WebM, AVI, WMV, MPEG) and Snagit `.snagx` captures, or from `snagit-converter` -(`D:\McapIndex\SnagitConverter`); `modules/Capture_File/README.md` describes the format. +(`D:\McapIndex\SnagitConverter`); `modules/Capture_File/README.md` describes the format. On +Windows, **PrintScreen** captures a region of the screen straight into the library, as Snagit does; +see [Screen capture](#screen-capture). ## Getting started @@ -148,6 +150,32 @@ counted (click one to search for it), and the commonest words (click one to sear of them are segmented, it also shows **What is in the images**: how many images hold each class (person, sky, dog ...). Click one to search `has:` for it. +## Screen capture + +On Windows, **PrintScreen** captures any part of the screen, from any app, even while mcap is hidden +in the tray: + +1. **The screens freeze** the moment it is pressed: everything on every screen is copied and shown + darkened over them, so menus, tooltips and video hold still while you choose. +2. **Drag a rectangle** over what you want. It shows at full brightness, outlined. A magnifier beside + the cursor shows the pixels under it, with the cursor's position, or the selection's size while + dragging. The arrow keys move the cursor a pixel at a time, ten with Shift. +3. **Let go** to take it. A **click** without dragging, or **Enter**, takes the whole screen the + cursor is on. **Escape**, or a right click, cancels; a right click while dragging starts the + selection again. + +What is taken is on the clipboard at once, ready to paste. It is also saved into the library, in +the folder **Screen Captures**, as a PNG in an `.mcap` with the window it was taken from (the window +under the middle of the selection): its title as the window title and its program as the app, so +searches find it as they find Snagit's. mcap then comes to the front showing it in the Capture +window, and OCR and labels read it once they are free (and segmentation, if the folder is set to +segment). + +The key is the `capture_region` line of `[global_hotkeys]` in the settings file. Only one program can +have PrintScreen: if Snagit is running it has it, and so does Windows while Settings > +Accessibility > Keyboard > "Use the Print screen key to open screen capture" is on. When mcap cannot +register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S capture_region`. + ## Importing **Import** (above the grid) brings a folder of screenshots, photos and videos into the library: diff --git a/assets/data/default_global_hotkeys.config b/assets/data/default_global_hotkeys.config index 551f8b1..da33f91 100644 --- a/assets/data/default_global_hotkeys.config +++ b/assets/data/default_global_hotkeys.config @@ -1,8 +1,14 @@ [global_hotkeys] # Registered with the system, so they work from any application, even while the app is hidden in the tray. # The format is that of [hotkeys], including "none" to turn an action off; keys are letters, -# digits, Space, F1-F24, Enter, Tab, Escape, the arrows, PageUp, PageDown, Home, End, Insert and -# Delete. For example: Ctrl-Alt-Space bring_to_foreground +# digits, Space, F1-F24, Enter, Tab, Escape, the arrows, PageUp, PageDown, Home, End, Insert, +# Delete and PrintScreen. For example: Ctrl-Alt-Space bring_to_foreground # Changes apply as soon as the file is saved. none bring_to_foreground + +# Freezes every screen to drag a region over and capture it, into the clipboard and the library. +# PrintScreen is Snagit's key too: only one program can have it, so quit Snagit or choose another +# key, such as Ctrl-Shift-S. Windows takes it as well while Settings > Accessibility > Keyboard > +# "Use the Print screen key to open screen capture" is on. +PrintScreen capture_region diff --git a/assets/data/default_global_hotkeys_macos.config b/assets/data/default_global_hotkeys_macos.config index ce2cc34..7c4b25e 100644 --- a/assets/data/default_global_hotkeys_macos.config +++ b/assets/data/default_global_hotkeys_macos.config @@ -5,3 +5,4 @@ # Changes apply as soon as the file is saved. none bring_to_foreground +none capture_region diff --git a/check_targets.jai b/check_targets.jai index 6f612f5..7ba0497 100644 --- a/check_targets.jai +++ b/check_targets.jai @@ -29,7 +29,7 @@ APP_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap"; GIT_BRANCH :: "check"; GIT_HASH :: "-"; -VERSION :: "0.1.0"; +VERSION :: "0.1.1"; BUILD_DATE :: "-"; DEBUG :: true; COMPILE_TESTS :: true; diff --git a/first.jai b/first.jai index 2fd830e..27cccfc 100644 --- a/first.jai +++ b/first.jai @@ -8,7 +8,7 @@ APP_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap"; APP_BUNDLE_IDENTIFIER :: "com.example.mcap"; -VERSION :: "0.1.0"; +VERSION :: "0.1.1"; // Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated // builds never write over the ones you run by hand. build_directory := "bin"; diff --git a/modules/Capture_File/README.md b/modules/Capture_File/README.md index 8d02877..147969c 100644 --- a/modules/Capture_File/README.md +++ b/modules/Capture_File/README.md @@ -6,7 +6,8 @@ then the original and rendered image, then any large chunks. There is no ZIP and it; the images and videos keep their own compression. A capture is a Snagit capture (from a `.snagx`), an imported image (a photo or screenshot in any image -format), or an imported video (the mcap app's Import window). +format), an imported video (the mcap app's Import window), or a screen capture (the mcap app's +PrintScreen). The parts a library browser needs (header, text, OCR, front chunks and preview) come first, so it reads the start of each file and never touches the images; a search, which needs no preview, reads less still. @@ -94,6 +95,29 @@ Orientation: 6 and, when the EXIF has them: Camer So the parts a library reads from Snagit's `metadata.json` (date, title, app) work for photos too. +## Screen captures + +A region of the screen taken with the mcap app's PrintScreen is stored as a PNG original with no +rendered image (kind 0), and its Capture Text holds two files: + +``` +[[file "metadata.json"]] +AppName: "Google Chrome" the program of the window under the region's middle: + its description, else its file's name; "" when unknown +CaptureDate: "2026-10-10 14:05:09" when it was taken, local time, as Snagit writes it +WindowName: "Assign Footprints - KiCad" that window's title + +[[file "capture.json"]] +Region: "selection" or "screen": a click took the whole screen +Left: -1920 on the virtual desktop, whose origin is the primary +Top: 40 screen's top left, so it can be negative +Width: 700 +Height: 300 +AppPath: "C:/Program Files/Google/Chrome/Application/chrome.exe" +ContentHash: "5c1e9a0d77b2f413" 64-bit FNV-1a of the PNG's bytes +Format: "PNG" +``` + ## Imported videos A video imported from a folder (MP4, MOV, MKV, WebM, AVI, WMV, MPEG; read with FFmpeg, on Windows) is diff --git a/src/App.jai b/src/App.jai index 53c2626..848e8c7 100644 --- a/src/App.jai +++ b/src/App.jai @@ -27,6 +27,8 @@ USING_WINDOWS :: OS == .WINDOWS; #load "app/segment_ui.jai"; #load "app/library_folders.jai"; #load "app/exit_shutdown.jai"; +#load "app/screen_capture.jai"; +#if OS == .WINDOWS #load "app/screen_capture_win32.jai"; #load "base/circle_buffer.jai"; #load "base/debug.jai"; @@ -182,6 +184,7 @@ App :: struct { show_style_editor: bool; minimize_to_tray_this_frame: bool; restore_window_this_frame: bool; + capture_region_this_frame: bool; // the capture_region global hotkey fired: app/screen_capture.jai is_master: bool; // Are we the first instance of this app launched? allow_multiple_instances: bool; // Skips the single-instance check, so a dev build can run beside an installed one. should_exit_early: bool; diff --git a/src/app/app_ui.jai b/src/app/app_ui.jai index ce76b83..a6405ba 100644 --- a/src/app/app_ui.jai +++ b/src/app/app_ui.jai @@ -15,12 +15,15 @@ app_ui_startup :: () { // Every frame, before input is handed to ImGui. Poll background work here. app_ui_frame_start :: () { + screen_capture_frame(); // also while hidden in the tray, where the frame ends before the UI } // Once, on the shutdown thread after the window is asked to close (exit_shutdown.jai): save and // stop background work here. Must not touch ImGui, since the main thread is still drawing frames // (the overlay; app_main_window_ui stops running the app's frames once shutdown starts). app_ui_shutdown :: () { + app_shutdown_step(.CAPTURE); + screen_capture_shutdown(); app_shutdown_step(.IMPORT); import_shutdown(); // before the viewer: it hands the viewer what it writes app_shutdown_step(.VIEWER); @@ -36,7 +39,7 @@ app_ui_shutdown :: () { } // Which step of app_ui_shutdown is running, and since when, for the overlay a slow shutdown shows. -App_Shutdown_Step :: enum s32 { NONE; IMPORT; VIEWER; OCR; SEGMENT; LABELS; STATS; } +App_Shutdown_Step :: enum s32 { NONE; CAPTURE; IMPORT; VIEWER; OCR; SEGMENT; LABELS; STATS; } app_shutdown: struct { step: App_Shutdown_Step; step_started_ns: s64; // monotonic @@ -54,6 +57,7 @@ app_ui_shutdown_status :: () -> string { text: string; if step == { case .NONE; return ""; + case .CAPTURE; text = "Saving a screen capture."; case .IMPORT; if importer.phase == .INDEXING text = "Stopping the import: it is indexing the library, and stops after the files it is on."; else if importer.running text = "Stopping the import: finishing the files it is on (a large video takes a while to copy)."; diff --git a/src/app/main.jai b/src/app/main.jai index 9108d3d..328b1e9 100644 --- a/src/app/main.jai +++ b/src/app/main.jai @@ -466,6 +466,13 @@ app_frame :: (in_live_resize := false) -> bool { platform_restore_window(); } + // Not from a live-resize frame: choosing the region runs a modal loop of its own. + if app.capture_region_this_frame && !in_live_resize { + app.capture_region_this_frame = false; + screen_capture_take(); + app.capture_region_this_frame = false; // the hotkey pressed again while choosing + } + if app.should_exit { app_exit_shutdown_start(); if app_exit_shutdown_poll() return false; diff --git a/src/app/screen_capture.jai b/src/app/screen_capture.jai new file mode 100644 index 0000000..1a245b8 --- /dev/null +++ b/src/app/screen_capture.jai @@ -0,0 +1,203 @@ +// Screen capture, as Snagit does it: PrintScreen (the capture_region global hotkey, from any app, +// even with mcap hidden in the tray) freezes every screen, and a rectangle dragged over them is taken +// (app/screen_capture_win32.jai; Windows only so far). What is taken goes onto the clipboard at once, +// and into the library as an .mcap in the folder SCREEN_CAPTURE_FOLDER: the pixels as a PNG, with +// the window it was taken from in metadata.json, its title as WindowName and its program as AppName, +// as Snagit records them, so searches find it the same way. mcap then comes to the front showing +// it in the Capture window, and OCR and labels read it once they are free. +// +// The PNG is made and the file written on a thread of its own, as a large capture takes a second or +// so to compress. + +SCREEN_CAPTURE_FOLDER :: "Screen Captures"; // in the library + +Screen_Capture_Job :: struct { + shot: Screen_Shot; + date: string; // CaptureDate; heap + thread: Thread; + busy: bool; // cleared by the thread as it ends + path: string; // what it wrote; heap + error: string; // or why it could not; heap +} + +screen_capture: struct { + jobs: [..] *Screen_Capture_Job; + scan_pending: [..] s32; // captures OCR and labels have yet to read + segment_pending: [..] s32; // and then segmentation, when the folder is set to segment +} + +// From app_frame when the hotkey fired, outside the ImGui frame: lets a region be chosen, copies it, +// and starts writing it into the library. +screen_capture_take :: () { + if app_exit_shutdown_started() return; + date := import_calendar_text(to_calendar(current_time_consensus(), .LOCAL)); + start := current_time_monotonic(); + shot, ok, error := platform_screen_capture_select(); + if !ok { + if error { log_error("[capture] %.", error); toast_show(.Error, "Screen capture: %", error); } + else log("[capture] Cancelled."); + return; + } + log("[capture] % x % at %, % (%) from \"%\" (%), chosen in % s.", shot.width, shot.height, shot.left, shot.top, + ifx shot.whole_screen then "the whole screen" else "a region", shot.window_title, shot.app_name, + FF(to_float64_seconds(current_time_monotonic() - start), 1)); + + if !platform_copy_image_to_clipboard(shot.pixels, shot.width, shot.height) log_error("[capture] Could not put the capture on the clipboard."); + + job := New(Screen_Capture_Job,, context.default_allocator); + job.shot = shot; + job.date = copy_string(date,, context.default_allocator); + job.busy = true; + if !thread_init(*job.thread, screen_capture_save_proc, temporary_storage_size = 1024 * 1024, name = "screen capture") { + log_error("[capture] Could not start the thread to save the capture."); + toast_show(.Error, "The screen capture could not be saved"); + screen_capture_job_free(job); + return; + } + thread_start(*job.thread, job); + screen_capture.jobs.allocator = context.default_allocator; + array_add(*screen_capture.jobs, job); +} + +// Every frame, even while hidden in the tray: the captures written, into the grid and the Capture +// window, and then read. +screen_capture_frame :: () { + if app_exit_shutdown_started() return; + for job: screen_capture.jobs { + if atomic_read(*job.busy) continue; + thread_is_done(*job.thread, -1); + thread_deinit(*job.thread); + if job.error { + log_error("[capture] Could not save the capture: %", job.error); + toast_show(.Error, "The screen capture could not be saved: %", job.error); + } else { + log("[capture] Saved %.", job.path); + index := viewer_add_capture(job.path); + if index >= 0 { + viewer_request_full(index); + screen_capture.scan_pending.allocator = context.default_allocator; + screen_capture.segment_pending.allocator = context.default_allocator; + array_add(*screen_capture.scan_pending, index); + if library_folder_segments(SCREEN_CAPTURE_FOLDER) array_add(*screen_capture.segment_pending, index); + } + app.restore_window_this_frame = true; + toast_show(.Success, "% x % captured and copied to the clipboard", job.shot.width, job.shot.height); + } + screen_capture_job_free(job); + remove job; + } + + // As an import's are, once the models are free: OCR and labels, then segmentation. + if screen_capture.scan_pending.count && !ocr_ui.thread_busy && !ocr_ui.scan.running && ocr_ui.engine && labels_ui_settled() { + ocr_scan_start(screen_capture.scan_pending); + array_reset_keeping_memory(*screen_capture.scan_pending); + } + // Once their catalog entries are in: the scan takes only images, and they say which. + if screen_capture.segment_pending.count && !screen_capture.scan_pending.count && !ocr_ui.thread_busy && !ocr_ui.scan.running { + catalogued := true; + for screen_capture.segment_pending if !viewer.captures[it].catalog && !viewer.captures[it].removed { catalogued = false; break; } + if catalogued { + segment_scan_start(screen_capture.segment_pending, quiet = true); + array_reset_keeping_memory(*screen_capture.segment_pending); + } + } +} + +// On the shutdown thread: a capture being written is finished, not lost. +screen_capture_shutdown :: () { + for screen_capture.jobs thread_is_done(*it.thread, -1); +} + +screen_capture_job_free :: (job: *Screen_Capture_Job) { + push_allocator(context.default_allocator); + screen_shot_free(*job.shot); + free(job.date); + free(job.path); + free(job.error); + free(job); +} + +screen_capture_save_proc :: (thread: *Thread) -> s64 { + job := cast(*Screen_Capture_Job) thread.data; + context.allocator = context.default_allocator; + defer atomic_swap(*job.busy, false); + path, error := screen_capture_write(job.shot, job.date); + if error job.error = copy_string(error); + else job.path = copy_string(path); + return 0; +} + +// Writes the capture into the library: its path, or why it could not be (temp). +screen_capture_write :: (shot: Screen_Shot, date: string) -> path: string, error: string { + image := Image.{pixels = shot.pixels.data, width = shot.width, height = shot.height}; + png, _, _, png_ok, png_error := image_preview_png(image, max(shot.width, shot.height)); // at its own size + if !png_ok return "", png_error; + preview, preview_width, preview_height, preview_ok, preview_error := image_preview_png(image, MCAP_PREVIEW_MAX_SIDE); + if !preview_ok return "", preview_error; + hash := import_hash(png); + + doc := New(Capture_Document,, temp); + capture_document_init(doc, temp); + doc.comment = tprint("Captured by % %.", APP_NAME, VERSION); + metadata := capture_add_file(doc, "metadata.json"); + import_add(doc, metadata, "AppName", import_text(shot.app_name)); + import_add(doc, metadata, "CaptureDate", date); + import_add(doc, metadata, "WindowName", import_text(shot.window_title)); + info := capture_add_file(doc, "capture.json"); + import_add(doc, info, "Region", ifx shot.whole_screen then "screen" else "selection"); + import_add_number(doc, info, "Left", xx shot.left); + import_add_number(doc, info, "Top", xx shot.top); + import_add_number(doc, info, "Width", xx shot.width); + import_add_number(doc, info, "Height", xx shot.height); + if shot.app_path import_add(doc, info, "AppPath", import_text(shot.app_path)); + import_add(doc, info, "ContentHash", import_hash_text(hash)); + import_add(doc, info, "Format", "PNG"); + + name := import_out_name(date, hash); + text, text_ok := import_document_text(doc, name); + if !text_ok return "", "its Capture Text does not read back cleanly (see the Event Log)"; + + m: Mcap; + m.text = text; + m.preview = .{width = cast(u16) preview_width, height = cast(u16) preview_height, png = preview}; + m.original_name = tprint("%.png", path_strip_extension(name)); + m.original = png; + m.rendered_kind = .NONE; + serializer, serialized, serialize_error := mcap_serialize(m,, temp); + if !serialized return "", serialize_error; + + folder := tprint("%/%", viewer_folder(), SCREEN_CAPTURE_FOLDER); + if !make_directory_if_it_does_not_exist(folder, recursive = true) return "", tprint("% could not be made", folder); + path := tprint("%/%", folder, name); + number := 2; + while file_exists(path) { + path = tprint("%/%_%.%", folder, path_strip_extension(name), number, MCAP_EXTENSION); + number += 1; + } + written, write_error := mcap_write(path, cast([] u8) string.{serializer.count, serializer.data}); + if !written return "", write_error; + return path, ""; +} + +#if OS != .WINDOWS { + Screen_Shot :: struct { + pixels: [] u8; + width, height: s32; + left, top: s32; + whole_screen: bool; + window_title, app_name, app_path: string; + } + + screen_shot_free :: (shot: *Screen_Shot) { + array_free(shot.pixels,, context.default_allocator); + shot.* = .{}; + } + + platform_screen_capture_select :: () -> Screen_Shot, ok: bool, error: string { + return .{}, false, "not available on this system yet"; + } + + platform_copy_image_to_clipboard :: (pixels: [] u8, width: s32, height: s32) -> bool { + return false; + } +} diff --git a/src/app/screen_capture_win32.jai b/src/app/screen_capture_win32.jai new file mode 100644 index 0000000..c451c8f --- /dev/null +++ b/src/app/screen_capture_win32.jai @@ -0,0 +1,652 @@ +// The screen capture overlay on Windows (app/screen_capture.jai is the rest). The moment the hotkey +// fires, everything on every screen is copied, and shown frozen and darkened in a window over all of +// them. A rectangle dragged over it shows at full brightness, and letting go takes it; a click takes +// the whole screen the cursor is on. A magnifier beside the cursor shows the pixels under it, with the +// position or the selection's size, so a region can be chosen to the pixel; the arrow keys move the +// cursor a pixel at a time (ten with Shift). Escape or a right click cancels. +// +// Drawn with GDI into a bitmap the size of the desktop, so it needs neither ImGui nor the D3D11 device +// and works while the main window is hidden in the tray. Only the parts that change are drawn again. +// The selection runs in a modal loop of its own: platform_screen_capture_select returns once a region +// is taken or the capture is cancelled. + +// The region taken, and the window it was taken from. +Screen_Shot :: struct { + pixels: [] u8; // RGBA, rows packed; heap + width, height: s32; + left, top: s32; // on the virtual desktop, whose origin is the primary screen's top left + whole_screen: bool; // a click took the screen under the cursor + window_title: string; // the window under the region's center; heap, as are the two below + app_name: string; // its program's description ("Google Chrome"), or its file name + app_path: string; // its program's file +} + +screen_shot_free :: (shot: *Screen_Shot) { + push_allocator(context.default_allocator); + array_free(shot.pixels); + free(shot.window_title); + free(shot.app_name); + free(shot.app_path); + shot.* = .{}; +} + +// Freezes the screens and lets a region be chosen. ok is false when it was cancelled (error "") or +// could not start (error says why). +platform_screen_capture_select :: () -> Screen_Shot, ok: bool, error: string { + shot: Screen_Shot; + fallback_window := GetForegroundWindow(); // before the overlay takes the focus + + if !overlay_freeze() { overlay_release(); return shot, false, "the screen could not be copied"; } + defer overlay_release(); + if !overlay_open() return shot, false, "the capture window could not be opened"; + + overlay.app_context = *context; + while !overlay.done { + msg: MSG; + got := GetMessageW(*msg, null, 0, 0); + if got == -1 { overlay.cancelled = true; break; } + if got == 0 { // WM_QUIT: the app is asked to end; the main loop sees it again + PostQuitMessage(cast(s32) msg.wParam); + overlay.cancelled = true; + break; + } + TranslateMessage(*msg); + DispatchMessageW(*msg); + } + DestroyWindow(overlay.hwnd); + overlay.hwnd = null; + if overlay.cancelled return shot, false, ""; + + r := overlay.result; + shot.width = r.right - r.left; + shot.height = r.bottom - r.top; + if shot.width <= 0 || shot.height <= 0 return shot, false, ""; + shot.left = overlay.left + r.left; + shot.top = overlay.top + r.top; + shot.whole_screen = overlay.whole_screen; + + // The desktop's bitmap is BGRA (its alpha undefined), top-down. + shot.pixels = NewArray(cast(s64) shot.width * shot.height * 4, u8, initialized = false,, context.default_allocator); + out := shot.pixels.data; + for y: r.top..r.bottom - 1 { + row := overlay.screen_pixels + cast(s64) y * overlay.width; + for x: r.left..r.right - 1 { + p := row[x]; + out[0] = cast,trunc(u8) (p >> 16); + out[1] = cast,trunc(u8) (p >> 8); + out[2] = cast,trunc(u8) p; + out[3] = 255; + out += 4; + } + } + + // The window under the region's middle, now that the overlay is gone; else the one in front. + center := POINT.{shot.left + shot.width / 2, shot.top + shot.height / 2}; + window := WindowFromPoint(center); + if window window = GetAncestor(window, GA_ROOT); + if !window window = fallback_window; + if window overlay_window_info(*shot, window); + return shot, true, ""; +} + +// Puts the RGBA pixels on the clipboard as a bitmap, which every program pastes. +platform_copy_image_to_clipboard :: (pixels: [] u8, width: s32, height: s32) -> bool { + row_bytes := cast(s64) width * 4; + size := size_of(BITMAPINFOHEADER) + row_bytes * height; + memory := GlobalAlloc(GMEM_MOVEABLE, cast(SIZE_T) size); + if !memory return false; + data := cast(*u8) GlobalLock(memory); + if !data { GlobalFree(memory); return false; } + + header := cast(*BITMAPINFOHEADER) data; + header.* = .{}; + header.biSize = size_of(BITMAPINFOHEADER); + header.biWidth = width; + header.biHeight = height; // bottom-up, as most programs expect of CF_DIB + header.biPlanes = 1; + header.biBitCount = 32; + header.biCompression = BI_RGB; + header.biSizeImage = cast(u32) (row_bytes * height); + out := data + size_of(BITMAPINFOHEADER); + for y: 0..height - 1 { + in := pixels.data + (height - 1 - y) * row_bytes; + for x: 0..width - 1 { + out[0] = in[2]; out[1] = in[1]; out[2] = in[0]; out[3] = 255; + out += 4; in += 4; + } + } + GlobalUnlock(memory); + + if !OpenClipboard(platform.hwnd) { GlobalFree(memory); return false; } + defer CloseClipboard(); + EmptyClipboard(); + if !SetClipboardData(CF_DIB, memory) { GlobalFree(memory); return false; } // the clipboard owns it once set + return true; +} + +#scope_file + +overlay: struct { + hwnd: HWND; + class_registered: bool; + app_context: *#Context; + + left, top: s32; // the virtual desktop's top left + width, height: s32; // and size, in pixels + scale: float; // the DPI scale of the screen the overlay's window counts as on + + screen_dc, dim_dc, back_dc: HDC; // the screens as copied; the same darkened; what is drawn + screen_bitmap, dim_bitmap, back_bitmap: HBITMAP; + screen_old, dim_old, back_old: HGDIOBJ; + screen_pixels: *u32; + font, font_old: HFONT; + accent_brush, black_brush, label_brush, frame_brush: HBRUSH; + + cursor: POINT; // in the overlay's pixels, as everything below + dragging: bool; + anchor: POINT; + drawn: Overlay_Layout; // what is on screen, to draw again where it was + + active: bool; // has had the focus; losing it after cancels + done, cancelled: bool; + result: RECT; + whole_screen: bool; +} + +Overlay_Layout :: struct { + valid: bool; + dragging: bool; + selection: RECT; // the pixels selected, while dragging + cursor: POINT; + magnifier: RECT; // its frame, and the label under it + label: RECT; + text: [48] u16; + text_count: s32; +} + +MAGNIFIER_PIXELS :: 21; // the pixels it shows across: odd, so one is in the middle +MAGNIFIER_ZOOM :: 6; // each shown this many pixels wide, at 100% +MAGNIFIER_OFFSET :: 24; // from the cursor +BORDER :: 2; // the selection's outline, at 100% +CLICK_SLOP :: 3; // a drag smaller than this either way is a click + +ACCENT_COLOR :: 0x0000A5FF; // COLORREF, 0x00BBGGRR: orange +LABEL_COLOR :: 0x00202020; +FRAME_COLOR :: 0x00FFFFFF; +TEXT_COLOR :: 0x00FFFFFF; + +OVERLAY_CLASS :: "mcapScreenCapture"; + +// Copies every screen into a bitmap, and a darkened copy of it. +overlay_freeze :: () -> bool { + overlay.left = GetSystemMetrics(SM_XVIRTUALSCREEN); + overlay.top = GetSystemMetrics(SM_YVIRTUALSCREEN); + overlay.width = GetSystemMetrics(SM_CXVIRTUALSCREEN); + overlay.height = GetSystemMetrics(SM_CYVIRTUALSCREEN); + if overlay.width <= 0 || overlay.height <= 0 return false; + + desktop := GetDC(null); + if !desktop return false; + defer ReleaseDC(null, desktop); + + dim_pixels: *u32; + ok: bool; + ok, overlay.screen_dc, overlay.screen_bitmap, overlay.screen_old, overlay.screen_pixels = overlay_bitmap(desktop); + if !ok return false; + ok, overlay.dim_dc, overlay.dim_bitmap, overlay.dim_old, dim_pixels = overlay_bitmap(desktop); + if !ok return false; + back_pixels: *u32; + ok, overlay.back_dc, overlay.back_bitmap, overlay.back_old, back_pixels = overlay_bitmap(desktop); + if !ok return false; + + // CAPTUREBLT takes layered windows too, such as tooltips and menus with shadows. + if !BitBlt(overlay.screen_dc, 0, 0, overlay.width, overlay.height, desktop, overlay.left, overlay.top, cast(ROP) (cast(u32) ROP.SRCCOPY | CAPTUREBLT)) return false; + + count := cast(s64) overlay.width * overlay.height; + for 0..count - 1 dim_pixels[it] = (overlay.screen_pixels[it] >> 1) & 0x7F7F7F7F; // half as bright + return true; +} + +overlay_bitmap :: (desktop: HDC) -> ok: bool, dc: HDC, bitmap: HBITMAP, old: HGDIOBJ, pixels: *u32 { + info: BITMAPINFO; + info.bmiHeader.biSize = size_of(BITMAPINFOHEADER); + info.bmiHeader.biWidth = overlay.width; + info.bmiHeader.biHeight = -overlay.height; // top-down + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + + dc := CreateCompatibleDC(desktop); + if !dc return false, null, null, null, null; + bits: *void; + bitmap := CreateDIBSection(dc, *info, DIB_RGB_COLORS, *bits, null, 0); + if !bitmap || !bits { DeleteDC(dc); return false, null, null, null, null; } + old := SelectObject(dc, bitmap); + return true, dc, bitmap, old, cast(*u32) bits; +} + +overlay_open :: () -> bool { + instance := GetModuleHandleW(null); + if !overlay.class_registered { + wc: WNDCLASSEXW; + wc.cbSize = size_of(WNDCLASSEXW); + wc.lpfnWndProc = xx overlay_proc; + wc.hInstance = instance; + wc.hCursor = LoadCursorW(null, IDC_CROSS); + wc.lpszClassName = utf8_to_wide(OVERLAY_CLASS,, context.default_allocator); + if RegisterClassExW(*wc) == 0 return false; + overlay.class_registered = true; + } + + overlay.cursor = .{}; + overlay.dragging = false; + overlay.drawn = .{}; + overlay.active = false; + overlay.done = false; + overlay.cancelled = false; + overlay.whole_screen = false; + + // Topmost, and a tool window so it is not in the taskbar or Alt+Tab. + overlay.hwnd = CreateWindowExW(WS_EX_TOPMOST | WS_EX_TOOLWINDOW, utf8_to_wide(OVERLAY_CLASS,, temp), utf8_to_wide("Screen capture",, temp), + WS_POPUP, overlay.left, overlay.top, overlay.width, overlay.height, null, null, instance, null); + if !overlay.hwnd return false; + + dpi := GetDpiForWindow(overlay.hwnd); + overlay.scale = ifx dpi then dpi / 96.0 else 1.0; + font_height := cast(s32) (15 * overlay.scale + 0.5); + overlay.font = CreateFontW(-font_height, 0, 0, 0, 600, 0, 0, 0, 1, 0, 0, CLEARTYPE_QUALITY, 0, utf8_to_wide("Segoe UI",, temp)); + if overlay.font overlay.font_old = SelectObject(overlay.back_dc, overlay.font); + overlay.accent_brush = CreateSolidBrush(ACCENT_COLOR); + overlay.black_brush = CreateSolidBrush(0); + overlay.label_brush = CreateSolidBrush(LABEL_COLOR); + overlay.frame_brush = CreateSolidBrush(FRAME_COLOR); + + cursor: POINT; + GetCursorPos(*cursor); + overlay.cursor = .{cursor.x - overlay.left, cursor.y - overlay.top}; + + ShowWindow(overlay.hwnd, SW_SHOW); + SetForegroundWindow(overlay.hwnd); // allowed: the hotkey made this the app the user last used + overlay_update(); + return true; +} + +overlay_release :: () { + release :: (dc: *HDC, bitmap: *HBITMAP, old: HGDIOBJ) { + if dc.* { + SelectObject(dc.*, old); + DeleteDC(dc.*); + dc.* = null; + } + if bitmap.* { DeleteObject(bitmap.*); bitmap.* = null; } + } + if overlay.font { + if overlay.back_dc SelectObject(overlay.back_dc, overlay.font_old); + DeleteObject(overlay.font); + overlay.font = null; + } + release(*overlay.screen_dc, *overlay.screen_bitmap, overlay.screen_old); + release(*overlay.dim_dc, *overlay.dim_bitmap, overlay.dim_old); + release(*overlay.back_dc, *overlay.back_bitmap, overlay.back_old); + overlay.screen_pixels = null; + for brush: HBRUSH.[overlay.accent_brush, overlay.black_brush, overlay.label_brush, overlay.frame_brush] if brush DeleteObject(brush); + overlay.accent_brush, overlay.black_brush, overlay.label_brush, overlay.frame_brush = null, null, null, null; +} + +overlay_proc :: (hwnd: HWND, message: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT #c_call { + if !overlay.app_context return DefWindowProcW(hwnd, message, wParam, lParam); + push_context overlay.app_context.* { + if message == { + case WM_SETCURSOR; + SetCursor(LoadCursorW(null, IDC_CROSS)); + return 1; + case WM_ERASEBKGND; + return 1; + case WM_DPICHANGED; + return 0; // the window keeps covering the desktop, whatever DPI it counts as at + case WM_PAINT; + overlay_paint(); + return 0; + case WM_ACTIVATE; + if (wParam & 0xFFFF) == WA_INACTIVE { + // Another window came in front: a stuck overlay would hide it. Not once done, as closing + // the overlay sends this too. + if overlay.active && !overlay.done overlay_cancel(); + } else { + overlay.active = true; + } + return 0; + case WM_MOUSEMOVE; + overlay.cursor = overlay_point(lParam); + overlay_update(); + return 0; + case WM_LBUTTONDOWN; + overlay.cursor = overlay_point(lParam); + overlay.anchor = overlay.cursor; + overlay.dragging = true; + SetCapture(hwnd); + overlay_update(); + return 0; + case WM_LBUTTONUP; + if overlay.dragging { + ReleaseCapture(); + overlay.cursor = overlay_point(lParam); + overlay_take(); + } + return 0; + case WM_RBUTTONDOWN; + if overlay.dragging { // let go of the selection, and start again + overlay.dragging = false; + ReleaseCapture(); + overlay_update(); + } else { + overlay_cancel(); + } + return 0; + case WM_KEYDOWN; + overlay_key(wParam); + return 0; + } + return DefWindowProcW(hwnd, message, wParam, lParam); + } +} + +overlay_point :: (lParam: LPARAM) -> POINT { + return .{cast(s32) LOWORD(lParam), cast(s32) HIWORD(lParam)}; // signed: a capture reaches past the edges +} + +overlay_key :: (key: WPARAM) { + step: s32 = ifx GetKeyState(VK_SHIFT) < 0 then cast(s32) 10 else 1; + dx, dy: s32; + if key == { + case VK_ESCAPE; overlay_cancel(); return; + case VK_RETURN; + if overlay.dragging ReleaseCapture(); + overlay_take(); + return; + case VK_LEFT; dx = -step; + case VK_RIGHT; dx = step; + case VK_UP; dy = -step; + case VK_DOWN; dy = step; + case; return; + } + // Moving the cursor sends WM_MOUSEMOVE, which does the rest. + x := clamp(overlay.cursor.x + dx, 0, overlay.width - 1); + y := clamp(overlay.cursor.y + dy, 0, overlay.height - 1); + capture_set_cursor_pos(overlay.left + x, overlay.top + y); +} + +overlay_cancel :: () { + overlay.cancelled = true; + overlay.done = true; +} + +// The selection, or on a click the screen under the cursor. +overlay_take :: () { + selection := overlay_selection(); + if !overlay.dragging || (selection.right - selection.left < CLICK_SLOP && selection.bottom - selection.top < CLICK_SLOP) { + selection = overlay_monitor_rect(overlay.cursor); + overlay.whole_screen = true; + } + overlay.dragging = false; + selection.left = clamp(selection.left, 0, overlay.width); + selection.right = clamp(selection.right, 0, overlay.width); + selection.top = clamp(selection.top, 0, overlay.height); + selection.bottom = clamp(selection.bottom, 0, overlay.height); + overlay.result = selection; + overlay.done = true; +} + +// From the anchor to the cursor, both pixels in it. +overlay_selection :: () -> RECT { + a, c := overlay.anchor, overlay.cursor; + c.x = clamp(c.x, 0, overlay.width - 1); + c.y = clamp(c.y, 0, overlay.height - 1); + return .{min(a.x, c.x), min(a.y, c.y), max(a.x, c.x) + 1, max(a.y, c.y) + 1}; +} + +// The screen a point is on, in the overlay's pixels. +overlay_monitor_rect :: (point: POINT) -> RECT { + monitor := MonitorFromPoint(.{point.x + overlay.left, point.y + overlay.top}, MONITOR_DEFAULTTONEAREST); + info: MONITORINFO; + info.cbSize = size_of(MONITORINFO); + if !monitor || !GetMonitorInfoW(monitor, *info) return .{0, 0, overlay.width, overlay.height}; + r := info.rcMonitor; + return .{r.left - overlay.left, r.top - overlay.top, r.right - overlay.left, r.bottom - overlay.top}; +} + +// Works out what to draw for the cursor and selection now, and has what changed drawn again: where +// each part was, and where it is. +overlay_update :: () { + layout: Overlay_Layout; + layout.valid = true; + layout.dragging = overlay.dragging; + layout.cursor = .{clamp(overlay.cursor.x, 0, overlay.width - 1), clamp(overlay.cursor.y, 0, overlay.height - 1)}; + if layout.dragging layout.selection = overlay_selection(); + + text: string; + if layout.dragging text = tprint("% × %", layout.selection.right - layout.selection.left, layout.selection.bottom - layout.selection.top); + else text = tprint("%, %", layout.cursor.x + overlay.left, layout.cursor.y + overlay.top); + wide := utf8_to_wide(text,, temp); + while wide && wide[layout.text_count] && layout.text_count < layout.text.count layout.text_count += 1; + memcpy(layout.text.data, wide, layout.text_count * size_of(u16)); + + // The magnifier below and right of the cursor, or on the other side where the screen ends. + zoom := overlay_zoom(); + side := MAGNIFIER_PIXELS * zoom + 2; // and its frame + offset := cast(s32) (MAGNIFIER_OFFSET * overlay.scale); + padding := cast(s32) (4 * overlay.scale); + extent: SIZE; + GetTextExtentPoint32W(overlay.back_dc, layout.text.data, layout.text_count, *extent); + label_height := extent.cy + 2 * padding; + screen := overlay_monitor_rect(layout.cursor); + x := layout.cursor.x + offset; + y := layout.cursor.y + offset; + if x + side > screen.right x = layout.cursor.x - offset - side; + if y + side + label_height > screen.bottom y = layout.cursor.y - offset - side - label_height; + layout.magnifier = .{x, y, x + side, y + side}; + label_width := max(side, extent.cx + 2 * padding); + layout.label = .{x, y + side, x + label_width, y + side + label_height}; + + previous := overlay.drawn; + overlay.drawn = layout; + if previous.valid overlay_invalidate(previous); + overlay_invalidate(layout); +} + +overlay_zoom :: () -> s32 { + return max(cast(s32) (MAGNIFIER_ZOOM * overlay.scale + 0.5), 2); +} + +overlay_invalidate :: (layout: Overlay_Layout) { + invalidate :: (r: RECT) { + rect := r; + InvalidateRect(overlay.hwnd, *rect, .FALSE); + } + border := overlay_border(); + if layout.dragging { + s := layout.selection; + invalidate(.{s.left - border, s.top - border, s.right + border, s.bottom + border}); + } else { + invalidate(.{0, layout.cursor.y, overlay.width, layout.cursor.y + 1}); + invalidate(.{layout.cursor.x, 0, layout.cursor.x + 1, overlay.height}); + } + invalidate(layout.magnifier); + invalidate(layout.label); +} + +overlay_border :: () -> s32 { + return max(cast(s32) (BORDER * overlay.scale + 0.5), 1); +} + +// Draws what has changed into the back bitmap, clipped to it, and puts it on screen. +overlay_paint :: () { + region := CreateRectRgn(0, 0, 0, 0); + if region GetUpdateRgn(overlay.hwnd, region, .FALSE); + paint: PAINTSTRUCT; + dc := BeginPaint(overlay.hwnd, *paint); + if region SelectClipRgn(overlay.back_dc, region); + overlay_draw(overlay.back_dc, overlay.drawn); + r := paint.rcPaint; + BitBlt(dc, r.left, r.top, r.right - r.left, r.bottom - r.top, overlay.back_dc, r.left, r.top, .SRCCOPY); + SelectClipRgn(overlay.back_dc, null); + if region DeleteObject(region); + EndPaint(overlay.hwnd, *paint); +} + +overlay_draw :: (dc: HDC, layout: Overlay_Layout) { + fill :: (r: RECT, brush: HBRUSH) #expand { + rect := r; + FillRect(`dc, *rect, brush); + } + BitBlt(dc, 0, 0, overlay.width, overlay.height, overlay.dim_dc, 0, 0, .SRCCOPY); + if !layout.valid return; + + border := overlay_border(); + if layout.dragging { + s := layout.selection; + BitBlt(dc, s.left, s.top, s.right - s.left, s.bottom - s.top, overlay.screen_dc, s.left, s.top, .SRCCOPY); + // The outline just outside it, so every pixel taken shows as it is. + fill(.{s.left - border, s.top - border, s.right + border, s.top}, overlay.accent_brush); + fill(.{s.left - border, s.bottom, s.right + border, s.bottom + border}, overlay.accent_brush); + fill(.{s.left - border, s.top, s.left, s.bottom}, overlay.accent_brush); + fill(.{s.right, s.top, s.right + border, s.bottom}, overlay.accent_brush); + } else { + fill(.{0, layout.cursor.y, overlay.width, layout.cursor.y + 1}, overlay.accent_brush); + fill(.{layout.cursor.x, 0, layout.cursor.x + 1, overlay.height}, overlay.accent_brush); + } + + // The magnifier: the pixels around the cursor, as copied, each zoom pixels wide. Past the + // desktop's edges it is black. + m := layout.magnifier; + fill(m, overlay.frame_brush); + inner := RECT.{m.left + 1, m.top + 1, m.right - 1, m.bottom - 1}; + fill(inner, overlay.black_brush); + zoom := overlay_zoom(); + half :: MAGNIFIER_PIXELS / 2; + source := RECT.{layout.cursor.x - half, layout.cursor.y - half, layout.cursor.x + half + 1, layout.cursor.y + half + 1}; + shown := RECT.{max(source.left, 0), max(source.top, 0), min(source.right, overlay.width), min(source.bottom, overlay.height)}; + if shown.right > shown.left && shown.bottom > shown.top { + SetStretchBltMode(dc, .COLORONCOLOR); + StretchBlt(dc, inner.left + (shown.left - source.left) * zoom, inner.top + (shown.top - source.top) * zoom, + (shown.right - shown.left) * zoom, (shown.bottom - shown.top) * zoom, + overlay.screen_dc, shown.left, shown.top, shown.right - shown.left, shown.bottom - shown.top, .SRCCOPY); + } + // The pixel under the cursor, outlined. + c := RECT.{inner.left + half * zoom, inner.top + half * zoom, inner.left + (half + 1) * zoom, inner.top + (half + 1) * zoom}; + fill(.{c.left - 1, c.top - 1, c.right + 1, c.top}, overlay.accent_brush); + fill(.{c.left - 1, c.bottom, c.right + 1, c.bottom + 1}, overlay.accent_brush); + fill(.{c.left - 1, c.top, c.left, c.bottom}, overlay.accent_brush); + fill(.{c.right, c.top, c.right + 1, c.bottom}, overlay.accent_brush); + + // Under it, the position, or the selection's size. + fill(layout.label, overlay.label_brush); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, TEXT_COLOR); + text := layout.text; + padding := cast(s32) (4 * overlay.scale); + TextOutW(dc, layout.label.left + padding, layout.label.top + padding, text.data, layout.text_count); +} + +// The window's title and its program. +overlay_window_info :: (shot: *Screen_Shot, window: HWND) { + push_allocator(context.default_allocator); + title: [1024] u16; + count := GetWindowTextW(window, title.data, title.count); + if count > 0 { + text, ok := wide_to_utf8(title.data, count,, temp); + if ok shot.window_title = copy_string(text); + } + + process_id: u32; + GetWindowThreadProcessId(window, *process_id); + if !process_id return; + process := OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, .FALSE, process_id); + if !process return; + defer CloseHandle(process); + path: [1024] u16; + length: u32 = path.count; + if !QueryFullProcessImageNameW(process, 0, path.data, *length) return; + text, ok := wide_to_utf8(path.data, cast(s32) length,, temp); + if !ok return; + shot.app_path = copy_string(text); + description := overlay_file_description(path.data); + shot.app_name = copy_string(ifx description then description else path_strip_extension(path_filename(text))); +} + +// A program's description, as Task Manager shows it: "Google Chrome" for chrome.exe. "" when it has none. Temp. +overlay_file_description :: (path: *u16) -> string { + handle: u32; + size := GetFileVersionInfoSizeW(path, *handle); + if !size return ""; + data := NewArray(size, u8, initialized = false,, temp); + if !GetFileVersionInfoW(path, 0, size, data.data) return ""; + + language := "040904b0"; // US English, Unicode: when it does not say + translation: *u16; + length: u32; + if VerQueryValueW(data.data, utf8_to_wide("\\VarFileInfo\\Translation",, temp), xx *translation, *length) != .FALSE && length >= 4 { + language = tprint("%1%2", formatInt(translation[0], base = 16, minimum_digits = 4), formatInt(translation[1], base = 16, minimum_digits = 4)); + } + value: *u16; + key := utf8_to_wide(tprint("\\StringFileInfo\\%\\FileDescription", language),, temp); + if !VerQueryValueW(data.data, key, xx *value, *length) || length <= 1 return ""; + text, ok := wide_to_utf8(value, cast(s32) (length - 1),, temp); // length counts the terminator + return ifx ok then trim(text) else ""; +} + +// ---------------------------------------------------------------- bindings + +gdi32 :: #library,system "gdi32"; +version_dll :: #library,system "version"; + +HFONT :: HANDLE; +HRGN :: HANDLE; + +PAINTSTRUCT :: struct { + hdc: HDC; + fErase: BOOL; + rcPaint: RECT; + fRestore: BOOL; + fIncUpdate: BOOL; + rgbReserved: [32] u8; +} + +CAPTUREBLT :: 0x40000000; +GA_ROOT :: 2; +WA_INACTIVE :: 0; +WM_ACTIVATE :: 0x0006; +TRANSPARENT :: 1; +CLEARTYPE_QUALITY :: 5; +PROCESS_QUERY_LIMITED_INFORMATION :: 0x1000; + +GetMessageW :: (msg: *MSG, hwnd: HWND, filter_min: u32, filter_max: u32) -> s32 #foreign user32; +BeginPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> HDC #foreign user32; +EndPaint :: (hwnd: HWND, paint: *PAINTSTRUCT) -> BOOL #foreign user32; +FillRect :: (dc: HDC, rect: *RECT, brush: HBRUSH) -> s32 #foreign user32; +GetUpdateRgn :: (hwnd: HWND, region: HRGN, erase: BOOL) -> s32 #foreign user32; +GetDpiForWindow :: (hwnd: HWND) -> u32 #foreign user32; +GetWindowTextW :: (hwnd: HWND, text: *u16, max_count: s32) -> s32 #foreign user32; +GetWindowThreadProcessId :: (hwnd: HWND, process_id: *u32) -> u32 #foreign user32; +WindowFromPoint :: (point: POINT) -> HWND #foreign user32; +GetAncestor :: (hwnd: HWND, flags: u32) -> HWND #foreign user32; +capture_set_cursor_pos :: (x: s32, y: s32) -> BOOL #foreign user32 "SetCursorPos"; + +CreateDIBSection :: (dc: HDC, info: *BITMAPINFO, usage: u32, bits: **void, section: HANDLE, offset: u32) -> HBITMAP #foreign gdi32; +CreateRectRgn :: (left: s32, top: s32, right: s32, bottom: s32) -> HRGN #foreign gdi32; +SelectClipRgn :: (dc: HDC, region: HRGN) -> s32 #foreign gdi32; +SetTextColor :: (dc: HDC, color: u32) -> u32 #foreign gdi32; +SetBkMode :: (dc: HDC, mode: s32) -> s32 #foreign gdi32; +TextOutW :: (dc: HDC, x: s32, y: s32, text: *u16, count: s32) -> BOOL #foreign gdi32; +GetTextExtentPoint32W :: (dc: HDC, text: *u16, count: s32, size: *SIZE) -> BOOL #foreign gdi32; +CreateFontW :: (height: s32, width: s32, escapement: s32, orientation: s32, weight: s32, italic: u32, underline: u32, + strike_out: u32, char_set: u32, out_precision: u32, clip_precision: u32, quality: u32, + pitch_and_family: u32, face: *u16) -> HFONT #foreign gdi32; + +OpenProcess :: (access: u32, inherit: BOOL, process_id: u32) -> HANDLE #foreign kernel32; +QueryFullProcessImageNameW :: (process: HANDLE, flags: u32, name: *u16, size: *u32) -> BOOL #foreign kernel32; +GlobalFree :: (memory: HGLOBAL) -> HGLOBAL #foreign kernel32; + +GetFileVersionInfoSizeW :: (path: *u16, handle: *u32) -> u32 #foreign version_dll; +GetFileVersionInfoW :: (path: *u16, handle: u32, length: u32, data: *void) -> BOOL #foreign version_dll; +VerQueryValueW :: (block: *void, sub_block: *u16, buffer: **void, length: *u32) -> BOOL #foreign version_dll; diff --git a/src/base/platform/platform_windows.jai b/src/base/platform/platform_windows.jai index 187f560..686bec9 100644 --- a/src/base/platform/platform_windows.jai +++ b/src/base/platform/platform_windows.jai @@ -298,6 +298,7 @@ platform_apply_global_hotkeys :: (actions: [] Hotkey_Action) { using platform; id := global_hotkey_id(action_index, binding_index); if !RegisterHotKey(hwnd, id, modifiers, vk) { log_error("[hotkeys] Could not register the global hotkey % (%); another program may be using it.", to_string(combo), action.name); + if vk == VK_SNAPSHOT log_error("[hotkeys] PrintScreen is taken by Snagit when it runs, and by Windows when Settings > Accessibility > Keyboard > \"Use the Print screen key to open screen capture\" is on."); array_add(*failed, to_string(combo)); continue; } @@ -323,6 +324,7 @@ win32_virtual_key :: (key: ImGui.Key) -> u32 { if k >= cast(s64) ImGui.Key._0 && k <= cast(s64) ImGui.Key._9 return cast(u32) (#char "0" + k - cast(s64) ImGui.Key._0); if k >= cast(s64) ImGui.Key.F1 && k <= cast(s64) ImGui.Key.F24 return cast(u32) (VK_F1 + k - cast(s64) ImGui.Key.F1); if key == { + case .PrintScreen; return VK_SNAPSHOT; case .Space; return VK_SPACE; case .Enter; return VK_RETURN; case .Tab; return VK_TAB; diff --git a/src/test/import_tests.jai b/src/test/import_tests.jai index ff4789c..0afcc79 100644 --- a/src/test/import_tests.jai +++ b/src/test/import_tests.jai @@ -600,6 +600,76 @@ segment_overlay_tests :: () { file_delete(bare); } +// A screen capture written into the library (app/screen_capture.jai): a PNG of its pixels, a preview, +// and the window it came from where search reads it. +screen_capture_tests :: () { + group("screen capture"); + #if OS == .WINDOWS library := tprint("%/mcap-tests/library", windows_expand_environment("%TEMP%")); + else library := "/tmp/mcap-tests/library"; + saved_library := app.library_folder; + app.library_folder = library; + defer app.library_folder = saved_library; + + WIDTH :: 700; + HEIGHT :: 300; + shot: Screen_Shot; + shot.width, shot.height = WIDTH, HEIGHT; + shot.left, shot.top = -1920, 40; // on a screen left of the primary one + shot.pixels = NewArray(WIDTH * HEIGHT * 4, u8,, temp); + for y: 0..HEIGHT - 1 for x: 0..WIDTH - 1 { + p := shot.pixels.data + (y * WIDTH + x) * 4; + p[0], p[1], p[2], p[3] = cast,trunc(u8) x, cast,trunc(u8) y, 200, 255; + } + shot.window_title = "Assign Footprints \x01- KiCad"; // a control character, as titles can hold + shot.app_name = "KiCad"; + shot.app_path = "C:/Program Files/KiCad/bin/kicad.exe"; + + path, error := screen_capture_write(shot, "2026-10-10 14:05:09"); + expect(!error, "written: %", error); + expect(begins_with(path, tprint("%/%/2026-10-10_14-05-09_", library, SCREEN_CAPTURE_FOLDER)), "named by its date, in its folder: %", path); + again, again_error := screen_capture_write(shot, "2026-10-10 14:05:09"); + expect(!again_error && again != path && ends_with(again, "_2.mcap"), "the same again gets a name of its own: %", again); + + m, parsed := mcap_parse(cast([] u8) read_entire_file(path,, temp)); + expect(parsed, "it reads back"); + if parsed { + expect(m.rendered_kind == .NONE && ends_with(m.original_name, ".png"), "the original is a PNG: %", m.original_name); + image, decoded := image_decode(m.original); + expect(decoded && image.width == WIDTH && image.height == HEIGHT, "at its own size"); + if decoded { + expect(memcmp(image.pixels, shot.pixels.data, shot.pixels.count) == 0, "every pixel as it was"); + image_free(*image); + } + expect(max(m.preview.width, m.preview.height) == MCAP_PREVIEW_MAX_SIDE, "a preview: % x %", m.preview.width, m.preview.height); + + text :: (object: Capture_Value, key: string) -> string { + value := capture_object_find(*object, key); + return ifx value && value.kind == .STRING then value.text else ""; + } + number :: (object: Capture_Value, key: string) -> float64 { + value := capture_object_find(*object, key); + return ifx value && value.kind == .NUMBER then value.number else 0; + } + doc: Capture_Document; + capture_document_init(*doc, temp); + expect(capture_text_parse(*doc, path, m.text), "its Capture Text reads"); + metadata := capture_find_file(*doc, "metadata.json"); + capture := capture_find_file(*doc, "capture.json"); + expect(metadata && capture, "metadata.json and capture.json"); + if metadata { + expect(text(metadata.root, "WindowName") == "Assign Footprints - KiCad", "WindowName: %", text(metadata.root, "WindowName")); + expect(text(metadata.root, "AppName") == "KiCad", "AppName"); + expect(text(metadata.root, "CaptureDate") == "2026-10-10 14:05:09", "CaptureDate"); + } + if capture { + expect(number(capture.root, "Left") == -1920 && number(capture.root, "Top") == 40, "where on the desktop"); + expect(text(capture.root, "Region") == "selection", "a selection"); + } + } + file_delete(path); + file_delete(again); +} + // What mcap_chunks_size counts, here where it cannot be seen. mcap_chunks_size_for_test :: (chunks: [] Mcap_Chunk) -> s64 { size: s64 = 4; @@ -623,5 +693,6 @@ run_import_tests :: (builder: *String_Builder = null) -> bool { segment_chunk_tests(); library_folders_tests(); segment_overlay_tests(); + screen_capture_tests(); return report(); } diff --git a/src/ui/imgui_keyboard.jai b/src/ui/imgui_keyboard.jai index e77c7a1..476d76f 100644 --- a/src/ui/imgui_keyboard.jai +++ b/src/ui/imgui_keyboard.jai @@ -308,12 +308,15 @@ hotkeys_missing_lines :: (actions: [] Hotkey_Action, bindings: [] Hotkey_Binding // Registered with the OS, so they fire from any app: RegisterHotKey on Windows, Carbon's // RegisterEventHotKey on macOS, none on Linux. They are never matched against ImGui's key state. bring_to_foreground_hotkey: Hotkey; +capture_region_hotkey: Hotkey; GLOBAL_HOTKEY_BRING_TO_FOREGROUND :: 0; // indices into global_hotkey_actions -GLOBAL_HOTKEY_ACTION_COUNT :: 1; // sizes the platforms' registration arrays +GLOBAL_HOTKEY_CAPTURE_REGION :: 1; +GLOBAL_HOTKEY_ACTION_COUNT :: 2; // sizes the platforms' registration arrays global_hotkey_actions_build :: (out: *[..] Hotkey_Action) { array_add(out, .{"bring_to_foreground", *bring_to_foreground_hotkey, false}); + array_add(out, .{"capture_region", *capture_region_hotkey, true}); // app/screen_capture.jai } global_hotkey_actions: [..] Hotkey_Action; @@ -369,6 +372,7 @@ global_hotkeys_install :: (bindings: [] Hotkey_Binding) { for global_hotkey_actions it.hotkey.binding_count = 0; for bindings { if it.explicit_none continue; + if OS != .WINDOWS && global_hotkey_actions[it.action].windows_only continue; hotkey := global_hotkey_actions[it.action].hotkey; if hotkey.binding_count == MAX_HOTKEY_BINDINGS { log("[settings] % already has % bindings; ignoring %.", global_hotkey_actions[it.action].name, MAX_HOTKEY_BINDINGS, to_string(it.combo)); @@ -403,6 +407,7 @@ global_hotkey_fired :: (id: s64) #c_call { if id < 1 return; action := (id - 1) / MAX_HOTKEY_BINDINGS; if action == GLOBAL_HOTKEY_BRING_TO_FOREGROUND app.restore_window_this_frame = true; + if action == GLOBAL_HOTKEY_CAPTURE_REGION app.capture_region_this_frame = true; } // Parses the lines of section in data, or every line when data has no such header. Returns the