Fix hacked GTK3/GLFW crash by vendoring a patched sqweek/dialog

The built hacked binary segfaults on Linux the moment a native GTK3
dialog (file/folder browse) opens while a GLFW/OpenGL context is live:
sqweek/dialog's Linux backend never calls XInitThreads() before
gtk_init(), and GLFW's own X11 backend installs a process-wide Xlib
error handler that collides with GTK's.

Vendor a pinned copy of github.com/sqweek/dialog (New BSD) into
modding/sqweek-dialog/ so it can be patched like every other vendored
source here: sqweek-dialog-patches/01-xinitthreads.patch adds the
missing XInitThreads() call, and hacked-patches/01-use-patched-dialog.patch
adds a go.mod replace directive pointing hacked's own sqweek/dialog
dependency at that local copy. build-hacked.sh now stages both hacked/
and sqweek-dialog/ into a scratch modding/build/ directory and applies
their patch series before compiling, rather than building hacked/ in
place; .gitignore picks up that new scratch directory.

Also expand modding/README.md: number its existing sections and add a
worked "Example: Editing a message" walkthrough covering Static World
Data, the Messages window's clipboard-only text editing, saving the
Mod Location, and the absolute-path requirement for run.sh's mod-folder
argument.
This commit is contained in:
ml
2026-09-26 10:37:01 +02:00
parent c91080aba7
commit e410d82680
20 changed files with 994 additions and 5 deletions
+3 -1
View File
@@ -43,5 +43,7 @@
*.jks *.jks
*.keystore *.keystore
# modding/hacked build output (see modding/README.md) # modding/hacked build output and scratch, patched source copies (see
# modding/README.md)
/modding/dist/ /modding/dist/
/modding/build/
+32 -3
View File
@@ -8,7 +8,7 @@ Both pieces here come from the [InkyBlackness](https://inkyblackness.github.io)
project and are vendored unmodified, each under its own upstream license (see project and are vendored unmodified, each under its own upstream license (see
below) - neither is relicensed by this repository's own MIT license. below) - neither is relicensed by this repository's own MIT license.
## Layout ## 1. Layout
- `hacked/` - [InkyBlackness HackEd](https://github.com/inkyblackness/hacked), - `hacked/` - [InkyBlackness HackEd](https://github.com/inkyblackness/hacked),
the graphical mod editor. A vendored source snapshot (New BSD License), the graphical mod editor. A vendored source snapshot (New BSD License),
@@ -38,7 +38,7 @@ support (level/resource files, `res/data/`); this folder is about the
external tools that create and inspect that content, not about changing the external tools that create and inspect that content, not about changing the
engine itself. engine itself.
## Building `hacked` ## 2. Building `hacked`
Packages `dist/<version>.linux64.tgz` and `dist/<version>.win64.zip` (native Packages `dist/<version>.linux64.tgz` and `dist/<version>.win64.zip` (native
Linux build, Windows amd64 cross-compiled via mingw-w64 - both from the same Linux build, Windows amd64 cross-compiled via mingw-w64 - both from the same
@@ -67,7 +67,7 @@ Run the result: `dist/linux/hacked/hacked` on Linux,
`dist\win\hacked\hacked.exe` on Windows. Needs OpenGL 3.2; usage is documented `dist\win\hacked\hacked.exe` on Windows. Needs OpenGL 3.2; usage is documented
on [hacked's own wiki](https://github.com/inkyblackness/hacked/wiki). on [hacked's own wiki](https://github.com/inkyblackness/hacked/wiki).
### Publishing the build-image ### 2.1. Publishing the build-image
Same registry as the root project's own build-image (see the repository Same registry as the root project's own build-image (see the repository
root's `registry.env.example`), under a separate image name so the two don't root's `registry.env.example`), under a separate image name so the two don't
@@ -82,3 +82,32 @@ Reads `REGISTRY`/`REGISTRY_USER`/`REGISTRY_PASSWORD` and (optionally)
Don't bump `hacked-build-image/VERSION` just because the Dockerfile changed - Don't bump `hacked-build-image/VERSION` just because the Dockerfile changed -
only once a build with the current version has actually succeeded, same only once a build with the current version has actually succeeded, same
policy as the root project's own `build-image/VERSION`. policy as the root project's own `build-image/VERSION`.
## 3. Example: Editing a message
`hacked` has no inline text editor - every text field is edited by right-click
-> **Copy to Clipboard**, editing the text in an external editor, then
right-click -> **Copy from Clipboard** back into the field. Example: changing
Rebecca Lansing's first email.
1. Run `dist/linux/hacked/hacked` (or the Windows build).
2. **Window -> Project.** Under **Static World Data**, **Add...** and browse to
the game's whole `res/assets/ss_ee/data/` directory (see the root
[README.md](../README.md) section 3 for where that comes from), so every
`.res`/`.dat` file is available.
3. **Window -> Messages** (F5). **Message Type**: `Mails`. **Index**: `26`
(Rebecca's first mail). Edit **Sender**/**Subject**/**Title**/the message
text via the clipboard round-trip above.
4. Back in the **Project** window, under **Mod Location**, click **Save**
(first time: **Browse...** to an empty folder, for example
`./modding/mymod`).
5. Test it from the root project's desktop build:
```sh
./dist/run.sh "$(pwd)/modding/mymod"
```
The mod-folder argument **must be an absolute path**: `run.sh` `cd`s into
`dist/` before launching the engine, so a relative path resolves against
the wrong directory and the mod silently fails to load (the engine's
`ProcessModDirectory()` reports nothing on a bad path). When the path is
right, the terminal shows `ProcessModDirectory ...` / `Found resource
file: ...` / `Loading mod file ...` for confirmation.
+19 -1
View File
@@ -14,12 +14,30 @@
set -euo pipefail set -euo pipefail
MODDING_ROOT="$(pwd)" MODDING_ROOT="$(pwd)"
HACKED_SRC="$MODDING_ROOT/hacked"
OUT_DIR="$MODDING_ROOT/dist" OUT_DIR="$MODDING_ROOT/dist"
SCRATCH_DIR="$MODDING_ROOT/build"
HACKED_SRC="$SCRATCH_DIR/hacked"
DIALOG_SRC="$SCRATCH_DIR/sqweek-dialog"
VERSION="hacked-$(date -u +%Y%m%d)" VERSION="hacked-$(date -u +%Y%m%d)"
echo "Version: $VERSION" echo "Version: $VERSION"
echo "== Preparing scratch, patched copies of hacked/ and sqweek-dialog/ =="
# hacked/ and sqweek-dialog/ are vendored unmodified (see README.md) - never
# built or patched in place. hacked-patches/'s go.mod edit points
# sqweek/dialog at ../sqweek-dialog (a sibling directory here), so both
# scratch copies must keep that same relative layout.
rm -rf "$SCRATCH_DIR"
mkdir -p "$SCRATCH_DIR"
cp -a "$MODDING_ROOT/hacked" "$HACKED_SRC"
cp -a "$MODDING_ROOT/sqweek-dialog" "$DIALOG_SRC"
for p in "$MODDING_ROOT"/hacked-patches/*.patch; do
patch -p1 -d "$HACKED_SRC" < "$p"
done
for p in "$MODDING_ROOT"/sqweek-dialog-patches/*.patch; do
patch -p1 -d "$DIALOG_SRC" < "$p"
done
rm -rf "$OUT_DIR" rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/linux/hacked" "$OUT_DIR/win/hacked" mkdir -p "$OUT_DIR/linux/hacked" "$OUT_DIR/win/hacked"
@@ -0,0 +1,13 @@
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,10 @@
go 1.25.0
+// Local, patched copy fixing a GTK3/GLFW X11 crash on Linux - see
+// ../sqweek-dialog-patches/ and README.md.
+replace github.com/sqweek/dialog => ../sqweek-dialog
+
require (
github.com/asticode/go-astisub v0.12.1
github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d
@@ -0,0 +1,20 @@
--- a/dlgs_linux.go
+++ b/dlgs_linux.go
@@ -1,8 +1,9 @@
package dialog
-// #cgo pkg-config: gtk+-3.0
+// #cgo pkg-config: gtk+-3.0 x11
// #include <gtk/gtk.h>
// #include <stdlib.h>
+// #include <X11/Xlib.h>
// static GtkWidget* msgdlg(GtkWindow *parent, GtkDialogFlags flags, GtkMessageType type, GtkButtonsType buttons, char *msg) {
// return gtk_message_dialog_new(parent, flags, type, buttons, "%s", msg);
// }
@@ -13,6 +14,7 @@
import "unsafe"
func init() {
+ C.XInitThreads()
C.gtk_init(nil, nil)
}
+16
View File
@@ -0,0 +1,16 @@
run:
deadline: 5m
linters:
enable-all: true
# linters are disabled if their majority of issues is considered false-positive (intended code)
# and the remaining issues (if existing) aren't worth it.
disable:
- gochecknoinits
- gochecknoglobals
issues:
exclude-use-default: false # disable filtering of defaults for better zero-issue policy
max-per-linter: 0 # disable limit; report all issues of a linter
max-same-issues: 0 # disable limit; report all issues of the same issue
+15
View File
@@ -0,0 +1,15 @@
ISC License
Copyright (c) 2018, the dialog authors.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+35
View File
@@ -0,0 +1,35 @@
# dialog
Simple cross-platform dialog API for go-lang
# examples
ok := dialog.Message("%s", "Do you want to continue?").Title("Are you sure?").YesNo()
Creates a dialog box titled "Are you sure?", containing the message "Do you want to continue?",
a "Yes" button and a "No" button. Returns true iff the dialog could be displayed and the user
pressed the "Yes" button.
filename, err := dialog.File().Filter("Mp3 audio file", "mp3").Load()
Creates a file selection dialog allowing the user to select a .mp3 file. The absolute path of
the file is returned, unless an error is encountered or the user cancels/closes the dialog.
In the latter case, `filename` will be the empty string and `err` will equal `dialog.Cancelled`.
filename, err := dialog.File().Filter("XML files", "xml").Title("Export to XML").Save()
Asks the user for a filename to write data into. If the user selects a file which already exists,
an additional dialog is spawned to confirm they want to overwrite the existing file.
directory, err := dialog.Directory().Title("Load images").Browse()
Asks the user for a directory.
# platform details
* OSX: uses Cocoa's NSAlert/NSSavePanel/NSOpenPanel classes
* Win32: uses MessageBox/GetOpenFileName/GetSaveFileName (via package github.com/TheTitanrain/w32)
* Linux: uses Gtk's MessageDialog/FileChooserDialog (via cgo; requires gtk3 development packages)
# build
```
go mod download
go build
```
+39
View File
@@ -0,0 +1,39 @@
#include <objc/NSObjCRuntime.h>
typedef enum {
MSG_YESNO,
MSG_ERROR,
MSG_INFO,
} AlertStyle;
typedef struct {
char* msg;
char* title;
AlertStyle style;
} AlertDlgParams;
#define LOADDLG 0
#define SAVEDLG 1
#define DIRDLG 2 // browse for directory
typedef struct {
int mode; /* which dialog style to invoke (see earlier defines) */
char* buf; /* buffer to store selected file */
int nbuf; /* number of bytes allocated at buf */
char* title; /* title for dialog box (can be nil) */
void** exts; /* list of valid extensions (elements actual type is NSString*) */
int numext; /* number of items in exts */
int relaxext; /* allow other extensions? */
} FileDlgParams;
typedef enum {
DLG_OK,
DLG_CANCEL,
DLG_URLFAIL,
} DlgResult;
DlgResult alertDlg(AlertDlgParams*);
DlgResult fileDlg(FileDlgParams*);
void* NSStr(void* buf, int len);
void NSRelease(void* obj);
+130
View File
@@ -0,0 +1,130 @@
#import <Cocoa/Cocoa.h>
#include "dlg.h"
void* NSStr(void* buf, int len) {
return (void*)[[NSString alloc] initWithBytes:buf length:len encoding:NSUTF8StringEncoding];
}
void NSRelease(void* obj) {
[(NSObject*)obj release];
}
@interface AlertDlg : NSObject {
AlertDlgParams* params;
DlgResult result;
}
+ (AlertDlg*)init:(AlertDlgParams*)params;
- (DlgResult)run;
@end
DlgResult alertDlg(AlertDlgParams* params) {
return [[AlertDlg init:params] run];
}
@implementation AlertDlg
+ (AlertDlg*)init:(AlertDlgParams*)params {
AlertDlg* d = [AlertDlg alloc];
d->params = params;
return d;
}
- (DlgResult)run {
if(![NSThread isMainThread]) {
[self performSelectorOnMainThread:@selector(run) withObject:nil waitUntilDone:YES];
return self->result;
}
NSAlert* alert = [[NSAlert alloc] init];
if(self->params->title != nil) {
[[alert window] setTitle:[[NSString alloc] initWithUTF8String:self->params->title]];
}
[alert setMessageText:[[NSString alloc] initWithUTF8String:self->params->msg]];
switch (self->params->style) {
case MSG_YESNO:
[alert addButtonWithTitle:@"Yes"];
[alert addButtonWithTitle:@"No"];
break;
case MSG_ERROR:
[alert setIcon:[NSImage imageNamed:NSImageNameCaution]];
[alert addButtonWithTitle:@"OK"];
break;
case MSG_INFO:
[alert setIcon:[NSImage imageNamed:NSImageNameInfo]];
[alert addButtonWithTitle:@"OK"];
break;
}
self->result = [alert runModal] == NSAlertFirstButtonReturn ? DLG_OK : DLG_CANCEL;
return self->result;
}
@end
@interface FileDlg : NSObject {
FileDlgParams* params;
DlgResult result;
}
+ (FileDlg*)init:(FileDlgParams*)params;
- (DlgResult)run;
@end
DlgResult fileDlg(FileDlgParams* params) {
return [[FileDlg init:params] run];
}
@implementation FileDlg
+ (FileDlg*)init:(FileDlgParams*)params {
FileDlg* d = [FileDlg alloc];
d->params = params;
return d;
}
- (DlgResult)run {
if(![NSThread isMainThread]) {
[self performSelectorOnMainThread:@selector(run) withObject:nil waitUntilDone:YES];
} else if(self->params->mode == SAVEDLG) {
self->result = [self save];
} else {
self->result = [self load];
}
return self->result;
}
- (NSInteger)runPanel:(NSSavePanel*)panel {
[panel setFloatingPanel:YES];
if(self->params->title != nil) {
[panel setTitle:[[NSString alloc] initWithUTF8String:self->params->title]];
}
if(self->params->numext > 0) {
[panel setAllowedFileTypes:[NSArray arrayWithObjects:(NSString**)self->params->exts count:self->params->numext]];
}
if(self->params->relaxext) {
[panel setAllowsOtherFileTypes:YES];
}
return [panel runModal];
}
- (DlgResult)save {
NSSavePanel* panel = [NSSavePanel savePanel];
if(![self runPanel:panel]) {
return DLG_CANCEL;
} else if(![[panel URL] getFileSystemRepresentation:self->params->buf maxLength:self->params->nbuf]) {
return DLG_URLFAIL;
}
return DLG_OK;
}
- (DlgResult)load {
NSOpenPanel* panel = [NSOpenPanel openPanel];
if(self->params->mode == DIRDLG) {
[panel setCanChooseDirectories:YES];
[panel setCanChooseFiles:NO];
}
if(![self runPanel:panel]) {
return DLG_CANCEL;
}
NSURL* url = [[panel URLs] objectAtIndex:0];
if(![url getFileSystemRepresentation:self->params->buf maxLength:self->params->nbuf]) {
return DLG_URLFAIL;
}
return DLG_OK;
}
@end
+114
View File
@@ -0,0 +1,114 @@
package cocoa
// #cgo darwin LDFLAGS: -framework Cocoa
// #include <stdlib.h>
// #include <sys/syslimits.h>
// #include "dlg.h"
import "C"
import (
"bytes"
"errors"
"unsafe"
)
type AlertParams struct {
p C.AlertDlgParams
}
func mkAlertParams(msg, title string, style C.AlertStyle) *AlertParams {
a := AlertParams{C.AlertDlgParams{msg: C.CString(msg), style: style}}
if title != "" {
a.p.title = C.CString(title)
}
return &a
}
func (a *AlertParams) run() C.DlgResult {
return C.alertDlg(&a.p)
}
func (a *AlertParams) free() {
C.free(unsafe.Pointer(a.p.msg))
if a.p.title != nil {
C.free(unsafe.Pointer(a.p.title))
}
}
func nsStr(s string) unsafe.Pointer {
return C.NSStr(unsafe.Pointer(&[]byte(s)[0]), C.int(len(s)))
}
func YesNoDlg(msg, title string) bool {
a := mkAlertParams(msg, title, C.MSG_YESNO)
defer a.free()
return a.run() == C.DLG_OK
}
func InfoDlg(msg, title string) {
a := mkAlertParams(msg, title, C.MSG_INFO)
defer a.free()
a.run()
}
func ErrorDlg(msg, title string) {
a := mkAlertParams(msg, title, C.MSG_ERROR)
defer a.free()
a.run()
}
const BUFSIZE = C.PATH_MAX
func FileDlg(save bool, title string, exts []string, relaxExt bool) (string, error) {
mode := C.LOADDLG
if save {
mode = C.SAVEDLG
}
return fileDlg(mode, title, exts, relaxExt)
}
func DirDlg(title string) (string, error) {
return fileDlg(C.DIRDLG, title, nil, false)
}
func fileDlg(mode int, title string, exts []string, relaxExt bool) (string, error) {
p := C.FileDlgParams{
mode: C.int(mode),
nbuf: BUFSIZE,
}
p.buf = (*C.char)(C.malloc(BUFSIZE))
defer C.free(unsafe.Pointer(p.buf))
buf := (*(*[BUFSIZE]byte)(unsafe.Pointer(p.buf)))[:]
if title != "" {
p.title = C.CString(title)
defer C.free(unsafe.Pointer(p.title))
}
if len(exts) > 0 {
if len(exts) > 999 {
panic("more than 999 extensions not supported")
}
ptrSize := int(unsafe.Sizeof(&title))
p.exts = (*unsafe.Pointer)(C.malloc(C.size_t(ptrSize * len(exts))))
defer C.free(unsafe.Pointer(p.exts))
cext := (*(*[999]unsafe.Pointer)(unsafe.Pointer(p.exts)))[:]
for i, ext := range exts {
i := i
cext[i] = nsStr(ext)
defer C.NSRelease(cext[i])
}
p.numext = C.int(len(exts))
if relaxExt {
p.relaxext = 1
}
}
switch C.fileDlg(&p) {
case C.DLG_OK:
// casting to string copies the [about-to-be-freed] bytes
return string(buf[:bytes.Index(buf, []byte{0})]), nil
case C.DLG_CANCEL:
return "", nil
case C.DLG_URLFAIL:
return "", errors.New("failed to get file-system representation for selected URL")
}
panic("unhandled case")
}
+146
View File
@@ -0,0 +1,146 @@
// Package dialog provides a simple cross-platform common dialog API.
// Eg. to prompt the user with a yes/no dialog:
//
// if dialog.MsgDlg("%s", "Do you want to continue?").YesNo() {
// // user pressed Yes
// }
//
// The general usage pattern is to call one of the toplevel *Dlg functions
// which return a *Builder structure. From here you can optionally call
// configuration functions (eg. Title) to customise the dialog, before
// using a launcher function to run the dialog.
package dialog
import (
"errors"
"fmt"
)
// ErrCancelled is an error returned when a user cancels/closes a dialog.
var ErrCancelled = errors.New("Cancelled")
// Cancelled refers to ErrCancelled.
// Deprecated: Use ErrCancelled instead.
var Cancelled = ErrCancelled
// Dlg is the common type for dialogs.
type Dlg struct {
Title string
}
// MsgBuilder is used for creating message boxes.
type MsgBuilder struct {
Dlg
Msg string
}
// Message initialises a MsgBuilder with the provided message.
func Message(format string, args ...interface{}) *MsgBuilder {
return &MsgBuilder{Msg: fmt.Sprintf(format, args...)}
}
// Title specifies what the title of the message dialog will be.
func (b *MsgBuilder) Title(title string) *MsgBuilder {
b.Dlg.Title = title
return b
}
// YesNo spawns the message dialog with two buttons, "Yes" and "No".
// Returns true iff the user selected "Yes".
func (b *MsgBuilder) YesNo() bool {
return b.yesNo()
}
// Info spawns the message dialog with an information icon and single button, "Ok".
func (b *MsgBuilder) Info() {
b.info()
}
// Error spawns the message dialog with an error icon and single button, "Ok".
func (b *MsgBuilder) Error() {
b.error()
}
// FileFilter represents a category of files (eg. audio files, spreadsheets).
type FileFilter struct {
Desc string
Extensions []string
}
// FileBuilder is used for creating file browsing dialogs.
type FileBuilder struct {
Dlg
StartDir string
Filters []FileFilter
}
// File initialises a FileBuilder using the default configuration.
func File() *FileBuilder {
return &FileBuilder{}
}
// Title specifies the title to be used for the dialog.
func (b *FileBuilder) Title(title string) *FileBuilder {
b.Dlg.Title = title
return b
}
// Filter adds a category of files to the types allowed by the dialog. Multiple
// calls to Filter are cumulative - any of the provided categories will be allowed.
// By default all files can be selected.
//
// The special extension '*' allows all files to be selected when the Filter is active.
func (b *FileBuilder) Filter(desc string, extensions ...string) *FileBuilder {
filt := FileFilter{desc, extensions}
if len(filt.Extensions) == 0 {
filt.Extensions = append(filt.Extensions, "*")
}
b.Filters = append(b.Filters, filt)
return b
}
// SetStartDir specifies the initial directory of the dialog.
func (b *FileBuilder) SetStartDir(startDir string) *FileBuilder {
b.StartDir = startDir
return b
}
// Load spawns the file selection dialog using the configured settings,
// asking the user to select a single file. Returns ErrCancelled as the error
// if the user cancels or closes the dialog.
func (b *FileBuilder) Load() (string, error) {
return b.load()
}
// Save spawns the file selection dialog using the configured settings,
// asking the user for a filename to save as. If the chosen file exists, the
// user is prompted whether they want to overwrite the file. Returns
// ErrCancelled as the error if the user cancels/closes the dialog, or selects
// not to overwrite the file.
func (b *FileBuilder) Save() (string, error) {
return b.save()
}
// DirectoryBuilder is used for directory browse dialogs.
type DirectoryBuilder struct {
Dlg
StartDir string
}
// Directory initialises a DirectoryBuilder using the default configuration.
func Directory() *DirectoryBuilder {
return &DirectoryBuilder{}
}
// Browse spawns the directory selection dialog using the configured settings,
// asking the user to select a single folder. Returns ErrCancelled as the error
// if the user cancels or closes the dialog.
func (b *DirectoryBuilder) Browse() (string, error) {
return b.browse()
}
// Title specifies the title to be used for the dialog.
func (b *DirectoryBuilder) Title(title string) *DirectoryBuilder {
b.Dlg.Title = title
return b
}
+58
View File
@@ -0,0 +1,58 @@
package dialog
import (
"github.com/sqweek/dialog/cocoa"
)
func (b *MsgBuilder) yesNo() bool {
return cocoa.YesNoDlg(b.Msg, b.Dlg.Title)
}
func (b *MsgBuilder) info() {
cocoa.InfoDlg(b.Msg, b.Dlg.Title)
}
func (b *MsgBuilder) error() {
cocoa.ErrorDlg(b.Msg, b.Dlg.Title)
}
func (b *FileBuilder) load() (string, error) {
return b.run(false)
}
func (b *FileBuilder) save() (string, error) {
return b.run(true)
}
func (b *FileBuilder) run(save bool) (string, error) {
star := false
var exts []string
for _, filt := range b.Filters {
for _, ext := range filt.Extensions {
if ext == "*" {
star = true
} else {
exts = append(exts, ext)
}
}
}
if star && save {
/* OSX doesn't allow the user to switch visible file types/extensions. Also
** NSSavePanel's allowsOtherFileTypes property has no effect for an open
** dialog, so if "*" is a possible extension we must always show all files. */
exts = nil
}
f, err := cocoa.FileDlg(save, b.Dlg.Title, exts, star)
if f == "" && err == nil {
return "", ErrCancelled
}
return f, err
}
func (b *DirectoryBuilder) browse() (string, error) {
f, err := cocoa.DirDlg(b.Dlg.Title)
if f == "" && err == nil {
return "", ErrCancelled
}
return f, err
}
+103
View File
@@ -0,0 +1,103 @@
package dialog
// #cgo pkg-config: gtk+-3.0
// #include <gtk/gtk.h>
// #include <stdlib.h>
// static GtkWidget* msgdlg(GtkWindow *parent, GtkDialogFlags flags, GtkMessageType type, GtkButtonsType buttons, char *msg) {
// return gtk_message_dialog_new(parent, flags, type, buttons, "%s", msg);
// }
// static GtkWidget* filedlg(char *title, GtkWindow *parent, GtkFileChooserAction action, char* acceptText) {
// return gtk_file_chooser_dialog_new(title, parent, action, "Cancel", GTK_RESPONSE_CANCEL, acceptText, GTK_RESPONSE_ACCEPT, NULL);
// }
import "C"
import "unsafe"
func init() {
C.gtk_init(nil, nil)
}
func closeDialog(dlg *C.GtkWidget) {
C.gtk_widget_destroy(dlg)
/* The Destroy call itself isn't enough to remove the dialog from the screen; apparently
** that happens once the GTK main loop processes some further events. But if we're
** in a non-GTK app the main loop isn't running, so we empty the event queue before
** returning from the dialog functions.
** Not sure how this interacts with an actual GTK app... */
for C.gtk_events_pending() != 0 {
C.gtk_main_iteration()
}
}
func runMsgDlg(defaultTitle string, flags C.GtkDialogFlags, msgtype C.GtkMessageType, buttons C.GtkButtonsType, b *MsgBuilder) C.gint {
cmsg := C.CString(b.Msg)
defer C.free(unsafe.Pointer(cmsg))
dlg := C.msgdlg(nil, flags, msgtype, buttons, cmsg)
ctitle := C.CString(firstOf(b.Dlg.Title, defaultTitle))
defer C.free(unsafe.Pointer(ctitle))
C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(dlg)), ctitle)
defer closeDialog(dlg)
return C.gtk_dialog_run((*C.GtkDialog)(unsafe.Pointer(dlg)))
}
func (b *MsgBuilder) yesNo() bool {
return runMsgDlg("Confirm?", 0, C.GTK_MESSAGE_QUESTION, C.GTK_BUTTONS_YES_NO, b) == C.GTK_RESPONSE_YES
}
func (b *MsgBuilder) info() {
runMsgDlg("Information", 0, C.GTK_MESSAGE_INFO, C.GTK_BUTTONS_OK, b)
}
func (b *MsgBuilder) error() {
runMsgDlg("Error", 0, C.GTK_MESSAGE_ERROR, C.GTK_BUTTONS_OK, b)
}
func (b *FileBuilder) load() (string, error) {
return chooseFile("Open File", "Open", C.GTK_FILE_CHOOSER_ACTION_OPEN, b)
}
func (b *FileBuilder) save() (string, error) {
f, err := chooseFile("Save File", "Save", C.GTK_FILE_CHOOSER_ACTION_SAVE, b)
if err != nil {
return "", err
}
return f, nil
}
func chooseFile(title string, buttonText string, action C.GtkFileChooserAction, b *FileBuilder) (string, error) {
ctitle := C.CString(title)
defer C.free(unsafe.Pointer(ctitle))
cbuttonText := C.CString(buttonText)
defer C.free(unsafe.Pointer(cbuttonText))
dlg := C.filedlg(ctitle, nil, action, cbuttonText)
fdlg := (*C.GtkFileChooser)(unsafe.Pointer(dlg))
for _, filt := range b.Filters {
filter := C.gtk_file_filter_new()
cdesc := C.CString(filt.Desc)
defer C.free(unsafe.Pointer(cdesc))
C.gtk_file_filter_set_name(filter, cdesc)
for _, ext := range filt.Extensions {
cpattern := C.CString("*." + ext)
defer C.free(unsafe.Pointer(cpattern))
C.gtk_file_filter_add_pattern(filter, cpattern)
}
C.gtk_file_chooser_add_filter(fdlg, filter)
}
if b.StartDir != "" {
cdir := C.CString(b.StartDir)
defer C.free(unsafe.Pointer(cdir))
C.gtk_file_chooser_set_current_folder(fdlg, cdir)
}
C.gtk_file_chooser_set_do_overwrite_confirmation(fdlg, C.TRUE)
r := C.gtk_dialog_run((*C.GtkDialog)(unsafe.Pointer(dlg)))
defer closeDialog(dlg)
if r == C.GTK_RESPONSE_ACCEPT {
return C.GoString(C.gtk_file_chooser_get_filename(fdlg)), nil
}
return "", ErrCancelled
}
func (b *DirectoryBuilder) browse() (string, error) {
return chooseFile("Open Folder", "Open", C.GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER, &FileBuilder{Dlg: b.Dlg})
}
+141
View File
@@ -0,0 +1,141 @@
package dialog
import (
"fmt"
"reflect"
"syscall"
"unicode/utf16"
"unsafe"
"github.com/TheTitanrain/w32"
)
type WinDlgError int
func (e WinDlgError) Error() string {
return fmt.Sprintf("CommDlgExtendedError: %#x", e)
}
func err() error {
e := w32.CommDlgExtendedError()
if e == 0 {
return ErrCancelled
}
return WinDlgError(e)
}
func (b *MsgBuilder) yesNo() bool {
r := w32.MessageBox(w32.HWND(0), b.Msg, firstOf(b.Dlg.Title, "Confirm?"), w32.MB_YESNO)
return r == w32.IDYES
}
func (b *MsgBuilder) info() {
w32.MessageBox(w32.HWND(0), b.Msg, firstOf(b.Dlg.Title, "Information"), w32.MB_OK|w32.MB_ICONINFORMATION)
}
func (b *MsgBuilder) error() {
w32.MessageBox(w32.HWND(0), b.Msg, firstOf(b.Dlg.Title, "Error"), w32.MB_OK|w32.MB_ICONERROR)
}
type filedlg struct {
buf []uint16
filters []uint16
opf *w32.OPENFILENAME
}
func (d filedlg) Filename() string {
i := 0
for i < len(d.buf) && d.buf[i] != 0 {
i++
}
return string(utf16.Decode(d.buf[:i]))
}
func (b *FileBuilder) load() (string, error) {
d := openfile(w32.OFN_FILEMUSTEXIST, b)
if w32.GetOpenFileName(d.opf) {
return d.Filename(), nil
}
return "", err()
}
func (b *FileBuilder) save() (string, error) {
d := openfile(w32.OFN_OVERWRITEPROMPT, b)
if w32.GetSaveFileName(d.opf) {
return d.Filename(), nil
}
return "", err()
}
/* syscall.UTF16PtrFromString not sufficient because we need to encode embedded NUL bytes */
func utf16ptr(utf16 []uint16) *uint16 {
if utf16[len(utf16)-1] != 0 {
panic("refusing to make ptr to non-NUL terminated utf16 slice")
}
h := (*reflect.SliceHeader)(unsafe.Pointer(&utf16))
return (*uint16)(unsafe.Pointer(h.Data))
}
func utf16slice(ptr *uint16) []uint16 {
hdr := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(ptr)), Len: 1, Cap: 1}
slice := *((*[]uint16)(unsafe.Pointer(&hdr)))
i := 0
for slice[len(slice)-1] != 0 {
i++
}
hdr.Len = i
slice = *((*[]uint16)(unsafe.Pointer(&hdr)))
return slice
}
func openfile(flags uint32, b *FileBuilder) (d filedlg) {
d.buf = make([]uint16, w32.MAX_PATH)
d.opf = &w32.OPENFILENAME{
File: utf16ptr(d.buf),
MaxFile: uint32(len(d.buf)),
Flags: flags,
}
d.opf.StructSize = uint32(unsafe.Sizeof(*d.opf))
if b.StartDir != "" {
d.opf.InitialDir, _ = syscall.UTF16PtrFromString(b.StartDir)
}
if b.Dlg.Title != "" {
d.opf.Title, _ = syscall.UTF16PtrFromString(b.Dlg.Title)
}
for _, filt := range b.Filters {
/* build utf16 string of form "Music File\0*.mp3;*.ogg;*.wav;\0" */
d.filters = append(d.filters, utf16.Encode([]rune(filt.Desc))...)
d.filters = append(d.filters, 0)
for _, ext := range filt.Extensions {
s := fmt.Sprintf("*.%s;", ext)
d.filters = append(d.filters, utf16.Encode([]rune(s))...)
}
d.filters = append(d.filters, 0)
}
if d.filters != nil {
d.filters = append(d.filters, 0, 0) // two extra NUL chars to terminate the list
d.opf.Filter = utf16ptr(d.filters)
}
return d
}
type dirdlg struct {
bi *w32.BROWSEINFO
}
func selectdir(b *DirectoryBuilder) (d dirdlg) {
d.bi = &w32.BROWSEINFO{Flags: w32.BIF_RETURNONLYFSDIRS | w32.BIF_NEWDIALOGSTYLE}
if b.Dlg.Title != "" {
d.bi.Title, _ = syscall.UTF16PtrFromString(b.Dlg.Title)
}
return d
}
func (b *DirectoryBuilder) browse() (string, error) {
d := selectdir(b)
res := w32.SHBrowseForFolder(d.bi)
if res == 0 {
return "", ErrCancelled
}
return w32.SHGetPathFromIDList(res), nil
}
@@ -0,0 +1,20 @@
package main
import (
"fmt"
"github.com/sqweek/dialog"
)
func main() {
/* Note that spawning a dialog from a non-graphical app like this doesn't
** quite work properly in OSX. The dialog appears fine, and mouse
** interaction works but keypresses go straight through the dialog.
** I'm guessing it has something to do with not having a main loop? */
dialog.Message("%s", "Please select a file").Title("Hello world!").Info()
file, err := dialog.File().Title("Save As").Filter("All Files", "*").Save()
fmt.Println(file)
fmt.Println("Error:", err)
dialog.Message("You chose file: %s", file).Title("Goodbye world!").Error()
dialog.Directory().Title("Now find a dir").Browse()
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"fmt"
"image"
"image/color"
"image/draw"
"github.com/skelterjohn/go.wde" // nolint: goimports
_ "github.com/skelterjohn/go.wde/init"
"github.com/sqweek/dialog"
)
var loadR, saveR image.Rectangle
func events(events <-chan interface{}) {
for ei := range events {
switch e := ei.(type) {
case wde.MouseUpEvent:
switch e.Which { // nolint: gocritic
case wde.LeftButton:
var f string
var err error
/* launching dialogs within the event loop like this has
** a serious problem in practice. On Linux/Windows the
** dialog is not modal, which means events on the main
** window still get queued up.
**
** But the dialog functions are synchronous, so we don't
** process any of these events until the dialog closes. End
** result is if the user clicks multiple times they end up
** facing multiple dialogs, one after the other.
**
** For this reason, it is recommended to launch dialogs
** in a separate goroutine, and arrange modality via some
** other mechanism (if desired). */
if e.Where.In(loadR) {
f, err = dialog.File().Title("LOL").Filter("Image", "png").Filter("Audio", "mp3").Filter("All files", "*").Load()
} else {
f, err = dialog.File().Title("Hilarious").Save()
}
fmt.Println(f)
fmt.Println("Error:", err)
}
case wde.KeyTypedEvent:
switch {
case e.Glyph == "a":
fmt.Println(dialog.Message("Is this sentence false?").YesNo())
case e.Glyph == "b":
fmt.Println(dialog.Message("R U OK?").Title("Just checking").YesNo())
case e.Glyph == "c":
dialog.Message("Operation failed").Error()
}
case wde.CloseEvent:
wde.Stop()
return
}
}
}
func main() {
go func() {
w, _ := wde.NewWindow(300, 300)
loadR = image.Rect(0, 0, 300, 150)
saveR = image.Rect(0, 150, 300, 300)
w.Show()
draw.Draw(w.Screen(), loadR, &image.Uniform{color.RGBA{0, 0xff, 0, 0xff}}, image.ZP, draw.Src)
draw.Draw(w.Screen(), saveR, &image.Uniform{color.RGBA{0xff, 0, 0, 0xff}}, image.ZP, draw.Src)
w.FlushImage()
go events(w.EventChan())
}()
wde.Run()
}
+5
View File
@@ -0,0 +1,5 @@
module github.com/sqweek/dialog
require (
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf
)
+2
View File
@@ -0,0 +1,2 @@
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
+10
View File
@@ -0,0 +1,10 @@
package dialog
func firstOf(args ...string) string {
for _, arg := range args {
if arg != "" {
return arg
}
}
return ""
}