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*.
+
+
+
+Naturally, it also implements ginger mode.
+
+
+
+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)
+}