diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index e15fe0d..7c48488 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -157,6 +157,7 @@ jobs: odin check win32/game_of_life -target:windows_amd64 $FLAGS odin check win32/open_window -target:windows_amd64 $FLAGS odin check win32/embedded_manifest -target:windows_amd64 $FLAGS + odin check win32/fentool -target:windows_amd64 $FLAGS odin check nanovg/example.odin -file $FLAGS odin check nanovg/fbo.odin -file $FLAGS diff --git a/win32/fentool/README.md b/win32/fentool/README.md new file mode 100644 index 0000000..e480b8b --- /dev/null +++ b/win32/fentool/README.md @@ -0,0 +1,144 @@ +FEN tool demo project +===================== + +Aims to demonstrate using Win32 embedded resources, in particular dialog templates, +in a somewhat realistic application setting. + +The tool lets the user visualize and configure a chess FEN (Forsyth-Edwards Notation) string. + +This application must be compiled with `-resource:resource.rc`, for example: + +``` +odin build . -vet -strict-style -resource:resource.rc +``` + +This requires the environment being set properly for Windows SDK builds. The easiest way +to get this going is to build from the *Developer Command Prompt for VS*. + +![Screenshot 1](doc/screenshot1.png) + +Naturally, it also implements ginger mode. + +![Screenshot 2](doc/screenshot2.png) + +Here is a rough overview of the topics covered: + + - Spinning up a message loop with a main window loaded from a dialog template. + - Defining resource IDs (`resource.h` + `resource.odin`). + - Instructing the resource compiler (`rc.exe`) to read the file as UTF-8. + - Using a dialog template resource (`DIALOGEX`) containing standard system control, as + well as user-defined controls (`CONTROL`) statement. + - Showing simple modal dialogs from a dialog template resource (`IDD_ABOUT`). + - Storing contextual data in `cbWndExtra` for non-dialogs (`board_control`). + - Storing contextual data in `DWLP_USER` for dialogs. + - Handling default dialog command IDs `IDOK` and `IDCANCEL` for `Esc` and `Enter` support. + - Defining main menu and context menus via the resource script. + - Loading string resources (`STRINGTABLE`) for use in user-facing messages. + - Using an accelerator table (`ACCELERATORS`) to implement keyboard shortcuts. + - Supporting standard keyboard navigation. + - Using various dialog item helper functions for manipulating dialog controls, such + as setting the text or checking/unchecking check boxes and radio buttons. + - Declaring and implementing DPI awareness. + - Buffered painting. + - Playing a .wav sound from a resource. + - Loading 24bpp Bitmaps from a resource and painting it with a transparency key. + - Loading 32bpp PNGs from a resource and painting it with alpha blending (using `png`). + - Setting the main window's icon and the executable's icon. + - Showing menu item help hints in the status bar. + - Localizing resources for different languages. + +Please note that Win32 application programming as demonstrated in this example is +inherently object oriented to some degree. For example, the `board_control` aims to +be a somewhat self-contained control class. + +Notes +----- + +**Resource IDs** + +The resource compiler (`rc.exe`) understands C preprocessor directives. Thus, in a +C program, you would include the same `resource.h` header file from the `.rc` file +as in the main C program. In Odin, the resource ID definitions must be synchronized +explicitly (see `resource.odin`). This is a matter of simple text transformation, +so it can be automated (not done in this project for simplicity). + +Not all resources need to have numeric IDs. For example, `DIALOG[EX]` resource IDs +can be strings. The associated resource loading functions (e.g. `CreateDialog...`) +accept either a pointer to a null-terminated string, or a 16-bit integer. + +This can occasionally be problematic in Odin currently, because the compiler will +not let you cast an arbitrary constant integer value to a `cstring16`. + +**DPI awareness** + +DPI awareness is a large topic. This example relies on Windows already doing most +of the work simply by virtue of using the dialog manager functions rather than +implementing the window contents manually in code. The painting code adjusts for +DPI by where necessary by calling `adjust_for_dpi`. + +Also note in general that the position and size numbers specified in dialog template +resources are "dialog units", not pixels. They can be converted with `MapDialogRect`. + +**Main message loop** + +If you intend to use a dialog template as the application's main window, you have +three options: + + 1. Just fire it up with `DialogBox[Param]W` + 2. Create the entire dialog window with `CreateDialog[Param]W`, but implement the + message loop manually (demonstrated in this example) + 3. Create a separate main window class, and use `CreateDialog[Param]W` only to + create a child control that fills out the main window. + +Option 1 is the easiest. It will require `EndDialog` to be called, and the system +handles the rest. However, this option does not give you control over the message +loop, in case you need to inject something there. That means, you will also not +be able to use an accelerator table. + +Option 3 is the most flexible. The dialog template must have the `WS_CHILD` flag, +and it must not have a `CAPTION`. Proper sizing must be done manually. The application +must also coordinate certain events (like window closing) between the main window +procedure and the dialog window procedure. + +Option 2 is a common compromise. One unfortunate flaw is that you cannot use +`CW_USEDEFAULT` upon window creation to let the system decide a good spot for +the window on the screen. This example works around this by creating an invisible +dummy window on startup just to get a suitable position. Alternatively, `DS_CENTER` +can be used to make it pop up in the center of the screen. + +**Odin context propagation** + +The Odin `context` gets lost when control goes through a callback chain without +the Odin calling convention, such as `WNDPROC` or `DLGPROC`. + + - `runtime.default_context()` would discard anything that the application has + set up in `main()`, such as logging. + - Passing a context to the window through `lpParam`/`CREATESTRUCT` into the + `WM_CREATE` message can work sometimes, but not in general. For example, the + board control in this application is created by the dialog manager functions. + It is not possible to hook into this mechanism in a way that would let the + caller pass in a contextual runtime value to specific children. + - Using a global (or TLS "pseudo-global") is straight forward, but not across + module boundaries. In such a case, the module containing the control would + need to have an explicit `init` call. Additionally, memory allocated using + the context's allocator must be freed with that same allocator. Because the + code that captures the `context` is decoupled from the window or dialog + procedure callback which uses the captured value, this relationship is not + as obvious as it would normally be in Odin. + +This example stores the `context` in TLS. + +**String resource IDs** + +String resources (declared in a `STRINGTABLE`) are grouped into blocks of 16. + +When using `LoadStringW`, the actual internal resource ID is the provided ID, +divided by 16. Then, `LoadStringW` will skip over N strings, where N is the +remainder of the division. + +Normally, this is not a problem, because resource IDs of different resource +types (e.g. `ICON`, `BITMAP`, `DIALOG`, etc.) do not collide with each other. +However, by convention programs use the *same* resource ID for menu items +and their corresponding help text strings. This lets the main window select +the appropriate help text automatically when a menu item is being hovered +over. In this case, it may be necessary to avoid ID conflicts. diff --git a/win32/fentool/about.bmp b/win32/fentool/about.bmp new file mode 100644 index 0000000..b2ffa0b Binary files /dev/null and b/win32/fentool/about.bmp differ diff --git a/win32/fentool/about.odin b/win32/fentool/about.odin new file mode 100644 index 0000000..4219138 --- /dev/null +++ b/win32/fentool/about.odin @@ -0,0 +1,67 @@ +package fen_tool + +import log "core:log" +import win "core:sys/windows" + +about_show_dialog :: proc(owner: win.HWND) { + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + win.DialogBoxW(hInstance, "IDD_ABOUT", owner, about_proc) +} + +about_proc :: proc "system" (hdlg: win.HWND, msg: win.UINT, wparam: win.WPARAM, lparam: win.LPARAM) -> win.INT_PTR { + + switch msg { + + case win.WM_INITDIALOG: + // Instruct the system to use default keyboard focus handling. + return 1 + + case win.WM_COMMAND: + context = window_context_tls + id := win.LOWORD(wparam) + nc := win.HIWORD(wparam) + log.debugf("About WM_COMMAND: %v %v", id, nc) + switch id + { + case win.IDOK: + // Generated by the default dialog proc on Enter. + win.EndDialog(hdlg, 0) + return 1 + case win.IDCANCEL: + // Generated by the default dialog proc when the window is closed ([x] or Esc). + win.EndDialog(hdlg, 0) + return 1 + } + + case win.WM_PAINT: + ps: win.PAINTSTRUCT + hdc := win.BeginPaint(hdlg, &ps) + defer win.EndPaint(hdlg, &ps) + + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + hbitmap := win.LoadImageW(hInstance, "IDB_ABOUT", win.IMAGE_BITMAP, 0, 0, win.LR_CREATEDIBSECTION) + defer win.DeleteObject(auto_cast hbitmap) + + // Retrieve w/h information about the bitmap. + bm: win.BITMAP + win.GetObjectW(hbitmap, size_of(bm), &bm) + bitmap_w := bm.bmWidth + bitmap_h := bm.bmHeight + + hdc_src := win.CreateCompatibleDC(hdc) + defer win.DeleteDC(hdc_src) + previous_bitmap := cast(win.HBITMAP)win.SelectObject(hdc_src, auto_cast hbitmap) + defer win.SelectObject(hdc_src, auto_cast previous_bitmap) + + // Arbitrarily declare the top-left corner pixel as the transparency key. + transparency_key := win.GetPixel(hdc_src, 0, 0) + + win.TransparentBlt( + hdc, 0, 0, bitmap_w, bitmap_h, + hdc_src, 0, 0, bitmap_w, bitmap_h, + transparency_key, + ) + } + + return 0 +} diff --git a/win32/fentool/app.manifest b/win32/fentool/app.manifest new file mode 100644 index 0000000..628b626 --- /dev/null +++ b/win32/fentool/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + true + PerMonitorV2 + + + \ No newline at end of file diff --git a/win32/fentool/billchess.png b/win32/fentool/billchess.png new file mode 100644 index 0000000..53602c4 Binary files /dev/null and b/win32/fentool/billchess.png differ diff --git a/win32/fentool/board.odin b/win32/fentool/board.odin new file mode 100644 index 0000000..ba3eca8 --- /dev/null +++ b/win32/fentool/board.odin @@ -0,0 +1,136 @@ +package fen_tool + +import fmt "core:fmt" +import strings "core:strings" + +Board :: struct { + pieces: Board_Pieces, + turn_player: Board_Piece_Color, + white_can_castle_kingside: bool, + white_can_castle_queenside: bool, + black_can_castle_kingside: bool, + black_can_castle_queenside: bool, + en_passant_target_square: Square_Specifier, + halfmove_clock: i32, + fullmove_number: i32, +} + +Square_Specifier :: struct { + file: i8, + rank: i8, +} + +Board_Piece :: struct { + type: Board_Piece_Type, + color: Board_Piece_Color, +} + +// Rank-major, file-minor. +// Starting position has white rook at index 0, white knight at index 1, and so on. +Board_Pieces :: distinct [64]Board_Piece + +Board_Piece_Type :: enum i8 { + none, + rook, + FIRST = rook, + knight, + bishop, + queen, + king, + pawn, + LAST = pawn, +} + +Board_Piece_Color :: enum i8 { + white, + FIRST = white, + black, + LAST = black, +} + +board_format_full :: proc(board: Board, allocator := context.temp_allocator) -> string { + sb := strings.builder_make_len_cap(0, 1000, allocator) + fmt.sbprintfln(&sb, "%v", board_format_pieces(board, allocator)) + fmt.sbprintfln(&sb, "White can castle kingside: %v", board_format_yesno(board.white_can_castle_kingside)) + fmt.sbprintfln(&sb, "White can castle queenside: %v", board_format_yesno(board.white_can_castle_queenside)) + fmt.sbprintfln(&sb, "Black can castle kingside: %v", board_format_yesno(board.black_can_castle_kingside)) + fmt.sbprintfln(&sb, "Black can castle queenside: %v", board_format_yesno(board.black_can_castle_queenside)) + fmt.sbprintfln(&sb, "En passant target square: %v", board_format_en_passant_target_square(board.en_passant_target_square)) + fmt.sbprintfln(&sb, "Halfmove clock: %v", board.halfmove_clock) + fmt.sbprintfln(&sb, "Fullmove number: %v", board.fullmove_number) + return strings.to_string(sb) +} + +board_format_position :: proc { board_format_position_file_rank, board_format_position_square } + +board_format_position_file_rank :: proc(#any_int file: i32, #any_int rank: i32, allocator := context.temp_allocator) -> string { + file_rune := cast(rune)('A' + file) + rank_rune := cast(rune)('1' + rank) + sb := strings.builder_make_len_cap(0, 4, context.temp_allocator) + strings.write_rune(&sb, file_rune) + strings.write_rune(&sb, rank_rune) + return strings.to_string(sb) +} + +board_format_position_square :: proc(square: Square_Specifier, allocator := context.temp_allocator) -> string { + return board_format_position_file_rank(square.file, square.rank, allocator) +} + +board_format_yesno :: proc(b: bool) -> string { + return b ? "yes" : "no" +} + +board_format_en_passant_target_square :: proc(ts: Square_Specifier, allocator := context.temp_allocator) -> string { + if ts == {} { + return "-" + } + return board_format_position(ts, allocator) +} + +board_format_pieces :: proc(board: Board, allocator := context.temp_allocator) -> string { + // Construct the string like this: + // 8 | rnbqkbnr + // 7 | pppppppp + // 6 | ........ + // 5 | ........ + // 4 | ........ + // 3 | ........ + // 2 | PPPPPPPP + // 1 | RNBQKBNR + // +--------- + // ABCDEFGH + sb := strings.builder_make_len_cap(0, 140, allocator) + for rank := 7; rank >= 0; rank -= 1 { + rank_name := cast(rune)('1' + rank) + fmt.sbprintf(&sb, "%v | ", rank_name) + for file in 0 ..< 8 { + boardwhere := rank * 8 + file + piece := board.pieces[boardwhere] + r := get_piece_rune(piece) + strings.write_rune(&sb, r) + } + fmt.sbprintln(&sb) + } + fmt.sbprintln(&sb, " +---------") + fmt.sbprint(&sb, " ABCDEFGH") + return strings.to_string(sb) +} + +get_piece_rune :: proc(piece: Board_Piece) -> rune { + switch { + case piece.type == .none: return '.' + case piece == { .rook, .white }: return 'R' + case piece == { .knight, .white }: return 'N' + case piece == { .bishop, .white }: return 'B' + case piece == { .queen, .white }: return 'Q' + case piece == { .king, .white }: return 'K' + case piece == { .pawn, .white }: return 'P' + case piece == { .rook, .black }: return 'r' + case piece == { .knight, .black }: return 'n' + case piece == { .bishop, .black }: return 'b' + case piece == { .queen, .black }: return 'q' + case piece == { .king, .black }: return 'k' + case piece == { .pawn, .black }: return 'p' + case: return '?' + } +} diff --git a/win32/fentool/board_control.odin b/win32/fentool/board_control.odin new file mode 100644 index 0000000..f50d388 --- /dev/null +++ b/win32/fentool/board_control.odin @@ -0,0 +1,872 @@ +package fen_tool + +import runtime "base:runtime" +import log "core:log" +import strings "core:strings" +import win "core:sys/windows" + +BOARD_CONTROL_CLASS_NAME :: "board_control" + +// Called on application startup to register the board control window class +// for use in the main dialog template. +board_control_register_class :: proc(instance: win.HINSTANCE) { + wndclass := win.WNDCLASSEXW { + cbSize = size_of(win.WNDCLASSEXW), + style = win.CS_HREDRAW | win.CS_VREDRAW, + lpfnWndProc = board_control_wndproc, + cbWndExtra = size_of(rawptr), + hInstance = instance, + hCursor = win.LoadCursorA(nil, win.IDC_ARROW), + hbrBackground = cast(win.HBRUSH)cast(uintptr)(win.COLOR_WINDOW + 1), + lpszClassName = BOARD_CONTROL_CLASS_NAME, + } + win.RegisterClassExW(&wndclass) +} + +// Called by the application whenever the board state changes. +board_control_set_board :: proc(hwnd: win.HWND, board: Board) { + ctx := board_control_get_ctx(hwnd) + if ctx.board != board { + ctx.board = board + win.InvalidateRect(hwnd, nil, true) + log.debugf("Board changed.") + } else { + log.debugf("No board change detected.") + } +} + +// Called by the application when the user selects a different spritemap. +board_control_set_spritemap :: proc(hwnd: win.HWND, spritemap: Board_Control_Spritemap) { + ctx := board_control_get_ctx(hwnd) + if ctx.spritemap != spritemap { + ctx.spritemap = spritemap + // Invalidate the cached spritemap bitmap. + win.DeleteObject(auto_cast ctx.spritemap_cache) + ctx.spritemap_cache = nil + win.InvalidateRect(hwnd, nil, true) + } +} + +Board_Control_Spritemap :: enum { + normal, + ginger, +} + +board_control_get_spritemap_resource_name :: proc(spritemap: Board_Control_Spritemap) -> string { + switch spritemap { + case .normal: return "IDR_PIECES_NORMAL" + case .ginger: return "IDR_PIECES_GINGER" + case: return "IDR_PIECES_NORMAL" + } +} + +@(private="file") +Board_Control_Ctx :: struct { + runtime_context: ^runtime.Context, + font: win.HFONT, + // Copy of the board state visualized by the board control. + board: Board, + + spritemap: Board_Control_Spritemap, + spritemap_cache: win.HBITMAP, + + // Last known mouse client coordinates; needed for piece drag and drop. + mouse_client: [2]i32, + + hover_mode: Board_Control_Hover_Mode, + hovered_square: Square_Specifier, + + menu_open: bool, + menu_square: Square_Specifier, + + pending_castling_right: Castling_Right, + pending_castling_right_state: Castling_Right_State, + + dragging: bool, + drag_from: Square_Specifier, +} + +@(private="file") +Board_Control_Hover_Mode :: enum { + none, + // User is currently using the mouse to highlight squares. + mouse, + // User is currently using the keyboard (arrow keys) to highlight squares. + keyboard, +} + +Castling_Right :: enum { + white_kingside, + white_queenside, + black_kingside, + black_queenside, +} + +Castling_Right_State :: enum { + allow, + disallow, +} + +// +// Notifications +// +// When the user interacts with the board control (e.g. place a piece), this control +// will notify the parent. The authority to execute the requested action lies with +// the parent. +// + +// Notification code for `NMHDR.code` +Board_Control_Notification_Code :: enum win.UINT { + none, + hover, + place_piece, + set_en_passant_target_square, + set_castling_right, + exchange_pieces, +} + +NM_BOARD_CONTROL_NOTIFICATION :: struct { + // Adhere to standard Win32 notification structure: + // This struct must start with an `NMHDR`. + hdr: win.NMHDR, + hovering: bool, + square: Square_Specifier, + piece: Board_Piece, + castling_right: Castling_Right, + castling_right_state: Castling_Right_State, + square2: Square_Specifier, +} + +// Called by the board control when the hovered square has changed, +// either through mouse or keyboard interaction. +// +// Will update state and notify the parent if appropriate. +board_control_set_hovered_square :: proc(hwnd: win.HWND, ctx: ^Board_Control_Ctx, mode: Board_Control_Hover_Mode, square: Square_Specifier) { + if ctx.hover_mode == mode && ctx.hovered_square == square { + // No change. + return + } + ctx.hover_mode = mode + ctx.hovered_square = square + is_hovering := mode != .none + board_control_notify_cell_hover(hwnd, is_hovering, square) + win.InvalidateRect(hwnd, nil, true) +} + +board_control_notify_cell_hover :: proc(hwnd: win.HWND, hovering: bool, square: Square_Specifier) { + parent := win.GetParent(hwnd) + if parent == nil { + return + } + nm := NM_BOARD_CONTROL_NOTIFICATION { + hdr = { + hwndFrom = hwnd, + idFrom = cast(uintptr)win.GetDlgCtrlID(hwnd), + code = cast(win.UINT)Board_Control_Notification_Code.hover, + }, + hovering = hovering, + square = square, + } + win.SendMessageW(parent, win.WM_NOTIFY, nm.hdr.idFrom, cast(win.LPARAM)cast(uintptr)&nm) +} + +board_control_notify_piece :: proc(hwnd: win.HWND, square: Square_Specifier, piece: Board_Piece) { + parent := win.GetParent(hwnd) + if parent == nil { + return + } + nm := NM_BOARD_CONTROL_NOTIFICATION { + hdr = { + hwndFrom = hwnd, + idFrom = cast(uintptr)win.GetDlgCtrlID(hwnd), + code = cast(win.UINT)Board_Control_Notification_Code.place_piece, + }, + square = square, + piece = piece, + } + win.SendMessageW(parent, win.WM_NOTIFY, nm.hdr.idFrom, cast(win.LPARAM)cast(uintptr)&nm) +} + +board_control_notify_exchange_pieces :: proc(hwnd: win.HWND, square1, square2: Square_Specifier) { + parent := win.GetParent(hwnd) + if parent == nil { + return + } + nm := NM_BOARD_CONTROL_NOTIFICATION { + hdr = { + hwndFrom = hwnd, + idFrom = cast(uintptr)win.GetDlgCtrlID(hwnd), + code = cast(win.UINT)Board_Control_Notification_Code.exchange_pieces, + }, + square = square1, + square2 = square2, + } + win.SendMessageW(parent, win.WM_NOTIFY, nm.hdr.idFrom, cast(win.LPARAM)cast(uintptr)&nm) +} + +board_control_notify_set_en_passant_target_square :: proc(hwnd: win.HWND, square: Square_Specifier) { + parent := win.GetParent(hwnd) + if parent == nil { + return + } + nm := NM_BOARD_CONTROL_NOTIFICATION { + hdr = { + hwndFrom = hwnd, + idFrom = cast(uintptr)win.GetDlgCtrlID(hwnd), + code = cast(win.UINT)Board_Control_Notification_Code.set_en_passant_target_square, + }, + square = square, + } + win.SendMessageW(parent, win.WM_NOTIFY, nm.hdr.idFrom, cast(win.LPARAM)cast(uintptr)&nm) +} + +board_control_notify_set_castling_right :: proc(hwnd: win.HWND, castling_right: Castling_Right, state: Castling_Right_State) { + parent := win.GetParent(hwnd) + if parent == nil { + return + } + nm := NM_BOARD_CONTROL_NOTIFICATION { + hdr = { + hwndFrom = hwnd, + idFrom = cast(uintptr)win.GetDlgCtrlID(hwnd), + code = cast(win.UINT)Board_Control_Notification_Code.set_castling_right, + }, + castling_right = castling_right, + castling_right_state = state, + } + win.SendMessageW(parent, win.WM_NOTIFY, nm.hdr.idFrom, cast(win.LPARAM)cast(uintptr)&nm) +} + +@(private="file") +board_control_get_ctx :: proc "contextless" (hwnd: win.HWND) -> ^Board_Control_Ctx { + return cast(^Board_Control_Ctx)cast(uintptr)win.GetWindowLongPtrW(hwnd, 0) +} + +@(private="file") +board_control_wndproc :: proc "system" (hwnd: win.HWND, msg: win.UINT, wparam: win.WPARAM, lparam: win.LPARAM) -> win.LRESULT { + + switch msg { + + case win.WM_CREATE: + // Setup window context. + context = window_context_tls + ctx := new(Board_Control_Ctx) + ctx.runtime_context = &window_context_tls + win.SetWindowLongPtrW(hwnd, 0, cast(win.LONG_PTR)cast(uintptr)ctx) + + case win.WM_NCDESTROY: + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + win.DeleteObject(auto_cast ctx.spritemap_cache) + ctx.spritemap_cache = nil + free(ctx) + + case win.WM_SETFONT: + // Standard Win32 infrastructure for controls that draw text. + // Called by the parent (either explicitly or through DS_SETFONT). + ctx := board_control_get_ctx(hwnd) + ctx.font = cast(win.HFONT)wparam + + case win.WM_GETFONT: + // Standard Win32 infrastructure for controls that draw text. + ctx := board_control_get_ctx(hwnd) + return cast(win.LRESULT)cast(uintptr)ctx.font + + case win.WM_SIZE: + // Make sure that the control stays a square. + rc: win.RECT + win.GetWindowRect(hwnd, &rc) + w := rc.right - rc.left + h := rc.bottom - rc.top + if w != h { + // Use the width here for both. + // NOTE: min(w,h) could cause the control to shrink unexpectedly on DPI changes. + win.SetWindowPos(hwnd, nil, 0, 0, w, w, win.SWP_NOZORDER | win.SWP_NOMOVE) + return 0 + } + + case win.WM_MOUSEMOVE: + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + x_client := win.GET_X_LPARAM(lparam) + y_client := win.GET_Y_LPARAM(lparam) + // Update drag and drop target coordinates for painting the dragged piece. + board_control_set_drag_target_coords(hwnd, ctx, x_client, y_client) + square, ok := board_control_get_square_from_client_coords(hwnd, x_client, y_client) + board_control_set_hovered_square(hwnd, ctx, ok ? .mouse : .none, square) + // Setup mouse event tracking so that we get WM_MOUSELEAVE. + tme := win.TRACKMOUSEEVENT { + cbSize = size_of(win.TRACKMOUSEEVENT), + hwndTrack = hwnd, + dwFlags = win.TME_LEAVE, + } + win.TrackMouseEvent(&tme) + + case win.WM_MOUSELEAVE: + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + board_control_set_hovered_square(hwnd, ctx, .none, {}) + + case win.WM_LBUTTONDOWN: + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + + win.SetCapture(hwnd) + + // Setup piece drag operation + x_client := win.GET_X_LPARAM(lparam) + y_client := win.GET_Y_LPARAM(lparam) + square, ok := board_control_get_square_from_client_coords(hwnd, x_client, y_client) + if ok { + win.SetFocus(hwnd) + // You can only drag if there is a piece on that square. + if ctx.board.pieces[square.rank * 8 + square.file].type != .none { + ctx.dragging = true + ctx.mouse_client = { x_client, y_client } + ctx.drag_from = square + win.InvalidateRect(hwnd, nil, true) + } + } + + case win.WM_LBUTTONUP: + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + + // Release mouse capture. + if win.GetCapture() == hwnd { + win.SetCapture(nil) + } + + if ctx.dragging { + // Complete piece dragging operation. + x_client := win.GET_X_LPARAM(lparam) + y_client := win.GET_Y_LPARAM(lparam) + ctx.dragging = false + square_to, ok := board_control_get_square_from_client_coords(hwnd, x_client, y_client) + square_from := ctx.drag_from + if ok { + if square_to == square_from { + // Released over the same square. Do nothing. + } else { + // Exchange these 2 pieces. This also handles the case where the target square is empty. + board_control_notify_exchange_pieces(hwnd, square_from, square_to) + } + } else { + // Was released somewhere not over the board. + // Delete the piece from the "from" square, but don't put it anywhere. + board_control_notify_piece(hwnd, square_from, {}) + } + win.InvalidateRect(hwnd, nil, true) + } + + case win.WM_CONTEXTMENU: + + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + x_screen := win.GET_X_LPARAM(lparam) + y_screen := win.GET_Y_LPARAM(lparam) + x_client: i32 + y_client: i32 + + if x_screen == -1 && y_screen == -1 { + // This was triggered by a non-mouse event (e.g. the "VK_APPS" key on the keyboard). + // NOTE: This is ambiguous. (-1, -1) could technically be valid screen coordinates + // in a multi-monitor scenario. This is an API flaw. + // In this case, we open the context menu at the center of the currently hovered square. + target_square := ctx.hovered_square + target_file := cast(i32)target_square.file + target_rank := cast(i32)target_square.rank + client: win.RECT + win.GetClientRect(hwnd, &client) + x, y, w, h := board_control_get_board_bounds(hwnd, client) + square_rect := win.RECT { + left = auto_cast (x + (w * (target_file )) / 8), + top = auto_cast (y + (h * (7-target_rank)) / 8), + right = auto_cast (x + (w * (target_file+1)) / 8), + bottom = auto_cast (y + (h * (8-target_rank)) / 8), + } + x_client = square_rect.left + (square_rect.right - square_rect.left) / 2 + y_client = square_rect.top + (square_rect.bottom - square_rect.top) / 2 + pt := win.POINT { x_client, y_client } + win.ClientToScreen(hwnd, &pt) + x_screen = pt.x + y_screen = pt.y + } else { + pt := win.POINT { x_screen, y_screen } + win.ScreenToClient(hwnd, &pt) + x_client = pt.x + y_client = pt.y + } + + log.debugf("lparam: 0x%8x - screen x, y: %v, %v - client x, y: %v %v", lparam, x_screen, y_screen, x_client, y_client) + square, ok := board_control_get_square_from_client_coords(hwnd, x_client, y_client) + if ok { + // Load and open the context menu. + hInstance := cast(win.HINSTANCE)cast(uintptr)win.GetWindowLongPtrW(hwnd, win.GWLP_HINSTANCE) + menu := win.LoadMenuW(hInstance, "IDM_BOARD_CONTEXT_MENU") + if menu == nil { + error := win.GetLastError() + msg := get_system_error_message(error) + log.errorf("Could not load menu: %v", msg) + break + } + defer win.DestroyMenu(menu) + // NOTE: A context menu must be a popup menu specifically. If defined in a resource as a menu template, + // the containing menu is just a dummy "main menu". + popup := win.GetSubMenu(menu, 0) + if popup == nil { + error := win.GetLastError() + msg := get_system_error_message(error) + log.errorf("Could not popup menu: %v", msg) + break + } + + // Display a string like "E4" in the square info text. + square_info_text := board_format_position(square, context.temp_allocator) + set_menu_item_text(popup, IDM_SQUARE_INFO, square_info_text) + + // Allow setting the en passant target square if rank is eligible for it (on the "3" or "6" rank). + allow_set_en_passant_target_square := square.rank == 2 || square.rank == 5 + win.EnableMenuItem(popup, IDM_SET_EN_PASSANT_TARGET_SQUARE, allow_set_en_passant_target_square ? win.MF_ENABLED : win.MF_GRAYED) + + // Allow toggling castling rights if the clicked square is a rook square. + // This menu item has dynamic functionality, depending on the clicked square and the previous castling rights. + castling_right_toggle, castling_right_toggle_state, castling_right_toggle_enable := board_control_get_castling_right_toggle_action(ctx, square) + win.EnableMenuItem(popup, IDM_TOGGLE_CASTLING_RIGHT, castling_right_toggle_enable ? win.MF_ENABLED : win.MF_GRAYED) + if castling_right_toggle_enable { + ctx.pending_castling_right = castling_right_toggle + ctx.pending_castling_right_state = castling_right_toggle_state + string_resource_id: win.UINT + switch castling_right_toggle { + case .white_kingside: + string_resource_id = castling_right_toggle_state == .allow ? IDS_ALLOW_CASTLING_WHITE_KINGSIDE : IDS_DISALLOW_CASTLING_WHITE_KINGSIDE + case .white_queenside: + string_resource_id = castling_right_toggle_state == .allow ? IDS_ALLOW_CASTLING_WHITE_QUEENSIDE : IDS_DISALLOW_CASTLING_WHITE_QUEENSIDE + case .black_kingside: + string_resource_id = castling_right_toggle_state == .allow ? IDS_ALLOW_CASTLING_BLACK_KINGSIDE : IDS_DISALLOW_CASTLING_BLACK_KINGSIDE + case .black_queenside: + string_resource_id = castling_right_toggle_state == .allow ? IDS_ALLOW_CASTLING_BLACK_QUEENSIDE : IDS_DISALLOW_CASTLING_BLACK_QUEENSIDE + } + label := load_string_resource(hInstance, string_resource_id, context.temp_allocator) + set_menu_item_text(popup, IDM_TOGGLE_CASTLING_RIGHT, label) + } + + ctx.menu_open = true + ctx.menu_square = square + defer { + ctx.menu_open = false + win.InvalidateRect(hwnd, nil, true) + } + + // Actually shows the menu, and returns when the menu is closed. + // The action chosen by the user (if any) is sent as a WM_COMMAND message. + win.TrackPopupMenu(popup, 0, x_screen, y_screen, 0, hwnd, nil) + } + + case win.WM_MENUSELECT: + // Conventional Win32 infrastructure: + // Notify the parent of the selected (i.e. hovered) menu item so that it can + // update the status bar text with a hint text, if applicable. + parent := win.GetParent(hwnd) + if parent != nil { + return win.SendMessageW(parent, msg, wparam, lparam) + } + + case win.WM_GETDLGCODE: + // Instructs `IsDialogMessageW` in the main message loop that this control + // wants to handle arrow keys. Otherwise, arrow keys would be swallowed by + // keyboard navigation logic in `IsDialogMessageW`. + // We need the arrow keys to navigate through the squares on the board. + return win.DLGC_WANTARROWS + + case win.WM_SETFOCUS: + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + if ctx.hover_mode != .keyboard { + ctx.hover_mode = .keyboard + win.InvalidateRect(hwnd, nil, true) + } + + case win.WM_KILLFOCUS: + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + board_control_set_hovered_square(hwnd, ctx, .none, {}) + win.InvalidateRect(hwnd, nil, true) + + case win.WM_KEYDOWN: + // Change selected square with the arrow keys. + vk := wparam + switch vk { + case win.VK_LEFT: keyboard_navigate_square(hwnd, -1, 0); return 0 + case win.VK_UP: keyboard_navigate_square(hwnd, 0, 1); return 0 + case win.VK_RIGHT: keyboard_navigate_square(hwnd, 1, 0); return 0 + case win.VK_DOWN: keyboard_navigate_square(hwnd, 0, -1); return 0 + } + + keyboard_navigate_square :: proc "contextless" (hwnd: win.HWND, delta_file, delta_rank: i8) { + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + // NOTE: We could use independent state variables for the "keyboard-hovered" square. + rank := clamp(ctx.hovered_square.rank + delta_rank, 0, 7) + file := clamp(ctx.hovered_square.file + delta_file, 0, 7) + square := Square_Specifier { rank = rank, file = file } + board_control_set_hovered_square(hwnd, ctx, .keyboard, square) + } + + case win.WM_COMMAND: + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + id := win.LOWORD(wparam) + nc := win.HIWORD(wparam) + log.debugf("Board control command: id: %v - nc: %v", id, nc) + + switch id { + case IDM_CLEAR_SQUARE: board_control_notify_piece(hwnd, ctx.menu_square, { .none, nil }) + + case IDM_ROOK_WHITE: board_control_notify_piece(hwnd, ctx.menu_square, { .rook, .white }) + case IDM_KNIGHT_WHITE: board_control_notify_piece(hwnd, ctx.menu_square, { .knight, .white }) + case IDM_BISHOP_WHITE: board_control_notify_piece(hwnd, ctx.menu_square, { .bishop, .white }) + case IDM_QUEEN_WHITE: board_control_notify_piece(hwnd, ctx.menu_square, { .queen, .white }) + case IDM_KING_WHITE: board_control_notify_piece(hwnd, ctx.menu_square, { .king, .white }) + case IDM_PAWN_WHITE: board_control_notify_piece(hwnd, ctx.menu_square, { .pawn, .white }) + case IDM_ROOK_BLACK: board_control_notify_piece(hwnd, ctx.menu_square, { .rook, .black }) + case IDM_KNIGHT_BLACK: board_control_notify_piece(hwnd, ctx.menu_square, { .knight, .black }) + case IDM_BISHOP_BLACK: board_control_notify_piece(hwnd, ctx.menu_square, { .bishop, .black }) + case IDM_QUEEN_BLACK: board_control_notify_piece(hwnd, ctx.menu_square, { .queen, .black }) + case IDM_KING_BLACK: board_control_notify_piece(hwnd, ctx.menu_square, { .king, .black }) + case IDM_PAWN_BLACK: board_control_notify_piece(hwnd, ctx.menu_square, { .pawn, .black }) + + case IDM_SET_EN_PASSANT_TARGET_SQUARE: + board_control_notify_set_en_passant_target_square(hwnd, ctx.menu_square) + case IDM_TOGGLE_CASTLING_RIGHT: + board_control_notify_set_castling_right(hwnd, ctx.pending_castling_right, ctx.pending_castling_right_state) + } + + case win.WM_ERASEBKGND: + // Suppress this: All painting is done in WM_PAINT through buffered painting to prevent flicker. + return 1 + + case win.WM_PAINT: + ctx := board_control_get_ctx(hwnd) + context = ctx.runtime_context^ + + // Setup WM_PAINT infrastructure. + ps: win.PAINTSTRUCT + hdc0 := win.BeginPaint(hwnd, &ps) + defer win.EndPaint(hwnd, &ps) + + // Setup buffered painting (to prevent flicker). + hdc: win.HDC + paintbuffer := win.BeginBufferedPaint(hdc0, &ps.rcPaint, .BPBF_TOPDOWNDIB, nil, &hdc) + defer win.EndBufferedPaint(paintbuffer, true) + + // Fill background + { + brush := win.CreateSolidBrush(win.RGB(255, 255, 255)) + defer win.DeleteObject(auto_cast brush) + win.FillRect(hdc, &ps.rcPaint, brush) + } + + // Prepare board squares + + client: win.RECT + win.GetClientRect(hwnd, &client) + bx, by, bw, bh := board_control_get_board_bounds(hwnd, client) + + dark_square_brush := win.CreateSolidBrush(win.RGB(175, 185, 170)) + defer win.DeleteObject(auto_cast dark_square_brush) + light_square_brush := win.CreateSolidBrush(win.RGB(205, 215, 200)) + defer win.DeleteObject(auto_cast light_square_brush) + highlight_square_brush: win.HBRUSH + if ctx.hover_mode != .none || ctx.menu_open { + // NOTE: This brush does not need to be freed. + highlight_square_brush = win.GetSysColorBrush(win.COLOR_HIGHLIGHT) + } + en_passant_highlight_color := win.RGB(255, 127, 0) + castling_right_highlight_color := win.RGB(0, 255, 255) + + highlight_thickness := adjust_for_dpi(hwnd, 3) + + should_highlight_square :: proc(ctx: ^Board_Control_Ctx, square: Square_Specifier) -> bool { + if ctx.menu_open { + return square == ctx.menu_square + } else if ctx.hover_mode != .none { + return square == ctx.hovered_square + } else { + return false + } + } + + // Paint board squares + + // rank index 0 is rank "1" + // file index 0 is file "A" + for rank in 0 ..< i32(8) { + for file in 0 ..< i32(8) { + square_rect := win.RECT { + left = auto_cast (bx + (bw * (file )) / 8), + top = auto_cast (by + (bh * (7-rank)) / 8), + right = auto_cast (bx + (bw * (file+1)) / 8), + bottom = auto_cast (by + (bh * (8-rank)) / 8), + } + square := Square_Specifier { rank = cast(i8)rank, file = cast(i8)file } + brush: win.HBRUSH + if should_highlight_square(ctx, square) { + brush = highlight_square_brush + } else { + is_dark_square := (rank + file) % 2 != 0 + brush = is_dark_square ? dark_square_brush : light_square_brush + } + win.FillRect(hdc, &square_rect, brush) + + // Highlight square if en passant + if ctx.board.en_passant_target_square != {} && ctx.board.en_passant_target_square == square { + stroke_rectangle_precise(hdc, square_rect, en_passant_highlight_color, thickness = highlight_thickness) + } + + // Highlight square if castling right + highlight_for_castling := (ctx.board.white_can_castle_queenside && file == 0 && rank == 0) || + (ctx.board.white_can_castle_kingside && file == 7 && rank == 0) || + (ctx.board.black_can_castle_queenside && file == 0 && rank == 7) || + (ctx.board.black_can_castle_kingside && file == 7 && rank == 7) + if highlight_for_castling { + stroke_rectangle_precise(hdc, square_rect, castling_right_highlight_color, thickness = highlight_thickness) + } + } + } + + // Paint border (board frame) + + stroke_rectangle_precise(hdc, client, win.RGB(0, 0, 0)) + board_rc := win.RECT { bx, by, bx + bw, by + bh } + stroke_rectangle_precise(hdc, board_rc, win.RGB(0, 0, 0), -1) + + // Paint file and rank names + + previous_font := cast(win.HFONT)win.SelectObject(hdc, auto_cast ctx.font) + defer win.SelectObject(hdc, auto_cast previous_font) + + for file in 0 ..< i32(8) { + label_rect := win.RECT { + left = auto_cast (bx + (bw * (file )) / 8), + top = board_rc.bottom, + right = auto_cast (bx + (bw * (file+1)) / 8), + bottom = client.bottom, + } + file_string := cast(rune)('A' + file) + sb := strings.builder_make_len_cap(0, 4, context.temp_allocator) + strings.write_rune(&sb, file_string) + win.DrawTextExW(hdc, win.utf8_to_wstring(strings.to_string(sb), context.temp_allocator), -1, &label_rect, .DT_SINGLELINE | .DT_CENTER | .DT_VCENTER | .DT_NOPREFIX, nil) + } + + for rank in 0 ..< i32(8) { + label_rect := win.RECT { + left = client.left, + top = auto_cast (by + (bh * (7-rank)) / 8), + right = board_rc.left, + bottom = auto_cast (by + (bh * (8-rank)) / 8), + } + rank_string := cast(rune)('1' + rank) + sb := strings.builder_make_len_cap(0, 4, context.temp_allocator) + strings.write_rune(&sb, rank_string) + win.DrawTextExW(hdc, win.utf8_to_wstring(strings.to_string(sb), context.temp_allocator), -1, &label_rect, .DT_SINGLELINE | .DT_CENTER | .DT_VCENTER | .DT_NOPREFIX, nil) + } + + // Prepare pieces + + if ctx.spritemap_cache == nil { + // We cache the image so we don't have to load it every time. + ctx.spritemap_cache = load_pieces_png(ctx.spritemap) + log.debugf("Spritemap '%v' loaded: %v", ctx.spritemap, ctx.spritemap_cache) + } + piece_sprites := ctx.spritemap_cache + if piece_sprites == nil { + err := win.GetLastError() + msg := get_system_error_message(err, allocator = context.temp_allocator) + log.errorf("Error loading piece sprite bitmap: %v", msg) + } + + load_pieces_png :: proc(spritemap: Board_Control_Spritemap) -> win.HBITMAP { + resource_name := board_control_get_spritemap_resource_name(spritemap) + hModule := win.GetModuleHandleW(nil) + return load_hbitmap_from_png_rcdata(hModule, win.utf8_to_wstring(resource_name, context.temp_allocator)) + } + + // Paint pieces + + if piece_sprites != nil { + hdc_src := win.CreateCompatibleDC(hdc) + defer win.DeleteDC(hdc_src) + previous_bitmap := win.SelectObject(hdc_src, auto_cast piece_sprites) + defer win.SelectObject(hdc_src, previous_bitmap) + for rank in 0 ..< i32(8) { + for file in 0 ..< i32(8) { + if ctx.dragging && ctx.drag_from.file == auto_cast file && ctx.drag_from.rank == auto_cast rank { + // Skip this; it's the piece currently being dragged. + continue + } + piece := ctx.board.pieces[rank * 8 + file] + if piece.type != .none { + square_rect := win.RECT { + left = auto_cast (bx + (bw * (file )) / 8), + top = auto_cast (by + (bh * (7-rank)) / 8), + right = auto_cast (bx + (bw * (file+1)) / 8), + bottom = auto_cast (by + (bh * (8-rank)) / 8), + } + xd := square_rect.left + yd := square_rect.top + wd := square_rect.right - square_rect.left + hd := square_rect.bottom - square_rect.top + sx, sy, sw, sh := board_control_get_sprite_src(piece) + bf := win.BLENDFUNCTION { + BlendOp = win.AC_SRC_OVER, + AlphaFormat = win.AC_SRC_ALPHA, + SourceConstantAlpha = 255, + } + win.GdiAlphaBlend( + hdc, xd, yd, wd, hd, + hdc_src, sx, sy, sw, sh, + bf, + ) + } + } + } + + // Paint the dragged piece, if any. + if ctx.dragging { + from_rank := cast(i32)ctx.drag_from.rank + from_file := cast(i32)ctx.drag_from.file + piece := ctx.board.pieces[from_rank * 8 + from_file] + square_rect := win.RECT { + left = auto_cast (bx + (bw * (from_file )) / 8), + top = auto_cast (by + (bh * (7-from_rank)) / 8), + right = auto_cast (bx + (bw * (from_file+1)) / 8), + bottom = auto_cast (by + (bh * (8-from_rank)) / 8), + } + wd := square_rect.right - square_rect.left + hd := square_rect.bottom - square_rect.top + sx, sy, sw, sh := board_control_get_sprite_src(piece) + xd := ctx.mouse_client[0] - wd / 2 + yd := ctx.mouse_client[1] - hd / 2 + bf := win.BLENDFUNCTION { + BlendOp = win.AC_SRC_OVER, + AlphaFormat = win.AC_SRC_ALPHA, + SourceConstantAlpha = 255, + } + win.GdiAlphaBlend( + hdc, xd, yd, wd, hd, + hdc_src, sx, sy, sw, sh, + bf, + ) + } + } + } + + return win.DefWindowProcW(hwnd, msg, wparam, lparam) +} + +adjust_for_dpi :: proc(hwnd: win.HWND, i: i32) -> i32 { + lpy := win.GetDpiForWindow(hwnd) + return cast(i32)(cast(f64)lpy / 96.0 * cast(f64)i) +} + +// Returns the castling right toggle action that is appropriate for clicking on the specified square. +// E.g. if the user clicks on A1, they can toggle the castling right for white queenside. +// The `toggle_state` will be the opposite state (allow/disallow) of the current board state. +board_control_get_castling_right_toggle_action :: proc( + ctx: ^Board_Control_Ctx, + square: Square_Specifier, +) -> ( + castling_right: Castling_Right, + toggle_state: Castling_Right_State, + enable: bool, +) { + currently_allowed: bool + switch { + case square.rank == 0 && square.file == 0: + castling_right = .white_queenside + currently_allowed = ctx.board.white_can_castle_queenside + case square.rank == 0 && square.file == 7: + castling_right = .white_kingside + currently_allowed = ctx.board.white_can_castle_kingside + case square.rank == 7 && square.file == 0: + castling_right = .black_queenside + currently_allowed = ctx.board.black_can_castle_queenside + case square.rank == 7 && square.file == 7: + castling_right = .black_kingside + currently_allowed = ctx.board.black_can_castle_kingside + case: + enable = false + return + } + enable = true + if currently_allowed { + toggle_state = .disallow + } else { + toggle_state = .allow + } + return +} + +board_control_get_board_bounds :: proc(hwnd: win.HWND, client: win.RECT) -> (x, y, w, h: i32) { + BORDER_WIDTH_PX :: 18 + border_thickness := adjust_for_dpi(hwnd, BORDER_WIDTH_PX) + x = client.left + border_thickness + y = client.top + border_thickness + w = client.right - x - border_thickness + h = client.bottom - y - border_thickness + min_wh := min(w, h) + w = min_wh + h = min_wh + return +} + +board_control_get_square_from_client_coords :: proc(hwnd: win.HWND, client_x, client_y: i32) -> (square: Square_Specifier, ok: bool) { + client: win.RECT + win.GetClientRect(hwnd, &client) + x, y, w, h := board_control_get_board_bounds(hwnd, client) + X := client_x - x + Y := client_y - y + if X < 0 || Y < 0 || X >= w || Y >= h { + ok = false + return + } + file := cast(i8)(cast(f64)X / cast(f64)w * 8.0) + rank := 7 - cast(i8)(cast(f64)Y / cast(f64)h * 8.0) + if file < 0 || file >= 8 || rank < 0 || rank >= 8 { + ok = false + return + } + return { file = file, rank = rank }, true +} + +// Get the source rectangle for the sprite for `piece` in the sprite map. +board_control_get_sprite_src :: proc(piece: Board_Piece) -> (x, y, w, h: i32) { + if piece.type == .none { + return + } + if piece.type < .FIRST || piece.type > .LAST { + return + } + if piece.color < .FIRST || piece.color > .LAST { + return + } + SQUARE_SIZE_PX :: 128 + w = SQUARE_SIZE_PX + h = SQUARE_SIZE_PX + col := cast(i32)piece.type - 1 + row := cast(i32)piece.color + x = col * SQUARE_SIZE_PX + y = row * SQUARE_SIZE_PX + return +} + +// Stores the specified mouse client coordinates for the visual position of the currently dragged piece. +// +// Coordinates values (-1, -1) mean that the mouse is not over the board. +board_control_set_drag_target_coords :: proc(hwnd: win.HWND, ctx: ^Board_Control_Ctx, x_client, y_client: i32) { + if ctx.dragging && ctx.mouse_client != { x_client, y_client } { + ctx.mouse_client = { x_client, y_client } + win.InvalidateRect(hwnd, nil, true) + } +} diff --git a/win32/fentool/clack.wav b/win32/fentool/clack.wav new file mode 100644 index 0000000..3f45d32 Binary files /dev/null and b/win32/fentool/clack.wav differ diff --git a/win32/fentool/doc/screenshot1.png b/win32/fentool/doc/screenshot1.png new file mode 100644 index 0000000..138b80a Binary files /dev/null and b/win32/fentool/doc/screenshot1.png differ diff --git a/win32/fentool/doc/screenshot2.png b/win32/fentool/doc/screenshot2.png new file mode 100644 index 0000000..60f38ce Binary files /dev/null and b/win32/fentool/doc/screenshot2.png differ diff --git a/win32/fentool/fen.odin b/win32/fentool/fen.odin new file mode 100644 index 0000000..2fc4af5 --- /dev/null +++ b/win32/fentool/fen.odin @@ -0,0 +1,471 @@ +package fen_tool + +import runtime "base:runtime" +import log "core:log" +import fmt "core:fmt" +import strings "core:strings" +import strconv "core:strconv" +import win "core:sys/windows" + +Parse_FEN_Error :: struct { + is_error: bool, + cursor: int, + display_message: string, +} + +// Retrieves the (possibly) localized error string with the specified ID from the embedded resource, and formats +// the error with the given arguments. +make_fen_errorf :: proc(ctx: Parse_Ctx, allocator: runtime.Allocator, string_resource_id: win.UINT, args: ..any) -> Parse_FEN_Error { + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + format := load_string_resource(hInstance, string_resource_id) + message := fmt.aprintf(format, args = args, allocator = allocator) + return Parse_FEN_Error { + is_error = true, + cursor = ctx.c, + display_message = message, + } +} + +parse_fen :: proc(s: string, allocator := context.temp_allocator) -> (board: Board, error: Parse_FEN_Error) { + + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + + ctx := Parse_Ctx { s = s } + eat_spaces(&ctx) + + // rank index 0 is the "1" rank on the board. + for rank := 7; rank >= 0; rank -= 1 { + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + // file index 0 is the "A" file on the board. + file := 0 + rank_loop: for { + c := cast(rune)ctx.s[ctx.c] if ctx.c < len(ctx.s) else 0 + piece: Maybe(Board_Piece) + switch c { + case 'r': piece = Board_Piece { .rook, .black } + case 'n': piece = Board_Piece { .knight, .black } + case 'b': piece = Board_Piece { .bishop, .black } + case 'q': piece = Board_Piece { .queen, .black } + case 'k': piece = Board_Piece { .king, .black } + case 'p': piece = Board_Piece { .pawn, .black } + case 'R': piece = Board_Piece { .rook, .white } + case 'N': piece = Board_Piece { .knight, .white } + case 'B': piece = Board_Piece { .bishop, .white } + case 'Q': piece = Board_Piece { .queen, .white } + case 'K': piece = Board_Piece { .king, .white } + case 'P': piece = Board_Piece { .pawn, .white } + case: + if c == '/' { + if file != 8 || rank == 0 { + log.warnf("Rank separator '/' at invalid position: %v", board_format_position(file, rank)) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_RANK_SEPARATOR_AT_INVALID_POSITION_0, board_format_position(file, rank)) + return + } + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + ctx.c += 1 + log.debugf("Rank %v complete - next rank.", rank) + break rank_loop + } else if c >= '1' && c <= '8' { + spaces := cast(int)(c - '0') + if file + spaces > 8 { + log.warnf("Rank overflow (cannot advance by %v empty squares): %v", spaces, board_format_position(file, rank)) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_RANK_OVERFLOW_ADVANCE_0_1, spaces, board_format_position(file, rank)) + return + } + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + ctx.c += 1 + log.debugf("Advance %v empty squares at %v.", spaces, board_format_position(file, rank)) + file += spaces + } else if c == ' ' { + if file != 8 || rank != 0 { + log.warnf("Board separator ' ' at invalid position: %v", board_format_position(file, rank)) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_BOARD_SEPARATOR_AT_INVALID_POSITION_0, board_format_position(file, rank)) + return + } + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + ctx.c += 1 + log.debugf("Pieces done.") + break rank_loop + } else { + log.warnf("Invalid character '%v' at position: %v", c, board_format_position(file, rank)) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_INVALID_CHARACTER_0_AT_POSITION_1, c, board_format_position(file, rank)) + return + } + } + + if piece != nil { + if file >= 8 { + log.warnf("Rank overflow (cannot place %v %v) at %v", piece.?.color, piece.?.type, board_format_position(file, rank)) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_RANK_OVERFLOW_CANNOT_PLACE_0_1_AT_2, piece.?.color, piece.?.type, board_format_position(file, rank)) + return + } + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + ctx.c += 1 + boardwhere := rank * 8 + file + log.debugf("Placed %v %v at %v", piece.?.color, piece.?.type, board_format_position(file, rank)) + ctx.board.pieces[boardwhere] = piece.? + file += 1 + } + } + } + + eat_spaces(&ctx) + + // Which player is to move? + + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + + { + c := cast(rune)ctx.s[ctx.c] if ctx.c < len(ctx.s) else 0 + switch c { + case 'w': + ctx.board.turn_player = .white + ctx.c += 1 + case 'b': + ctx.board.turn_player = .black + ctx.c += 1 + case: + log.warnf("Expected player to move (w/b), got %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_EXPECTED_PLAYER_TO_MOVE_0, c) + return + } + } + + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + + { + c := cast(rune)ctx.s[ctx.c] if ctx.c < len(ctx.s) else 0 + if c != ' ' { + log.warnf("Expected space, got %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_EXPECTED_SPACE_0, c) + return + } + ctx.c += 1 + } + + eat_spaces(&ctx) + + // Castling rights + + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + + castling_rights_loop: for { + c := cast(rune)ctx.s[ctx.c] if ctx.c < len(ctx.s) else 0 + switch c { + case 'K': + if ctx.board.white_can_castle_kingside { + log.warnf("Duplicate castling right specifier: %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_DUPLICATE_CASTLING_RIGHT_SPECIFIER_0, c) + return + } + ctx.board.white_can_castle_kingside = true + case 'Q': + if ctx.board.white_can_castle_queenside { + log.warnf("Duplicate castling right specifier: %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_DUPLICATE_CASTLING_RIGHT_SPECIFIER_0, c) + return + } + ctx.board.white_can_castle_queenside = true + case 'k': + if ctx.board.black_can_castle_kingside { + log.warnf("Duplicate castling right specifier: %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_DUPLICATE_CASTLING_RIGHT_SPECIFIER_0, c) + return + } + ctx.board.black_can_castle_kingside = true + case 'q': + if ctx.board.black_can_castle_queenside { + log.warnf("Duplicate castling right specifier: %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_DUPLICATE_CASTLING_RIGHT_SPECIFIER_0, c) + return + } + ctx.board.black_can_castle_queenside = true + case '-': + // No castling rights. This is only valid + // if no castling rights are being specified. + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1 + // ^ + if ctx.board.white_can_castle_kingside || + ctx.board.white_can_castle_queenside || + ctx.board.black_can_castle_kingside || + ctx.board.black_can_castle_queenside { + log.warnf("Castling right specifier conflict") + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_CASTLING_RIGHT_SPECIFIER_CONFLICT) + return + } + case ' ': + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + ctx.c += 1 + break castling_rights_loop + case: + log.warnf("Expected castling rights specifier, got %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_EXPECTED_CASTLING_RIGHTS_SPECIFIER_0, c) + return + } + ctx.c += 1 + } + + eat_spaces(&ctx) + + // En passant target square + + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq e3 0 1 + // ^ + + { + c := cast(rune)ctx.s[ctx.c] if ctx.c < len(ctx.s) else 0 + if c == '-' { + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + ctx.c += 1 + } else { + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq e3 0 1 + // ^ + if c < 'a' || c > 'h' { + log.warnf("Invalid en passant target square file: %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_INVALID_EN_PASSANT_TARGET_SQUARE_FILE_0, c) + return + } + file := c - 'a' + ctx.c += 1 + + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq e3 0 1 + // ^ + + c = cast(rune)ctx.s[ctx.c] if ctx.c < len(ctx.s) else 0 + if c != '3' && c != '6' { + log.warnf("Invalid en passant target square rank: %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_INVALID_EN_PASSANT_TARGET_SQUARE_RANK_0, c) + return + } + rank := c - '1' + ctx.c += 1 + ctx.board.en_passant_target_square = { cast(i8)file, cast(i8)rank } + } + + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + + c = cast(rune)ctx.s[ctx.c] if ctx.c < len(ctx.s) else 0 + if c != ' ' { + log.warnf("Expected space, got %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_EXPECTED_SPACE_0, c) + return + } + ctx.c += 1 + } + + eat_spaces(&ctx) + + // Halfmove clock + + { + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + number_string := read_number(&ctx) + n, n_ok := strconv.parse_int(number_string, 10) + if !n_ok { + log.warnf("Invalid halfmove clock: %v", number_string) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_INVALID_HALFMOVE_CLOCK_0, number_string) + return + } + ctx.board.halfmove_clock = cast(i32)n + + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + + c := cast(rune)ctx.s[ctx.c] if ctx.c < len(ctx.s) else 0 + if c != ' ' { + log.warnf("Expected space, got %v", c) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_EXPECTED_SPACE_0, c) + return + } + ctx.c += 1 + } + + eat_spaces(&ctx) + + // Fullmove number + + { + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + number_string := read_number(&ctx) + n, n_ok := strconv.parse_int(number_string, 10) + if !n_ok { + log.warnf("Invalid fullmove number: %v", number_string) + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_INVALID_FULLMOVE_NUMBER_0, number_string) + return + } + ctx.board.fullmove_number = cast(i32)n + + // rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 + // ^ + + eat_spaces(&ctx) + if ctx.c < len(ctx.s) { + log.warnf("Expected end of string") + error = make_fen_errorf(ctx, allocator, IDS_FEN_ERR_EXPECTED_END_OF_STRING) + return + } + } + + log.debugf("FEN string parsed successfully: %v", s) + + board = ctx.board + return +} + +format_fen :: proc(board: Board, allocator := context.temp_allocator) -> string { + sb := strings.builder_make_len_cap(0, 200, allocator) + + fen_write_pieces(&sb, board.pieces) + + strings.write_string(&sb, " ") + + switch board.turn_player { + case .white: + strings.write_string(&sb, "w") + case .black: + strings.write_string(&sb, "b") + case: + strings.write_string(&sb, "?") + } + + strings.write_string(&sb, " ") + + if !board.white_can_castle_kingside && + !board.white_can_castle_queenside && + !board.black_can_castle_kingside && + !board.black_can_castle_queenside { + strings.write_string(&sb, "-") + } else { + if board.white_can_castle_kingside { strings.write_string(&sb, "K") } + if board.white_can_castle_queenside { strings.write_string(&sb, "Q") } + if board.black_can_castle_kingside { strings.write_string(&sb, "k") } + if board.black_can_castle_queenside { strings.write_string(&sb, "q") } + } + + strings.write_string(&sb, " ") + + fen_write_en_passant_target_square(&sb, board.en_passant_target_square) + + strings.write_string(&sb, " ") + + strings.write_int(&sb, auto_cast board.halfmove_clock) + + strings.write_string(&sb, " ") + + strings.write_int(&sb, auto_cast board.fullmove_number) + + return strings.to_string(sb) +} + +fen_write_pieces :: proc(sb: ^strings.Builder, pieces: Board_Pieces) { + assert(len(pieces) == 64) + + // file index 0 is the 'A' file. + // rank index 0 is the '1' rank. + for rank := 7; rank >= 0; rank -= 1 { + + file := 0 + + file_loop: for { + if file == 8 { + break file_loop + } + assert(file < 8) + p := pieces[rank * 8 + file] + if p.type == .none { + // Consolidate consecutive empty squares within a rank. + num_consecutive_empty_squares: int + for file < 8 && pieces[rank * 8 + file].type == .none { + num_consecutive_empty_squares += 1 + file += 1 + } + assert(num_consecutive_empty_squares > 0) + assert(num_consecutive_empty_squares <= 8) + strings.write_rune(sb, cast(rune)('0' + num_consecutive_empty_squares)) + } else { + switch { + case p == { .rook, .white }: strings.write_rune(sb, 'R') + case p == { .knight, .white }: strings.write_rune(sb, 'N') + case p == { .bishop, .white }: strings.write_rune(sb, 'B') + case p == { .queen, .white }: strings.write_rune(sb, 'Q') + case p == { .king, .white }: strings.write_rune(sb, 'K') + case p == { .pawn, .white }: strings.write_rune(sb, 'P') + case p == { .rook, .black }: strings.write_rune(sb, 'r') + case p == { .knight, .black }: strings.write_rune(sb, 'n') + case p == { .bishop, .black }: strings.write_rune(sb, 'b') + case p == { .queen, .black }: strings.write_rune(sb, 'q') + case p == { .king, .black }: strings.write_rune(sb, 'k') + case p == { .pawn, .black }: strings.write_rune(sb, 'p') + case: strings.write_rune(sb, '?') + } + file += 1 + } + } + + assert(file == 8) + + if rank != 0 { + strings.write_rune(sb, '/') + } + } +} + +fen_write_en_passant_target_square :: proc(sb: ^strings.Builder, square: Square_Specifier) { + if square == {} { + // NOTE: This works because (0, 0) is never a valid en passant square. + strings.write_string(sb, "-") + } else { + if square.file < 0 || square.file >= 8 { + strings.write_string(sb, "?") + } else { + strings.write_rune(sb, cast(rune)('a' + square.file)) + } + + if square.rank < 0 || square.rank >= 8 { + strings.write_string(sb, "?") + } else { + strings.write_rune(sb, cast(rune)('1' + square.rank)) + } + } +} + +@(private="file") +Parse_Ctx :: struct { + s: string, + c: int, + board: Board, +} + +@(private="file") +eat_spaces :: proc(ctx: ^Parse_Ctx) { + for ctx.c < len(ctx.s) && strings.is_ascii_space(cast(rune)ctx.s[ctx.c]) { + ctx.c += 1 + } +} + +@(private="file") +read_number :: proc(ctx: ^Parse_Ctx) -> string { + start := ctx.c + for { + c := ctx.s[ctx.c] if ctx.c < len(ctx.s) else 0 + if c >= '0' && c <= '9' { + ctx.c += 1 + } else { + break + } + } + return ctx.s[start:ctx.c] +} diff --git a/win32/fentool/file_dialog.odin b/win32/fentool/file_dialog.odin new file mode 100644 index 0000000..02f1da5 --- /dev/null +++ b/win32/fentool/file_dialog.odin @@ -0,0 +1,127 @@ +package fen_tool + +import runtime "base:runtime" +import strings "core:strings" +import log "core:log" +import win "core:sys/windows" + +File_Dialog_Mode :: enum { + open, + save, +} + +File_Dialog_Filter :: struct { + // The full line of text displayed to the user in the selection box. + // + // Examples: + // - `"Text files (*.txt)"` + // - `"All files (*.*)"` + display_text: string, + + // The internal search pattern used to filter the files. + // + // Examples: + // - `"*.txt"` + // - `"*.*"` // NOTE: This includes files without an extension. + filter: string, +} + +file_dialog :: proc( + owner: win.HWND, + mode: File_Dialog_Mode, + initial_file: Maybe(string) = nil, + filters: []File_Dialog_Filter = nil, + #any_int selected_filter_index: u32 = 0, + dialog_caption: Maybe(string) = nil, + initial_directory: Maybe(string) = nil, + allocator: runtime.Allocator = context.allocator, +) -> ( + dialog_confirmed: bool, + selected_file: string, +) { + file_buf := make([]u16, win.MAX_PATH_WIDE, context.temp_allocator) + file_buf[0] = 0 + + if initial_file != nil { + converted := win.utf8_to_wstring(file_buf, initial_file.?) + if converted == nil { + // Conversion error. + log.warnf("Unable to convert initial file name string.") + file_buf[0] = 0 + } + } + + ofn := win.OPENFILENAMEW { + lStructSize = size_of(win.OPENFILENAMEW), + hwndOwner = owner, + lpstrFilter = file_dialog_build_filter_string(filters), + nFilterIndex = selected_filter_index + 1, + lpstrFile = cast(win.LPWSTR)raw_data(file_buf[:]), + nMaxFile = win.MAX_PATH_WIDE, + } + + if dialog_caption != nil { + ofn.lpstrTitle = win.utf8_to_wstring(dialog_caption.?, context.temp_allocator) + } + if initial_directory != nil { + ofn.lpstrInitialDir = win.utf8_to_wstring(initial_directory.?, context.temp_allocator) + } + + confirmed: win.BOOL + + switch mode { + case .open: + ofn.Flags = win.OFN_PATHMUSTEXIST | win.OFN_FILEMUSTEXIST | win.OFN_EXPLORER + confirmed = win.GetOpenFileNameW(&ofn) + case .save: + ofn.Flags = win.OFN_OVERWRITEPROMPT | win.OFN_EXPLORER + confirmed = win.GetSaveFileNameW(&ofn) + case: + unreachable() + } + + if confirmed { + // Convert result to string. + path := cast(win.wstring)ofn.lpstrFile + path_str, err1 := win.wstring_to_utf8(path, N = -1, allocator = allocator) + if err1 != nil { + log.warnf("File dialog confirmed - string conversion error: %v", err1) + return false, "" + } + return true, path_str + } else { + return false, "" + } +} + +// Converts the `filters` slice to the string format required by `OPENFILENAMEW.lpstrFilter`. +// +// Returns `nil` if `filters` is an empty slice. +file_dialog_build_filter_string :: proc(filters: []File_Dialog_Filter, allocator: runtime.Allocator = context.temp_allocator) -> win.LPCWSTR { + + if len(filters) == 0 { + return nil + } + + needed_capacity := 0 + for f in filters { + needed_capacity += len(f.display_text) + needed_capacity += 1 + needed_capacity += len(f.filter) + needed_capacity += 1 + } + needed_capacity += 1 + + sb: strings.Builder + strings.builder_init_len_cap(&sb, 0, needed_capacity, context.temp_allocator) + + for f in filters { + strings.write_string(&sb, f.display_text) + strings.write_string(&sb, "\x00") + strings.write_string(&sb, f.filter) + strings.write_string(&sb, "\x00") + } + strings.write_string(&sb, "\x00") + + return win.utf8_to_wstring(strings.to_string(sb), allocator) +} diff --git a/win32/fentool/icon.ico b/win32/fentool/icon.ico new file mode 100644 index 0000000..67f1f37 Binary files /dev/null and b/win32/fentool/icon.ico differ diff --git a/win32/fentool/main.odin b/win32/fentool/main.odin new file mode 100644 index 0000000..00933e8 --- /dev/null +++ b/win32/fentool/main.odin @@ -0,0 +1,771 @@ +package fen_tool + +import runtime "base:runtime" +import fmt "core:fmt" +import log "core:log" +import os "core:os" +import strings "core:strings" +import win "core:sys/windows" + +@(thread_local) window_context_tls: runtime.Context + +MAIN_WNDCLASS_NAME :: "fen_tool_main" + +main :: proc() { + + startup_info : win.STARTUPINFOW + win.GetStartupInfoW(&startup_info) + nCmdShow := (startup_info.dwFlags & win.STARTF_USESHOWWINDOW) != 0 ? cast(win.c_int)startup_info.wShowWindow : win.SW_SHOWDEFAULT + + // For testing purposes - toggles the language of loaded resources. + //win.SetThreadUILanguage(0x0413) + + context.logger = log.create_console_logger() + + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + board_control_register_class(hInstance) + + icce: win.INITCOMMONCONTROLSEX + icce.dwSize = size_of(win.INITCOMMONCONTROLSEX) + icce.dwICC = win.ICC_BAR_CLASSES // Enables status bar and toolbar classes + win.InitCommonControlsEx(&icce) + + window_context_tls = context + + hdlg := win.CreateDialogW(hInstance, "IDD_MAIN", nil, dlg_proc) + assert(hdlg != nil, "Could not create main dialog. Is 'resource.rc' compiled properly?") + fudge_window_starting_position(hdlg) + + win.ShowWindow(hdlg, nCmdShow) + + accelerator_table := win.LoadAcceleratorsW(hInstance, "IDC_ACCELERATOR_TABLE") + + msg: win.MSG + for win.GetMessageW(&msg, nil, 0, 0) != 0 { + if win.TranslateAcceleratorW(hdlg, accelerator_table, &msg) == 0 { + if !win.IsDialogMessageW(hdlg, &msg) { + win.TranslateMessage(&msg) + win.DispatchMessageW(&msg) + } + } + free_all(context.temp_allocator) + } + + log.debugf("Exit code: %v", msg.wParam) + os.exit(cast(int)msg.wParam) +} + +Main_Ctx :: struct { + runtime_context: ^runtime.Context, + // Used to suppress any change notifications while we're + // updating control contents programmatically. + updating: int, + current_pieces: Board_Pieces, + // Last opened or saved file path. + // Used as the save path in `IDM_SAVE`. + // When empty, no project file is active. + // NOTE: This instance owns the memory associated with the string. + active_project_file_path: string, + project_dirty: bool, +} + +get_main_ctx :: proc "contextless" (hdlg: win.HWND) -> ^Main_Ctx { + return cast(^Main_Ctx)cast(uintptr)win.GetWindowLongPtrW(hdlg, win.DWLP_USER) +} + +dlg_proc :: proc "system" (hdlg: win.HWND, msg: win.UINT, wparam: win.WPARAM, lparam: win.LPARAM) -> win.INT_PTR { + + switch msg { + + case win.WM_INITDIALOG: + // Setup main window context + context = window_context_tls + ctx := new(Main_Ctx) + ctx.runtime_context = &window_context_tls + win.SetWindowLongPtrW(hdlg, win.DWLP_USER, cast(win.LONG_PTR)cast(uintptr)ctx) + + // Load and set the window icon. + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + idi_main := cast(rawptr)cast(uintptr)IDI_MAIN + icon_big := win.LoadImageW(hInstance, cast(win.LPCWSTR)idi_main, win.IMAGE_ICON, win.GetSystemMetrics(win.SM_CXICON), win.GetSystemMetrics(win.SM_CYICON), win.LR_DEFAULTCOLOR | win.LR_SHARED) + icon_small := win.LoadImageW(hInstance, cast(win.LPCWSTR)idi_main, win.IMAGE_ICON, win.GetSystemMetrics(win.SM_CXSMICON), win.GetSystemMetrics(win.SM_CYSMICON), win.LR_DEFAULTCOLOR | win.LR_SHARED) + win.SendMessageW(hdlg, win.WM_SETICON, win.ICON_BIG, cast(win.LPARAM)cast(uintptr)icon_big) + win.SendMessageW(hdlg, win.WM_SETICON, win.ICON_SMALL, cast(win.LPARAM)cast(uintptr)icon_small) + + // Check initial visuals option. + hmenu := win.GetMenu(hdlg) + win.CheckMenuRadioItem(hmenu, IDM_VISUALS_NORMAL, IDM_VISUALS_GINGER, IDM_VISUALS_NORMAL, win.MF_BYCOMMAND) + + reset_to_starting_position(hdlg, ctx, set_dirty = false) + + case win.WM_NCDESTROY: + ctx := get_main_ctx(hdlg) + context = ctx.runtime_context^ + delete(ctx.active_project_file_path) + ctx.active_project_file_path = "" + free(ctx) + + case win.WM_DESTROY: + ctx := get_main_ctx(hdlg) + context = ctx.runtime_context^ + // Main window destroyed - quit message loop. + win.PostQuitMessage(0) + // Return 1 to stop DefDlgProc message handling - we handle this explicitly because we're the application's main window. + return 1 + + case win.WM_CLOSE: + ctx := get_main_ctx(hdlg) + context = ctx.runtime_context^ + if prompt_if_project_dirty(hdlg, ctx) { + win.DestroyWindow(hdlg) + } + // Return 1 to stop DefDlgProc message handling - we handle this explicitly because we're the application's main window. + return 1 + + case win.WM_SIZE: + ctx := get_main_ctx(hdlg) + context = ctx.runtime_context^ + // The status bar will adjust itself to the window size, it just needs a WM_SIZE message to trigger it. + status_bar := win.GetDlgItem(hdlg, IDC_STATUSBAR) + win.SendMessageW(status_bar, win.WM_SIZE, 0, 0) + + case win.WM_COMMAND: + + ctx := get_main_ctx(hdlg) + context = ctx.runtime_context^ + id := win.LOWORD(wparam) + nc := win.HIWORD(wparam) + + switch id { + + case IDM_RESET_TO_STARTING_POSITION: + reset_to_starting_position(hdlg, ctx, prompt_for_confirmation = true) + + case IDM_NEW: + file_new(hdlg, ctx) + + case IDM_OPEN: + file_open(hdlg, ctx) + + case IDM_SAVE: + file_save(hdlg, ctx) + + case IDM_SAVE_AS: + file_save_as(hdlg, ctx) + + case IDM_EXIT: + if prompt_if_project_dirty(hdlg, ctx) { + win.DestroyWindow(hdlg) + } + + case IDM_PLAY_SOUND: + // Toggle Play Sound option. + hmenu := win.GetMenu(hdlg) + mii: win.MENUITEMINFOW + mii.cbSize = size_of(win.MENUITEMINFOW) + mii.fMask = win.MIIM_STATE + win.GetMenuItemInfoW(hmenu, IDM_PLAY_SOUND, false, &mii) + checked := (mii.fState & win.MFS_CHECKED) != 0 + if checked { + win.CheckMenuItem(hmenu, IDM_PLAY_SOUND, win.MF_BYCOMMAND | win.MF_UNCHECKED) + } else { + win.CheckMenuItem(hmenu, IDM_PLAY_SOUND, win.MF_BYCOMMAND | win.MF_CHECKED) + } + + case IDM_VISUALS_NORMAL: + hmenu := win.GetMenu(hdlg) + win.CheckMenuRadioItem(hmenu, IDM_VISUALS_NORMAL, IDM_VISUALS_GINGER, IDM_VISUALS_NORMAL, win.MF_BYCOMMAND) + board_control := win.GetDlgItem(hdlg, IDC_BOARD_CONTROL) + board_control_set_spritemap(board_control, .normal) + + case IDM_VISUALS_GINGER: + hmenu := win.GetMenu(hdlg) + win.CheckMenuRadioItem(hmenu, IDM_VISUALS_NORMAL, IDM_VISUALS_GINGER, IDM_VISUALS_GINGER, win.MF_BYCOMMAND) + board_control := win.GetDlgItem(hdlg, IDC_BOARD_CONTROL) + board_control_set_spritemap(board_control, .ginger) + + case IDM_ABOUT: + about_show_dialog(hdlg) + + case IDC_TEXTBOX_FEN: + if nc == win.EN_CHANGE { + if ctx.updating == 0 { + reparse_fen(hdlg, ctx, interactive = true) + set_project_dirty(hdlg, ctx) + } + } + + case IDC_RADIOBUTTON_WHITE_TO_MOVE, + IDC_RADIOBUTTON_BLACK_TO_MOVE: + if nc == win.BN_CLICKED { + // Check the clicked radio button. + win.CheckRadioButton(hdlg, IDC_RADIOBUTTON_WHITE_TO_MOVE, IDC_RADIOBUTTON_BLACK_TO_MOVE, auto_cast id) + update_from_control_states(hdlg, ctx) + } + + case IDC_CHECKBOX_WHITE_CASTLE_KINGSIDE, + IDC_CHECKBOX_WHITE_CASTLE_QUEENSIDE, + IDC_CHECKBOX_BLACK_CASTLE_KINGSIDE, + IDC_CHECKBOX_BLACK_CASTLE_QUEENSIDE: + if nc == win.BN_CLICKED { + // Toggle the check state of the clicked check box. + current_state := win.IsDlgButtonChecked(hdlg, auto_cast id) == win.BST_CHECKED + new_state := current_state ? win.BST_UNCHECKED : win.BST_CHECKED + win.CheckDlgButton(hdlg, auto_cast id, auto_cast new_state) + update_from_control_states(hdlg, ctx) + } + + case IDC_TEXTBOX_EN_PASSANT_TARGET_SQUARE: + if nc == win.EN_CHANGE { + if ctx.updating == 0 { + show_en_passant_validation_tip(hdlg) + update_from_control_states(hdlg, ctx) + } + } + + case IDC_TEXTBOX_HALFMOVE_CLOCK, + IDC_TEXTBOX_FULLMOVE_NUMBER: + if nc == win.EN_CHANGE { + if ctx.updating == 0 { + update_from_control_states(hdlg, ctx) + } + } + + case IDC_BUTTON_CLEAR_EN_PASSANT: + ctx.updating += 1 + defer ctx.updating -= 1 + set_dialog_item_text(hdlg, IDC_TEXTBOX_EN_PASSANT_TARGET_SQUARE, "-") + update_from_control_states(hdlg, ctx) + } + + case win.WM_NOTIFY: + + switch wparam { + + case IDC_BOARD_CONTROL: + + ctx := get_main_ctx(hdlg) + context = ctx.runtime_context^ + nm := cast(^NM_BOARD_CONTROL_NOTIFICATION)cast(uintptr)lparam + code := cast(Board_Control_Notification_Code)nm.hdr.code + + switch code { + + case .none: + + case .hover: + if nm.hovering { + // Build a string like "A1 - Rook (White)" + square := nm.square + rank := square.rank + file := square.file + piece := ctx.current_pieces[rank * 8 + file] + piece_str := format_piece_display_text(piece, context.temp_allocator) + pos_str := board_format_position(square, context.temp_allocator) + status_text: string + if piece_str != "" { + status_text = fmt.tprintf("%v - %v", pos_str, piece_str) + } else { + status_text = pos_str + } + set_dialog_item_text(hdlg, IDC_STATUSBAR, status_text) + } else { + set_dialog_item_text(hdlg, IDC_STATUSBAR, "") + } + + case .place_piece: + rank := nm.square.rank + file := nm.square.file + piece := nm.piece + ctx.current_pieces[rank * 8 + file] = piece + update_from_control_states(hdlg, ctx) + play_piece_placing_sound_if_enabled(hdlg) + + case .set_en_passant_target_square: + square := nm.square + set_en_passant_target_square(hdlg, square) + update_from_control_states(hdlg, ctx) + + case .set_castling_right: + castling_right := nm.castling_right + state := nm.castling_right_state + button_state: win.UINT + switch state { + case .allow: button_state = win.BST_CHECKED + case .disallow: button_state = win.BST_UNCHECKED + case: unreachable() + } + switch castling_right { + case .white_kingside: win.CheckDlgButton(hdlg, IDC_CHECKBOX_WHITE_CASTLE_KINGSIDE, button_state) + case .white_queenside: win.CheckDlgButton(hdlg, IDC_CHECKBOX_WHITE_CASTLE_QUEENSIDE, button_state) + case .black_kingside: win.CheckDlgButton(hdlg, IDC_CHECKBOX_BLACK_CASTLE_KINGSIDE, button_state) + case .black_queenside: win.CheckDlgButton(hdlg, IDC_CHECKBOX_BLACK_CASTLE_QUEENSIDE, button_state) + } + update_from_control_states(hdlg, ctx) + + case .exchange_pieces: + rank1 := nm.square.rank + file1 := nm.square.file + rank2 := nm.square2.rank + file2 := nm.square2.file + tmp := ctx.current_pieces[rank1 * 8 + file1] + ctx.current_pieces[rank1 * 8 + file1] = ctx.current_pieces[rank2 * 8 + file2] + ctx.current_pieces[rank2 * 8 + file2] = tmp + update_from_control_states(hdlg, ctx) + play_piece_placing_sound_if_enabled(hdlg) + } + } + + case win.WM_MENUSELECT: + // User is hovering over any menu item. + // Try to get a help hint for the status bar by looking for a string resource + // with the same ID as the hovered menu item ID. + // This works because by convention, we share resource IDs in this particular way. + ctx := get_main_ctx(hdlg) + context = ctx.runtime_context^ + menu_item_id := cast(win.UINT)win.LOWORD(wparam) + flags := win.HIWORD(wparam) + hmenu := cast(win.HMENU)cast(uintptr)lparam + if flags == 0xffff && hmenu == nil { + set_dialog_item_text(hdlg, IDC_STATUSBAR, "") + } else { + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + // NOTE: If no matching string exists for the specified menu item ID, this will return "", + // in which case the status bar shows nothing. + string_resource, _ := load_string_resource(hInstance, menu_item_id, context.temp_allocator) + set_dialog_item_text(hdlg, IDC_STATUSBAR, string_resource) + } + } + + return 0 +} + +// +// File New/Open/Save/SaveAs +// + +file_new :: proc(hdlg: win.HWND, ctx: ^Main_Ctx) -> bool { + if !prompt_if_project_dirty(hdlg, ctx) { + return false + } + delete(ctx.active_project_file_path) + ctx.active_project_file_path = "" + reset_to_starting_position(hdlg, ctx, set_dirty = false) + set_project_dirty(hdlg, ctx, false) + return true +} + +file_open :: proc(hdlg: win.HWND, ctx: ^Main_Ctx) -> bool { + + if !prompt_if_project_dirty(hdlg, ctx) { + return false + } + + filters := get_file_dialog_filters() + dialog_confirmed, file_path := file_dialog( + owner = hdlg, + mode = .open, + filters = filters[:], + allocator = context.allocator, + ) + + if !dialog_confirmed { + return false + } + + data, error := os.read_entire_file_from_path(file_path, context.temp_allocator) + if error != nil { + // NOTE: The Win32 system error message can actually be nicer than what the OS package gives us here. + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + error_message := os.error_string(error) + error_caption := load_string_resource(hInstance, IDS_ERROR_CAPTION, context.temp_allocator) + win.MessageBoxW(hdlg, win.utf8_to_wstring(error_message, context.temp_allocator), win.utf8_to_wstring(error_caption, context.temp_allocator), win.MB_OK) + return false + } + + fen := transmute(string)data + set_dialog_item_text(hdlg, IDC_TEXTBOX_FEN, fen) + delete(ctx.active_project_file_path) + ctx.active_project_file_path = file_path + set_project_dirty(hdlg, ctx, false) + return true +} + +file_save :: proc(hdlg: win.HWND, ctx: ^Main_Ctx) -> bool { + + if ctx.active_project_file_path == "" { + return file_save_as(hdlg, ctx) + } + + fen := get_dialog_item_text(hdlg, IDC_TEXTBOX_FEN, 512) + error := os.write_entire_file(ctx.active_project_file_path, fen) + if error != nil { + // NOTE: The Win32 system error message can actually be nicer than what the OS package gives us here. + error_message := os.error_string(error) + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + error_caption := load_string_resource(hInstance, IDS_ERROR_CAPTION, context.temp_allocator) + win.MessageBoxW(hdlg, win.utf8_to_wstring(error_message, context.temp_allocator), win.utf8_to_wstring(error_caption, context.temp_allocator), win.MB_OK) + return false + } + set_project_dirty(hdlg, ctx, false) + return true +} + +file_save_as :: proc(hdlg: win.HWND, ctx: ^Main_Ctx) -> bool { + + filters := get_file_dialog_filters() + dialog_confirmed, file_path := file_dialog( + owner = hdlg, + mode = .save, + filters = filters[:], + allocator = context.allocator, + ) + + if !dialog_confirmed { + return false + } + + fen := get_dialog_item_text(hdlg, IDC_TEXTBOX_FEN, 512) + error := os.write_entire_file(file_path, fen) + if error != nil { + // NOTE: The Win32 system error message can actually be nicer than what the OS package gives us here. + error_message := os.error_string(error) + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + error_caption := load_string_resource(hInstance, IDS_ERROR_CAPTION, context.temp_allocator) + win.MessageBoxW(hdlg, win.utf8_to_wstring(error_message, context.temp_allocator), win.utf8_to_wstring(error_caption, context.temp_allocator), win.MB_OK) + return false + } + + delete(ctx.active_project_file_path) + ctx.active_project_file_path = file_path + set_project_dirty(hdlg, ctx, false) + return true +} + +get_file_dialog_filters :: proc() -> [2]File_Dialog_Filter { + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + return [2]File_Dialog_Filter { + { display_text = load_string_resource(hInstance, IDS_FEN_FILES_FILTER), filter = "*.fen" }, + { display_text = load_string_resource(hInstance, IDS_ALL_FILES_FILTER), filter = "*.*" }, + } +} + +prompt_if_project_dirty :: proc(hdlg: win.HWND, ctx: ^Main_Ctx) -> bool { + if ctx.project_dirty { + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + text := load_string_resource(hInstance, IDS_UNSAVED_CHANGES_MESSAGE) + caption := load_string_resource(hInstance, IDS_UNSAVED_CHANGES_CAPTION) + choice := win.MessageBoxW(hdlg, win.utf8_to_wstring(text), win.utf8_to_wstring(caption), win.MB_YESNOCANCEL) + switch choice { + case win.IDYES: + if !file_save(hdlg, ctx) { + // Saving failed or got canceled by user. + return false + } + case win.IDNO: + // OK, discard. + case: + return false + } + } + return true +} + +set_project_dirty :: proc(hdlg: win.HWND, ctx: ^Main_Ctx, dirty := true) { + if ctx.project_dirty != dirty { + ctx.project_dirty = dirty + if dirty { + // Append "*" to the main window caption. + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + caption := load_string_resource(hInstance, IDS_TITLE, context.temp_allocator) + caption_dirty := strings.concatenate({ caption, "*" }, context.temp_allocator) + win.SetWindowTextW(hdlg, win.utf8_to_wstring(caption_dirty, context.temp_allocator)) + } else { + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + caption := load_string_resource(hInstance, IDS_TITLE, context.temp_allocator) + win.SetWindowTextW(hdlg, win.utf8_to_wstring(caption, context.temp_allocator)) + } + } +} + +play_piece_placing_sound_if_enabled :: proc(hdlg: win.HWND) { + // Query the "Play Sound" option directly from the menu. + hmenu := win.GetMenu(hdlg) + mii: win.MENUITEMINFOW + mii.cbSize = size_of(win.MENUITEMINFOW) + mii.fMask = win.MIIM_STATE + win.GetMenuItemInfoW(hmenu, IDM_PLAY_SOUND, false, &mii) + checked := (mii.fState & win.MFS_CHECKED) != 0 + if checked { + // PlaySoundW can play the sound directly from the resource. + win.PlaySoundW("IDW_CLACK", win.GetModuleHandleW(nil), win.SND_ASYNC | win.SND_RESOURCE) + } +} + +show_en_passant_validation_tip :: proc(hdlg: win.HWND) { + + text := get_dialog_item_text(hdlg, IDC_TEXTBOX_EN_PASSANT_TARGET_SQUARE, 512, context.temp_allocator) + trimmed := strings.trim_space(text) + if len(trimmed) < 2 { + return + } + + _, ok := parse_en_passant_target_square(trimmed) + if ok { + return + } + + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + + tip := win.EDITBALLOONTIP { + cbStruct = size_of(win.EDITBALLOONTIP), + pszTitle = win.utf8_to_wstring_alloc(load_string_resource(hInstance, IDS_INVALID_EN_PASSANT_TARGET_SQUARE, context.temp_allocator), context.temp_allocator), + pszText = win.utf8_to_wstring_alloc(load_string_resource(hInstance, IDS_INVALID_EN_PASSANT_TARGET_SQUARE_HINT, context.temp_allocator), context.temp_allocator), + ttiIcon = win.TTI_ERROR, + } + + // NOTE: We send the message to display the balloon tip, but we do not prevent user input here, because that would be irritating. + win.SendDlgItemMessageW(hdlg, IDC_TEXTBOX_EN_PASSANT_TARGET_SQUARE, win.EM_SHOWBALLOONTIP, 0, cast(win.LPARAM)cast(uintptr)&tip) +} + +format_piece_display_text :: proc(piece: Board_Piece, allocator := context.temp_allocator) -> string { + + type_str: string + + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + + switch piece.type { + case .none: return "" + case .rook: type_str = load_string_resource(hInstance, IDS_ROOK, context.temp_allocator) + case .knight: type_str = load_string_resource(hInstance, IDS_KNIGHT, context.temp_allocator) + case .bishop: type_str = load_string_resource(hInstance, IDS_BISHOP, context.temp_allocator) + case .queen: type_str = load_string_resource(hInstance, IDS_QUEEN, context.temp_allocator) + case .king: type_str = load_string_resource(hInstance, IDS_KING, context.temp_allocator) + case .pawn: type_str = load_string_resource(hInstance, IDS_PAWN, context.temp_allocator) + case: type_str = "?" + } + + color_str: string + + switch piece.color { + case .white: color_str = load_string_resource(hInstance, IDS_WHITE, context.temp_allocator) + case .black: color_str = load_string_resource(hInstance, IDS_BLACK, context.temp_allocator) + case: color_str = "?" + } + + return fmt.aprintf("%v (%v)", type_str, color_str, allocator = allocator) +} + +STARTING_POSITION_FEN :: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + +reset_to_starting_position :: proc(hdlg: win.HWND, ctx: ^Main_Ctx, set_dirty := true, prompt_for_confirmation := false) { + + if prompt_for_confirmation { + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + message := win.utf8_to_wstring(load_string_resource(hInstance, IDS_CONFIRM_RESET_MESSAGE, context.temp_allocator)) + caption := win.utf8_to_wstring(load_string_resource(hInstance, IDS_CONFIRM_RESET_CAPTION, context.temp_allocator)) + choice := win.MessageBoxW(hdlg, message, caption, win.MB_YESNO) + if choice != win.IDYES { + return + } + } + + { + ctx.updating += 1 + defer ctx.updating -= 1 + set_dialog_item_text_cstring16(hdlg, IDC_TEXTBOX_FEN, STARTING_POSITION_FEN) + if set_dirty { + set_project_dirty(hdlg, ctx) + } + } + + reparse_fen(hdlg, ctx, interactive = false) +} + +// Parses the contents of the FEN string text box, and updates all +// controls accordingly to the board state it denotes. +// +// - `interactive`: Displays a balloon tip if the text box contains an invalid FEN string. +// Should only be used if this was called after direct user interaction. +reparse_fen :: proc(hdlg: win.HWND, ctx: ^Main_Ctx, interactive: bool) { + + fen_string := get_dialog_item_text(hdlg, IDC_TEXTBOX_FEN, 512, context.temp_allocator) + if fen_string == "" { + return + } + + board, error := parse_fen(fen_string) + if error.is_error { + log.debugf("FEN string is invalid.") + if interactive { + // Display balloon tip with the error message. + hInstance := cast(win.HINSTANCE)win.GetModuleHandleW(nil) + tip := win.EDITBALLOONTIP { + cbStruct = size_of(win.EDITBALLOONTIP), + pszTitle = win.utf8_to_wstring_alloc(load_string_resource(hInstance, IDS_INVALID_FEN_STRING, context.temp_allocator), context.temp_allocator), + pszText = win.utf8_to_wstring_alloc(error.display_message, context.temp_allocator), + ttiIcon = win.TTI_ERROR, + } + win.SendDlgItemMessageW(hdlg, IDC_TEXTBOX_FEN, win.EM_SHOWBALLOONTIP, 0, cast(win.LPARAM)cast(uintptr)&tip) + } + return + } + + log.debugf("Board:\r\n%v", board_format_full(board)) + + ctx.updating += 1 + defer ctx.updating -= 1 + + ctx.current_pieces = board.pieces + + board_control := win.GetDlgItem(hdlg, IDC_BOARD_CONTROL) + board_control_set_board(board_control, board) + + check_button: win.c_int + switch board.turn_player { + case .white: + check_button = IDC_RADIOBUTTON_WHITE_TO_MOVE + case .black: + check_button = IDC_RADIOBUTTON_BLACK_TO_MOVE + } + win.CheckRadioButton(hdlg, IDC_RADIOBUTTON_WHITE_TO_MOVE, IDC_RADIOBUTTON_BLACK_TO_MOVE, check_button) + + win.CheckDlgButton(hdlg, IDC_CHECKBOX_WHITE_CASTLE_KINGSIDE, board.white_can_castle_kingside ? win.BST_CHECKED : win.BST_UNCHECKED) + win.CheckDlgButton(hdlg, IDC_CHECKBOX_WHITE_CASTLE_QUEENSIDE, board.white_can_castle_queenside ? win.BST_CHECKED : win.BST_UNCHECKED) + win.CheckDlgButton(hdlg, IDC_CHECKBOX_BLACK_CASTLE_KINGSIDE, board.black_can_castle_kingside ? win.BST_CHECKED : win.BST_UNCHECKED) + win.CheckDlgButton(hdlg, IDC_CHECKBOX_BLACK_CASTLE_QUEENSIDE, board.black_can_castle_queenside ? win.BST_CHECKED : win.BST_UNCHECKED) + + set_en_passant_target_square(hdlg, board.en_passant_target_square) + + win.SetDlgItemInt(hdlg, IDC_TEXTBOX_HALFMOVE_CLOCK, cast(win.UINT)board.halfmove_clock, true) + win.SetDlgItemInt(hdlg, IDC_TEXTBOX_FULLMOVE_NUMBER, cast(win.UINT)board.fullmove_number, true) +} + +set_en_passant_target_square :: proc(hdlg: win.HWND, square: Square_Specifier) { + square_text := board_format_en_passant_target_square(square) + set_dialog_item_text(hdlg, IDC_TEXTBOX_EN_PASSANT_TARGET_SQUARE, square_text) +} + +// Queries the state of all controls, and then displays the +// corresponding FEN string in the FEN string text box, and the +// board state in the board control. +update_from_control_states :: proc(hdlg: win.HWND, ctx: ^Main_Ctx) { + + // Construct board from control states. + board, board_ok := get_board_from_control_states(hdlg, ctx) + if !board_ok { + log.warn("Could not construct board from control states.") + return + } + + // Display the board state in the board control. + board_control := win.GetDlgItem(hdlg, IDC_BOARD_CONTROL) + board_control_set_board(board_control, board) + + // Convert board info to FEN string. + fen_string, fen_string_ok := construct_fen_string_from_board(board) + if !fen_string_ok { + log.warn("Could not construct FEN string from board.") + return + } + + // Put the constructed FEN string the FEN string text box. + old_fen_string := get_dialog_item_text(hdlg, IDC_TEXTBOX_FEN, 512, context.temp_allocator) + if fen_string != old_fen_string { + ctx.updating += 1 + defer ctx.updating -= 1 + log.debugf("FEN string changing from %q to %q", old_fen_string, fen_string) + fen_wstr := win.utf8_to_wstring_alloc(fen_string, context.temp_allocator) + win.SetDlgItemTextW(hdlg, IDC_TEXTBOX_FEN, fen_wstr) + set_project_dirty(hdlg, ctx) + } +} + +get_board_from_control_states :: proc(hdlg: win.HWND, ctx: ^Main_Ctx) -> (result: Board, ok: bool) { + + log.debugf("Constructing board from control states...") + + // Collect all information in this board instance. + board: Board + + board.pieces = ctx.current_pieces + + if win.IsDlgButtonChecked(hdlg, IDC_RADIOBUTTON_WHITE_TO_MOVE) == win.BST_CHECKED { + board.turn_player = .white + } else if win.IsDlgButtonChecked(hdlg, IDC_RADIOBUTTON_BLACK_TO_MOVE) == win.BST_CHECKED { + board.turn_player = .black + } else { + ok = false + return + } + + board.white_can_castle_kingside = win.IsDlgButtonChecked(hdlg, IDC_CHECKBOX_WHITE_CASTLE_KINGSIDE) == win.BST_CHECKED + board.white_can_castle_queenside = win.IsDlgButtonChecked(hdlg, IDC_CHECKBOX_WHITE_CASTLE_QUEENSIDE) == win.BST_CHECKED + board.black_can_castle_kingside = win.IsDlgButtonChecked(hdlg, IDC_CHECKBOX_BLACK_CASTLE_KINGSIDE) == win.BST_CHECKED + board.black_can_castle_queenside = win.IsDlgButtonChecked(hdlg, IDC_CHECKBOX_BLACK_CASTLE_QUEENSIDE) == win.BST_CHECKED + + en_passant_target_square_text := get_dialog_item_text(hdlg, IDC_TEXTBOX_EN_PASSANT_TARGET_SQUARE, 10, context.temp_allocator) + en_passant_target_square, en_passant_target_square_ok := parse_en_passant_target_square(en_passant_target_square_text) + if !en_passant_target_square_ok { + ok = false + return + } + board.en_passant_target_square = en_passant_target_square + + { + success: win.BOOL + board.halfmove_clock = cast(i32)win.GetDlgItemInt(hdlg, IDC_TEXTBOX_HALFMOVE_CLOCK, &success, bSigned = false) + if !success { + ok = false + return + } + } + + { + success: win.BOOL + board.fullmove_number = cast(i32)win.GetDlgItemInt(hdlg, IDC_TEXTBOX_FULLMOVE_NUMBER, &success, bSigned = false) + if !success { + ok = false + return + } + } + + return board, true +} + +construct_fen_string_from_board :: proc(board: Board) -> (result: string, ok: bool) { + fen_string := format_fen(board) + return fen_string, true +} + +parse_en_passant_target_square :: proc(s0: string) -> (result: Square_Specifier, ok: bool) { + + s := strings.trim_space(s0) + if s == "" || s == "-" { + // We interpret an empty string as "no en passant". + return {}, true + } + if len(s) != 2 { + log.warnf("Invalid en passant target square (string length must be 2): %v", s) + return {}, false + } + + // NOTE: While FEN only allows lowercase letters here, the text box accepts both. + file: int + if s[0] >= 'A' && s[0] <= 'H' { + file = cast(int)s[0] - 'A' + } else if s[0] >= 'a' && s[0] <= 'h' { + file = cast(int)s[0] - 'a' + } else { + log.warnf("Invalid file in en passant target square: %v", s) + return {}, false + } + + rank := cast(int)s[1] - '1' + if rank < 0 || rank >= 8 { + log.warnf("Invalid rank in en passant target square: %v", s) + return {}, false + } + + return { file = cast(i8)file, rank = cast(i8)rank }, true +} diff --git a/win32/fentool/piecesnormal.png b/win32/fentool/piecesnormal.png new file mode 100644 index 0000000..cd89893 Binary files /dev/null and b/win32/fentool/piecesnormal.png differ diff --git a/win32/fentool/resource.h b/win32/fentool/resource.h new file mode 100644 index 0000000..3200d0f --- /dev/null +++ b/win32/fentool/resource.h @@ -0,0 +1,86 @@ +#define IDC_STATIC -1 + +#define IDI_MAIN 1 + +#define IDC_TEXTBOX_FEN 102 +#define IDC_BOARD_CONTROL 104 +#define IDC_RADIOBUTTON_WHITE_TO_MOVE 105 +#define IDC_RADIOBUTTON_BLACK_TO_MOVE 106 +#define IDC_CHECKBOX_WHITE_CASTLE_KINGSIDE 107 +#define IDC_CHECKBOX_WHITE_CASTLE_QUEENSIDE 108 +#define IDC_CHECKBOX_BLACK_CASTLE_KINGSIDE 109 +#define IDC_CHECKBOX_BLACK_CASTLE_QUEENSIDE 110 +#define IDC_TEXTBOX_EN_PASSANT_TARGET_SQUARE 111 +#define IDC_TEXTBOX_HALFMOVE_CLOCK 112 +#define IDC_TEXTBOX_FULLMOVE_NUMBER 113 +#define IDC_STATUSBAR 114 +#define IDM_SQUARE_INFO 115 +#define IDM_CLEAR_SQUARE 116 +#define IDM_ROOK_WHITE 117 +#define IDM_KNIGHT_WHITE 118 +#define IDM_BISHOP_WHITE 119 +#define IDM_QUEEN_WHITE 120 +#define IDM_KING_WHITE 121 +#define IDM_PAWN_WHITE 122 +#define IDM_ROOK_BLACK 123 +#define IDM_KNIGHT_BLACK 124 +#define IDM_BISHOP_BLACK 125 +#define IDM_QUEEN_BLACK 126 +#define IDM_KING_BLACK 127 +#define IDM_PAWN_BLACK 128 +#define IDM_SET_EN_PASSANT_TARGET_SQUARE 129 +#define IDM_TOGGLE_CASTLING_RIGHT 130 +#define IDM_RESET_TO_STARTING_POSITION 131 +#define IDM_EXIT 132 +#define IDM_ABOUT 133 +#define IDC_BUTTON_CLEAR_EN_PASSANT 134 +#define IDM_OPEN 135 +#define IDM_SAVE_AS 136 +#define IDM_SAVE 137 +#define IDM_NEW 138 +#define IDM_PLAY_SOUND 139 +#define IDM_VISUALS_NORMAL 140 +#define IDM_VISUALS_GINGER 141 + +#define IDS_FEN_ERR_RANK_SEPARATOR_AT_INVALID_POSITION_0 400 +#define IDS_FEN_ERR_RANK_OVERFLOW_ADVANCE_0_1 401 +#define IDS_FEN_ERR_BOARD_SEPARATOR_AT_INVALID_POSITION_0 402 +#define IDS_FEN_ERR_INVALID_CHARACTER_0_AT_POSITION_1 403 +#define IDS_FEN_ERR_RANK_OVERFLOW_CANNOT_PLACE_0_1_AT_2 404 +#define IDS_FEN_ERR_EXPECTED_PLAYER_TO_MOVE_0 405 +#define IDS_FEN_ERR_EXPECTED_SPACE_0 406 +#define IDS_FEN_ERR_DUPLICATE_CASTLING_RIGHT_SPECIFIER_0 407 +#define IDS_FEN_ERR_CASTLING_RIGHT_SPECIFIER_CONFLICT 408 +#define IDS_FEN_ERR_EXPECTED_CASTLING_RIGHTS_SPECIFIER_0 409 +#define IDS_FEN_ERR_INVALID_EN_PASSANT_TARGET_SQUARE_FILE_0 410 +#define IDS_FEN_ERR_INVALID_EN_PASSANT_TARGET_SQUARE_RANK_0 411 +#define IDS_FEN_ERR_INVALID_HALFMOVE_CLOCK_0 412 +#define IDS_FEN_ERR_INVALID_FULLMOVE_NUMBER_0 413 +#define IDS_FEN_ERR_EXPECTED_END_OF_STRING 414 +#define IDS_INVALID_EN_PASSANT_TARGET_SQUARE 415 +#define IDS_INVALID_EN_PASSANT_TARGET_SQUARE_HINT 416 +#define IDS_INVALID_FEN_STRING 417 +#define IDS_ROOK 418 +#define IDS_KNIGHT 419 +#define IDS_BISHOP 420 +#define IDS_QUEEN 421 +#define IDS_KING 422 +#define IDS_PAWN 423 +#define IDS_WHITE 424 +#define IDS_BLACK 425 +#define IDS_TITLE 426 +#define IDS_CONFIRM_RESET_CAPTION 427 +#define IDS_CONFIRM_RESET_MESSAGE 428 +#define IDS_ERROR_CAPTION 429 +#define IDS_FEN_FILES_FILTER 430 +#define IDS_ALL_FILES_FILTER 431 +#define IDS_UNSAVED_CHANGES_CAPTION 432 +#define IDS_UNSAVED_CHANGES_MESSAGE 433 +#define IDS_ALLOW_CASTLING_WHITE_KINGSIDE 434 +#define IDS_DISALLOW_CASTLING_WHITE_KINGSIDE 435 +#define IDS_ALLOW_CASTLING_WHITE_QUEENSIDE 436 +#define IDS_DISALLOW_CASTLING_WHITE_QUEENSIDE 437 +#define IDS_ALLOW_CASTLING_BLACK_KINGSIDE 438 +#define IDS_DISALLOW_CASTLING_BLACK_KINGSIDE 439 +#define IDS_ALLOW_CASTLING_BLACK_QUEENSIDE 440 +#define IDS_DISALLOW_CASTLING_BLACK_QUEENSIDE 441 diff --git a/win32/fentool/resource.odin b/win32/fentool/resource.odin new file mode 100644 index 0000000..508f935 --- /dev/null +++ b/win32/fentool/resource.odin @@ -0,0 +1,89 @@ +package fen_tool + +IDI_MAIN :: 1 + +IDC_TEXTBOX_FEN :: 102 +IDC_BOARD_CONTROL :: 104 +IDC_RADIOBUTTON_WHITE_TO_MOVE :: 105 +IDC_RADIOBUTTON_BLACK_TO_MOVE :: 106 +IDC_CHECKBOX_WHITE_CASTLE_KINGSIDE :: 107 +IDC_CHECKBOX_WHITE_CASTLE_QUEENSIDE :: 108 +IDC_CHECKBOX_BLACK_CASTLE_KINGSIDE :: 109 +IDC_CHECKBOX_BLACK_CASTLE_QUEENSIDE :: 110 +IDC_TEXTBOX_EN_PASSANT_TARGET_SQUARE :: 111 +IDC_TEXTBOX_HALFMOVE_CLOCK :: 112 +IDC_TEXTBOX_FULLMOVE_NUMBER :: 113 +IDC_STATUSBAR :: 114 +IDM_SQUARE_INFO :: 115 +IDM_CLEAR_SQUARE :: 116 +IDM_ROOK_WHITE :: 117 +IDM_KNIGHT_WHITE :: 118 +IDM_BISHOP_WHITE :: 119 +IDM_QUEEN_WHITE :: 120 +IDM_KING_WHITE :: 121 +IDM_PAWN_WHITE :: 122 +IDM_ROOK_BLACK :: 123 +IDM_KNIGHT_BLACK :: 124 +IDM_BISHOP_BLACK :: 125 +IDM_QUEEN_BLACK :: 126 +IDM_KING_BLACK :: 127 +IDM_PAWN_BLACK :: 128 +IDM_SET_EN_PASSANT_TARGET_SQUARE :: 129 +IDM_TOGGLE_CASTLING_RIGHT :: 130 +IDM_RESET_TO_STARTING_POSITION :: 131 +IDM_EXIT :: 132 +IDM_ABOUT :: 133 +IDC_BUTTON_CLEAR_EN_PASSANT :: 134 +IDM_OPEN :: 135 +IDM_SAVE_AS :: 136 +IDM_SAVE :: 137 +IDM_NEW :: 138 +IDM_PLAY_SOUND :: 139 +IDM_VISUALS_NORMAL :: 140 +IDM_VISUALS_GINGER :: 141 + +// String resource IDs +// Convention: The numbers correspond to numbered argument inserts for aprintf `{0}`, `{1}` etc. + +IDS_FEN_ERR_RANK_SEPARATOR_AT_INVALID_POSITION_0 :: 400 +IDS_FEN_ERR_RANK_OVERFLOW_ADVANCE_0_1 :: 401 +IDS_FEN_ERR_BOARD_SEPARATOR_AT_INVALID_POSITION_0 :: 402 +IDS_FEN_ERR_INVALID_CHARACTER_0_AT_POSITION_1 :: 403 +IDS_FEN_ERR_RANK_OVERFLOW_CANNOT_PLACE_0_1_AT_2 :: 404 +IDS_FEN_ERR_EXPECTED_PLAYER_TO_MOVE_0 :: 405 +IDS_FEN_ERR_EXPECTED_SPACE_0 :: 406 +IDS_FEN_ERR_DUPLICATE_CASTLING_RIGHT_SPECIFIER_0 :: 407 +IDS_FEN_ERR_CASTLING_RIGHT_SPECIFIER_CONFLICT :: 408 +IDS_FEN_ERR_EXPECTED_CASTLING_RIGHTS_SPECIFIER_0 :: 409 +IDS_FEN_ERR_INVALID_EN_PASSANT_TARGET_SQUARE_FILE_0 :: 410 +IDS_FEN_ERR_INVALID_EN_PASSANT_TARGET_SQUARE_RANK_0 :: 411 +IDS_FEN_ERR_INVALID_HALFMOVE_CLOCK_0 :: 412 +IDS_FEN_ERR_INVALID_FULLMOVE_NUMBER_0 :: 413 +IDS_FEN_ERR_EXPECTED_END_OF_STRING :: 414 +IDS_INVALID_EN_PASSANT_TARGET_SQUARE :: 415 +IDS_INVALID_EN_PASSANT_TARGET_SQUARE_HINT :: 416 +IDS_INVALID_FEN_STRING :: 417 +IDS_ROOK :: 418 +IDS_KNIGHT :: 419 +IDS_BISHOP :: 420 +IDS_QUEEN :: 421 +IDS_KING :: 422 +IDS_PAWN :: 423 +IDS_WHITE :: 424 +IDS_BLACK :: 425 +IDS_TITLE :: 426 +IDS_CONFIRM_RESET_CAPTION :: 427 +IDS_CONFIRM_RESET_MESSAGE :: 428 +IDS_ERROR_CAPTION :: 429 +IDS_FEN_FILES_FILTER :: 430 +IDS_ALL_FILES_FILTER :: 431 +IDS_UNSAVED_CHANGES_CAPTION :: 432 +IDS_UNSAVED_CHANGES_MESSAGE :: 433 +IDS_ALLOW_CASTLING_WHITE_KINGSIDE :: 434 +IDS_DISALLOW_CASTLING_WHITE_KINGSIDE :: 435 +IDS_ALLOW_CASTLING_WHITE_QUEENSIDE :: 436 +IDS_DISALLOW_CASTLING_WHITE_QUEENSIDE :: 437 +IDS_ALLOW_CASTLING_BLACK_KINGSIDE :: 438 +IDS_DISALLOW_CASTLING_BLACK_KINGSIDE :: 439 +IDS_ALLOW_CASTLING_BLACK_QUEENSIDE :: 440 +IDS_DISALLOW_CASTLING_BLACK_QUEENSIDE :: 441 diff --git a/win32/fentool/resource.rc b/win32/fentool/resource.rc new file mode 100644 index 0000000..472dad8 --- /dev/null +++ b/win32/fentool/resource.rc @@ -0,0 +1,421 @@ +// Read this file as UTF-8 +#pragma code_page(65001) + +#include +#include +#include "resource.h" + +LANGUAGE LANG_NEUTRAL, SUBLANG_NEUTRAL + +CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "app.manifest" + +// NOTE: The icon with the lowest ID value becomes the executable's main icon (displayed in Windows Explorer). +IDI_MAIN ICON "icon.ico" +IDB_ABOUT BITMAP "about.bmp" +IDR_PIECES_NORMAL RCDATA "piecesnormal.png" +IDR_PIECES_GINGER RCDATA "billchess.png" +IDW_CLACK WAVE "clack.wav" + +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +IDM_MAIN_MENU MENU +BEGIN + POPUP "&File" + BEGIN + MENUITEM "&Reset to starting position", IDM_RESET_TO_STARTING_POSITION + MENUITEM SEPARATOR + MENUITEM "&New\tCtrl+N", IDM_NEW + MENUITEM "&Open...\tCtrl+O", IDM_OPEN + MENUITEM "&Save\tCtrl+S", IDM_SAVE + MENUITEM "Save &As...\tCtrl+Shift+S", IDM_SAVE_AS + MENUITEM SEPARATOR + MENUITEM "E&xit", IDM_EXIT + END + POPUP "&Options" + BEGIN + MENUITEM "Play &sound", IDM_PLAY_SOUND, CHECKED + MENUITEM SEPARATOR + MENUITEM "&Normal visuals", IDM_VISUALS_NORMAL + MENUITEM "&Ginger visuals", IDM_VISUALS_GINGER + END + POPUP "&Help" + BEGIN + MENUITEM "&About\tF12", IDM_ABOUT + END +END + +// Help hints for IDM_MAIN_MENU +STRINGTABLE +BEGIN + IDM_NEW "Creates a new position." + IDM_OPEN "Opens a position from a file." + IDM_SAVE "Saves the current position to the current file." + IDM_SAVE_AS "Saves the current position to a new file." + IDM_EXIT "Closes the application." + IDM_RESET_TO_STARTING_POSITION "Discards the current position and reloads the starting position." + IDM_ABOUT "Shows information about this application." + IDM_PLAY_SOUND "Toggles whether to play a sound when a piece is placed." +END + +IDD_MAIN DIALOGEX 0, 0, 410, 320 +CAPTION "FEN tool" +STYLE WS_OVERLAPPEDWINDOW | DS_SHELLFONT | DS_CENTER +FONT 8, "MS Shell Dlg", 0, 0, 0x0 +MENU IDM_MAIN_MENU +BEGIN + LTEXT "FEN:", IDC_STATIC, 11, 10, 248, 10 + EDITTEXT IDC_TEXTBOX_FEN, 10, 22, 390, 12, ES_AUTOHSCROLL + + RADIOBUTTON "White to move", IDC_RADIOBUTTON_WHITE_TO_MOVE, 10, 50, 120, 12 + RADIOBUTTON "Black to move", IDC_RADIOBUTTON_BLACK_TO_MOVE, 10, 62, 120, 12 + + CHECKBOX "White can castle kingside", IDC_CHECKBOX_WHITE_CASTLE_KINGSIDE, 10, 80, 120, 12 + CHECKBOX "White can castle queenside", IDC_CHECKBOX_WHITE_CASTLE_QUEENSIDE, 10, 92, 120, 12 + CHECKBOX "Black can castle kingside", IDC_CHECKBOX_BLACK_CASTLE_KINGSIDE, 10, 104, 120, 12 + CHECKBOX "Black can castle queenside", IDC_CHECKBOX_BLACK_CASTLE_QUEENSIDE, 10, 116, 120, 12 + + LTEXT "En passant target square:", IDC_STATIC, 11, 134, 120, 12 + EDITTEXT IDC_TEXTBOX_EN_PASSANT_TARGET_SQUARE, 10, 146, 90, 12 + PUSHBUTTON "❌", IDC_BUTTON_CLEAR_EN_PASSANT, 110, 146, 16, 12 + + LTEXT "Halfmove clock:", IDC_STATIC, 11, 160, 120, 12 + EDITTEXT IDC_TEXTBOX_HALFMOVE_CLOCK, 10, 172, 120, 12, ES_NUMBER + + LTEXT "Fullmove number:", IDC_STATIC, 11, 190, 120, 12 + EDITTEXT IDC_TEXTBOX_FULLMOVE_NUMBER, 10, 202, 120, 12, ES_NUMBER + + CONTROL "", IDC_BOARD_CONTROL, "board_control", WS_CHILD | WS_VISIBLE | WS_TABSTOP, 140, 50, 260, 260 + + CONTROL "", IDC_STATUSBAR, STATUSCLASSNAME, WS_CHILD | WS_VISIBLE | SBARS_SIZEGRIP, 0, 115, 300, 15 +END + +IDM_BOARD_CONTEXT_MENU MENU +BEGIN + POPUP "" + BEGIN + MENUITEM "xx", IDM_SQUARE_INFO, INACTIVE + MENUITEM SEPARATOR + MENUITEM "&Clear square", IDM_CLEAR_SQUARE + MENUITEM SEPARATOR + POPUP "&Pawn" + BEGIN + MENUITEM "&White", IDM_PAWN_WHITE + MENUITEM "&Black", IDM_PAWN_BLACK + END + POPUP "&Bishop" + BEGIN + MENUITEM "&White", IDM_BISHOP_WHITE + MENUITEM "&Black", IDM_BISHOP_BLACK + END + POPUP "K&night" + BEGIN + MENUITEM "&White", IDM_KNIGHT_WHITE + MENUITEM "&Black", IDM_KNIGHT_BLACK + END + POPUP "&Rook" + BEGIN + MENUITEM "&White", IDM_ROOK_WHITE + MENUITEM "&Black", IDM_ROOK_BLACK + END + POPUP "&Queen" + BEGIN + MENUITEM "&White", IDM_QUEEN_WHITE + MENUITEM "&Black", IDM_QUEEN_BLACK + END + POPUP "&King" + BEGIN + MENUITEM "&White", IDM_KING_WHITE + MENUITEM "&Black", IDM_KING_BLACK + END + MENUITEM SEPARATOR + MENUITEM "Set &En Passant target square", IDM_SET_EN_PASSANT_TARGET_SQUARE + MENUITEM "Toggle castling right", IDM_TOGGLE_CASTLING_RIGHT + END +END + +// Help hints for IDM_BOARD_CONTEXT_MENU +STRINGTABLE +BEGIN + IDM_SQUARE_INFO "Displays the selected square." + IDM_CLEAR_SQUARE "Removes the piece from the selected square." + IDM_ROOK_WHITE "Sets a rook for white on the selected square." + IDM_KNIGHT_WHITE "Sets a knight for white on the selected square." + IDM_BISHOP_WHITE "Sets a bishop for white on the selected square." + IDM_QUEEN_WHITE "Sets a queen for white on the selected square." + IDM_KING_WHITE "Sets a king for white on the selected square." + IDM_PAWN_WHITE "Sets a pawn for white on the selected square." + IDM_ROOK_BLACK "Sets a rook for black on the selected square." + IDM_KNIGHT_BLACK "Sets a knight for black on the selected square." + IDM_BISHOP_BLACK "Sets a bishop for black on the selected square." + IDM_QUEEN_BLACK "Sets a queen for black on the selected square." + IDM_KING_BLACK "Sets a king for black on the selected square." + IDM_PAWN_BLACK "Sets a pawn for black on the selected square." + IDM_SET_EN_PASSANT_TARGET_SQUARE "Sets this square as the target for a possible en passant capture." + IDM_TOGGLE_CASTLING_RIGHT "Toggles the castling right associated with the rook on the selected square." +END + +IDD_ABOUT DIALOGEX 0, 0, 150, 70 +CAPTION "About" +STYLE WS_POPUP | WS_CAPTION | WS_SYSMENU | DS_MODALFRAME | DS_SHELLFONT +FONT 8, "MS Shell Dlg", 0, 0, 0x0 +BEGIN + DEFPUSHBUTTON "OK", IDOK, 80, 45, 60, 15 + RTEXT "FEN tool\r\nDemo project", IDC_STATIC, 80, 10, 60, 20 +END + +STRINGTABLE +BEGIN + IDS_FEN_ERR_RANK_SEPARATOR_AT_INVALID_POSITION_0 "Rank separator '/' at invalid position: {0}" + IDS_FEN_ERR_RANK_OVERFLOW_ADVANCE_0_1 "Rank overflow (cannot advance by {0} empty squares): {1}" + IDS_FEN_ERR_BOARD_SEPARATOR_AT_INVALID_POSITION_0 "Board separator ' ' at invalid position: {0}" + IDS_FEN_ERR_INVALID_CHARACTER_0_AT_POSITION_1 "Invalid character '{0}' at position: {1}" + IDS_FEN_ERR_RANK_OVERFLOW_CANNOT_PLACE_0_1_AT_2 "Rank overflow (cannot place {0} {1}) at {2}" + IDS_FEN_ERR_EXPECTED_PLAYER_TO_MOVE_0 "Expected player to move (w/b), got {0}" + IDS_FEN_ERR_EXPECTED_SPACE_0 "Expected space, got {0}" + IDS_FEN_ERR_DUPLICATE_CASTLING_RIGHT_SPECIFIER_0 "Duplicate castling right specifier: {0}" + IDS_FEN_ERR_CASTLING_RIGHT_SPECIFIER_CONFLICT "Castling right specifier conflict" + IDS_FEN_ERR_EXPECTED_CASTLING_RIGHTS_SPECIFIER_0 "Expected castling rights specifier, got {0}" + IDS_FEN_ERR_INVALID_EN_PASSANT_TARGET_SQUARE_FILE_0 "Invalid en passant target square file: {0}" + IDS_FEN_ERR_INVALID_EN_PASSANT_TARGET_SQUARE_RANK_0 "Invalid en passant target square rank: {0}" + IDS_FEN_ERR_INVALID_HALFMOVE_CLOCK_0 "Invalid halfmove clock: {0}" + IDS_FEN_ERR_INVALID_FULLMOVE_NUMBER_0 "Invalid fullmove number: {0}" + IDS_FEN_ERR_EXPECTED_END_OF_STRING "Expected end of string" + IDS_INVALID_EN_PASSANT_TARGET_SQUARE "Invalid en passant target square" + IDS_INVALID_EN_PASSANT_TARGET_SQUARE_HINT "Enter a square such as ""e3"", or ""-""." + IDS_INVALID_FEN_STRING "Invalid FEN string" + IDS_ROOK "Rook" + IDS_KNIGHT "Knight" + IDS_BISHOP "Bishop" + IDS_QUEEN "Queen" + IDS_KING "King" + IDS_PAWN "Pawn" + IDS_WHITE "White" + IDS_BLACK "Black" + IDS_TITLE "FEN tool" + IDS_CONFIRM_RESET_CAPTION "Confirm reset" + IDS_CONFIRM_RESET_MESSAGE "Really discard the current position and reset to starting position?" + IDS_ERROR_CAPTION "Error" + IDS_FEN_FILES_FILTER "FEN files (*.fen)" + IDS_ALL_FILES_FILTER "All files" + IDS_UNSAVED_CHANGES_CAPTION "Unsaved changes" + IDS_UNSAVED_CHANGES_MESSAGE "There are unsaved changes. Do you want to save?" + IDS_ALLOW_CASTLING_WHITE_KINGSIDE "Allow &castling kingside for white" + IDS_DISALLOW_CASTLING_WHITE_KINGSIDE "Disallow &castling kingside for white" + IDS_ALLOW_CASTLING_WHITE_QUEENSIDE "Allow &castling queenside for white" + IDS_DISALLOW_CASTLING_WHITE_QUEENSIDE "Disallow &castling queenside for white" + IDS_ALLOW_CASTLING_BLACK_KINGSIDE "Allow &castling kingside for black" + IDS_DISALLOW_CASTLING_BLACK_KINGSIDE "Disallow &castling kingside for black" + IDS_ALLOW_CASTLING_BLACK_QUEENSIDE "Allow &castling queenside for black" + IDS_DISALLOW_CASTLING_BLACK_QUEENSIDE "Disallow &castling queenside for black" +END + +IDC_ACCELERATOR_TABLE ACCELERATORS +BEGIN + "N", IDM_NEW, VIRTKEY, CONTROL + "O", IDM_OPEN, VIRTKEY, CONTROL + "S", IDM_SAVE, VIRTKEY, CONTROL + "S", IDM_SAVE_AS, VIRTKEY, CONTROL, SHIFT + VK_F12, IDM_ABOUT, VIRTKEY +END + +LANGUAGE LANG_DUTCH, SUBLANG_DUTCH + +IDM_MAIN_MENU MENU +BEGIN + POPUP "&Bestand" + BEGIN + MENUITEM "&Reset naar beginpositie", IDM_RESET_TO_STARTING_POSITION + MENUITEM SEPARATOR + MENUITEM "&Nieuw\tCtrl+N", IDM_NEW + MENUITEM "&Openen...\tCtrl+O", IDM_OPEN + MENUITEM "&Opslaan\tCtrl+S", IDM_SAVE + MENUITEM "Opslaan &als...\tCtrl+Shift+S", IDM_SAVE_AS + MENUITEM SEPARATOR + MENUITEM "A&fsluiten", IDM_EXIT + END + POPUP "&Opties" + BEGIN + MENUITEM "Geluid a&fspelen", IDM_PLAY_SOUND, CHECKED + MENUITEM SEPARATOR + MENUITEM "&Normale vormgeving", IDM_VISUALS_NORMAL + MENUITEM "&Ginger-vormgeving", IDM_VISUALS_GINGER + END + POPUP "&Help" + BEGIN + MENUITEM "&Over\tF12", IDM_ABOUT + END +END + +// Help hints for IDM_MAIN_MENU +STRINGTABLE +BEGIN + IDM_NEW "Maakt een nieuwe positie aan." + IDM_OPEN "Opent een positie uit een bestand." + IDM_SAVE "Slaat de huidige positie op in het huidige bestand." + IDM_SAVE_AS "Slaat de huidige positie op in een nieuw bestand." + IDM_EXIT "Sluit de toepassing." + IDM_RESET_TO_STARTING_POSITION "Verwerpt de huidige positie en laadt de beginpositie opnieuw." + IDM_ABOUT "Toont informatie over deze toepassing." + IDM_PLAY_SOUND "Schakelt het geluid in of uit wanneer een stuk wordt geplaatst." + IDS_FEN_FILES_FILTER "FEN-bestanden (*.fen)" + IDS_ALL_FILES_FILTER "Alle bestanden" + IDS_UNSAVED_CHANGES_CAPTION "Niet-opgeslagen wijzigingen" + IDS_UNSAVED_CHANGES_MESSAGE "Er zijn niet-opgeslagen wijzigingen. Wilt u deze opslaan?" + IDS_ALLOW_CASTLING_WHITE_KINGSIDE "Korte &rokade voor wit toestaan" + IDS_DISALLOW_CASTLING_WHITE_KINGSIDE "Korte &rokade voor wit verbieden" + IDS_ALLOW_CASTLING_WHITE_QUEENSIDE "Lange &rokade voor wit toestaan" + IDS_DISALLOW_CASTLING_WHITE_QUEENSIDE "Lange &rokade voor wit verbieden" + IDS_ALLOW_CASTLING_BLACK_KINGSIDE "Korte &rokade voor zwart toestaan" + IDS_DISALLOW_CASTLING_BLACK_KINGSIDE "Korte &rokade voor zwart verbieden" + IDS_ALLOW_CASTLING_BLACK_QUEENSIDE "Lange &rokade voor zwart toestaan" + IDS_DISALLOW_CASTLING_BLACK_QUEENSIDE "Lange &rokade voor zwart verbieden" +END + +IDD_MAIN DIALOGEX 0, 0, 410, 320 +CAPTION "FEN tool" +STYLE WS_OVERLAPPEDWINDOW | DS_SHELLFONT | DS_CENTER +FONT 8, "MS Shell Dlg", 0, 0, 0x0 +MENU IDM_MAIN_MENU +BEGIN + LTEXT "FEN:", IDC_STATIC, 11, 10, 248, 10 + EDITTEXT IDC_TEXTBOX_FEN, 10, 22, 390, 12, ES_AUTOHSCROLL + + RADIOBUTTON "Wit aan zet", IDC_RADIOBUTTON_WHITE_TO_MOVE, 10, 50, 120, 12 + RADIOBUTTON "Zwart aan zet", IDC_RADIOBUTTON_BLACK_TO_MOVE, 10, 62, 120, 12 + + CHECKBOX "Wit mag kort rokeren", IDC_CHECKBOX_WHITE_CASTLE_KINGSIDE, 10, 80, 120, 12 + CHECKBOX "Wit mag lang rokeren", IDC_CHECKBOX_WHITE_CASTLE_QUEENSIDE, 10, 92, 120, 12 + CHECKBOX "Zwart mag kort rokeren", IDC_CHECKBOX_BLACK_CASTLE_KINGSIDE, 10, 104, 120, 12 + CHECKBOX "Zwart mag lang rokeren", IDC_CHECKBOX_BLACK_CASTLE_QUEENSIDE, 10, 116, 120, 12 + + LTEXT "En-passantdoelveld:", IDC_STATIC, 11, 134, 120, 12 + EDITTEXT IDC_TEXTBOX_EN_PASSANT_TARGET_SQUARE, 10, 146, 90, 12 + PUSHBUTTON "❌", IDC_BUTTON_CLEAR_EN_PASSANT, 110, 146, 16, 12 + + LTEXT "Halfzettenklok:", IDC_STATIC, 11, 160, 120, 12 + EDITTEXT IDC_TEXTBOX_HALFMOVE_CLOCK, 10, 172, 120, 12, ES_NUMBER + + LTEXT "Volledige zet:", IDC_STATIC, 11, 190, 120, 12 + EDITTEXT IDC_TEXTBOX_FULLMOVE_NUMBER, 10, 202, 120, 12, ES_NUMBER + + CONTROL "", IDC_BOARD_CONTROL, "board_control", WS_CHILD | WS_VISIBLE | WS_TABSTOP, 140, 50, 260, 260 + + CONTROL "", IDC_STATUSBAR, STATUSCLASSNAME, WS_CHILD | WS_VISIBLE | SBARS_SIZEGRIP, 0, 115, 300, 15 +END + +IDM_BOARD_CONTEXT_MENU MENU +BEGIN + POPUP "" + BEGIN + MENUITEM "xx", IDM_SQUARE_INFO, INACTIVE + MENUITEM SEPARATOR + MENUITEM "Vak &leegmaken", IDM_CLEAR_SQUARE + MENUITEM SEPARATOR + POPUP "&Pion" + BEGIN + MENUITEM "&Wit", IDM_PAWN_WHITE + MENUITEM "&Zwart", IDM_PAWN_BLACK + END + POPUP "&Loper" + BEGIN + MENUITEM "&Wit", IDM_BISHOP_WHITE + MENUITEM "&Zwart", IDM_BISHOP_BLACK + END + POPUP "&Paard" + BEGIN + MENUITEM "&Wit", IDM_KNIGHT_WHITE + MENUITEM "&Zwart", IDM_KNIGHT_BLACK + END + POPUP "&Toren" + BEGIN + MENUITEM "&Wit", IDM_ROOK_WHITE + MENUITEM "&Zwart", IDM_ROOK_BLACK + END + POPUP "&Dame" + BEGIN + MENUITEM "&Wit", IDM_QUEEN_WHITE + MENUITEM "&Zwart", IDM_QUEEN_BLACK + END + POPUP "K&oning" + BEGIN + MENUITEM "&Wit", IDM_KING_WHITE + MENUITEM "&Zwart", IDM_KING_BLACK + END + MENUITEM SEPARATOR + MENUITEM "En-passantdoelveld in&stellen", IDM_SET_EN_PASSANT_TARGET_SQUARE + MENUITEM "Rokaderecht omschakelen", IDM_TOGGLE_CASTLING_RIGHT + END +END + +// Help hints for IDM_BOARD_CONTEXT_MENU +STRINGTABLE +BEGIN + IDM_SQUARE_INFO "Toont het geselecteerde veld." + IDM_CLEAR_SQUARE "Verwijdert het stuk van het geselecteerde veld." + IDM_ROOK_WHITE "Plaatst een witte toren op het geselecteerde veld." + IDM_KNIGHT_WHITE "Plaatst een wit paard op het geselecteerde veld." + IDM_BISHOP_WHITE "Plaatst een witte loper op het geselecteerde veld." + IDM_QUEEN_WHITE "Plaatst een witte dame op het geselecteerde veld." + IDM_KING_WHITE "Plaatst een witte koning op het geselecteerde veld." + IDM_PAWN_WHITE "Plaatst een witte pion op het geselecteerde veld." + IDM_ROOK_BLACK "Plaatst een zwarte toren op het geselecteerde veld." + IDM_KNIGHT_BLACK "Plaatst een zwart paard op het geselecteerde veld." + IDM_BISHOP_BLACK "Plaatst een zwarte loper op het geselecteerde veld." + IDM_QUEEN_BLACK "Plaatst een zwarte dame op het geselecteerde veld." + IDM_KING_BLACK "Plaatst een zwarte koning op het geselecteerde veld." + IDM_PAWN_BLACK "Plaatst een zwarte pion op het geselecteerde veld." + IDM_SET_EN_PASSANT_TARGET_SQUARE "Stelt dit veld in als doel voor een mogelijke en-passantslag." + IDM_TOGGLE_CASTLING_RIGHT "Schakelt het rokaderecht van de toren op dit veld om." +END + +IDD_ABOUT DIALOGEX 0, 0, 150, 70 +CAPTION "Over" +STYLE WS_POPUP | WS_CAPTION | WS_SYSMENU | DS_MODALFRAME | DS_SHELLFONT +FONT 8, "MS Shell Dlg", 0, 0, 0x0 +BEGIN + DEFPUSHBUTTON "OK", IDOK, 80, 45, 60, 15 + RTEXT "FEN tool\r\nDemoproject", IDC_STATIC, 80, 10, 60, 20 +END + +STRINGTABLE +BEGIN + IDS_FEN_ERR_RANK_SEPARATOR_AT_INVALID_POSITION_0 "Rangscheidingsteken '/' op ongeldige positie: {0}" + IDS_FEN_ERR_RANK_OVERFLOW_ADVANCE_0_1 "Rij loopt over (kan niet {0} lege velden opschuiven): {1}" + IDS_FEN_ERR_BOARD_SEPARATOR_AT_INVALID_POSITION_0 "Bordscheidingsteken ' ' op ongeldige positie: {0}" + IDS_FEN_ERR_INVALID_CHARACTER_0_AT_POSITION_1 "Ongeldig teken '{0}' op positie: {1}" + IDS_FEN_ERR_RANK_OVERFLOW_CANNOT_PLACE_0_1_AT_2 "Rij loopt over (kan {0} {1} niet plaatsen) op {2}" + IDS_FEN_ERR_EXPECTED_PLAYER_TO_MOVE_0 "Speler aan zet verwacht (w/b), kreeg {0}" + IDS_FEN_ERR_EXPECTED_SPACE_0 "Spatie verwacht, kreeg {0}" + IDS_FEN_ERR_DUPLICATE_CASTLING_RIGHT_SPECIFIER_0 "Dubbele aanduiding voor rokaderecht: {0}" + IDS_FEN_ERR_CASTLING_RIGHT_SPECIFIER_CONFLICT "Tegenstrijdige aanduidingen voor rokaderecht" + IDS_FEN_ERR_EXPECTED_CASTLING_RIGHTS_SPECIFIER_0 "Aanduiding voor rokaderechten verwacht, kreeg {0}" + IDS_FEN_ERR_INVALID_EN_PASSANT_TARGET_SQUARE_FILE_0 "Ongeldige kolom van het en-passantdoelveld: {0}" + IDS_FEN_ERR_INVALID_EN_PASSANT_TARGET_SQUARE_RANK_0 "Ongeldige rij van het en-passantdoelveld: {0}" + IDS_FEN_ERR_INVALID_HALFMOVE_CLOCK_0 "Ongeldige halfzettenklok: {0}" + IDS_FEN_ERR_INVALID_FULLMOVE_NUMBER_0 "Ongeldig volledig zetnummer: {0}" + IDS_FEN_ERR_EXPECTED_END_OF_STRING "Einde van de tekenreeks verwacht" + IDS_INVALID_EN_PASSANT_TARGET_SQUARE "Ongeldig en-passantdoelveld" + IDS_INVALID_EN_PASSANT_TARGET_SQUARE_HINT "Voer een veld in, zoals ""e3"", of ""-""." + IDS_INVALID_FEN_STRING "Ongeldige FEN-tekenreeks" + IDS_ROOK "Toren" + IDS_KNIGHT "Paard" + IDS_BISHOP "Loper" + IDS_QUEEN "Dame" + IDS_KING "Koning" + IDS_PAWN "Pion" + IDS_WHITE "Wit" + IDS_BLACK "Zwart" + IDS_TITLE "FEN tool" + IDS_CONFIRM_RESET_CAPTION "Beginpositie herstellen?" + IDS_CONFIRM_RESET_MESSAGE "Wilt u de huidige positie echt verwerpen en de beginpositie herstellen?" + IDS_ERROR_CAPTION "Fout" +END + +IDC_ACCELERATOR_TABLE ACCELERATORS +BEGIN + "N", IDM_NEW, VIRTKEY, CONTROL + "O", IDM_OPEN, VIRTKEY, CONTROL + "S", IDM_SAVE, VIRTKEY, CONTROL + "S", IDM_SAVE_AS, VIRTKEY, CONTROL, SHIFT + VK_F12, IDM_ABOUT, VIRTKEY +END diff --git a/win32/fentool/util.odin b/win32/fentool/util.odin new file mode 100644 index 0000000..d9a9f39 --- /dev/null +++ b/win32/fentool/util.odin @@ -0,0 +1,227 @@ +package fen_tool + +import runtime "base:runtime" +import log "core:log" +import png "core:image/png" +import win "core:sys/windows" + +system_error_message_default_language := win.MAKELANGID(win.LANG_NEUTRAL, win.SUBLANG_DEFAULT) + +get_system_error_message :: proc(error_code: win.DWORD, language_id: win.DWORD = system_error_message_default_language, allocator := context.allocator) -> (result: string, error: runtime.Allocator_Error) #optional_allocator_error { + // Let Windows allocate the buffer internally, because only it knows how big it needs to be. + // It is temporary anyway because it will be converted to UTF-8. + message_buffer: win.wstring + rc := win.FormatMessageW(win.FORMAT_MESSAGE_FROM_SYSTEM | win.FORMAT_MESSAGE_IGNORE_INSERTS | win.FORMAT_MESSAGE_ALLOCATE_BUFFER, + nil, error_code, language_id, cast(win.LPWSTR)&message_buffer, 0, nil) + if rc == 0 { + // FormatMessageW failed. The most likely cause is that the caller gave us an invalid error code. + // Could try to build a string containing the error code? + return "", nil + } + + defer win.LocalFree(cast(win.LPVOID)message_buffer) + + return win.wstring_to_utf8(message_buffer, N = -1, allocator = allocator) +} + +get_dialog_item_text :: proc(hdlg: win.HWND, id: win.c_int, capacity: int, allocator := context.temp_allocator) -> (result: string, error: runtime.Allocator_Error) #optional_allocator_error { + buf := make([]win.WCHAR, capacity, allocator) + if win.GetDlgItemTextW(hdlg, id, auto_cast raw_data(buf), auto_cast len(buf)) <= 0 { + return "", nil + } + return win.wstring_to_utf8(cast(win.wstring)raw_data(buf), N = -1, allocator = allocator) +} + +set_dialog_item_text_string :: proc(hdlg: win.HWND, id: win.c_int, text: string) -> bool { + wstr := win.utf8_to_wstring(text, context.temp_allocator) + return cast(bool)win.SetDlgItemTextW(hdlg, id, wstr) +} + +set_dialog_item_text_cstring16 :: proc(hdlg: win.HWND, id: win.c_int, text: cstring16) -> bool { + return cast(bool)win.SetDlgItemTextW(hdlg, id, text) +} + +set_dialog_item_text :: proc { set_dialog_item_text_string, set_dialog_item_text_cstring16 } + +set_menu_item_text :: proc(hMenu: win.HMENU, itemID: win.UINT, text: string) { + wstr := win.utf8_to_wstring_alloc(text, context.temp_allocator) + if wstr == nil { + return + } + mii := win.MENUITEMINFOW { + cbSize = size_of(win.MENUITEMINFOW), + fMask = win.MIIM_STRING, + dwTypeData = cast(win.LPWSTR)wstr, + } + win.SetMenuItemInfoW(hMenu, itemID, false, &mii) +} + +// This procedure is "needed" because painting a rectangle with gid32's `Rectangle` API function +// is inherently inaccurate if the pen thickness is not equal to 1. +// +// A positive thickness will paint an inset rectangle (rect defines its outer border), a negative +// thickness will paint an outset rectangle (rect defines its inner border). +stroke_rectangle_precise :: proc(hdc: win.HDC, rc: win.RECT, color: win.COLORREF, #any_int thickness: i32 = 1) -> bool { + if thickness == 0 { + return true + } + + thickness := thickness + rc := rc + if thickness < 0 { + // Outset rectangle. We do this by inflating `rc` by the thickness, then + // paint an inset rectangle. + thickness *= -1 + rc.left -= thickness + rc.top -= thickness + rc.right += thickness + rc.bottom += thickness + } + + brush := win.CreateSolidBrush(color) + if brush == nil { + return false + } + defer win.DeleteObject(auto_cast brush) + + part: win.RECT + + // Top + part.left = rc.left + part.top = rc.top + part.right = rc.right + part.bottom = min(rc.bottom, rc.top + thickness) + win.FillRect(hdc, &part, brush) + + // Left + part.right = min(rc.right, rc.left + thickness) + part.bottom = rc.bottom + win.FillRect(hdc, &part, brush) + + // Bottom + part.top = max(rc.top, rc.bottom - thickness) + part.right = rc.right + win.FillRect(hdc, &part, brush) + + // Right + part.left = max(rc.left, rc.right - thickness) + part.top = rc.top + win.FillRect(hdc, &part, brush) + + return true +} + +load_string_resource :: proc(hInstance: win.HINSTANCE, id: win.UINT, allocator := context.temp_allocator) -> (result: string, error: runtime.Allocator_Error) #optional_allocator_error { + buffer: win.LPWSTR + // This particular usage (cchBufferMax = 0) makes it so that we receive a read-only + // pointer to the string resource. We then have to copy it out. + string_length := win.LoadStringW(hInstance, id, cast(win.LPWSTR)&buffer, 0) + if string_length <= 0 || buffer == nil { + return "", nil + } + return win.utf16_to_utf8((cast([^]win.WCHAR)buffer)[:string_length], allocator = allocator) +} + +// Loads a PNG image stored as an `RCDATA` type resource, and converts it into a GDI compatible `HBITMAP`. +load_hbitmap_from_png_rcdata :: proc { + load_hbitmap_from_png_rcdata_int, + load_hbitmap_from_png_rcdata_wstr, + load_hbitmap_from_png_rcdata_hrsrc, +} + +load_hbitmap_from_png_rcdata_int :: proc(hModule: win.HMODULE, resource_id: int) -> win.HBITMAP { + rt_rcdata := win.RT_RCDATA + resource_info := win.FindResourceW(hModule, cast(win.LPCWSTR)cast(rawptr)cast(uintptr)resource_id, cast(win.LPCWSTR)rt_rcdata) + if resource_info == nil { + log.errorf("Could not find resource: %v", resource_id) + return nil + } + return load_hbitmap_from_png_rcdata(hModule, resource_info) +} + +load_hbitmap_from_png_rcdata_wstr :: proc(hModule: win.HMODULE, resource_id: win.wstring) -> win.HBITMAP { + rt_rcdata := win.RT_RCDATA + resource_info := win.FindResourceW(hModule, resource_id, cast(win.LPCWSTR)rt_rcdata) + if resource_info == nil { + log.errorf("Could not find resource: %v", resource_id) + return nil + } + return load_hbitmap_from_png_rcdata(hModule, resource_info) +} + +load_hbitmap_from_png_rcdata_hrsrc :: proc(hModule: win.HMODULE, resource_info: win.HRSRC) -> win.HBITMAP { + resource_size := win.SizeofResource(hModule, resource_info) + if resource_size == 0 { + error := win.GetLastError() + log.errorf("Could not load resource: %v", get_system_error_message(error)) + return nil + } + hresource := win.LoadResource(hModule, resource_info) + if hresource == nil { + error := win.GetLastError() + log.errorf("Could not load resource: %v", get_system_error_message(error)) + return nil + } + defer win.FreeResource(hresource) + data := (cast([^]byte)win.LockResource(hresource))[:resource_size] + img, err := png.load_from_bytes(data, allocator = context.temp_allocator) + if err != nil { + log.errorf("png.load_from_bytes failed: %v", err) + return nil + } + // Declare a specialized `BITMAPINFO` variant with no color table entries. + BITMAPINFO :: struct { + hdr: win.BITMAPINFOHEADER, + } + bmi := BITMAPINFO { + hdr = { + biSize = size_of(win.BITMAPV5HEADER), + biWidth = cast(win.LONG)img.width, + biHeight = cast(win.LONG)-img.height, + biPlanes = 1, + biBitCount = 32, + biCompression = win.BI_RGB, + }, + } + pixels: [^]win.RGBQUAD + dib := win.CreateDIBSection(nil, cast(^win.BITMAPINFO)&bmi, win.DIB_RGB_COLORS, cast(^rawptr)&pixels, nil, 0) + if dib == nil { + error := win.GetLastError() + log.errorf("CreateDIBSection failed: %v", get_system_error_message(error)) + return nil + } + // We need to convert the pixel format that `png` gives us from RGBA to BGRA. + num_pixels := img.width * img.height // NOTE: This only works because `stride == width` here. + src := cast([^][4]win.BYTE)raw_data(img.pixels.buf) + for i in 0 ..< num_pixels { + pixels[i] = win.RGBQUAD { + rgbBlue = src[i].b, + rgbGreen = src[i].g, + rgbRed = src[i].r, + rgbReserved = src[i].a, + } + } + return dib +} + +fudge_window_starting_position :: proc(hwnd: win.HWND) { + // Create an invisible dummy window to make the operating system generate a valid starting position + rc: win.RECT + if !win.GetWindowRect(hwnd, &rc) { + return + } + w := rc.right - rc.left + h := rc.bottom - rc.top + dummy := win.CreateWindowW("STATIC", "", win.WS_OVERLAPPEDWINDOW, win.CW_USEDEFAULT, 0, w, h, nil, nil, nil, nil) + if dummy == nil { + return + } + defer win.DestroyWindow(dummy) + dummy_rc: win.RECT + if !win.GetWindowRect(dummy, &dummy_rc) { + return + } + x := dummy_rc.left + y := dummy_rc.top + win.SetWindowPos(hwnd, nil, x, y, 0, 0, win.SWP_NOZORDER | win.SWP_NOSIZE) +}