Files
VRCWT-OSC/cmd/vrc_osc_gui/window_windows.go
messypy dadc65065c
All checks were successful
build-windows-exe / build (push) Successful in 1m12s
Load GUI history asynchronously
2026-07-27 23:32:17 +09:00

4534 lines
130 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build windows
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"unsafe"
appPkg "vrc_osc_go/internal/app"
"vrc_osc_go/internal/config"
)
const (
wsOverlappedWindow = 0x00CF0000
wsVisible = 0x10000000
wsChild = 0x40000000
wsPopup = 0x80000000
wsClipChildren = 0x02000000
wsClipSiblings = 0x04000000
wsBorder = 0x00800000
wsVScroll = 0x00200000
esMultiline = 0x0004
esReadonly = 0x0800
esAutovscroll = 0x0040
esAutohscroll = 0x0080
wsTabstop = 0x00010000
wmCreateGUI = 0x0001
wmDestroyGUI = 0x0002
wmPaint = 0x000F
wmEraseBkgnd = 0x0014
wmSize = 0x0005
wmVScroll = 0x0115
wmMouseWheel = 0x020A
wmCloseGUI = 0x0010
wmLButtonDown = 0x0201
wmLButtonUpGUI = 0x0202
wmChar = 0x0102
wmNCLButtonDown = 0x00A1
wmTimerGUI = 0x0113
wmCommandGUI = 0x0111
wmSetFont = 0x0030
wmHistoryExportDone = 0x0401
wmHistoryRefreshDone = 0x0402
enChange = 0x0300
enKillFocus = 0x0200
bnClicked = 0x0000
htCaption = 2
tbButtonStructSize = 0x041E
tbAddButtons = 0x0414
tbAddStringW = 0x044D
tbAutoSize = 0x0421
tbSetButtonSize = 0x041F
tbSetExtendedStyle = 0x0454
tbSetMaxTextRows = 0x0428
tbstyleFlat = 0x0800
tbstyleList = 0x1000
tbstyleExMixedBtns = 0x00000008
btNsButton = 0x0000
btNsAutoSize = 0x0010
btNsNoPrefix = 0x0020
btNsShowText = 0x0040
tbstateEnabled = 0x04
ccsTop = 0x00000001
ccsNoResize = 0x00000004
ccsNoParentAlign = 0x00000008
ccsNoDivider = 0x00000040
iccBarClasses = 0x00000004
toolbarClass = "ToolbarWindow32"
editClass = "EDIT"
paneClass = "VRC_OSC_PANE"
idStatus = 2001
idLog = 2002
idHistoryExportDir = 3010
idHistoryExportCustom = 3011
idHistoryExportBrowse = 3012
idHistoryExportSave = 3013
bifReturnOnlyFsDirs = 0x0001
bifEditBox = 0x0010
bifNewDialogStyle = 0x0040
bffmInitialized = 1
bffmSetSelectionW = 0x0467
timerRefresh = 1
headerHeight = 56
navBarHeight = 58
contentTop = headerHeight + navBarHeight
bottomBarHeight = 48
windowWidth = 760
leftPaneWidth = 445
leftPaneHeight = 560
rightPaneWidth = windowWidth - leftPaneWidth
settingsPaneWidth = rightPaneWidth
settingsPaneHeight = leftPaneHeight
clrMainBg = 0x00311c0b
clrHeaderBg = 0x004d2c08
clrNavBg = 0x0024150b
clrNavActiveBg = 0x00311f12
clrPaneBg = 0x00372513
clrPaneBgAlt = 0x00332110
clrPaneBorder = 0x005b3500
clrText = 0x00e8edf8
clrMutedText = 0x007d8aa0
clrAccentGreen = 0x0067e244
clrAccentRed = 0x006165ff
clrAccentBlue = 0x00ff9c1f
clrAvatarBg = 0x005a3900
guiLogTailBytes = 2 * 1024 * 1024
)
const translateTabEnabled = false
var joinLeaveLinePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
var runtimeTimePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]`)
var worldIdPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+)`)
var instanceIdPattern = regexp.MustCompile(`instanceId=([^,}\s]+)`)
var worldNamePattern = regexp.MustCompile(`worldName=([^,}]+)`)
var worldLocationPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+):([^\s,\]\)\"']+)`)
var worldPattern = regexp.MustCompile(`(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)`)
var enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`)
var vrcLogTimePattern = regexp.MustCompile(`^(\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2})`)
var vrcJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
var vrcLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
var joinLeaveBodyPattern = regexp.MustCompile(`^\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
var hwndTopMostFlag uintptr = ^uintptr(0)
var hwndNotTopMostFlag uintptr = ^uintptr(0) - 1
var hwndNoMove uintptr = 0x0002
var hwndNoZOrder uintptr = 0x0004
var hwndNoSize uintptr = 0x0001
var hwndNoActivate uintptr = 0x0010
var hwndShowWindow uintptr = 0x0040
var swRestore uintptr = 9
var swHideControl uintptr = 0
var swShowControl uintptr = 5
var swpVisibleFlags uintptr = hwndNoActivate | hwndShowWindow
var swpFlags uintptr = hwndNoMove | hwndNoSize | hwndNoActivate
var swpShowFlags uintptr = hwndNoMove | hwndNoSize | hwndShowWindow
var smXVirtualScreen int32 = 76
var smYVirtualScreen int32 = 77
var smCXVirtualScreen int32 = 78
var smCYVirtualScreen int32 = 79
var setWindowPosProc *syscall.LazyProc
var sendMessageProc *syscall.LazyProc
var postMessageProc *syscall.LazyProc
var shellExecuteProc *syscall.LazyProc
var createFontProc *syscall.LazyProc
var deleteObjectProc *syscall.LazyProc
var getStockObjectProc *syscall.LazyProc
var showWindowProc *syscall.LazyProc
var initCommonControlsExProc *syscall.LazyProc
var createSolidBrushProc *syscall.LazyProc
var getSystemMetricsProc *syscall.LazyProc
var beginPaintProc *syscall.LazyProc
var endPaintProc *syscall.LazyProc
var getClientRectProc *syscall.LazyProc
var fillRectProc *syscall.LazyProc
var getSysColorBrushProc *syscall.LazyProc
var selectObjectProc *syscall.LazyProc
var setBkModeProc *syscall.LazyProc
var setTextColorProc *syscall.LazyProc
var textOutProc *syscall.LazyProc
var getTextExtentPoint32Proc *syscall.LazyProc
var getTextMetricsProc *syscall.LazyProc
var invalidateRectProc *syscall.LazyProc
var setScrollInfoProc *syscall.LazyProc
var getScrollInfoProc *syscall.LazyProc
var setTimerProc *syscall.LazyProc
var createPenProc *syscall.LazyProc
var moveToExProc *syscall.LazyProc
var lineToProc *syscall.LazyProc
var ellipseProc *syscall.LazyProc
var mainBgBrush uintptr
var paneBgBrush uintptr
var navBgBrush uintptr
var navActiveBrush uintptr
var paneAltBrush uintptr
var defaultGUIFont uintptr
var vrchatRunningMu sync.Mutex
var vrchatRunningLastCheck time.Time
var vrchatRunningLastValue bool
var guestSnapshotCacheMu sync.Mutex
var guestSnapshotCache struct {
modTime time.Time
size int64
items []appPkg.GuestStatus
}
var worldHistoryCacheMu sync.Mutex
var worldHistoryCache struct {
modTime time.Time
size int64
items []guiWorldVisitRecord
}
var joinLeaveEventsCacheMu sync.Mutex
var joinLeaveEventsCache struct {
jsonMod time.Time
jsonSize int64
logMod time.Time
logSize int64
items []joinLeaveEventSnapshot
}
type guiApp struct {
hwnd uintptr
navBarHwnd uintptr
leftHwnd uintptr
rightHwnd uintptr
settingsPaneHwnd uintptr
tabJoin uintptr
tabTranslate uintptr
tabSettings uintptr
btnTopMost uintptr
btnAutoUnmute uintptr
btnFontDown uintptr
btnFontUp uintptr
activeTab string
topMost bool
autoUnmuteOnLeave bool
lastTopMost bool
fontSize int
hFont uintptr
joinFontSize int
joinHFont uintptr
joinBoldFontSize int
joinBoldHFont uintptr
joinHeadlineSize int
joinHeadlineHFont uintptr
leftPaneText string
rightPaneText string
refreshIntervalValue int
refreshIntervalUnit string
historyRegex string
historyFromDate string
historyToDate string
historyMonth time.Time
historySelectedDate time.Time
historySelectedKeys map[string]bool
historyStatus string
historyCalendarCells []historyCalendarCell
historyDetailRows []historyDetailRow
historyPrevMonthRect winRect
historyNextMonthRect winRect
historyReloadRect winRect
historyExportRect winRect
historyReloadPending bool
historyRefreshRunning bool
historyRefreshResultCh chan historyRefreshResult
historyCacheLoaded bool
historyDetailContentHeightPx int32
settingsExportDirEdit uintptr
settingsExportBrowseBtn uintptr
settingsExportCustomEdit uintptr
settingsExportCustomSaveBtn uintptr
settingsEditSyncing bool
historyExportDir string
historyExportIncludeTime bool
historyExportIncludeWorld bool
historyExportIncludeJoinLeave bool
historyExportCustomEnabled bool
historyExportCustom string
historyExportRunning bool
historyExportResultCh chan historyExportResult
lastSettingsVisible bool
discordMuted bool
currentUsers []userState
currentWorld string
currentUserCount int
historyRows []guiWorldVisitRow
memberScrollPos int
eventScrollPos int
historyScrollPos int
lastSettingsAction time.Time
lastTabAction time.Time
lastInstanceCount int
lastLayoutHeight int
}
type userState struct {
Name string
Present bool
LastJoin time.Time
LastLeave time.Time
}
func runtimeDir() string {
exe, err := os.Executable()
if err != nil {
return "runtime"
}
return filepath.Join(filepath.Dir(exe), "runtime")
}
type initCommonControlsEx struct {
dwSize uint32
dwICC uint32
}
type toolbarButton struct {
iBitmap int32
idCommand int32
fsState byte
fsStyle byte
bReserved [2]byte
dwData uintptr
iString uintptr
}
type worldState struct {
location string
worldID string
instanceID string
worldName string
pendingWorldName string
initialized bool
}
func guiLog(text string) {
_ = appPkg.AppendRuntimeLog("GUI", text)
}
func runNativeGUI(initialTab string) error {
user32 := syscall.NewLazyDLL("user32.dll")
kernel32 := syscall.NewLazyDLL("kernel32.dll")
gdi32 := syscall.NewLazyDLL("gdi32.dll")
comctl32 := syscall.NewLazyDLL("comctl32.dll")
shell32 := syscall.NewLazyDLL("shell32.dll")
registerClass := user32.NewProc("RegisterClassW")
createWindowEx := user32.NewProc("CreateWindowExW")
defWindowProc := user32.NewProc("DefWindowProcW")
showWindowProc = user32.NewProc("ShowWindow")
setWindowPosProc = user32.NewProc("SetWindowPos")
sendMessageProc = user32.NewProc("SendMessageW")
createFontProc = gdi32.NewProc("CreateFontW")
deleteObjectProc = gdi32.NewProc("DeleteObject")
getStockObjectProc = gdi32.NewProc("GetStockObject")
initCommonControlsExProc = comctl32.NewProc("InitCommonControlsEx")
createSolidBrushProc = gdi32.NewProc("CreateSolidBrush")
getSystemMetricsProc = user32.NewProc("GetSystemMetrics")
beginPaintProc = user32.NewProc("BeginPaint")
endPaintProc = user32.NewProc("EndPaint")
getClientRectProc = user32.NewProc("GetClientRect")
fillRectProc = user32.NewProc("FillRect")
getSysColorBrushProc = user32.NewProc("GetSysColorBrush")
selectObjectProc = gdi32.NewProc("SelectObject")
setBkModeProc = gdi32.NewProc("SetBkMode")
setTextColorProc = gdi32.NewProc("SetTextColor")
textOutProc = gdi32.NewProc("TextOutW")
getTextExtentPoint32Proc = gdi32.NewProc("GetTextExtentPoint32W")
getTextMetricsProc = gdi32.NewProc("GetTextMetricsW")
invalidateRectProc = user32.NewProc("InvalidateRect")
setScrollInfoProc = user32.NewProc("SetScrollInfo")
getScrollInfoProc = user32.NewProc("GetScrollInfo")
createPenProc = gdi32.NewProc("CreatePen")
moveToExProc = gdi32.NewProc("MoveToEx")
lineToProc = gdi32.NewProc("LineTo")
ellipseProc = gdi32.NewProc("Ellipse")
if getStockObjectProc != nil {
defaultGUIFont, _, _ = getStockObjectProc.Call(17)
}
getMessage := user32.NewProc("GetMessageW")
translateMessage := user32.NewProc("TranslateMessage")
dispatchMessage := user32.NewProc("DispatchMessageW")
postQuitMessage := user32.NewProc("PostQuitMessage")
postMessageProc = user32.NewProc("PostMessageW")
releaseCapture := user32.NewProc("ReleaseCapture")
setTimerProc = user32.NewProc("SetTimer")
loadCursor := user32.NewProc("LoadCursorW")
setWindowText := user32.NewProc("SetWindowTextW")
getWindowText := user32.NewProc("GetWindowTextW")
getWindowRect := user32.NewProc("GetWindowRect")
isWindowVisible := user32.NewProc("IsWindowVisible")
isIconic := user32.NewProc("IsIconic")
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
loadImage := user32.NewProc("LoadImageW")
setActiveWindow := user32.NewProc("SetActiveWindow")
setForegroundWindow := user32.NewProc("SetForegroundWindow")
bringWindowToTop := user32.NewProc("BringWindowToTop")
shellExecuteProc = shell32.NewProc("ShellExecuteW")
type wndClass struct {
style uint32
lpfnWndProc uintptr
cbClsExtra int32
cbWndExtra int32
hInstance uintptr
hIcon uintptr
hCursor uintptr
hbrBackground uintptr
lpszMenuName *uint16
lpszClassName *uint16
}
type msg struct {
hwnd uintptr
message uint32
wParam uintptr
lParam uintptr
time uint32
pt struct{ X, Y int32 }
}
hInstance, _, _ := getModuleHandle.Call(0)
if createSolidBrushProc != nil {
mainBgBrush, _, _ = createSolidBrushProc.Call(clrMainBg)
paneBgBrush, _, _ = createSolidBrushProc.Call(clrPaneBg)
navBgBrush, _, _ = createSolidBrushProc.Call(clrNavBg)
navActiveBrush, _, _ = createSolidBrushProc.Call(clrNavActiveBg)
paneAltBrush, _, _ = createSolidBrushProc.Call(clrPaneBgAlt)
}
className, _ := syscall.UTF16PtrFromString("VRC_OSC_NATIVE_GUI")
paneClassName, _ := syscall.UTF16PtrFromString(paneClass)
title, _ := syscall.UTF16PtrFromString("VRC OSC")
cursor, _, _ := loadCursor.Call(0, 32512)
iconPath := trayIconPath()
iconPathPtr, _ := syscall.UTF16PtrFromString(iconPath)
const (
lrLoadFromFile = 0x00000010
imageIcon = 1
)
icon, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(iconPathPtr)), imageIcon, 0, 0, lrLoadFromFile)
if icon == 0 {
icon, _, _ = user32.NewProc("LoadIconW").Call(0, 32512)
}
guiLog("stage=prepare window")
var app guiApp
app.activeTab = normalizeInitialTab(initialTab)
if app.activeTab == "" {
app.activeTab = "join"
}
if app.activeTab == "translate" && !translateTabEnabled {
app.activeTab = "join"
}
guiCfg := loadGUISettings()
app.topMost = guiCfg.TopMost
app.autoUnmuteOnLeave = guiCfg.AutoUnmuteOnSelfLeave
if guiCfg.FontSize > 0 {
app.fontSize = guiCfg.FontSize
} else {
app.fontSize = 18
}
app.refreshIntervalValue = guiCfg.RefreshIntervalValue
if app.refreshIntervalValue <= 0 {
app.refreshIntervalValue = 2
}
app.refreshIntervalUnit = strings.ToLower(strings.TrimSpace(guiCfg.RefreshIntervalUnit))
if app.refreshIntervalUnit != "min" {
app.refreshIntervalUnit = "sec"
}
app.historyRegex = strings.TrimSpace(guiCfg.HistoryRegex)
app.historyFromDate = strings.TrimSpace(guiCfg.HistoryFromDate)
app.historyToDate = strings.TrimSpace(guiCfg.HistoryToDate)
app.historyExportDir = strings.TrimSpace(guiCfg.HistoryExportDir)
if app.historyExportDir == "" {
app.historyExportDir = filepath.Join(runtimeDir(), "exports")
}
app.historyExportIncludeTime = guiCfg.HistoryExportIncludeTime
app.historyExportIncludeWorld = guiCfg.HistoryExportIncludeWorld
app.historyExportIncludeJoinLeave = guiCfg.HistoryExportIncludeJoinLeave
app.historyExportCustomEnabled = guiCfg.HistoryExportCustomEnabled
app.historyExportCustom = strings.TrimSpace(guiCfg.HistoryExportCustom)
app.historyReloadPending = true
app.hFont = createAppFont(app.fontSize)
paneWndProc := syscall.NewCallback(func(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr {
defer func() {
if r := recover(); r != nil {
guiLog(fmt.Sprintf("panic in paneWndProc hwnd=%d msg=0x%04x: %v\n%s", hwnd, message, r, debug.Stack()))
}
}()
switch message {
case wmPaint:
switch hwnd {
case app.leftHwnd:
return paintLeftPane(hwnd, &app)
case app.rightHwnd:
return paintRightPane(hwnd, &app)
case app.settingsPaneHwnd:
return paintSettingsPane(hwnd, &app)
default:
return paintJoinPane(hwnd, &app)
}
case wmEraseBkgnd:
return paintPaneBackground(hwnd, &app, wParam)
case wmSize:
if invalidateRectProc != nil {
invalidateRectProc.Call(hwnd, 0, 1)
}
return 0
case wmVScroll:
if handlePaneVScroll(hwnd, &app, wParam) {
return 0
}
case wmMouseWheel:
if handlePaneMouseWheel(hwnd, &app, int32(int16(wParam>>16))) {
return 0
}
case wmCommandGUI:
if hwnd == app.settingsPaneHwnd {
cmd := uint16(wParam & 0xffff)
notify := uint16((wParam >> 16) & 0xffff)
if cmd == idHistoryExportBrowse && notify == bnClicked {
if handleHistoryExportBrowseCommand(&app, setWindowText) {
return 0
}
}
if cmd == idHistoryExportSave && notify == bnClicked {
if handleHistoryExportSaveCommand(&app, getWindowText, setWindowText) {
return 0
}
}
if notify == enChange || notify == enKillFocus {
if handleHistoryExportEditCommand(&app, cmd, notify, getWindowText, setWindowText) {
return 0
}
}
}
case wmLButtonUpGUI:
if (hwnd == app.settingsPaneHwnd || hwnd == app.leftHwnd) && app.activeTab == "settings" {
if handleSettingsPaneClick(&app, int32(lParam&0xffff), int32((lParam>>16)&0xffff), getWindowText, setWindowText) {
return 0
}
}
if app.activeTab == "history" {
if hwnd == app.leftHwnd || hwnd == app.rightHwnd {
if handleHistoryPaneClick(&app, hwnd, int32(lParam&0xffff), int32((lParam>>16)&0xffff), getWindowText, setWindowText) {
return 0
}
}
}
}
if defWindowProc != nil {
ret, _, _ := defWindowProc.Call(hwnd, uintptr(message), wParam, lParam)
return ret
}
return 0
})
guiLog("stage=register pane class")
atomPane, _, regPaneErr := registerClass.Call(uintptr(unsafe.Pointer(&wndClass{
style: 0,
lpfnWndProc: paneWndProc,
hInstance: hInstance,
hIcon: 0,
hCursor: cursor,
hbrBackground: mainBgBrush,
lpszClassName: paneClassName,
})))
if atomPane == 0 {
guiLog(fmt.Sprintf("stage=register pane class failed err=%v", regPaneErr))
return fmt.Errorf("register pane class: %v", regPaneErr)
}
wndProc := syscall.NewCallback(func(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr {
defer func() {
if r := recover(); r != nil {
guiLog(fmt.Sprintf("panic in wndProc hwnd=%d msg=0x%04x: %v\n%s", hwnd, message, r, debug.Stack()))
}
}()
switch message {
case wmPaint:
return paintMainWindow(hwnd, &app)
case wmEraseBkgnd:
return 1
case wmCreateGUI:
app.hwnd = hwnd
buttonClass, _ := syscall.UTF16PtrFromString("BUTTON")
editClassTitle, _ := syscall.UTF16PtrFromString(editClass)
leftTitle, _ := syscall.UTF16PtrFromString("")
rightTitle, _ := syscall.UTF16PtrFromString("")
topMostTitle, _ := syscall.UTF16PtrFromString("Top most")
autoUnmuteTitle, _ := syscall.UTF16PtrFromString("Leave unmute")
fontDownTitle, _ := syscall.UTF16PtrFromString("A-")
fontUpTitle, _ := syscall.UTF16PtrFromString("A+")
browseTitle, _ := syscall.UTF16PtrFromString("参照")
saveTitle, _ := syscall.UTF16PtrFromString("保存")
toolbarTitle, _ := syscall.UTF16PtrFromString("")
if initCommonControlsExProc != nil {
icc := initCommonControlsEx{
dwSize: uint32(unsafe.Sizeof(initCommonControlsEx{})),
dwICC: iccBarClasses,
}
initCommonControlsExProc.Call(uintptr(unsafe.Pointer(&icc)))
}
app.navBarHwnd, _, _ = createWindowEx.Call(
0,
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(toolbarClass))),
uintptr(unsafe.Pointer(toolbarTitle)),
wsChild|wsClipChildren|wsBorder|tbstyleList|ccsNoResize|ccsNoParentAlign|ccsNoDivider,
0, headerHeight, windowWidth, navBarHeight, hwnd, 3000, hInstance, 0,
)
if app.navBarHwnd != 0 && sendMessageProc != nil {
sendMessageProc.Call(app.navBarHwnd, tbButtonStructSize, uintptr(unsafe.Sizeof(toolbarButton{})), 0)
sendMessageProc.Call(app.navBarHwnd, tbSetButtonSize, 0, uintptr(120)|(uintptr(24)<<16))
sendMessageProc.Call(app.navBarHwnd, tbSetExtendedStyle, 0, tbstyleExMixedBtns)
toolbarStrings := []uint16{
'\u30ed', '\u30b0', 0,
'\u7ffb', '\u8a33', 0,
'\u8a2d', '\u5b9a', 0,
'\u5c65', '\u6b74', 0,
0,
}
baseStringIndex, _, _ := sendMessageProc.Call(app.navBarHwnd, tbAddStringW, 0, uintptr(unsafe.Pointer(&toolbarStrings[0])))
buttons := []toolbarButton{
{iBitmap: -2, idCommand: 3001, fsState: tbstateEnabled, fsStyle: btNsButton | btNsAutoSize | btNsNoPrefix | btNsShowText, iString: baseStringIndex},
{iBitmap: -2, idCommand: 3002, fsState: 0, fsStyle: btNsButton | btNsAutoSize | btNsNoPrefix | btNsShowText, iString: baseStringIndex + 1},
{iBitmap: -2, idCommand: 3003, fsState: tbstateEnabled, fsStyle: btNsButton | btNsAutoSize | btNsNoPrefix | btNsShowText, iString: baseStringIndex + 2},
{iBitmap: -2, idCommand: 3004, fsState: tbstateEnabled, fsStyle: btNsButton | btNsAutoSize | btNsNoPrefix | btNsShowText, iString: baseStringIndex + 3},
}
sendMessageProc.Call(app.navBarHwnd, tbAddButtons, uintptr(len(buttons)), uintptr(unsafe.Pointer(&buttons[0])))
sendMessageProc.Call(app.navBarHwnd, tbSetMaxTextRows, 1, 0)
sendMessageProc.Call(app.navBarHwnd, tbAutoSize, 0, 0)
}
paneStyle := uintptr(wsVisible | wsChild | wsClipChildren | wsClipSiblings | wsVScroll)
buttonStyle := uintptr(wsChild | wsClipSiblings)
app.leftHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(paneClassName)), uintptr(unsafe.Pointer(leftTitle)), paneStyle, 0, contentTop, leftPaneWidth, leftPaneHeight, hwnd, idStatus, hInstance, 0)
app.rightHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(paneClassName)), uintptr(unsafe.Pointer(rightTitle)), paneStyle, leftPaneWidth, contentTop, rightPaneWidth, leftPaneHeight, hwnd, idLog, hInstance, 0)
settingsPaneTitle, _ := syscall.UTF16PtrFromString("")
app.settingsPaneHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(paneClassName)), uintptr(unsafe.Pointer(settingsPaneTitle)), paneStyle, 0, contentTop, windowWidth, settingsPaneHeight, hwnd, idLog+1, hInstance, 0)
app.btnTopMost, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(topMostTitle)), buttonStyle, leftPaneWidth+32, contentTop+86, 140, 30, hwnd, 3005, hInstance, 0)
app.btnAutoUnmute, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(autoUnmuteTitle)), buttonStyle, leftPaneWidth+32, contentTop+132, 180, 30, hwnd, 3008, hInstance, 0)
app.btnFontDown, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontDownTitle)), buttonStyle, leftPaneWidth+32, contentTop+178, 52, 30, hwnd, 3006, hInstance, 0)
app.btnFontUp, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontUpTitle)), buttonStyle, leftPaneWidth+92, contentTop+178, 52, 30, hwnd, 3007, hInstance, 0)
exportDirTitle, _ := syscall.UTF16PtrFromString("")
app.settingsExportDirEdit, _, _ = createWindowEx.Call(
0,
uintptr(unsafe.Pointer(editClassTitle)),
uintptr(unsafe.Pointer(exportDirTitle)),
uintptr(wsChild|wsVisible|wsBorder|wsTabstop|esAutohscroll),
116, 410, 520, 24,
app.settingsPaneHwnd, idHistoryExportDir, hInstance, 0,
)
browseStyle := uintptr(wsChild | wsTabstop)
app.settingsExportBrowseBtn, _, _ = createWindowEx.Call(
0,
uintptr(unsafe.Pointer(buttonClass)),
uintptr(unsafe.Pointer(browseTitle)),
browseStyle,
644, 410, 92, 24,
app.settingsPaneHwnd, idHistoryExportBrowse, hInstance, 0,
)
exportCustomTitle, _ := syscall.UTF16PtrFromString("")
app.settingsExportCustomEdit, _, _ = createWindowEx.Call(
0,
uintptr(unsafe.Pointer(editClassTitle)),
uintptr(unsafe.Pointer(exportCustomTitle)),
uintptr(wsChild|wsVisible|wsBorder|wsTabstop|esAutohscroll),
116, 520, 520, 24,
app.settingsPaneHwnd, idHistoryExportCustom, hInstance, 0,
)
app.settingsExportCustomSaveBtn, _, _ = createWindowEx.Call(
0,
uintptr(unsafe.Pointer(buttonClass)),
uintptr(unsafe.Pointer(saveTitle)),
browseStyle,
644, 520, 92, 24,
app.settingsPaneHwnd, idHistoryExportSave, hInstance, 0,
)
applyRefreshTimer(hwnd, &app)
applyFont(&app)
syncHistoryExportEditControls(&app, setWindowText)
refreshGUI(setWindowText, &app, true)
applyTopMost(hwnd, &app)
ensureWindowVisible("startup", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetricsProc)
raiseWindowToFront(hwnd, showWindowProc, setWindowPosProc, setActiveWindow, setForegroundWindow, bringWindowToTop)
logWindowState("after-ensure", hwnd, getWindowRect, isWindowVisible, isIconic)
return 0
case wmCommandGUI:
needsFullRefresh := false
switch uint16(wParam & 0xffff) {
case 3001:
guiLog("click tab command join")
app.activeTab = "join"
needsFullRefresh = true
case 3002:
guiLog("click tab command translate")
if translateTabEnabled {
app.activeTab = "translate"
needsFullRefresh = true
} else {
guiLog("translate tab disabled")
return 0
}
case 3003:
guiLog("click tab command settings")
app.activeTab = "settings"
needsFullRefresh = true
case 3004:
guiLog("click tab command history")
app.activeTab = "history"
app.lastSettingsVisible = false
if !app.historyCacheLoaded {
requestHistoryRefresh(&app, "reloading...")
}
syncPaneVisibility(&app)
refreshCommandUI(setWindowText, &app)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
if invalidateRectProc != nil {
if app.leftHwnd != 0 {
invalidateRectProc.Call(app.leftHwnd, 0, 1)
}
if app.rightHwnd != 0 {
invalidateRectProc.Call(app.rightHwnd, 0, 1)
}
if app.hwnd != 0 {
invalidateRectProc.Call(app.hwnd, 0, 1)
}
}
return 0
case 3005:
guiLog("click settings command topmost")
if !allowRapidSettingsAction(&app) {
return 0
}
app.topMost = !app.topMost
saveGUISettings(&app)
applyTopMost(hwnd, &app)
case 3008:
guiLog("click settings command leave_unmute")
if !allowRapidSettingsAction(&app) {
return 0
}
app.autoUnmuteOnLeave = !app.autoUnmuteOnLeave
saveGUISettings(&app)
case 3012:
guiLog("click settings command export_browse")
if handleHistoryExportBrowseCommand(&app, setWindowText) {
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
return 0
}
case 3013:
guiLog("click settings command export_save")
if handleHistoryExportSaveCommand(&app, getWindowText, setWindowText) {
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
return 0
}
case 3006:
guiLog("click settings command font_down")
if !allowRapidSettingsAction(&app) {
return 0
}
if app.fontSize > 10 {
app.fontSize -= 2
oldFont := app.hFont
app.hFont = createAppFont(app.fontSize)
applyFont(&app)
if oldFont != 0 && deleteObjectProc != nil {
deleteObjectProc.Call(oldFont)
}
saveGUISettings(&app)
}
case 3007:
guiLog("click settings command font_up")
if !allowRapidSettingsAction(&app) {
return 0
}
if app.fontSize < 30 {
app.fontSize += 2
oldFont := app.hFont
app.hFont = createAppFont(app.fontSize)
applyFont(&app)
if oldFont != 0 && deleteObjectProc != nil {
deleteObjectProc.Call(oldFont)
}
saveGUISettings(&app)
}
}
if needsFullRefresh {
refreshGUI(setWindowText, &app, false)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
} else {
refreshCommandUI(setWindowText, &app)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
}
return 0
case wmLButtonDown:
x := int32(lParam & 0xffff)
y := int32((lParam >> 16) & 0xffff)
if y >= 0 && y < headerHeight {
var rc winRect
width := int32(windowWidth)
if getClientRectProc != nil {
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
if rc.Right > 0 {
width = rc.Right
}
}
if x >= width-42 && x <= width-22 && y >= 12 && y <= 32 {
guiLog("close button ignored to keep GUI visible")
return 0
}
releaseCapture.Call()
sendMessageProc.Call(hwnd, wmNCLButtonDown, htCaption, 0)
return 0
}
case wmLButtonUpGUI:
x := int32(lParam & 0xffff)
y := int32((lParam >> 16) & 0xffff)
if y >= headerHeight && y < contentTop {
nextTab := app.activeTab
switch {
case x < 120:
nextTab = "join"
case x < 240:
nextTab = "history"
case x < 360:
if translateTabEnabled {
nextTab = "translate"
}
case x < 480:
nextTab = "settings"
}
guiLog(fmt.Sprintf("click tab area x=%d y=%d active=%s next=%s", x, y, app.activeTab, nextTab))
if nextTab == app.activeTab {
return 0
}
if !allowRapidTabAction(&app) {
return 0
}
app.activeTab = nextTab
if nextTab == "history" {
app.lastSettingsVisible = false
if !app.historyCacheLoaded {
requestHistoryRefresh(&app, "reloading...")
}
syncPaneVisibility(&app)
refreshCommandUI(setWindowText, &app)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
if invalidateRectProc != nil {
if app.leftHwnd != 0 {
invalidateRectProc.Call(app.leftHwnd, 0, 1)
}
if app.rightHwnd != 0 {
invalidateRectProc.Call(app.rightHwnd, 0, 1)
}
if app.hwnd != 0 {
invalidateRectProc.Call(app.hwnd, 0, 1)
}
}
return 0
}
refreshGUI(setWindowText, &app, false)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
if invalidateRectProc != nil {
invalidateRectProc.Call(hwnd, 0, 1)
}
return 0
}
case wmTimerGUI:
drainHistoryExportResult(&app)
drainHistoryRefreshResult(&app)
reloadData := app.activeTab == "join" || app.activeTab == "translate"
if app.activeTab == "history" && app.historyReloadPending {
startHistoryRefresh(&app, true)
refreshGUI(setWindowText, &app, false)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
return 0
}
refreshGUI(setWindowText, &app, reloadData)
refreshSettingsControls(showWindowProc, setWindowPosProc, &app)
return 0
case wmHistoryRefreshDone:
drainHistoryRefreshResult(&app)
if invalidateRectProc != nil {
if app.leftHwnd != 0 {
invalidateRectProc.Call(app.leftHwnd, 0, 1)
}
if app.rightHwnd != 0 {
invalidateRectProc.Call(app.rightHwnd, 0, 1)
}
if app.hwnd != 0 {
invalidateRectProc.Call(app.hwnd, 0, 1)
}
}
return 0
case wmHistoryExportDone:
drainHistoryExportResult(&app)
if invalidateRectProc != nil {
if app.rightHwnd != 0 {
invalidateRectProc.Call(app.rightHwnd, 0, 1)
}
if app.hwnd != 0 {
invalidateRectProc.Call(app.hwnd, 0, 1)
}
}
return 0
case wmDestroyGUI:
guiLog("wmDestroy received")
postQuitMessage.Call(0)
return 0
case wmCloseGUI:
guiLog("wmClose ignored to keep GUI visible")
return 0
}
ret, _, _ := defWindowProc.Call(hwnd, uintptr(message), wParam, lParam)
return ret
})
guiLog("stage=register class")
atom, _, regErr := registerClass.Call(uintptr(unsafe.Pointer(&wndClass{
style: 0,
lpfnWndProc: wndProc,
hInstance: hInstance,
hIcon: icon,
hCursor: cursor,
hbrBackground: mainBgBrush,
lpszClassName: className,
})))
if atom == 0 {
guiLog(fmt.Sprintf("stage=register class failed err=%v", regErr))
return fmt.Errorf("register class: %v", regErr)
}
guiLog("stage=create window")
app.hwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), wsPopup|wsVisible|wsClipChildren, 200, 120, windowWidth, contentTop+leftPaneHeight+bottomBarHeight, 0, 0, hInstance, 0)
if app.hwnd == 0 {
guiLog("stage=create window failed")
return fmt.Errorf("create window failed")
}
guiLog(fmt.Sprintf("stage=create window ok hwnd=%d", app.hwnd))
logWindowState("after-create", app.hwnd, getWindowRect, isWindowVisible, isIconic)
guiLog("stage=show window")
prevVisible, _, _ := showWindowProc.Call(app.hwnd, 1)
guiLog(fmt.Sprintf("stage=show window ret=%d", prevVisible))
guiLog("stage=ensure visible")
ensureWindowVisible("startup", app.hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetricsProc)
if bringWindowToTop != nil {
bringWindowToTop.Call(app.hwnd)
}
if setForegroundWindow != nil {
setForegroundWindow.Call(app.hwnd)
}
guiLog("stage=ensure visible done")
logWindowState("after-show", app.hwnd, getWindowRect, isWindowVisible, isIconic)
guiLog("stage=message loop")
var m msg
for {
r, _, _ := getMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
if int32(r) < 0 {
guiLog("message loop exited with error")
break
}
if int32(r) == 0 {
guiLog("message loop received WM_QUIT")
break
}
translateMessage.Call(uintptr(unsafe.Pointer(&m)))
dispatchMessage.Call(uintptr(unsafe.Pointer(&m)))
}
return nil
}
func logWindowState(stage string, hwnd uintptr, getWindowRect, isWindowVisible, isIconic *syscall.LazyProc) {
if hwnd == 0 {
guiLog(stage + " hwnd=0")
return
}
var rect struct {
Left int32
Top int32
Right int32
Bottom int32
}
getWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&rect)))
visible, _, _ := isWindowVisible.Call(hwnd)
minimized, _, _ := isIconic.Call(hwnd)
guiLog(fmt.Sprintf(
"%s hwnd=%d visible=%t minimized=%t rect=(%d,%d)-(%d,%d) size=%dx%d",
stage,
hwnd,
visible != 0,
minimized != 0,
rect.Left,
rect.Top,
rect.Right,
rect.Bottom,
rect.Right-rect.Left,
rect.Bottom-rect.Top,
))
}
func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp, reloadData bool) {
if app == nil {
return
}
start := time.Now()
defer func() {
if d := time.Since(start); d > 100*time.Millisecond {
guiLog(fmt.Sprintf("refreshGUI slow=%s tab=%s reload=%t history=%d current=%d", d, app.activeTab, reloadData, len(app.historyRows), app.currentUserCount))
}
}()
current := app.currentUsers
instanceCount := app.currentUserCount
worldLabel := app.currentWorld
state := readRuntimeSnapshot()
historyDirty := app.activeTab == "history" && app.historyReloadPending
if reloadData {
if app.activeTab == "translate" {
guiLog("refreshGUI activeTab=translate light refresh")
if instanceCount == 0 {
instanceCount = countPresent(current)
}
if instanceCount == 0 && app.lastInstanceCount > 0 {
instanceCount = app.lastInstanceCount
}
if instanceCount > 0 {
app.lastInstanceCount = instanceCount
}
if world := strings.TrimSpace(stateString(state, "world")); world != "" {
worldLabel = world
}
if strings.TrimSpace(worldLabel) == "" {
worldLabel = "(unknown)"
}
app.currentWorld = worldLabel
} else if app.activeTab == "history" {
if historyDirty {
guiLog(fmt.Sprintf("refreshGUI activeTab=history current=%d reload=%t", instanceCount, app.historyReloadPending))
} else {
guiLog(fmt.Sprintf("refreshGUI activeTab=history cached current=%d", instanceCount))
}
if out, ok := currentUsersFromGuestSnapshot(); ok {
current = out
instanceCount = countPresent(out)
app.currentUsers = current
app.currentUserCount = instanceCount
} else if instanceCount == 0 && app.lastInstanceCount > 0 {
instanceCount = app.lastInstanceCount
}
if instanceCount > 0 {
app.lastInstanceCount = instanceCount
}
if world := strings.TrimSpace(stateString(state, "world")); world != "" {
worldLabel = world
}
if strings.TrimSpace(worldLabel) == "" {
worldLabel = "(unknown)"
}
app.currentWorld = worldLabel
} else if app.activeTab == "settings" {
if world := strings.TrimSpace(stateString(state, "world")); world != "" {
worldLabel = world
}
if instanceCount == 0 && len(current) == 0 {
if out, ok := currentUsersFromGuestSnapshot(); ok {
current = out
instanceCount = countPresent(out)
app.currentUsers = current
app.currentUserCount = instanceCount
}
}
if instanceCount == 0 && app.lastInstanceCount > 0 {
instanceCount = app.lastInstanceCount
}
if instanceCount > 0 {
app.lastInstanceCount = instanceCount
}
if strings.TrimSpace(worldLabel) == "" {
worldLabel = "(unknown)"
}
app.currentWorld = worldLabel
} else {
current, instanceCount = currentUsersFromJoinLeave()
guiLog(fmt.Sprintf("refreshGUI activeTab=%s current=%d", app.activeTab, instanceCount))
if instanceCount == 0 && app.lastInstanceCount > 0 {
instanceCount = app.lastInstanceCount
}
if instanceCount > 0 {
app.lastInstanceCount = instanceCount
}
app.currentUsers = current
app.currentUserCount = instanceCount
app.historyRows = historyRows()
worldLabel = currentWorldLabel()
if strings.TrimSpace(worldLabel) == "" {
worldLabel = "(unknown)"
}
app.currentWorld = worldLabel
}
} else {
if instanceCount == 0 {
instanceCount = countPresent(current)
}
if strings.TrimSpace(worldLabel) == "" {
worldLabel = "(unknown)"
}
guiLog(fmt.Sprintf("refreshGUI cached activeTab=%s current=%d", app.activeTab, instanceCount))
}
app.discordMuted = stateBool(state, "discord_muted")
syncPaneVisibility(app)
if app.leftHwnd != 0 {
if app.activeTab == "join" || app.activeTab == "history" {
showWindowProc.Call(app.leftHwnd, swShowControl)
switch app.activeTab {
case "join":
app.leftPaneText = buildGuestPaneText(worldLabel, current)
if reloadData {
if err := appPkg.AppendDesktopJoinLog("Join Log", worldLabel, app.leftPaneText); err != nil {
_ = appPkg.AppendRuntimeLog("GUI JOIN LOG WRITE FAIL", err.Error())
}
}
updateJoinPaneLayout(app, instanceCount)
case "history":
ensureHistoryState(app)
app.leftPaneText = ""
default:
app.leftPaneText = ""
}
if invalidateRectProc != nil {
if app.activeTab != "history" || !reloadData || historyDirty {
invalidateRectProc.Call(app.leftHwnd, 0, 1)
}
}
} else {
showWindowProc.Call(app.leftHwnd, swHideControl)
}
}
if app.btnTopMost != 0 {
label := "Top most: OFF"
if app.topMost {
label = "Top most: ON"
}
t, _ := syscall.UTF16PtrFromString(label)
setWindowText.Call(app.btnTopMost, uintptr(unsafe.Pointer(t)))
}
if app.btnAutoUnmute != 0 {
label := "Leave unmute: OFF"
if app.autoUnmuteOnLeave {
label = "Leave unmute: ON"
}
t, _ := syscall.UTF16PtrFromString(label)
setWindowText.Call(app.btnAutoUnmute, uintptr(unsafe.Pointer(t)))
}
if app.activeTab == "settings" {
if !app.lastSettingsVisible {
resizeGUIForSettings(app)
if setWindowText != nil {
syncHistoryExportEditControls(app, setWindowText)
}
if invalidateRectProc != nil && app.settingsPaneHwnd != 0 {
invalidateRectProc.Call(app.settingsPaneHwnd, 0, 1)
}
}
return
}
app.lastSettingsVisible = false
if app.rightHwnd != 0 {
state["top_most"] = app.topMost
state["font_size"] = app.fontSize
state["auto_unmute_on_self_leave"] = app.autoUnmuteOnLeave
if app.activeTab == "translate" || app.activeTab == "join" || app.activeTab == "history" {
app.rightPaneText = formatRightPane(app.activeTab, state, worldLabel, instanceCount, current, len(app.historyRows))
showWindowProc.Call(app.rightHwnd, swShowControl)
if invalidateRectProc != nil {
if app.activeTab != "history" || !reloadData || historyDirty {
invalidateRectProc.Call(app.rightHwnd, 0, 1)
}
}
} else {
showWindowProc.Call(app.rightHwnd, swHideControl)
if invalidateRectProc != nil {
invalidateRectProc.Call(app.rightHwnd, 0, 1)
}
}
}
if app.settingsPaneHwnd != 0 {
showWindowProc.Call(app.settingsPaneHwnd, swHideControl)
}
for _, hwnd := range []uintptr{app.btnTopMost, app.btnAutoUnmute, app.btnFontDown, app.btnFontUp} {
if hwnd != 0 {
showWindowProc.Call(hwnd, swHideControl)
}
}
if invalidateRectProc != nil && app.hwnd != 0 {
invalidateRectProc.Call(app.hwnd, 0, 1)
}
}
func refreshCommandUI(setWindowText *syscall.LazyProc, app *guiApp) {
if app == nil || setWindowText == nil {
return
}
if app.btnTopMost != 0 {
label := "Top most: OFF"
if app.topMost {
label = "Top most: ON"
}
t, _ := syscall.UTF16PtrFromString(label)
setWindowText.Call(app.btnTopMost, uintptr(unsafe.Pointer(t)))
}
if app.btnAutoUnmute != 0 {
label := "Leave unmute: OFF"
if app.autoUnmuteOnLeave {
label = "Leave unmute: ON"
}
t, _ := syscall.UTF16PtrFromString(label)
setWindowText.Call(app.btnAutoUnmute, uintptr(unsafe.Pointer(t)))
}
}
func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, app *guiApp) {
if showWindowProc == nil || setWindowPosProc == nil || app == nil {
return
}
if app.activeTab == "settings" {
if app.lastSettingsVisible {
return
}
app.lastSettingsVisible = true
for _, hwnd := range []uintptr{app.btnTopMost, app.btnAutoUnmute, app.btnFontDown, app.btnFontUp} {
if hwnd != 0 {
showWindowProc.Call(hwnd, swHideControl)
}
}
for _, hwnd := range []uintptr{app.settingsExportDirEdit, app.settingsExportCustomEdit} {
if hwnd != 0 {
showWindowProc.Call(hwnd, swShowControl)
}
}
for _, hwnd := range []uintptr{app.settingsExportBrowseBtn, app.settingsExportCustomSaveBtn} {
if hwnd != 0 {
showWindowProc.Call(hwnd, swShowControl)
}
}
return
}
if !app.lastSettingsVisible {
return
}
app.lastSettingsVisible = false
if app.settingsPaneHwnd != 0 {
showWindowProc.Call(app.settingsPaneHwnd, swHideControl)
}
for _, hwnd := range []uintptr{app.btnTopMost, app.btnAutoUnmute, app.btnFontDown, app.btnFontUp, app.settingsExportDirEdit, app.settingsExportCustomEdit, app.settingsExportBrowseBtn, app.settingsExportCustomSaveBtn} {
if hwnd != 0 {
showWindowProc.Call(hwnd, swHideControl)
}
}
}
func syncPaneVisibility(app *guiApp) {
if app == nil || showWindowProc == nil {
return
}
switch app.activeTab {
case "settings":
if app.leftHwnd != 0 {
showWindowProc.Call(app.leftHwnd, swHideControl)
}
if app.rightHwnd != 0 {
showWindowProc.Call(app.rightHwnd, swHideControl)
}
if app.settingsPaneHwnd != 0 {
showWindowProc.Call(app.settingsPaneHwnd, swShowControl)
}
default:
if app.leftHwnd != 0 {
showWindowProc.Call(app.leftHwnd, swShowControl)
}
if app.rightHwnd != 0 {
showWindowProc.Call(app.rightHwnd, swShowControl)
}
if app.settingsPaneHwnd != 0 {
showWindowProc.Call(app.settingsPaneHwnd, swHideControl)
}
}
}
func ensureWindowVisible(stage string, hwnd uintptr, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics *syscall.LazyProc) {
if hwnd == 0 || getSystemMetrics == nil {
return
}
var rect struct {
Left int32
Top int32
Right int32
Bottom int32
}
if getWindowRect != nil {
getWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&rect)))
}
minimized := false
visible := true
if isIconic != nil {
v, _, _ := isIconic.Call(hwnd)
minimized = v != 0
}
if isWindowVisible != nil {
v, _, _ := isWindowVisible.Call(hwnd)
visible = v != 0
}
if minimized && showWindowProc != nil {
showWindowProc.Call(hwnd, swRestore)
guiLog(stage + " restored from minimized")
}
vx, _, _ := getSystemMetrics.Call(uintptr(smXVirtualScreen))
vy, _, _ := getSystemMetrics.Call(uintptr(smYVirtualScreen))
vw, _, _ := getSystemMetrics.Call(uintptr(smCXVirtualScreen))
vh, _, _ := getSystemMetrics.Call(uintptr(smCYVirtualScreen))
left := int32(vx)
top := int32(vy)
width := int32(vw)
height := int32(vh)
w := rect.Right - rect.Left
h := rect.Bottom - rect.Top
if w <= 0 {
w = 760
}
if h <= 0 {
h = 380
}
offscreen := rect.Right <= left || rect.Bottom <= top || rect.Left >= left+width || rect.Top >= top+height
if offscreen && setWindowPosProc != nil {
nx := left + (width-w)/2
ny := top + (height-h)/2
setWindowPosProc.Call(hwnd, 0, uintptr(nx), uintptr(ny), 0, 0, swpShowFlags)
guiLog(fmt.Sprintf("%s recentered visible=%t minimized=%t old=(%d,%d)-(%d,%d) new=(%d,%d) screen=(%d,%d %dx%d)",
stage, visible, minimized, rect.Left, rect.Top, rect.Right, rect.Bottom, nx, ny, left, top, width, height))
return
}
guiLog(fmt.Sprintf("%s visible=%t minimized=%t rect=(%d,%d)-(%d,%d) screen=(%d,%d %dx%d)",
stage, visible, minimized, rect.Left, rect.Top, rect.Right, rect.Bottom, left, top, width, height))
}
func raiseWindowToFront(hwnd uintptr, showWindowProc, setWindowPosProc, setActiveWindow, setForegroundWindow, bringWindowToTop *syscall.LazyProc) {
if hwnd == 0 {
return
}
if showWindowProc != nil {
showWindowProc.Call(hwnd, swRestore)
}
if setWindowPosProc != nil {
setWindowPosProc.Call(hwnd, hwndTopMostFlag, 0, 0, 0, 0, swpShowFlags)
setWindowPosProc.Call(hwnd, hwndNotTopMostFlag, 0, 0, 0, 0, swpShowFlags)
}
if bringWindowToTop != nil {
bringWindowToTop.Call(hwnd)
}
if setForegroundWindow != nil {
setForegroundWindow.Call(hwnd)
}
if setActiveWindow != nil {
setActiveWindow.Call(hwnd)
}
guiLog("startup raised window to front")
}
func applyTopMost(hwnd uintptr, app *guiApp) {
if hwnd == 0 || app.topMost == app.lastTopMost {
return
}
if app.topMost {
setWindowPosProc.Call(hwnd, hwndTopMostFlag, 0, 0, 0, 0, swpFlags)
} else {
setWindowPosProc.Call(hwnd, hwndNotTopMostFlag, 0, 0, 0, 0, swpFlags)
}
app.lastTopMost = app.topMost
}
func createAppFont(size int) uintptr {
return createAppFontWithWeight(size, 400)
}
func createAppFontWithWeight(size int, weight int32) uintptr {
if createFontProc == nil {
return defaultGUIFont
}
h, _, _ := createFontProc.Call(
uintptr(-size), 0, 0, 0, uintptr(weight), 0, 0, 0,
128, 0, 0, 0, 0,
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Yu Gothic UI"))),
)
if h == 0 && defaultGUIFont != 0 {
return defaultGUIFont
}
return h
}
func applyFont(app *guiApp) {
if sendMessageProc == nil || app.hFont == 0 {
return
}
targets := []uintptr{app.rightHwnd, app.navBarHwnd, app.settingsPaneHwnd, app.settingsExportDirEdit, app.settingsExportBrowseBtn, app.settingsExportCustomEdit, app.settingsExportCustomSaveBtn}
for _, hwnd := range targets {
if hwnd == 0 {
continue
}
sendMessageProc.Call(hwnd, wmSetFont, app.hFont, 1)
}
applyJoinLogFont(app, app.lastInstanceCount)
}
func applyJoinLogFont(app *guiApp, presentCount int) {
if app == nil || app.leftHwnd == 0 {
return
}
size := joinLogFontSize(app.fontSize, presentCount)
if size <= 0 {
size = app.fontSize
}
if size <= 0 {
size = 18
}
if app.joinHFont != 0 && app.joinBoldHFont != 0 && app.joinHeadlineHFont != 0 &&
app.joinFontSize == size && app.joinBoldFontSize == size && app.joinHeadlineSize == size+2 {
return
}
newFont := createAppFont(size)
if newFont == 0 {
newFont = defaultGUIFont
}
if newFont == 0 {
return
}
boldFont := createAppFontWithWeight(size, 700)
headlineSize := size + 2
if headlineSize > 30 {
headlineSize = 30
}
headlineFont := createAppFontWithWeight(headlineSize, 700)
oldFont := app.joinHFont
oldBoldFont := app.joinBoldHFont
oldHeadlineFont := app.joinHeadlineHFont
app.joinHFont = newFont
app.joinFontSize = size
app.joinBoldHFont = boldFont
app.joinBoldFontSize = size
app.joinHeadlineHFont = headlineFont
app.joinHeadlineSize = headlineSize
if oldFont != 0 && oldFont != app.hFont && oldFont != defaultGUIFont && deleteObjectProc != nil {
deleteObjectProc.Call(oldFont)
}
if oldBoldFont != 0 && oldBoldFont != oldFont && oldBoldFont != app.hFont && oldBoldFont != defaultGUIFont && deleteObjectProc != nil {
deleteObjectProc.Call(oldBoldFont)
}
if oldHeadlineFont != 0 && oldHeadlineFont != oldFont && oldHeadlineFont != oldBoldFont && oldHeadlineFont != app.hFont && oldHeadlineFont != defaultGUIFont && deleteObjectProc != nil {
deleteObjectProc.Call(oldHeadlineFont)
}
}
func updateJoinPaneLayout(app *guiApp, presentCount int) {
if app == nil || app.leftHwnd == 0 {
return
}
lines := joinPaneLineCount(app.leftPaneText)
if lines < 1 {
lines = 1
}
contentHeight := joinPaneContentHeight(app.fontSize, lines)
if contentHeight < leftPaneHeight {
contentHeight = leftPaneHeight
}
availableHeight := maxJoinPaneContentHeight()
fontSize := fitJoinLogFontSize(app.fontSize, lines, availableHeight)
applyJoinLogFontSize(app, fontSize)
contentHeight = joinPaneContentHeight(fontSize, lines)
if contentHeight < leftPaneHeight {
contentHeight = leftPaneHeight
}
if availableHeight > 0 && contentHeight > availableHeight {
contentHeight = availableHeight
}
if contentHeight != app.lastLayoutHeight {
resizeGUIForJoinContent(app, contentHeight)
app.lastLayoutHeight = contentHeight
}
if invalidateRectProc != nil {
invalidateRectProc.Call(app.leftHwnd, 0, 1)
}
}
func applyJoinLogFontSize(app *guiApp, size int) {
if app == nil || app.leftHwnd == 0 {
return
}
if size <= 0 {
size = 18
}
if app.joinHFont != 0 && app.joinBoldHFont != 0 && app.joinFontSize == size && app.joinBoldFontSize == size {
return
}
newFont := createAppFont(size)
if newFont == 0 {
newFont = defaultGUIFont
}
if newFont == 0 {
return
}
boldFont := createAppFontWithWeight(size, 700)
oldFont := app.joinHFont
oldBoldFont := app.joinBoldHFont
app.joinHFont = newFont
app.joinFontSize = size
app.joinBoldHFont = boldFont
app.joinBoldFontSize = size
if oldFont != 0 && oldFont != app.hFont && oldFont != defaultGUIFont && deleteObjectProc != nil {
deleteObjectProc.Call(oldFont)
}
if oldBoldFont != 0 && oldBoldFont != oldFont && oldBoldFont != app.hFont && oldBoldFont != defaultGUIFont && deleteObjectProc != nil {
deleteObjectProc.Call(oldBoldFont)
}
}
func fitJoinLogFontSize(baseSize, lineCount, availableHeight int) int {
size := baseSize
if size <= 0 {
size = 18
}
if lineCount <= 0 {
lineCount = 1
}
if availableHeight <= 0 {
availableHeight = 300
}
for size > 8 {
if joinPaneContentHeight(size, lineCount) <= availableHeight {
break
}
size -= 2
}
if size < 8 {
size = 8
}
return size
}
func joinPaneLineCount(text string) int {
trimmed := strings.TrimRight(text, "\r\n")
if trimmed == "" {
return 1
}
return len(strings.Split(trimmed, "\n"))
}
func joinPaneContentHeight(fontSize, lineCount int) int {
lineHeight := joinPaneLineHeight(fontSize)
height := lineCount*lineHeight + 16
if height < leftPaneHeight {
height = leftPaneHeight
}
return height
}
func joinPaneLineHeight(fontSize int) int {
lineHeight := fontSize + 3
if lineHeight < 12 {
lineHeight = 12
}
return lineHeight
}
func maxJoinPaneContentHeight() int {
if getSystemMetricsProc == nil {
return 0
}
vh, _, _ := getSystemMetricsProc.Call(uintptr(smCYVirtualScreen))
if vh <= 0 {
return 0
}
return int(vh) - contentTop - 56
}
func resizeGUIForJoinContent(app *guiApp, contentHeight int) {
if app == nil || setWindowPosProc == nil || app.hwnd == 0 {
return
}
if contentHeight < leftPaneHeight {
contentHeight = leftPaneHeight
}
resizeFlags := hwndNoMove | hwndNoZOrder | hwndNoActivate
windowHeight := contentTop + contentHeight + bottomBarHeight
setWindowPosProc.Call(app.hwnd, 0, 0, 0, windowWidth, uintptr(windowHeight), resizeFlags)
for _, item := range []struct {
hwnd uintptr
width int
}{
{app.leftHwnd, leftPaneWidth},
{app.rightHwnd, rightPaneWidth},
{app.settingsPaneHwnd, settingsPaneWidth},
} {
if item.hwnd != 0 {
setWindowPosProc.Call(item.hwnd, 0, 0, 0, uintptr(item.width), uintptr(contentHeight), resizeFlags)
}
}
}
func resizeGUIForSettings(app *guiApp) {
if app == nil || setWindowPosProc == nil || app.hwnd == 0 {
return
}
resizeFlags := hwndNoMove | hwndNoZOrder | hwndNoActivate
windowHeight := contentTop + leftPaneHeight + bottomBarHeight
setWindowPosProc.Call(app.hwnd, 0, 0, 0, windowWidth, uintptr(windowHeight), resizeFlags)
if app.settingsPaneHwnd != 0 {
setWindowPosProc.Call(app.settingsPaneHwnd, 0, 0, uintptr(contentTop), windowWidth, uintptr(leftPaneHeight), resizeFlags)
}
}
type winRect struct {
Left int32
Top int32
Right int32
Bottom int32
}
type paintStruct struct {
Hdc uintptr
FErase int32
RcPaint winRect
FRestore int32
FIncUpdate int32
RgbReserved [32]byte
}
type textMetric struct {
Height int32
Ascent int32
Descent int32
InternalLeading int32
ExternalLeading int32
AveCharWidth int32
MaxCharWidth int32
Weight int32
Overhang int32
DigitizedAspectX int32
DigitizedAspectY int32
FirstChar byte
LastChar byte
DefaultChar byte
BreakChar byte
Italic byte
Underlined byte
StruckOut byte
PitchAndFamily byte
CharSet byte
}
type textSize struct {
Cx int32
Cy int32
}
const (
sbLineUp = 0
sbLineDown = 1
sbPageUp = 2
sbPageDown = 3
sbThumbPosition = 4
sbThumbTrack = 5
sbTop = 6
sbBottom = 7
sbEndScroll = 8
sifRange = 0x0001
sifPage = 0x0002
sifPos = 0x0004
sifDisableNoScroll = 0x0008
sifTrackPos = 0x0010
sifAll = sifRange | sifPage | sifPos | sifTrackPos
siVert = 1
scrollLineStep = 3
)
type scrollInfo struct {
cbSize uint32
fMask uint32
nMin int32
nMax int32
nPage uint32
nPos int32
nTrackPos int32
}
func paintMainWindow(hwnd uintptr, app *guiApp) uintptr {
if app == nil || beginPaintProc == nil || endPaintProc == nil || getClientRectProc == nil {
return 0
}
var ps paintStruct
hdc, _, _ := beginPaintProc.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
if hdc == 0 {
return 0
}
defer endPaintProc.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
if setBkModeProc != nil {
setBkModeProc.Call(hdc, 1)
}
fillSolid(hdc, rc, clrMainBg)
fillSolid(hdc, winRect{Left: 0, Top: 0, Right: rc.Right, Bottom: headerHeight}, clrHeaderBg)
fillSolid(hdc, winRect{Left: 0, Top: headerHeight, Right: rc.Right, Bottom: contentTop}, clrNavBg)
drawPaneText(hdc, 22, 16, "VRC OSC", app.hFont, clrText)
drawWindowDot(hdc, rc.Right-92, 22, 0x0059a7ff)
drawWindowDot(hdc, rc.Right-62, 22, 0x004ee343)
drawWindowDot(hdc, rc.Right-32, 22, 0x005a5cff)
drawPaneText(hdc, rc.Right-38, 13, "×", app.hFont, clrText)
activeLeft := int32(0)
switch app.activeTab {
case "history":
activeLeft = 120
case "translate":
activeLeft = 240
case "settings":
activeLeft = 360
}
fillSolid(hdc, winRect{Left: activeLeft, Top: headerHeight, Right: activeLeft + 120, Bottom: contentTop}, clrNavActiveBg)
drawPaneText(hdc, 36, headerHeight+18, "ログ", app.hFont, tabColor(app.activeTab == "join"))
drawPaneText(hdc, 154, headerHeight+18, "履歴", app.hFont, tabColor(app.activeTab == "history"))
drawPaneText(hdc, 274, headerHeight+18, "翻訳", app.hFont, tabLabelColor(app.activeTab == "translate", translateTabEnabled))
drawPaneText(hdc, 394, headerHeight+18, "設定", app.hFont, tabColor(app.activeTab == "settings"))
drawLine(hdc, activeLeft, contentTop-3, activeLeft+120, contentTop-3, clrAccentBlue)
drawLine(hdc, 0, contentTop, rc.Right, contentTop, clrPaneBorder)
paintFooter(hdc, rc, app)
return 0
}
func paintFooter(hdc uintptr, rc winRect, app *guiApp) {
if app == nil {
return
}
statusTop := rc.Bottom - bottomBarHeight
fillSolid(hdc, winRect{Left: 0, Top: statusTop, Right: rc.Right, Bottom: rc.Bottom}, 0x00281709)
drawLine(hdc, 0, statusTop, rc.Right, statusTop, clrPaneBorder)
world := strings.TrimSpace(app.currentWorld)
if world == "" {
world = "(unknown)"
}
users := app.currentUserCount
if users == 0 {
users = countPresent(app.currentUsers)
}
muted := app.discordMuted
drawFilledEllipse(hdc, 28, statusTop+15, 50, statusTop+37, clrAccentBlue)
drawPaneText(hdc, 66, statusTop+14, ellipsizeRunes(world, 22), app.hFont, clrText)
drawPaneText(hdc, 250, statusTop+14, "|", app.hFont, clrPaneBorder)
drawPaneText(hdc, 286, statusTop+14, "人数", app.hFont, clrMutedText)
drawPaneText(hdc, 330, statusTop+14, fmt.Sprintf("%d人", users), app.hFont, clrText)
badgeLeft := rc.Right - 166
badgeRight := rc.Right - 20
badgeColor := uint32(0x00183f16)
label := "● ミュート解除"
textColor := uint32(clrAccentGreen)
if muted {
badgeColor = 0x001b164c
label = "● ミュート中"
textColor = uint32(clrAccentRed)
}
fillSolid(hdc, winRect{Left: badgeLeft, Top: statusTop + 9, Right: badgeRight, Bottom: statusTop + 40}, badgeColor)
drawLine(hdc, badgeLeft, statusTop+9, badgeRight, statusTop+9, textColor)
drawLine(hdc, badgeLeft, statusTop+40, badgeRight, statusTop+40, textColor)
drawLine(hdc, badgeLeft, statusTop+9, badgeLeft, statusTop+40, textColor)
drawLine(hdc, badgeRight, statusTop+9, badgeRight, statusTop+40, textColor)
drawPaneText(hdc, badgeLeft+18, statusTop+15, label, app.hFont, textColor)
}
func paintJoinPane(hwnd uintptr, app *guiApp) uintptr {
if app == nil {
return 0
}
return paintMemberPane(hwnd, app)
}
func paintLeftPane(hwnd uintptr, app *guiApp) uintptr {
if app == nil {
return 0
}
switch app.activeTab {
case "join":
return paintMemberPane(hwnd, app)
case "history":
return paintHistoryCalendarPane(hwnd, app)
case "settings":
return paintSettingsPane(hwnd, app)
default:
return paintPaneBackground(hwnd, app, 0)
}
}
func paintRightPane(hwnd uintptr, app *guiApp) uintptr {
if app == nil {
return 0
}
if app.activeTab == "join" {
return paintEventPane(hwnd, app)
}
if app.activeTab == "history" {
return paintHistoryDetailPane(hwnd, app)
}
return paintTextPane(hwnd, app, app.rightPaneText)
}
func paintSettingsPane(hwnd uintptr, app *guiApp) uintptr {
if app == nil {
return 0
}
start := time.Now()
defer func() {
if d := time.Since(start); d > 80*time.Millisecond {
guiLog(fmt.Sprintf("paintSettingsPane slow=%s", d))
}
}()
hdc, done := beginPanePaint(hwnd)
if hdc == 0 {
return 0
}
defer done()
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
fillSolid(hdc, rc, clrPaneBg)
drawPaneText(hdc, 24, 20, "設定", app.joinBoldHFont, clrAccentGreen)
drawLine(hdc, 24, 50, rc.Right-24, 50, clrPaneBorder)
drawPaneText(hdc, 24, 68, "表示", app.hFont, clrMutedText)
drawSettingsWideButton(hdc, settingsTopMostRect(), "Top most", onOffBool(app.topMost), app.hFont, app.topMost)
drawSettingsWideButton(hdc, settingsAutoUnmuteRect(), "Leave unmute", onOffBool(app.autoUnmuteOnLeave), app.hFont, app.autoUnmuteOnLeave)
drawPaneText(hdc, 24, 190, "文字サイズ", app.hFont, clrMutedText)
drawSettingsRefreshControl(hdc, app)
drawSettingsFontControl(hdc, app)
drawPaneText(hdc, 24, 390, "履歴 Export", app.hFont, clrMutedText)
drawPaneText(hdc, 24, 414, "出力先フォルダ", app.hFont, clrMutedText)
drawSettingsBox(hdc, settingsHistoryExportDirRect(), 0x0038281a, clrPaneBorder)
drawPaneText(hdc, 120, 414, exportDirDisplayText(app.historyExportDir), app.hFont, clrText)
drawSettingsWideButton(hdc, settingsHistoryExportBrowseRect(), "参照", "", app.hFont, true)
drawSettingsWideButton(hdc, settingsHistoryExportTimeRect(), "Time", onOffBool(app.historyExportIncludeTime), app.hFont, app.historyExportIncludeTime)
drawSettingsWideButton(hdc, settingsHistoryExportWorldRect(), "World", onOffBool(app.historyExportIncludeWorld), app.hFont, app.historyExportIncludeWorld)
drawSettingsWideButton(hdc, settingsHistoryExportJoinLeaveRect(), "Join/Leave", onOffBool(app.historyExportIncludeJoinLeave), app.hFont, app.historyExportIncludeJoinLeave)
drawSettingsWideButton(hdc, settingsHistoryExportCustomToggleRect(), "Custom", onOffBool(app.historyExportCustomEnabled), app.hFont, app.historyExportCustomEnabled)
drawPaneText(hdc, 24, 522, "Custom 条件", app.hFont, clrMutedText)
drawSettingsBox(hdc, settingsHistoryExportCustomRect(), 0x0038281a, clrPaneBorder)
drawPaneText(hdc, 120, 522, previewLine(app.historyExportCustom, 60), app.hFont, clrText)
drawSettingsWideButton(hdc, settingsHistoryExportCustomSaveRect(), "保存", "", app.hFont, true)
return 0
}
func settingsTopMostRect() winRect {
return winRect{Left: 24, Top: 92, Right: 268, Bottom: 128}
}
func settingsAutoUnmuteRect() winRect {
return winRect{Left: 24, Top: 140, Right: 268, Bottom: 176}
}
func settingsRefreshDownRect() winRect {
return winRect{Left: 24, Top: 214, Right: 104, Bottom: 250}
}
func settingsRefreshValueRect() winRect {
return winRect{Left: 106, Top: 214, Right: 186, Bottom: 250}
}
func settingsRefreshUpRect() winRect {
return winRect{Left: 188, Top: 214, Right: 268, Bottom: 250}
}
func settingsRefreshUnitRect() winRect {
return winRect{Left: 24, Top: 258, Right: 268, Bottom: 294}
}
func settingsFontDownRect() winRect {
return winRect{Left: 24, Top: 338, Right: 104, Bottom: 374}
}
func settingsFontValueRect() winRect {
return winRect{Left: 106, Top: 338, Right: 186, Bottom: 374}
}
func settingsFontUpRect() winRect {
return winRect{Left: 188, Top: 338, Right: 268, Bottom: 374}
}
func settingsHistoryExportDirRect() winRect {
return winRect{Left: 116, Top: 410, Right: 636, Bottom: 434}
}
func settingsHistoryExportBrowseRect() winRect {
return winRect{Left: 644, Top: 410, Right: 736, Bottom: 434}
}
func settingsHistoryExportTimeRect() winRect {
return winRect{Left: 24, Top: 442, Right: 360, Bottom: 476}
}
func settingsHistoryExportWorldRect() winRect {
return winRect{Left: 376, Top: 442, Right: 736, Bottom: 476}
}
func settingsHistoryExportJoinLeaveRect() winRect {
return winRect{Left: 24, Top: 480, Right: 360, Bottom: 514}
}
func settingsHistoryExportCustomToggleRect() winRect {
return winRect{Left: 376, Top: 480, Right: 736, Bottom: 514}
}
func settingsHistoryExportCustomRect() winRect {
return winRect{Left: 116, Top: 520, Right: 636, Bottom: 544}
}
func settingsHistoryExportCustomSaveRect() winRect {
return winRect{Left: 644, Top: 520, Right: 736, Bottom: 544}
}
func handleSettingsPaneClick(app *guiApp, x, y int32, getWindowText, setWindowText *syscall.LazyProc) bool {
if app == nil || !allowRapidSettingsAction(app) {
return true
}
switch {
case pointInRect(x, y, settingsTopMostRect()):
app.topMost = !app.topMost
saveGUISettings(app)
applyTopMost(app.hwnd, app)
case pointInRect(x, y, settingsAutoUnmuteRect()):
app.autoUnmuteOnLeave = !app.autoUnmuteOnLeave
saveGUISettings(app)
case pointInRect(x, y, settingsRefreshDownRect()):
if adjustRefreshInterval(app, -1) {
saveGUISettings(app)
applyRefreshTimer(app.hwnd, app)
}
case pointInRect(x, y, settingsRefreshUpRect()):
if adjustRefreshInterval(app, 1) {
saveGUISettings(app)
applyRefreshTimer(app.hwnd, app)
}
case pointInRect(x, y, settingsRefreshUnitRect()):
if toggleRefreshIntervalUnit(app) {
saveGUISettings(app)
applyRefreshTimer(app.hwnd, app)
}
case pointInRect(x, y, settingsFontDownRect()):
if app.fontSize > 10 {
app.fontSize -= 2
app.hFont = createAppFont(app.fontSize)
applyFont(app)
saveGUISettings(app)
}
case pointInRect(x, y, settingsFontUpRect()):
if app.fontSize < 30 {
app.fontSize += 2
app.hFont = createAppFont(app.fontSize)
applyFont(app)
saveGUISettings(app)
}
case pointInRect(x, y, settingsHistoryExportTimeRect()):
app.historyExportIncludeTime = !app.historyExportIncludeTime
saveGUISettings(app)
case pointInRect(x, y, settingsHistoryExportWorldRect()):
app.historyExportIncludeWorld = !app.historyExportIncludeWorld
saveGUISettings(app)
case pointInRect(x, y, settingsHistoryExportJoinLeaveRect()):
app.historyExportIncludeJoinLeave = !app.historyExportIncludeJoinLeave
saveGUISettings(app)
case pointInRect(x, y, settingsHistoryExportCustomToggleRect()):
app.historyExportCustomEnabled = !app.historyExportCustomEnabled
saveGUISettings(app)
case pointInRect(x, y, settingsHistoryExportBrowseRect()):
if handleHistoryExportBrowseCommand(app, setWindowText) {
return true
}
case pointInRect(x, y, settingsHistoryExportCustomSaveRect()):
if handleHistoryExportSaveCommand(app, getWindowText, setWindowText) {
return true
}
default:
return false
}
refreshCommandUI(setWindowText, app)
if invalidateRectProc != nil {
if app.settingsPaneHwnd != 0 {
invalidateRectProc.Call(app.settingsPaneHwnd, 0, 1)
}
}
return true
}
func drawSettingsRefreshControl(hdc uintptr, app *guiApp) {
font := app.hFont
fill := uint32(0x00443120)
border := uint32(clrPaneBorder)
drawSettingsBox(hdc, settingsRefreshDownRect(), fill, border)
drawSettingsBox(hdc, settingsRefreshValueRect(), uint32(0x0038281a), border)
drawSettingsBox(hdc, settingsRefreshUpRect(), fill, border)
drawSettingsWideButton(hdc, settingsRefreshUnitRect(), "\u5358\u4f4d", refreshIntervalUnitLabel(app.refreshIntervalUnit), font, true)
drawCenteredPaneText(hdc, settingsRefreshDownRect(), "A-", font, clrText)
drawCenteredPaneText(hdc, settingsRefreshValueRect(), refreshIntervalValueLabel(app.refreshIntervalValue, app.refreshIntervalUnit), font, clrMutedText)
drawCenteredPaneText(hdc, settingsRefreshUpRect(), "A+", font, clrText)
}
func refreshIntervalValueLabel(value int, unit string) string {
value, unit = normalizeRefreshIntervalSettings(value, unit)
return strconv.Itoa(value)
}
func refreshIntervalUnitLabel(unit string) string {
_, unit = normalizeRefreshIntervalSettings(1, unit)
if unit == "min" {
return "\u5206"
}
return "\u79d2"
}
func normalizeRefreshIntervalSettings(value int, unit string) (int, string) {
unit = strings.ToLower(strings.TrimSpace(unit))
if unit != "min" {
unit = "sec"
}
if value <= 0 {
value = 2
}
switch unit {
case "min":
if value > 60 {
value = 60
}
default:
if value > 3600 {
value = 3600
}
}
return value, unit
}
func refreshIntervalDuration(value int, unit string) time.Duration {
value, unit = normalizeRefreshIntervalSettings(value, unit)
if unit == "min" {
return time.Duration(value) * time.Minute
}
return time.Duration(value) * time.Second
}
func adjustRefreshInterval(app *guiApp, delta int) bool {
if app == nil || delta == 0 {
return false
}
value, unit := normalizeRefreshIntervalSettings(app.refreshIntervalValue, app.refreshIntervalUnit)
value += delta
if unit == "min" {
if value < 1 {
value = 1
}
if value > 60 {
value = 60
}
} else {
if value < 1 {
value = 1
}
if value > 3600 {
value = 3600
}
}
app.refreshIntervalValue = value
app.refreshIntervalUnit = unit
return true
}
func toggleRefreshIntervalUnit(app *guiApp) bool {
if app == nil {
return false
}
value, unit := normalizeRefreshIntervalSettings(app.refreshIntervalValue, app.refreshIntervalUnit)
if unit == "sec" {
next := (value + 59) / 60
if next < 1 {
next = 1
}
if next > 60 {
next = 60
}
app.refreshIntervalValue = next
app.refreshIntervalUnit = "min"
return true
}
next := value * 60
if next < 1 {
next = 1
}
if next > 3600 {
next = 3600
}
app.refreshIntervalValue = next
app.refreshIntervalUnit = "sec"
return true
}
func applyRefreshTimer(hwnd uintptr, app *guiApp) {
if hwnd == 0 || app == nil || setTimerProc == nil {
return
}
interval := refreshIntervalDuration(app.refreshIntervalValue, app.refreshIntervalUnit)
ms := int64(interval / time.Millisecond)
if ms < 1000 {
ms = 1000
}
if ms > int64(^uint32(0)) {
ms = int64(^uint32(0))
}
setTimerProc.Call(hwnd, timerRefresh, uintptr(uint32(ms)), 0)
}
func pointInRect(x, y int32, rc winRect) bool {
return x >= rc.Left && x < rc.Right && y >= rc.Top && y < rc.Bottom
}
func handlePaneVScroll(hwnd uintptr, app *guiApp, wParam uintptr) bool {
if app == nil || getClientRectProc == nil {
return false
}
code := uint32(wParam & 0xffff)
var pos *int
var contentHeight int32
switch hwnd {
case app.rightHwnd:
switch app.activeTab {
case "join":
pos = &app.eventScrollPos
contentHeight = eventPaneContentHeight(hwnd, app)
case "history":
pos = &app.historyScrollPos
contentHeight = historyDetailContentHeight(hwnd, app)
default:
return false
}
default:
return false
}
if pos == nil {
return false
}
pageHeight := paneClientHeight(hwnd)
if pageHeight <= 0 {
return false
}
maxPos := scrollMax(contentHeight, pageHeight)
next := *pos
switch code {
case sbLineUp:
next -= scrollLineStep
case sbLineDown:
next += scrollLineStep
case sbPageUp:
next -= int(pageHeight / 3)
case sbPageDown:
next += int(pageHeight / 3)
case sbThumbPosition, sbThumbTrack:
if getScrollInfoProc != nil {
info := scrollInfo{
cbSize: uint32(unsafe.Sizeof(scrollInfo{})),
fMask: sifTrackPos | sifPos,
}
if r1, _, _ := getScrollInfoProc.Call(hwnd, siVert, uintptr(unsafe.Pointer(&info))); r1 != 0 && info.nTrackPos >= 0 {
next = int(info.nTrackPos)
} else {
next = int(uint16(wParam >> 16))
}
} else {
next = int(uint16(wParam >> 16))
}
case sbTop:
next = 0
case sbBottom:
next = maxPos
case sbEndScroll:
return true
default:
return false
}
if next < 0 {
next = 0
}
if next > maxPos {
next = maxPos
}
if next == *pos {
return true
}
*pos = next
applyPaneScroll(hwnd, next, contentHeight, pageHeight)
if invalidateRectProc != nil {
invalidateRectProc.Call(hwnd, 0, 1)
}
return true
}
func handlePaneMouseWheel(hwnd uintptr, app *guiApp, delta int32) bool {
if delta == 0 {
return false
}
if app != nil && hwnd == app.rightHwnd && app.activeTab == "history" {
if delta > 0 {
return handlePaneVScroll(hwnd, app, sbLineUp)
}
return handlePaneVScroll(hwnd, app, sbLineDown)
}
if delta > 0 {
return handlePaneVScroll(hwnd, app, sbLineUp)
}
return handlePaneVScroll(hwnd, app, sbLineDown)
}
func paneClientHeight(hwnd uintptr) int32 {
if getClientRectProc == nil {
return 0
}
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
return rc.Bottom - rc.Top
}
func scrollMax(contentHeight, pageHeight int32) int {
maxPos := int(contentHeight - pageHeight)
if maxPos < 0 {
maxPos = 0
}
return maxPos
}
func applyPaneScroll(hwnd uintptr, pos int, contentHeight, pageHeight int32) {
if setScrollInfoProc == nil || hwnd == 0 {
return
}
maxPos := scrollMax(contentHeight, pageHeight)
if pos < 0 {
pos = 0
}
if pos > maxPos {
pos = maxPos
}
info := scrollInfo{
cbSize: uint32(unsafe.Sizeof(scrollInfo{})),
fMask: sifAll | sifDisableNoScroll,
nMin: 0,
nMax: contentHeight - 1,
nPage: uint32(pageHeight),
nPos: int32(pos),
nTrackPos: int32(pos),
}
setScrollInfoProc.Call(hwnd, siVert, uintptr(unsafe.Pointer(&info)), 1)
}
func memberPaneContentHeight(hwnd uintptr, app *guiApp) int32 {
if app == nil || getClientRectProc == nil {
return 0
}
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
users := app.currentUsers
if len(users) == 0 {
users, _ = currentUsersFromJoinLeave()
}
present := make([]userState, 0, len(users))
for _, u := range users {
if u.Present {
present = append(present, u)
}
}
columns := 1
if len(present) > 24 {
columns = 2
}
if len(present) > 60 {
columns = 3
}
availableHeight := rc.Bottom - rc.Top - 78
if availableHeight < 120 {
availableHeight = 120
}
rowsPerCol := (len(present) + columns - 1) / columns
if rowsPerCol < 1 {
rowsPerCol = 1
}
rowHeight := availableHeight / int32(rowsPerCol)
if rowHeight < 24 {
rowHeight = 24
}
return 70 + int32(rowsPerCol)*rowHeight + 8
}
func eventPaneContentHeight(hwnd uintptr, app *guiApp) int32 {
if app == nil || getClientRectProc == nil {
return 0
}
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
events := joinLeaveRowsForCurrentWorld(app)
y := int32(80)
for _, ev := range events {
nameLines := splitDisplayName(ev.name, 8)
if len(nameLines) > 1 {
y += 76
} else {
y += 54
}
}
if y < 120 {
y = 120
}
return y
}
func paintPaneBackground(hwnd uintptr, app *guiApp, wParam uintptr) uintptr {
if fillRectProc == nil || getClientRectProc == nil {
return 1
}
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
brush := paneBgBrush
if brush == 0 && getSysColorBrushProc != nil {
brush, _, _ = getSysColorBrushProc.Call(5)
}
if brush != 0 {
fillRectProc.Call(wParam, uintptr(unsafe.Pointer(&rc)), brush)
}
_ = app
return 1
}
func paintMemberPane(hwnd uintptr, app *guiApp) uintptr {
hdc, done := beginPanePaint(hwnd)
if hdc == 0 {
return 0
}
defer done()
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
fillSolid(hdc, rc, clrPaneBg)
drawLine(hdc, rc.Right-1, 0, rc.Right-1, rc.Bottom, clrPaneBorder)
users := app.currentUsers
if len(users) == 0 {
users, _ = currentUsersFromJoinLeave()
}
drawPaneText(hdc, 24, 24, fmt.Sprintf("現在のメンバー (%d)", countPresent(users)), app.hFont, clrMutedText)
drawLine(hdc, 0, 58, rc.Right, 58, clrPaneBorder)
present := make([]userState, 0, len(users))
for _, u := range users {
if u.Present {
present = append(present, u)
}
}
sort.SliceStable(present, func(i, j int) bool {
if present[i].LastJoin.Equal(present[j].LastJoin) {
return strings.ToLower(present[i].Name) < strings.ToLower(present[j].Name)
}
if present[i].LastJoin.IsZero() {
return false
}
if present[j].LastJoin.IsZero() {
return true
}
return present[i].LastJoin.After(present[j].LastJoin)
})
if len(present) == 0 {
applyPaneScroll(hwnd, 0, 120, rc.Bottom-rc.Top)
return 0
}
columns := 1
if len(present) > 24 {
columns = 2
}
if len(present) > 60 {
columns = 3
}
contentTopY := int32(70)
contentBottom := rc.Bottom - 8
availableHeight := contentBottom - contentTopY
if availableHeight < 120 {
availableHeight = 120
}
rowsPerCol := (len(present) + columns - 1) / columns
rowHeight := availableHeight / int32(rowsPerCol)
if rowHeight < 24 {
rowHeight = 24
}
if rowHeight > 58 {
rowHeight = 58
}
colWidth := rc.Right / int32(columns)
if colWidth < 160 {
colWidth = 160
}
avatarRadius := int32(15)
if rowHeight > 32 {
avatarRadius = 19
}
contentHeight := contentTopY + int32(rowsPerCol)*rowHeight + 8
pageHeight := rc.Bottom - rc.Top
maxPos := scrollMax(contentHeight, pageHeight)
if app.memberScrollPos > maxPos {
app.memberScrollPos = maxPos
}
applyPaneScroll(hwnd, app.memberScrollPos, contentHeight, pageHeight)
for i, u := range present {
col := i / rowsPerCol
row := i % rowsPerCol
if col >= columns {
break
}
x0 := int32(col) * colWidth
y := contentTopY - int32(app.memberScrollPos) + int32(row)*rowHeight
if y+rowHeight > contentBottom {
continue
}
centerY := y + rowHeight/2
drawAvatar(hdc, x0+28, centerY, avatarRadius, initialForName(u.Name), app.hFont)
nameMax := 14
if columns == 1 {
nameMax = 18
}
name := ellipsizeRunes(u.Name, nameMax)
drawPaneText(hdc, x0+58, centerY-8, name, app.hFont, clrText)
stamp := "--:--"
if !u.LastJoin.IsZero() {
stamp = u.LastJoin.Format("15:04")
}
stampX := x0 + colWidth - 58
drawPaneText(hdc, stampX, centerY-8, stamp, app.hFont, clrMutedText)
}
return 0
}
func paintEventPane(hwnd uintptr, app *guiApp) uintptr {
hdc, done := beginPanePaint(hwnd)
if hdc == 0 {
return 0
}
defer done()
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
fillSolid(hdc, rc, clrPaneBgAlt)
drawPaneText(hdc, 24, 24, "入退室ログ", app.hFont, clrMutedText)
drawLine(hdc, 0, 58, rc.Right, 58, clrPaneBorder)
rows := joinLeaveRowsForCurrentWorld(app)
pageHeight := rc.Bottom - rc.Top
contentHeight := eventPaneContentHeight(hwnd, app)
maxPos := scrollMax(contentHeight, pageHeight)
if app.eventScrollPos > maxPos {
app.eventScrollPos = maxPos
}
applyPaneScroll(hwnd, app.eventScrollPos, contentHeight, pageHeight)
y := int32(80 - app.eventScrollPos)
for _, ev := range rows {
if y > rc.Bottom-34 {
break
}
color := uint32(clrAccentGreen)
if !ev.join {
color = uint32(clrAccentRed)
}
icon := "▲"
if !ev.join {
icon = "▼"
}
drawPaneText(hdc, 24, y, ev.at.Format("15:04:05"), app.hFont, clrMutedText)
drawPaneText(hdc, 146, y, icon, app.hFont, clrText)
nameLines := splitDisplayName(ev.name, 8)
for i, line := range nameLines {
if i >= 2 {
break
}
drawPaneText(hdc, 186, y+int32(i*28), line, app.hFont, color)
}
if len(nameLines) > 1 {
y += 76
} else {
y += 54
}
}
return 0
}
func beginPanePaint(hwnd uintptr) (uintptr, func()) {
if beginPaintProc == nil || endPaintProc == nil || getClientRectProc == nil {
return 0, func() {}
}
var ps paintStruct
hdc, _, _ := beginPaintProc.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
if hdc == 0 {
return 0, func() {}
}
if setBkModeProc != nil {
setBkModeProc.Call(hdc, 1)
}
return hdc, func() { endPaintProc.Call(hwnd, uintptr(unsafe.Pointer(&ps))) }
}
func paintTextPane(hwnd uintptr, app *guiApp, text string) uintptr {
if app == nil || beginPaintProc == nil || endPaintProc == nil || getClientRectProc == nil {
return 0
}
var ps paintStruct
hdc, _, _ := beginPaintProc.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
if hdc == 0 {
return 0
}
defer endPaintProc.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
brush := paneBgBrush
if brush == 0 && getSysColorBrushProc != nil {
brush, _, _ = getSysColorBrushProc.Call(5)
}
if fillRectProc != nil && brush != 0 {
fillRectProc.Call(hdc, uintptr(unsafe.Pointer(&rc)), brush)
}
if setBkModeProc != nil {
setBkModeProc.Call(hdc, 1)
}
regularFont := app.joinHFont
if regularFont == 0 {
regularFont = app.hFont
}
boldFont := app.joinBoldHFont
if boldFont == 0 {
boldFont = regularFont
}
headlineFont := app.joinHeadlineHFont
if headlineFont == 0 {
headlineFont = boldFont
}
if regularFont == 0 {
return 0
}
var tm textMetric
lineHeight := int32(joinPaneLineHeight(app.fontSize))
if getTextMetricsProc != nil {
if _, _, err := getTextMetricsProc.Call(hdc, uintptr(unsafe.Pointer(&tm))); err == syscall.Errno(0) {
if tm.Height > 0 {
lineHeight = tm.Height + tm.ExternalLeading + 1
}
}
}
if lineHeight < 12 {
lineHeight = 12
}
lines := strings.Split(text, "\n")
x := int32(10)
y := int32(8)
for idx, raw := range lines {
line := strings.TrimRight(raw, "\r")
if line == "" {
y += lineHeight / 2
continue
}
if idx == 0 {
drawPaneHeadlineLine(hdc, x, y, line, headlineFont, boldFont)
y += lineHeight + 2
continue
}
switch {
case strings.HasPrefix(line, "========"):
drawPaneLine(hdc, x, y, line, regularFont, clrMutedText)
case strings.Contains(line, "\u25b2"):
drawPaneLine(hdc, x, y, line, boldFont, clrAccentGreen)
case strings.Contains(line, "\u25bc"):
drawPaneLine(hdc, x, y, line, boldFont, clrAccentRed)
case strings.HasPrefix(line, "\u73fe\u5728\u306e\u30e1\u30f3\u30d0\u30fc") || strings.HasPrefix(line, "\u5165\u9000\u5ba4\u30ed\u30b0") || strings.HasPrefix(line, "\u8a2d\u5b9a") || strings.HasPrefix(line, "\u7ffb\u8a33"):
drawPaneLine(hdc, x, y, line, boldFont, clrMutedText)
default:
drawPaneLine(hdc, x, y, line, regularFont, clrText)
}
y += lineHeight
if y > rc.Bottom-10 {
break
}
}
return 0
}
func drawPaneHeadlineLine(hdc uintptr, x, y int32, text string, headlineFont, fallbackFont uintptr) {
if headlineFont == 0 {
headlineFont = fallbackFont
}
if headlineFont == 0 {
return
}
drawPaneText(hdc, x, y, text, headlineFont, 0x008000)
}
func drawPaneLine(hdc uintptr, x, y int32, text string, font uintptr, color uint32) {
drawPaneText(hdc, x, y, text, font, color)
}
func drawSettingsWideButton(hdc uintptr, rc winRect, label, value string, font uintptr, active bool) {
fill := uint32(0x00443120)
border := uint32(clrPaneBorder)
valueColor := uint32(clrMutedText)
if active {
fill = 0x00183f16
border = clrAccentGreen
valueColor = clrAccentGreen
}
drawSettingsBox(hdc, rc, fill, border)
drawPaneText(hdc, rc.Left+18, rc.Top+10, label, font, clrText)
valueWidth := int32(len(value) * 10)
drawPaneText(hdc, rc.Right-18-valueWidth, rc.Top+10, value, font, valueColor)
}
func drawSettingsFontControl(hdc uintptr, app *guiApp) {
font := app.hFont
fill := uint32(0x00443120)
border := uint32(clrPaneBorder)
drawSettingsBox(hdc, settingsFontDownRect(), fill, border)
drawSettingsBox(hdc, settingsFontValueRect(), uint32(0x0038281a), border)
drawSettingsBox(hdc, settingsFontUpRect(), fill, border)
drawCenteredPaneText(hdc, settingsFontDownRect(), "A-", font, clrText)
drawCenteredPaneText(hdc, settingsFontValueRect(), strconv.Itoa(app.fontSize), font, clrMutedText)
drawCenteredPaneText(hdc, settingsFontUpRect(), "A+", font, clrText)
}
func drawSettingsBox(hdc uintptr, rc winRect, fill, border uint32) {
fillSolid(hdc, rc, fill)
drawLine(hdc, rc.Left, rc.Top, rc.Right, rc.Top, border)
drawLine(hdc, rc.Left, rc.Bottom, rc.Right, rc.Bottom, border)
drawLine(hdc, rc.Left, rc.Top, rc.Left, rc.Bottom, border)
drawLine(hdc, rc.Right, rc.Top, rc.Right, rc.Bottom, border)
}
func drawCenteredPaneText(hdc uintptr, rc winRect, text string, font uintptr, color uint32) {
width := int32(len([]rune(text)) * 10)
if getTextExtentPoint32Proc != nil {
buf := syscall.StringToUTF16(text)
if len(buf) > 1 {
var sz textSize
getTextExtentPoint32Proc.Call(hdc, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)-1), uintptr(unsafe.Pointer(&sz)))
if sz.Cx > 0 {
width = sz.Cx
}
}
}
x := rc.Left + ((rc.Right - rc.Left - width) / 2)
if x < rc.Left+4 {
x = rc.Left + 4
}
drawPaneText(hdc, x, rc.Top+10, text, font, color)
}
func drawPaneText(hdc uintptr, x, y int32, text string, font uintptr, color uint32) int32 {
if textOutProc == nil || selectObjectProc == nil || setTextColorProc == nil {
return 0
}
prevFont, _, _ := selectObjectProc.Call(hdc, font)
defer selectObjectProc.Call(hdc, prevFont)
setTextColorProc.Call(hdc, uintptr(color))
buf := syscall.StringToUTF16(text)
if len(buf) <= 1 {
return 0
}
textOutProc.Call(hdc, uintptr(x), uintptr(y), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)-1))
if getTextExtentPoint32Proc == nil {
return int32(len(text) * 8)
}
var sz textSize
getTextExtentPoint32Proc.Call(hdc, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)-1), uintptr(unsafe.Pointer(&sz)))
if sz.Cx <= 0 {
return int32(len(text) * 8)
}
return sz.Cx
}
func tabColor(active bool) uint32 {
if active {
return clrText
}
return clrMutedText
}
func tabLabelColor(active, enabled bool) uint32 {
if !enabled {
return clrMutedText
}
return tabColor(active)
}
func fillSolid(hdc uintptr, rc winRect, color uint32) {
if fillRectProc == nil || createSolidBrushProc == nil {
return
}
brush, _, _ := createSolidBrushProc.Call(uintptr(color))
if brush == 0 {
return
}
fillRectProc.Call(hdc, uintptr(unsafe.Pointer(&rc)), brush)
if deleteObjectProc != nil {
deleteObjectProc.Call(brush)
}
}
func drawLine(hdc uintptr, x1, y1, x2, y2 int32, color uint32) {
if createPenProc == nil || selectObjectProc == nil || moveToExProc == nil || lineToProc == nil {
return
}
pen, _, _ := createPenProc.Call(0, 1, uintptr(color))
if pen == 0 {
return
}
old, _, _ := selectObjectProc.Call(hdc, pen)
moveToExProc.Call(hdc, uintptr(x1), uintptr(y1), 0)
lineToProc.Call(hdc, uintptr(x2), uintptr(y2))
selectObjectProc.Call(hdc, old)
if deleteObjectProc != nil {
deleteObjectProc.Call(pen)
}
}
func drawWindowDot(hdc uintptr, cx, cy int32, color uint32) {
drawFilledEllipse(hdc, cx-10, cy-10, cx+10, cy+10, color)
}
func drawAvatar(hdc uintptr, cx, cy, radius int32, label string, font uintptr) {
if radius <= 0 {
radius = 23
}
drawFilledEllipse(hdc, cx-radius, cy-radius, cx+radius, cy+radius, clrAvatarBg)
if strings.TrimSpace(label) != "" {
drawPaneText(hdc, cx-8, cy-10, label, font, clrText)
}
}
func memberRowHeight(rc winRect, count int) int32 {
if count <= 0 {
return 58
}
available := rc.Bottom - 72
if available <= 0 {
return 58
}
row := available / int32(count)
if row > 64 {
return 64
}
minRow := int32(42)
if count > 32 {
minRow = 24
} else if count > 24 {
minRow = 32
}
if row < minRow {
return minRow
}
return row
}
func drawFilledEllipse(hdc uintptr, left, top, right, bottom int32, color uint32) {
if ellipseProc == nil || createSolidBrushProc == nil || selectObjectProc == nil {
return
}
brush, _, _ := createSolidBrushProc.Call(uintptr(color))
if brush == 0 {
return
}
oldBrush, _, _ := selectObjectProc.Call(hdc, brush)
ellipseProc.Call(hdc, uintptr(left), uintptr(top), uintptr(right), uintptr(bottom))
selectObjectProc.Call(hdc, oldBrush)
if deleteObjectProc != nil {
deleteObjectProc.Call(brush)
}
}
func initialForName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "?"
}
for _, r := range name {
if r == '[' || r == ']' || r == '(' || r == ')' || r == ' ' {
continue
}
return string(r)
}
return "?"
}
func splitDisplayName(name string, width int) []string {
name = strings.TrimSpace(name)
if name == "" {
return []string{""}
}
if width <= 0 {
width = 8
}
runes := []rune(name)
if len(runes) <= width {
return []string{name}
}
out := make([]string, 0, 2)
for len(runes) > 0 && len(out) < 2 {
n := width
if len(runes) < n {
n = len(runes)
}
out = append(out, string(runes[:n]))
runes = runes[n:]
}
return out
}
func ellipsizeRunes(text string, limit int) string {
text = strings.TrimSpace(text)
if limit <= 0 {
return text
}
runes := []rune(text)
if len(runes) <= limit {
return text
}
if limit <= 1 {
return string(runes[:limit])
}
return string(runes[:limit-1]) + "…"
}
func ellipsizeTextToWidth(hdc uintptr, text string, maxWidth int32, font uintptr) string {
text = strings.TrimSpace(text)
if text == "" || maxWidth <= 0 {
return ""
}
if measureTextWidth(hdc, text, font) <= maxWidth {
return text
}
runes := []rune(text)
if len(runes) <= 1 {
return "..."
}
lo, hi := 1, len(runes)
best := "..."
for lo <= hi {
mid := (lo + hi) / 2
cand := string(runes[:mid]) + "..."
if measureTextWidth(hdc, cand, font) <= maxWidth {
best = cand
lo = mid + 1
} else {
hi = mid - 1
}
}
return best
}
func measureTextWidth(hdc uintptr, text string, font uintptr) int32 {
if text == "" {
return 0
}
if selectObjectProc == nil || getTextExtentPoint32Proc == nil {
return int32(len([]rune(text)) * 10)
}
buf := syscall.StringToUTF16(text)
if len(buf) <= 1 {
return 0
}
prevFont, _, _ := selectObjectProc.Call(hdc, font)
defer selectObjectProc.Call(hdc, prevFont)
var sz textSize
getTextExtentPoint32Proc.Call(hdc, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)-1), uintptr(unsafe.Pointer(&sz)))
if sz.Cx <= 0 {
return int32(len([]rune(text)) * 10)
}
return sz.Cx
}
type guiEventRow struct {
at time.Time
name string
join bool
}
func eventRows(users []userState) []guiEventRow {
rows := make([]guiEventRow, 0, len(users))
for _, u := range users {
if u.Present {
if !u.LastJoin.IsZero() {
rows = append(rows, guiEventRow{at: u.LastJoin, name: u.Name, join: true})
}
continue
}
if !u.LastLeave.IsZero() {
rows = append(rows, guiEventRow{at: u.LastLeave, name: u.Name, join: false})
}
}
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].at.Equal(rows[j].at) {
return strings.ToLower(rows[i].name) < strings.ToLower(rows[j].name)
}
return rows[i].at.After(rows[j].at)
})
return rows
}
func joinLeaveRowsForCurrentWorld(app *guiApp) []guiEventRow {
events := historyEventsFromSnapshot()
if len(events) == 0 {
users, _ := currentUsersFromJoinLeave()
return eventRows(users)
}
since := currentWorldSinceFromRuntime()
if since.IsZero() {
since = currentWorldStartTime()
}
rows := make([]guiEventRow, 0, len(events))
for _, ev := range events {
if ev.At.IsZero() {
continue
}
if !since.IsZero() && ev.At.Before(since) {
continue
}
rows = append(rows, guiEventRow{
at: ev.At,
name: ev.Name,
join: strings.EqualFold(ev.Kind, "join"),
})
}
if len(rows) == 0 {
users, _ := currentUsersFromJoinLeave()
return eventRows(users)
}
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].at.Equal(rows[j].at) {
if rows[i].join != rows[j].join {
return rows[i].join
}
return strings.ToLower(rows[i].name) < strings.ToLower(rows[j].name)
}
return rows[i].at.After(rows[j].at)
})
return rows
}
type guiWorldVisitRow struct {
Label string
StartedAt time.Time
EndedAt time.Time
Current bool
}
func historyRows() []guiWorldVisitRow {
rows := make([]guiWorldVisitRow, 0, 16)
if label, since, ok := currentWorldVisitInfo(); ok {
rows = append(rows, guiWorldVisitRow{
Label: label,
StartedAt: since,
Current: true,
})
}
for _, visit := range readWorldHistory() {
rows = append(rows, guiWorldVisitRow{
Label: visit.WorldLabel,
StartedAt: visit.StartedAt,
EndedAt: visit.EndedAt,
})
}
sort.SliceStable(rows, func(i, j int) bool {
ti := rows[i].EndedAt
if ti.IsZero() {
ti = rows[i].StartedAt
}
tj := rows[j].EndedAt
if tj.IsZero() {
tj = rows[j].StartedAt
}
if ti.Equal(tj) {
return strings.ToLower(rows[i].Label) < strings.ToLower(rows[j].Label)
}
return ti.After(tj)
})
return rows
}
func historyPaneContentHeight(hwnd uintptr, app *guiApp) int32 {
if app == nil || getClientRectProc == nil {
return 0
}
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
rows := app.historyRows
if rows == nil {
return 120
}
contentHeight := int32(84 + len(rows)*38)
if contentHeight < 120 {
contentHeight = 120
}
return contentHeight
}
func paintHistoryPane(hwnd uintptr, app *guiApp) uintptr {
hdc, done := beginPanePaint(hwnd)
if hdc == 0 {
return 0
}
defer done()
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
fillSolid(hdc, rc, clrPaneBgAlt)
drawPaneText(hdc, 24, 24, "ワールド訪問履歴", app.joinBoldHFont, clrAccentGreen)
drawLine(hdc, 0, 58, rc.Right, 58, clrPaneBorder)
rows := app.historyRows
if rows == nil {
rows = []guiWorldVisitRow{}
}
pageHeight := rc.Bottom - rc.Top
contentHeight := historyPaneContentHeight(hwnd, app)
maxPos := scrollMax(contentHeight, pageHeight)
if app.historyScrollPos > maxPos {
app.historyScrollPos = maxPos
}
applyPaneScroll(hwnd, app.historyScrollPos, contentHeight, pageHeight)
y := int32(82 - app.historyScrollPos)
for _, row := range rows {
if y > rc.Bottom-30 {
break
}
color := uint32(clrText)
font := app.hFont
if row.Current {
color = uint32(clrAccentGreen)
font = app.joinBoldHFont
}
started := row.StartedAt
ended := row.EndedAt
if ended.IsZero() {
ended = time.Now()
}
label := ellipsizeTextToWidth(hdc, strings.TrimSpace(row.Label), rc.Right-292, font)
timeLabel := started.Format("15:04")
if row.Current {
timeLabel = started.Format("15:04") + " 継続中"
} else if !row.EndedAt.IsZero() {
timeLabel = started.Format("15:04") + " - " + row.EndedAt.Format("15:04")
}
drawPaneText(hdc, 24, y, "["+timeLabel+"]", app.hFont, clrMutedText)
drawPaneText(hdc, 150, y, label, font, color)
drawPaneText(hdc, rc.Right-132, y, humanDurationLabel(started, ended, row.Current), app.hFont, clrMutedText)
y += 38
}
return 0
}
type guiWorldVisitRecord struct {
WorldLabel string `json:"world_label"`
WorldID string `json:"world_id"`
InstanceID string `json:"instance_id"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
}
type guiWorldHistoryFile struct {
UpdatedAt string `json:"updated_at"`
Visits []guiWorldVisitRecord `json:"visits"`
}
func readWorldHistory() []guiWorldVisitRecord {
p := filepath.Join(runtimeDir(), "world_history.json")
info, err := os.Stat(p)
if err == nil {
worldHistoryCacheMu.Lock()
if worldHistoryCache.modTime.Equal(info.ModTime()) && worldHistoryCache.size == info.Size() && worldHistoryCache.items != nil {
out := append([]guiWorldVisitRecord(nil), worldHistoryCache.items...)
worldHistoryCacheMu.Unlock()
return out
}
worldHistoryCacheMu.Unlock()
}
b, err := os.ReadFile(p)
if err != nil || len(b) == 0 {
return nil
}
var visits []guiWorldVisitRecord
if err := json.Unmarshal(b, &visits); err != nil {
var snap guiWorldHistoryFile
if err := json.Unmarshal(b, &snap); err != nil {
return nil
}
visits = snap.Visits
}
out := dedupeWorldHistory(visits)
if info, err := os.Stat(p); err == nil {
worldHistoryCacheMu.Lock()
worldHistoryCache.modTime = info.ModTime()
worldHistoryCache.size = info.Size()
worldHistoryCache.items = append([]guiWorldVisitRecord(nil), out...)
worldHistoryCacheMu.Unlock()
}
return out
}
func dedupeWorldHistory(visits []guiWorldVisitRecord) []guiWorldVisitRecord {
if len(visits) <= 1 {
return visits
}
type bucket struct {
visit guiWorldVisitRecord
}
seen := make(map[string]int, len(visits))
out := make([]bucket, 0, len(visits))
for _, visit := range visits {
key := visit.WorldLabel + "|" + visit.WorldID + "|" + visit.InstanceID + "|" + visit.StartedAt.UTC().Format(time.RFC3339Nano)
if idx, ok := seen[key]; ok {
if visit.EndedAt.After(out[idx].visit.EndedAt) {
out[idx].visit.EndedAt = visit.EndedAt
}
if strings.TrimSpace(out[idx].visit.WorldLabel) == "" && strings.TrimSpace(visit.WorldLabel) != "" {
out[idx].visit.WorldLabel = visit.WorldLabel
}
continue
}
seen[key] = len(out)
out = append(out, bucket{visit: visit})
}
sort.SliceStable(out, func(i, j int) bool {
ti := out[i].visit.EndedAt
if ti.IsZero() {
ti = out[i].visit.StartedAt
}
tj := out[j].visit.EndedAt
if tj.IsZero() {
tj = out[j].visit.StartedAt
}
if ti.Equal(tj) {
return strings.ToLower(out[i].visit.WorldLabel) < strings.ToLower(out[j].visit.WorldLabel)
}
return ti.After(tj)
})
merged := make([]guiWorldVisitRecord, 0, len(out))
for _, item := range out {
merged = append(merged, item.visit)
}
return merged
}
func currentWorldVisitInfo() (string, time.Time, bool) {
if !vrchatProcessRunning() {
return "", time.Time{}, false
}
state := readRuntimeSnapshot()
label := strings.TrimSpace(stateString(state, "world"))
sinceText := strings.TrimSpace(stateString(state, "world_since"))
if label != "" && sinceText != "" {
if since, err := time.Parse(time.RFC3339, sinceText); err == nil {
return label, since, true
}
}
if label != "" {
return label, time.Now(), true
}
if tailLabel, tailSince := currentWorldVisitFromVRChatLog(); tailLabel != "" && !tailSince.IsZero() {
return tailLabel, tailSince, true
}
return "", time.Time{}, false
}
func currentWorldVisitFromVRChatLog() (string, time.Time) {
logPath, err := findLatestVRChatLog()
if err != nil {
return "", time.Time{}
}
b, err := readFileTail(logPath, guiLogTailBytes)
if err != nil || len(b) == 0 {
return "", time.Time{}
}
text := appPkg.DecodeVRChatLog(b)
if strings.TrimSpace(text) == "" {
return "", time.Time{}
}
state := &worldState{}
latestLabel := ""
latestTime := time.Time{}
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if line == "" {
continue
}
at := parseLogTime(line)
if changed, label := updateWorldState(state, line); changed {
if shouldPreferWorldCandidate(label, latestLabel) {
latestLabel = label
latestTime = at
}
continue
}
candidate := ""
if strings.Contains(line, "Memory Usage: after world loaded") {
if m := worldPattern.FindStringSubmatch(line); len(m) == 2 {
candidate = strings.TrimSpace(m[1])
}
}
if candidate == "" {
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 {
candidate = strings.TrimSpace(m[1])
}
}
if candidate == "" {
if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 {
candidate = strings.TrimSpace(m[1])
}
}
if candidate == "" {
continue
}
if !shouldPreferWorldCandidate(candidate, latestLabel) {
continue
}
if at.IsZero() || latestTime.IsZero() || !at.Before(latestTime) {
latestLabel = candidate
if !at.IsZero() {
latestTime = at
}
}
}
return latestLabel, latestTime
}
func shouldPreferWorldCandidate(candidate, current string) bool {
candidate = strings.TrimSpace(candidate)
current = strings.TrimSpace(current)
if candidate == "" {
return false
}
if strings.HasPrefix(strings.ToLower(candidate), "wrld_") && current != "" && !strings.HasPrefix(strings.ToLower(current), "wrld_") {
return false
}
return true
}
func humanDurationLabel(started, ended time.Time, current bool) string {
if started.IsZero() {
return "0 min"
}
if ended.IsZero() {
ended = time.Now()
}
if ended.Before(started) {
ended = started
}
d := ended.Sub(started)
if current {
return formatDurationShort(d) + " active"
}
return formatDurationShort(d)
}
func formatDurationShort(d time.Duration) string {
if d < time.Minute {
return "<1 min"
}
minutes := int(d.Minutes())
hours := minutes / 60
mins := minutes % 60
switch {
case hours > 0 && mins > 0:
return fmt.Sprintf("%dh%dm", hours, mins)
case hours > 0:
return fmt.Sprintf("%dh", hours)
default:
return fmt.Sprintf("%dm", mins)
}
}
func joinLogFontSize(baseSize, presentCount int) int {
size := baseSize
if size <= 0 {
size = 18
}
if presentCount <= 32 {
return size
}
switch {
case presentCount >= 40:
size -= 6
case presentCount >= 36:
size -= 4
case presentCount >= 33:
size -= 2
}
if size < 8 {
size = 8
}
return size
}
func allowRapidSettingsAction(app *guiApp) bool {
if app == nil {
return false
}
now := time.Now()
if !app.lastSettingsAction.IsZero() && now.Sub(app.lastSettingsAction) < 150*time.Millisecond {
guiLog("settings action skipped: debounce")
return false
}
app.lastSettingsAction = now
return true
}
func allowRapidTabAction(app *guiApp) bool {
if app == nil {
return false
}
now := time.Now()
if !app.lastTabAction.IsZero() && now.Sub(app.lastTabAction) < 120*time.Millisecond {
guiLog("tab change skipped: debounce")
return false
}
app.lastTabAction = now
return true
}
func currentUsersFromJoinLeave() ([]userState, int) {
if out, ok := currentUsersFromGuestSnapshot(); ok {
guiLog(fmt.Sprintf("currentUsers source=guest_snapshot count=%d total=%d", countPresent(out), len(out)))
return out, countPresent(out)
}
if out, ok := currentUsersFromJoinLeaveSnapshot(); ok {
guiLog(fmt.Sprintf("currentUsers source=join_leave_snapshot count=%d total=%d", countPresent(out), len(out)))
return out, countPresent(out)
}
if out, ok := currentUsersFromVRChatLog(); ok {
guiLog(fmt.Sprintf("currentUsers source=vrc_log count=%d total=%d", countPresent(out), len(out)))
return out, countPresent(out)
}
guiLog("currentUsers source=none")
return nil, 0
}
func currentUsersFromGuestSnapshot() ([]userState, bool) {
snap := readGuestSnapshot()
if len(snap) == 0 {
return nil, false
}
out := make([]userState, 0, len(snap))
for _, s := range snap {
out = append(out, userState{
Name: s.Name,
Present: s.Present,
LastJoin: s.LastJoin,
LastLeave: s.LastLeave,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Present != out[j].Present {
return out[i].Present
}
if out[i].LastLeave.Equal(out[j].LastLeave) {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
}
return out[i].LastLeave.After(out[j].LastLeave)
})
return out, true
}
func currentUsersFromJoinLeaveSnapshot() ([]userState, bool) {
since := currentWorldSinceFromRuntime()
if since.IsZero() {
return nil, false
}
events := readJoinLeaveEventsSnapshot()
if len(events) == 0 {
return nil, false
}
users := map[string]*userState{}
for _, ev := range events {
if ev.At.IsZero() || ev.At.Before(since) {
continue
}
name := strings.TrimSpace(ev.Name)
if name == "" {
continue
}
u := users[name]
if u == nil {
u = &userState{Name: name}
users[name] = u
}
switch ev.Kind {
case "join":
u.Present = true
u.LastJoin = ev.At
case "leave":
u.Present = false
u.LastLeave = ev.At
}
}
if len(users) == 0 {
return nil, false
}
return sortUserStates(users), true
}
func currentUsersFromVRChatLog() ([]userState, bool) {
logPath, err := findLatestVRChatLog()
if err != nil {
return nil, false
}
b, err := readFileTail(logPath, guiLogTailBytes)
if err != nil || len(b) == 0 {
return nil, false
}
text := appPkg.DecodeVRChatLog(b)
if strings.TrimSpace(text) == "" {
return nil, false
}
users := map[string]*userState{}
lines := strings.Split(text, "\n")
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if strings.Contains(line, "Entering Room") || strings.Contains(line, "Joining or Creating Room") {
users = map[string]*userState{}
continue
}
at := parseLogTime(line)
if m := vrcJoinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
u := users[name]
if u == nil {
u = &userState{Name: name}
users[name] = u
}
u.Present = true
if !at.IsZero() {
u.LastJoin = at
}
continue
}
if m := vrcLeftPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
u := users[name]
if u == nil {
u = &userState{Name: name}
users[name] = u
}
u.Present = false
if !at.IsZero() {
u.LastLeave = at
}
}
}
return sortUserStates(users), len(users) > 0
}
func currentWorldSinceFromRuntime() time.Time {
if !vrchatProcessRunning() {
return time.Time{}
}
state := readRuntimeSnapshot()
sinceText := strings.TrimSpace(stateString(state, "world_since"))
if sinceText == "" {
return time.Time{}
}
since, err := time.Parse(time.RFC3339, sinceText)
if err != nil {
return time.Time{}
}
return since
}
func readJoinLeaveEventsSnapshot() []joinLeaveEventSnapshot {
jsonPath := filepath.Join(runtimeDir(), "join_leave_events.json")
logPath := filepath.Join(runtimeDir(), "join_leave.log")
jsonInfo, jsonErr := os.Stat(jsonPath)
logInfo, logErr := os.Stat(logPath)
if jsonErr == nil || logErr == nil {
joinLeaveEventsCacheMu.Lock()
if joinLeaveEventsCache.items != nil &&
((jsonErr == nil && joinLeaveEventsCache.jsonMod.Equal(jsonInfo.ModTime()) && joinLeaveEventsCache.jsonSize == jsonInfo.Size()) || jsonErr != nil) &&
((logErr == nil && joinLeaveEventsCache.logMod.Equal(logInfo.ModTime()) && joinLeaveEventsCache.logSize == logInfo.Size()) || logErr != nil) {
out := append([]joinLeaveEventSnapshot(nil), joinLeaveEventsCache.items...)
joinLeaveEventsCacheMu.Unlock()
return out
}
joinLeaveEventsCacheMu.Unlock()
}
if events := readJoinLeaveEventsSnapshotFromJSON(jsonPath); len(events) > 0 {
if jsonErr == nil {
joinLeaveEventsCacheMu.Lock()
joinLeaveEventsCache.jsonMod = jsonInfo.ModTime()
joinLeaveEventsCache.jsonSize = jsonInfo.Size()
joinLeaveEventsCache.logMod = time.Time{}
joinLeaveEventsCache.logSize = 0
joinLeaveEventsCache.items = append([]joinLeaveEventSnapshot(nil), events...)
joinLeaveEventsCacheMu.Unlock()
}
return events
}
events := readJoinLeaveEventsSnapshotFromLog(logPath)
if logErr == nil {
joinLeaveEventsCacheMu.Lock()
joinLeaveEventsCache.jsonMod = time.Time{}
joinLeaveEventsCache.jsonSize = 0
joinLeaveEventsCache.logMod = logInfo.ModTime()
joinLeaveEventsCache.logSize = logInfo.Size()
joinLeaveEventsCache.items = append([]joinLeaveEventSnapshot(nil), events...)
joinLeaveEventsCacheMu.Unlock()
}
return events
}
func readJoinLeaveEventsSnapshotFromJSON(path string) []joinLeaveEventSnapshot {
b, err := os.ReadFile(path)
if err != nil || len(b) == 0 {
return nil
}
type snapshot struct {
UpdatedAt time.Time `json:"updated_at"`
Events []struct {
At time.Time `json:"at"`
Kind string `json:"kind"`
Name string `json:"name"`
Count int `json:"count"`
} `json:"events"`
}
var snap snapshot
if err := json.Unmarshal(b, &snap); err != nil || len(snap.Events) == 0 {
var flat []joinLeaveEventSnapshot
if err := json.Unmarshal(b, &flat); err == nil && len(flat) > 0 {
return flat
}
return nil
}
out := make([]joinLeaveEventSnapshot, 0, len(snap.Events))
for _, ev := range snap.Events {
out = append(out, joinLeaveEventSnapshot{
At: ev.At,
Kind: ev.Kind,
Name: ev.Name,
Count: ev.Count,
})
}
return out
}
func readJoinLeaveEventsSnapshotFromLog(path string) []joinLeaveEventSnapshot {
b, err := os.ReadFile(path)
if err != nil || len(b) == 0 {
return nil
}
events := make([]joinLeaveEventSnapshot, 0, 256)
var currentAt time.Time
for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if line == "" {
continue
}
if strings.Contains(line, "VRC JOIN/LEAVE") {
if at, ok := parseRuntimeTime(line); ok {
currentAt = at
}
continue
}
m := joinLeaveBodyPattern.FindStringSubmatch(line)
if len(m) != 4 {
continue
}
count, _ := strconv.Atoi(m[3])
events = append(events, joinLeaveEventSnapshot{
At: currentAt,
Kind: m[1],
Name: strings.TrimSpace(m[2]),
Count: count,
})
}
return events
}
func sortUserStates(users map[string]*userState) []userState {
out := make([]userState, 0, len(users))
for _, u := range users {
out = append(out, *u)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Present != out[j].Present {
return out[i].Present
}
if out[i].LastLeave.Equal(out[j].LastLeave) {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
}
return out[i].LastLeave.After(out[j].LastLeave)
})
return out
}
func parseLogTime(line string) time.Time {
m := vrcLogTimePattern.FindStringSubmatch(line)
if len(m) != 2 {
return time.Time{}
}
at, err := time.Parse("2006.01.02 15:04:05", m[1])
if err != nil {
return time.Time{}
}
return at
}
func findLatestVRChatLog() (string, error) {
dir := filepath.Join(os.Getenv("USERPROFILE"), "AppData", "LocalLow", "VRChat", "VRChat")
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var latest string
var latestMod time.Time
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".log") && !strings.HasSuffix(strings.ToLower(name), ".txt") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.Size() == 0 {
continue
}
if info.ModTime().After(latestMod) {
latestMod = info.ModTime()
latest = filepath.Join(dir, name)
}
}
if latest == "" {
return "", os.ErrNotExist
}
return latest, nil
}
func readGuestSnapshot() []appPkg.GuestStatus {
p := filepath.Join(runtimeDir(), "guest_snapshot.json")
info, err := os.Stat(p)
if err == nil {
guestSnapshotCacheMu.Lock()
if guestSnapshotCache.modTime.Equal(info.ModTime()) && guestSnapshotCache.size == info.Size() && guestSnapshotCache.items != nil {
out := append([]appPkg.GuestStatus(nil), guestSnapshotCache.items...)
guestSnapshotCacheMu.Unlock()
return out
}
guestSnapshotCacheMu.Unlock()
}
b, err := readFileTail(p, guiLogTailBytes)
if err != nil {
return nil
}
var out []appPkg.GuestStatus
if err := json.Unmarshal(b, &out); err != nil {
return nil
}
if info, err := os.Stat(p); err == nil {
guestSnapshotCacheMu.Lock()
guestSnapshotCache.modTime = info.ModTime()
guestSnapshotCache.size = info.Size()
guestSnapshotCache.items = append([]appPkg.GuestStatus(nil), out...)
guestSnapshotCacheMu.Unlock()
}
return out
}
func buildGuestPaneText(worldLabel string, items []userState) string {
var b strings.Builder
headline := strings.TrimSpace(worldLabel)
if headline == "" {
headline = "(unknown)"
}
headline = headline + " (" + strconv.Itoa(countPresent(items)) + ")"
b.WriteString(headline)
b.WriteString("\r\n")
b.WriteString("\u73fe\u5728\u306e\u30e1\u30f3\u30d0\u30fc")
b.WriteString("\r\n")
present := make([]userState, 0, len(items))
for _, item := range items {
if item.Present {
present = append(present, item)
}
}
sort.SliceStable(present, func(i, j int) bool {
if present[i].LastJoin.IsZero() != present[j].LastJoin.IsZero() {
return !present[i].LastJoin.IsZero()
}
if present[i].LastJoin.Equal(present[j].LastJoin) {
return strings.ToLower(present[i].Name) < strings.ToLower(present[j].Name)
}
return present[i].LastJoin.After(present[j].LastJoin)
})
for _, item := range present {
stamp := "--:--"
if !item.LastJoin.IsZero() {
stamp = item.LastJoin.Format("15:04")
}
b.WriteString("[")
b.WriteString(stamp)
b.WriteString("] ")
b.WriteString(item.Name)
b.WriteString("\r\n")
}
return strings.TrimRight(b.String(), "\r\n")
}
func formatGuestPane(title, worldLabel string, items []userState) string {
text := buildGuestPaneText(worldLabel, items)
if err := appPkg.AppendDesktopJoinLog(title, worldLabel, text); err != nil {
_ = appPkg.AppendRuntimeLog("GUI JOIN LOG WRITE FAIL", err.Error())
}
return text
}
func currentSelfName() string {
if cfg, err := config.Load(""); err == nil && cfg != nil {
if name := strings.TrimSpace(cfg.VrcLog.SelfName); name != "" {
return name
}
}
cwd, err := os.Getwd()
if err != nil {
return ""
}
cfg, err := config.Load(filepath.Join(cwd, "config", "config.toml"))
if err != nil || cfg == nil {
return ""
}
return strings.TrimSpace(cfg.VrcLog.SelfName)
}
func loadGUISettings() config.GUIConfig {
cfg, err := config.Load("")
if err != nil || cfg == nil {
return config.GUIConfig{
TopMost: false,
FontSize: 18,
RefreshIntervalValue: 2,
RefreshIntervalUnit: "sec",
HistoryExportIncludeTime: true,
HistoryExportIncludeWorld: true,
HistoryExportIncludeJoinLeave: true,
}
}
if cfg.GUI.FontSize <= 0 {
cfg.GUI.FontSize = 18
}
cfg.GUI.RefreshIntervalValue, cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalSettings(cfg.GUI.RefreshIntervalValue, cfg.GUI.RefreshIntervalUnit)
cfg.GUI.HistoryRegex = strings.TrimSpace(cfg.GUI.HistoryRegex)
cfg.GUI.HistoryFromDate = strings.TrimSpace(cfg.GUI.HistoryFromDate)
cfg.GUI.HistoryToDate = strings.TrimSpace(cfg.GUI.HistoryToDate)
cfg.GUI.HistoryExportDir = strings.TrimSpace(cfg.GUI.HistoryExportDir)
cfg.GUI.HistoryExportCustom = strings.TrimSpace(cfg.GUI.HistoryExportCustom)
return cfg.GUI
}
func saveGUISettings(app *guiApp) {
if app == nil {
return
}
app.historyExportDir = normalizeHistoryExportDir(app.historyExportDir)
guiCfg := config.GUIConfig{
TopMost: app.topMost,
FontSize: app.fontSize,
AutoUnmuteOnSelfLeave: app.autoUnmuteOnLeave,
RefreshIntervalValue: app.refreshIntervalValue,
RefreshIntervalUnit: app.refreshIntervalUnit,
HistoryRegex: strings.TrimSpace(app.historyRegex),
HistoryFromDate: strings.TrimSpace(app.historyFromDate),
HistoryToDate: strings.TrimSpace(app.historyToDate),
HistoryExportDir: strings.TrimSpace(app.historyExportDir),
HistoryExportIncludeTime: app.historyExportIncludeTime,
HistoryExportIncludeWorld: app.historyExportIncludeWorld,
HistoryExportIncludeJoinLeave: app.historyExportIncludeJoinLeave,
HistoryExportCustomEnabled: app.historyExportCustomEnabled,
HistoryExportCustom: strings.TrimSpace(app.historyExportCustom),
}
if err := config.SaveGUI("", guiCfg); err != nil {
guiLog("saveGUISettings failed: " + err.Error())
return
}
guiLog("saveGUISettings ok")
}
func syncHistoryExportSettingsFromControls(app *guiApp, getWindowText, setWindowText *syscall.LazyProc) {
if app == nil {
return
}
if app.settingsEditSyncing {
return
}
app.historyExportDir = normalizeHistoryExportDir(app.historyExportDir)
app.historyExportCustom = strings.TrimSpace(app.historyExportCustom)
if getWindowText != nil {
app.historyExportDir = normalizeHistoryExportDir(readWindowText(getWindowText, app.settingsExportDirEdit))
app.historyExportCustom = strings.TrimSpace(readWindowText(getWindowText, app.settingsExportCustomEdit))
}
saveGUISettings(app)
if setWindowText != nil {
syncHistoryExportEditControls(app, setWindowText)
}
}
func startHistoryExport(app *guiApp, getWindowText, setWindowText *syscall.LazyProc) bool {
if app == nil {
return false
}
if app.historyExportRunning {
app.historyStatus = "exporting..."
invalidateHistoryPanes(app)
return true
}
req := historyExportRequest{
Day: app.historySelectedDate,
Selected: cloneHistorySelection(app.historySelectedKeys),
Rows: cloneHistoryDetailRows(app.historyDetailRows),
ExportDir: app.historyExportDir,
IncludeTime: app.historyExportIncludeTime,
IncludeWorld: app.historyExportIncludeWorld,
IncludeJoinLeave: app.historyExportIncludeJoinLeave,
CustomEnabled: app.historyExportCustomEnabled,
Custom: strings.TrimSpace(app.historyExportCustom),
RegexText: strings.TrimSpace(app.historyRegex),
Query: historyQueryFromApp(app),
}
if req.Day.IsZero() {
req.Day = time.Now()
}
if app.historyExportResultCh == nil {
app.historyExportResultCh = make(chan historyExportResult, 1)
}
guiLog(fmt.Sprintf("history export start day=%s dir=%s selected=%d", req.Day.Format("2006-01-02"), req.ExportDir, len(req.Selected)))
app.historyExportRunning = true
app.historyStatus = "exporting..."
invalidateHistoryPanes(app)
hwnd := app.hwnd
go func(ch chan<- historyExportResult, req historyExportRequest, hwnd uintptr) {
defer func() {
if r := recover(); r != nil {
result := historyExportResult{
Err: fmt.Errorf("export panic: %v", r),
Day: req.Day,
SelectedCount: len(req.Selected),
}
select {
case ch <- result:
default:
}
if postMessageProc != nil && hwnd != 0 {
postMessageProc.Call(hwnd, uintptr(wmHistoryExportDone), 0, 0)
}
}
}()
path, err := exportHistoryDayFromRequest(req)
result := historyExportResult{
Path: path,
Err: err,
Day: req.Day,
SelectedCount: len(req.Selected),
}
select {
case ch <- result:
default:
}
if postMessageProc != nil && hwnd != 0 {
postMessageProc.Call(hwnd, uintptr(wmHistoryExportDone), 0, 0)
}
}(app.historyExportResultCh, req, hwnd)
return true
}
func drainHistoryExportResult(app *guiApp) bool {
if app == nil || app.historyExportResultCh == nil || !app.historyExportRunning {
return false
}
handled := false
for {
select {
case res := <-app.historyExportResultCh:
handled = true
app.historyExportRunning = false
if res.Err != nil {
app.historyStatus = "export failed: " + res.Err.Error()
guiLog(fmt.Sprintf("history export failed day=%s err=%v", res.Day.Format("2006-01-02"), res.Err))
} else if res.Path != "" {
app.historyStatus = "exported: " + filepath.Base(res.Path)
guiLog(fmt.Sprintf("history export ok day=%s path=%s selected=%d", res.Day.Format("2006-01-02"), res.Path, res.SelectedCount))
if dir := strings.TrimSpace(filepath.Dir(res.Path)); dir != "" {
if ok := openFolderInExplorer(dir); ok {
guiLog("history export folder opened: " + dir)
} else {
guiLog("history export folder open failed: " + dir)
}
}
} else {
app.historyStatus = "exported: nothing selected"
guiLog(fmt.Sprintf("history export skipped day=%s selected=%d", res.Day.Format("2006-01-02"), res.SelectedCount))
}
invalidateHistoryPanes(app)
default:
return handled
}
}
}
func openFolderInExplorer(path string) bool {
path = strings.TrimSpace(path)
if path == "" || shellExecuteProc == nil {
return false
}
op, _ := syscall.UTF16PtrFromString("open")
target, err := syscall.UTF16PtrFromString(path)
if err != nil {
return false
}
r, _, _ := shellExecuteProc.Call(0, uintptr(unsafe.Pointer(op)), uintptr(unsafe.Pointer(target)), 0, 0, 1)
return r > 32
}
func syncHistoryExportEditControls(app *guiApp, setWindowText *syscall.LazyProc) {
if app == nil || setWindowText == nil || app.settingsEditSyncing {
return
}
app.settingsEditSyncing = true
defer func() {
app.settingsEditSyncing = false
}()
if app.settingsExportDirEdit != 0 {
text, _ := syscall.UTF16PtrFromString(normalizeHistoryExportDir(app.historyExportDir))
setWindowText.Call(app.settingsExportDirEdit, uintptr(unsafe.Pointer(text)))
}
if app.settingsExportCustomEdit != 0 {
text, _ := syscall.UTF16PtrFromString(app.historyExportCustom)
setWindowText.Call(app.settingsExportCustomEdit, uintptr(unsafe.Pointer(text)))
}
}
func handleHistoryExportBrowseCommand(app *guiApp, setWindowText *syscall.LazyProc) bool {
if app == nil {
return false
}
path, ok := browseForFolderPath(app.hwnd, "履歴 export フォルダを選択", app.historyExportDir)
if !ok || strings.TrimSpace(path) == "" {
return true
}
app.historyExportDir = normalizeHistoryExportDir(path)
saveGUISettings(app)
if setWindowText != nil {
syncHistoryExportEditControls(app, setWindowText)
}
if invalidateRectProc != nil && app.settingsPaneHwnd != 0 {
invalidateRectProc.Call(app.settingsPaneHwnd, 0, 1)
}
return true
}
func handleHistoryExportSaveCommand(app *guiApp, getWindowText, setWindowText *syscall.LazyProc) bool {
if app == nil {
return false
}
syncHistoryExportSettingsFromControls(app, getWindowText, nil)
saveGUISettings(app)
if invalidateRectProc != nil && app.settingsPaneHwnd != 0 {
invalidateRectProc.Call(app.settingsPaneHwnd, 0, 1)
}
return true
}
func browseForFolderPath(owner uintptr, title, initial string) (string, bool) {
shell32 := syscall.NewLazyDLL("shell32.dll")
ole32 := syscall.NewLazyDLL("ole32.dll")
browseForFolder := shell32.NewProc("SHBrowseForFolderW")
shGetPathFromIDList := shell32.NewProc("SHGetPathFromIDListW")
coTaskMemFree := ole32.NewProc("CoTaskMemFree")
initial = normalizeHistoryExportDir(initial)
if initial == "" {
initial = filepath.Join(runtimeDir(), "exports")
}
titlePtr, _ := syscall.UTF16PtrFromString(title)
initialPtr, _ := syscall.UTF16PtrFromString(initial)
displayName := make([]uint16, 260)
callback := syscall.NewCallback(func(hwnd uintptr, msg, lParam, lpData uintptr) uintptr {
if msg == bffmInitialized && lParam != 0 && sendMessageProc != nil {
sendMessageProc.Call(hwnd, bffmSetSelectionW, 1, lParam)
}
return 0
})
type browseInfo struct {
HwndOwner uintptr
PidlRoot uintptr
PszDisplayName *uint16
LpszTitle *uint16
UlFlags uint32
Lpfn uintptr
LParam uintptr
IImage int32
}
bi := browseInfo{
HwndOwner: owner,
PszDisplayName: &displayName[0],
LpszTitle: titlePtr,
UlFlags: bifReturnOnlyFsDirs | bifEditBox | bifNewDialogStyle,
Lpfn: callback,
LParam: uintptr(unsafe.Pointer(initialPtr)),
}
pidl, _, _ := browseForFolder.Call(uintptr(unsafe.Pointer(&bi)))
if pidl == 0 {
return "", false
}
defer func() {
if coTaskMemFree != nil {
coTaskMemFree.Call(pidl)
}
}()
pathBuf := make([]uint16, 260)
ok, _, _ := shGetPathFromIDList.Call(pidl, uintptr(unsafe.Pointer(&pathBuf[0])))
if ok == 0 {
return "", false
}
return strings.TrimSpace(syscall.UTF16ToString(pathBuf)), true
}
func handleHistoryExportEditCommand(app *guiApp, cmd uint16, notify uint16, getWindowText, setWindowText *syscall.LazyProc) bool {
if app == nil {
return false
}
if cmd != idHistoryExportDir && cmd != idHistoryExportCustom {
return false
}
if app.settingsEditSyncing {
return true
}
if notify != enKillFocus && notify != enChange {
return true
}
if notify == enChange {
return true
}
syncHistoryExportSettingsFromControls(app, getWindowText, nil)
return true
}
func normalizeHistoryExportDir(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", ""))
if value == "" {
return filepath.Join(runtimeDir(), "exports")
}
value = strings.ReplaceAll(value, "/", `\`)
if strings.HasPrefix(value, `\\?\`) {
return filepath.Clean(value)
}
if strings.HasPrefix(value, `\\`) {
rest := strings.TrimLeft(value[2:], `\`)
for strings.Contains(rest, `\\`) {
rest = strings.ReplaceAll(rest, `\\`, `\`)
}
return filepath.Clean(`\\` + rest)
}
for strings.Contains(value, `\\`) {
value = strings.ReplaceAll(value, `\\`, `\`)
}
value = filepath.Clean(value)
if value == "." {
return filepath.Join(runtimeDir(), "exports")
}
return value
}
func exportDirDisplayText(value string) string {
return previewLine(normalizeHistoryExportDir(value), 60)
}
func formatRightPane(tab string, state map[string]any, worldLabel string, currentUsers int, current []userState, historyCount int) string {
var b strings.Builder
switch tab {
case "translate":
b.WriteString("\u7ffb\u8a33")
b.WriteString("\r\n================")
b.WriteString("\r\nOCR: ")
b.WriteString(onOffLine(stateString(state, "ocr")))
b.WriteString("\r\nOCR text: ")
b.WriteString(previewLine(stateString(state, "ocr"), 96))
b.WriteString("\r\nTranslate: ")
b.WriteString(onOffLine(stateString(state, "translate")))
b.WriteString("\r\nTranslation: ")
b.WriteString(previewLine(stateString(state, "translate"), 96))
return b.String()
case "settings":
b.WriteString("\u8a2d\u5b9a")
b.WriteString("\r\n========")
b.WriteString("\r\nTop most: ")
b.WriteString(onOffBool(stateBool(state, "top_most")))
b.WriteString("\r\nFont size: ")
fontSize, _ := state["font_size"].(int)
if fontSize == 0 {
fontSize = 18
}
b.WriteString(strconv.Itoa(fontSize))
b.WriteString("\r\nLeave unmute: ")
b.WriteString(onOffBool(stateBool(state, "auto_unmute_on_self_leave")))
return b.String()
case "history":
b.WriteString("ワールド訪問履歴")
b.WriteString("\r\n========")
b.WriteString("\r\nvisits: ")
b.WriteString(strconv.Itoa(historyCount))
return b.String()
}
b.WriteString("\u5165\u9000\u5ba4\u30ed\u30b0")
b.WriteString("\r\n========")
events := formatJoinLeaveEvents(current)
if len(events) == 0 {
b.WriteString("\r\nnone")
return b.String()
}
b.WriteString("\r\n")
for _, line := range events {
b.WriteString(line)
b.WriteString("\r\n")
}
return strings.TrimRight(b.String(), "\r\n")
}
func formatSettingsPane(state map[string]any) string {
var b strings.Builder
b.WriteString("\u8a2d\u5b9a")
b.WriteString("\r\n========")
b.WriteString("\r\nTop most: ")
b.WriteString(onOffBool(stateBool(state, "top_most")))
b.WriteString("\r\nFont size: ")
fontSize, _ := state["font_size"].(int)
if fontSize == 0 {
fontSize = 18
}
b.WriteString(strconv.Itoa(fontSize))
b.WriteString("\r\nLeave unmute: ")
b.WriteString(onOffBool(stateBool(state, "auto_unmute_on_self_leave")))
if updatedAt, _ := state["updated_at"].(string); strings.TrimSpace(updatedAt) != "" {
b.WriteString("\r\nUpdated: ")
b.WriteString(updatedAt)
}
b.WriteString("\r\nOCR: ")
b.WriteString(onOffLine(stateString(state, "ocr")))
return b.String()
}
func formatJoinLeaveEvents(current []userState) []string {
type eventLine struct {
at time.Time
line string
}
events := make([]eventLine, 0, len(current))
for _, item := range current {
at := item.LastJoin
icon := "\u25b2"
if !item.Present {
at = item.LastLeave
icon = "\u25bc"
}
if at.IsZero() {
continue
}
events = append(events, eventLine{
at: at,
line: fmt.Sprintf("[%s] %s %s", at.Format("15:04:05"), icon, item.Name),
})
}
sort.SliceStable(events, func(i, j int) bool {
if events[i].at.Equal(events[j].at) {
return strings.ToLower(events[i].line) < strings.ToLower(events[j].line)
}
return events[i].at.After(events[j].at)
})
out := make([]string, 0, len(events))
for _, ev := range events {
out = append(out, ev.line)
}
return out
}
func onOffLine(value string) string {
if strings.TrimSpace(value) == "" {
return "OFF"
}
return "ON"
}
func onOffBool(value bool) string {
if value {
return "ON"
}
return "OFF"
}
func stateString(state map[string]any, key string) string {
v, _ := state[key].(string)
return v
}
func previewLine(text string, limit int) string {
clean := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(text, "\r", " "), "\n", " "))
if clean == "" {
return "none"
}
if limit > 0 && len(clean) > limit {
return clean[:limit-3] + "..."
}
return clean
}
func readWindowText(getWindowText *syscall.LazyProc, hwnd uintptr) string {
if getWindowText == nil || hwnd == 0 {
return ""
}
buf := make([]uint16, 1024)
n, _, _ := getWindowText.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
if n == 0 {
return ""
}
size := int(n)
if size > len(buf) {
size = len(buf)
}
return strings.TrimSpace(syscall.UTF16ToString(buf[:size]))
}
func stateBool(state map[string]any, key string) bool {
v, _ := state[key].(bool)
return v
}
func currentWorldLabel() string {
if world, _, ok := currentWorldVisitInfo(); ok && strings.TrimSpace(world) != "" {
return world
}
p := filepath.Join(runtimeDir(), "runtime.log")
b, err := readFileTail(p, guiLogTailBytes)
latest := ""
if err == nil {
lines := strings.Split(string(b), "\n")
for i := 0; i < len(lines); i++ {
if !strings.Contains(lines[i], "VRC WORLD") {
continue
}
if i+1 < len(lines) {
latest = strings.TrimSpace(lines[i+1])
}
}
}
if strings.TrimSpace(latest) != "" {
return latest
}
state := readRuntimeSnapshot()
if world := strings.TrimSpace(stateString(state, "world")); world != "" {
return world
}
return ""
}
func vrchatProcessRunning() bool {
vrchatRunningMu.Lock()
defer vrchatRunningMu.Unlock()
now := time.Now()
if !vrchatRunningLastCheck.IsZero() && now.Sub(vrchatRunningLastCheck) < 2*time.Second {
return vrchatRunningLastValue
}
vrchatRunningLastCheck = now
vrchatRunningLastValue = false
kernel32 := syscall.NewLazyDLL("kernel32.dll")
createToolhelp32Snapshot := kernel32.NewProc("CreateToolhelp32Snapshot")
process32FirstW := kernel32.NewProc("Process32FirstW")
process32NextW := kernel32.NewProc("Process32NextW")
closeHandle := kernel32.NewProc("CloseHandle")
const th32CSSnapProcess = 0x00000002
type processEntry32 struct {
dwSize uint32
cntUsage uint32
th32ProcessID uint32
th32DefaultHeapID uintptr
th32ModuleID uint32
cntThreads uint32
th32ParentProcessID uint32
pcPriClassBase int32
dwFlags uint32
szExeFile [260]uint16
}
snapshot, _, _ := createToolhelp32Snapshot.Call(th32CSSnapProcess, 0)
if snapshot == 0 || snapshot == ^uintptr(0) {
return false
}
defer closeHandle.Call(snapshot)
var entry processEntry32
entry.dwSize = uint32(unsafe.Sizeof(entry))
r1, _, _ := process32FirstW.Call(snapshot, uintptr(unsafe.Pointer(&entry)))
if r1 == 0 {
return false
}
for {
if strings.EqualFold(syscall.UTF16ToString(entry.szExeFile[:]), "VRChat.exe") {
vrchatRunningLastValue = true
return true
}
r1, _, _ = process32NextW.Call(snapshot, uintptr(unsafe.Pointer(&entry)))
if r1 == 0 {
break
}
}
return vrchatRunningLastValue
}
func currentWorldFromVRChatLog() string {
label, _ := currentWorldVisitFromVRChatLog()
return label
}
func updateWorldState(state *worldState, line string) (bool, string) {
worldID := ""
instanceID := ""
worldName := ""
roomTitle := ""
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 {
roomTitle = strings.TrimSpace(m[1])
state.pendingWorldName = roomTitle
}
if m := worldLocationPattern.FindStringSubmatch(line); len(m) == 3 {
worldID = strings.TrimSpace(m[1])
instanceID = strings.TrimSpace(m[2])
}
if m := worldIdPattern.FindStringSubmatch(line); len(m) == 2 {
worldID = strings.TrimSpace(m[1])
}
if m := instanceIdPattern.FindStringSubmatch(line); len(m) == 2 {
instanceID = strings.TrimSpace(m[1])
}
if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 {
worldName = strings.TrimSpace(m[1])
}
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 && worldID == "" {
if worldMatch := worldPattern.FindStringSubmatch(m[1]); len(worldMatch) == 2 {
worldID = worldMatch[1]
}
}
nextLocation := ""
if worldID != "" {
nextLocation = worldID
if instanceID != "" {
nextLocation = worldID + ":" + instanceID
}
}
if nextLocation == "" && roomTitle != "" {
nextLocation = roomTitle
}
if nextLocation != "" && nextLocation != state.location {
state.location = nextLocation
state.worldID = worldID
state.instanceID = instanceID
if worldName != "" {
state.worldName = worldName
} else if roomTitle != "" {
state.worldName = roomTitle
}
label := state.worldName
if label == "" {
label = state.worldID
}
if label == "" {
label = nextLocation
}
state.initialized = true
return true, label
}
if worldName != "" {
state.worldName = worldName
state.pendingWorldName = ""
return false, ""
}
if roomTitle != "" {
state.worldName = roomTitle
}
return false, ""
}
func readRuntimeSnapshot() map[string]any {
p := filepath.Join(runtimeDir(), "state.json")
b, err := os.ReadFile(p)
if err != nil {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(b, &out); err != nil || out == nil {
return map[string]any{}
}
return out
}
func readFileTail(path string, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 {
return os.ReadFile(path)
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if info.Size() <= maxBytes {
return os.ReadFile(path)
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
if _, err := f.Seek(info.Size()-maxBytes, 0); err != nil {
return nil, err
}
b, err := io.ReadAll(f)
if err != nil {
return nil, err
}
for i, c := range b {
if c == '\n' && i+1 < len(b) {
return b[i+1:], nil
}
}
return b, nil
}
func currentWorldStartTime() time.Time {
p := filepath.Join(runtimeDir(), "runtime.log")
b, err := readFileTail(p, guiLogTailBytes)
if err != nil {
return time.Time{}
}
var latest time.Time
for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if !(strings.Contains(line, "VRC WORLD") ||
strings.Contains(line, "Joining or Creating Room") ||
strings.Contains(line, "world loaded") ||
strings.Contains(line, "Successfully joined room")) {
continue
}
if at, ok := parseRuntimeTime(line); ok && at.After(latest) {
latest = at
}
}
return latest
}
func parseJoinLeaveLine(line string) (time.Time, string, string) {
m := joinLeaveLinePattern.FindStringSubmatch(line)
if len(m) != 4 {
return time.Time{}, "", ""
}
at, err := time.Parse("2006-01-02 15:04:05", m[1])
if err != nil {
return time.Time{}, "", ""
}
return at, m[2], strings.TrimSpace(m[3])
}
func parseJoinLeaveCount(line string) int {
m := regexp.MustCompile(`\((\d+)\)$`).FindStringSubmatch(line)
if len(m) != 2 {
return 0
}
n, err := strconv.Atoi(m[1])
if err != nil {
return 0
}
return n
}
func parseRuntimeTime(line string) (time.Time, bool) {
m := runtimeTimePattern.FindStringSubmatch(line)
if len(m) != 2 {
return time.Time{}, false
}
at, err := time.Parse("2006-01-02 15:04:05", m[1])
if err != nil {
return time.Time{}, false
}
return at, true
}
func countPresent(items []userState) int {
n := 0
for _, item := range items {
if item.Present {
n++
}
}
return n
}
func timeAgo(at time.Time) string {
if at.IsZero() {
return ""
}
d := time.Since(at)
if d < time.Minute {
return "1\u5206\u524d"
}
if d < time.Hour {
return fmt.Sprintf("%d\u5206\u524d", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%d\u6642\u9593\u524d", int(d.Hours()))
}
return fmt.Sprintf("%d\u65e5\u524d", int(d.Hours()/24))
}