//go:build windows package main import ( "encoding/json" "fmt" "io" "os" "path/filepath" "regexp" "runtime" "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 const ( defaultGUIFontSize = 14 minGUIFontSize = 8 maxGUIFontSize = 30 ) 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 swMinimize uintptr = 6 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 { runtime.LockOSThread() defer runtime.UnlockOSThread() 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 = defaultGUIFontSize } 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("") exportDirRect := settingsHistoryExportDirRect() app.settingsExportDirEdit, _, _ = createWindowEx.Call( 0, uintptr(unsafe.Pointer(editClassTitle)), uintptr(unsafe.Pointer(exportDirTitle)), uintptr(wsChild|wsVisible|wsBorder|wsTabstop|esAutohscroll), uintptr(exportDirRect.Left), uintptr(exportDirRect.Top), uintptr(exportDirRect.Right-exportDirRect.Left), uintptr(exportDirRect.Bottom-exportDirRect.Top), app.settingsPaneHwnd, idHistoryExportDir, hInstance, 0, ) browseStyle := uintptr(wsChild | wsTabstop) browseRect := settingsHistoryExportBrowseRect() app.settingsExportBrowseBtn, _, _ = createWindowEx.Call( 0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(browseTitle)), browseStyle, uintptr(browseRect.Left), uintptr(browseRect.Top), uintptr(browseRect.Right-browseRect.Left), uintptr(browseRect.Bottom-browseRect.Top), app.settingsPaneHwnd, idHistoryExportBrowse, hInstance, 0, ) exportCustomTitle, _ := syscall.UTF16PtrFromString("") customRect := settingsHistoryExportCustomRect() app.settingsExportCustomEdit, _, _ = createWindowEx.Call( 0, uintptr(unsafe.Pointer(editClassTitle)), uintptr(unsafe.Pointer(exportCustomTitle)), uintptr(wsChild|wsVisible|wsBorder|wsTabstop|esAutohscroll), uintptr(customRect.Left), uintptr(customRect.Top), uintptr(customRect.Right-customRect.Left), uintptr(customRect.Bottom-customRect.Top), app.settingsPaneHwnd, idHistoryExportCustom, hInstance, 0, ) saveRect := settingsHistoryExportCustomSaveRect() app.settingsExportCustomSaveBtn, _, _ = createWindowEx.Call( 0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(saveTitle)), browseStyle, uintptr(saveRect.Left), uintptr(saveRect.Top), uintptr(saveRect.Right-saveRect.Left), uintptr(saveRect.Bottom-saveRect.Top), 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 > minGUIFontSize { app.fontSize -= 2 if app.fontSize < minGUIFontSize { app.fontSize = minGUIFontSize } 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 < maxGUIFontSize { app.fontSize += 2 if app.fontSize > maxGUIFontSize { app.fontSize = maxGUIFontSize } 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 } } switch { case pointInHeaderDot(x, y, width-92, 22): guiLog("window control minimize") if showWindowProc != nil { showWindowProc.Call(hwnd, swMinimize) } return 0 case pointInHeaderDot(x, y, width-62, 22): guiLog("window control topmost toggle") app.topMost = !app.topMost saveGUISettings(&app) applyTopMost(hwnd, &app) if invalidateRectProc != nil { invalidateRectProc.Call(hwnd, 0, 1) } return 0 case pointInHeaderDot(x, y, width-32, 22): guiLog("window control close") postQuitMessage.Call(0) 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 received") postQuitMessage.Call(0) 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 } if app.activeTab == "history" && !reloadData { syncPaneVisibility(app) 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 = defaultGUIFontSize } 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 = defaultGUIFontSize } 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 = defaultGUIFontSize } if lineCount <= 0 { lineCount = 1 } if availableHeight <= 0 { availableHeight = 300 } for size > minGUIFontSize { if joinPaneContentHeight(size, lineCount) <= availableHeight { break } size -= 2 } if size < minGUIFontSize { size = minGUIFontSize } 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 pointInHeaderDot(x, y, cx, cy int32) bool { return x >= cx-11 && x <= cx+11 && y >= cy-11 && y <= cy+11 } 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, 374, "履歴 Export", app.hFont, clrMutedText) drawPaneText(hdc, 24, 396, "出力先フォルダ", app.hFont, clrMutedText) 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, 516, "Custom 条件", app.hFont, clrMutedText) 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: 420, Right: 636, Bottom: 444} } func settingsHistoryExportBrowseRect() winRect { return winRect{Left: 644, Top: 420, Right: 736, Bottom: 444} } func settingsHistoryExportTimeRect() winRect { return winRect{Left: 24, Top: 452, Right: 360, Bottom: 482} } func settingsHistoryExportWorldRect() winRect { return winRect{Left: 376, Top: 452, Right: 736, Bottom: 482} } func settingsHistoryExportJoinLeaveRect() winRect { return winRect{Left: 24, Top: 488, Right: 360, Bottom: 518} } func settingsHistoryExportCustomToggleRect() winRect { return winRect{Left: 376, Top: 488, Right: 736, Bottom: 518} } func settingsHistoryExportCustomRect() winRect { return winRect{Left: 116, Top: 536, Right: 636, Bottom: 558} } func settingsHistoryExportCustomSaveRect() winRect { return winRect{Left: 644, Top: 536, Right: 736, Bottom: 558} } 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 > minGUIFontSize { app.fontSize -= 2 if app.fontSize < minGUIFontSize { app.fontSize = minGUIFontSize } app.hFont = createAppFont(app.fontSize) applyFont(app) saveGUISettings(app) } case pointInRect(x, y, settingsFontUpRect()): if app.fontSize < maxGUIFontSize { app.fontSize += 2 if app.fontSize > maxGUIFontSize { app.fontSize = maxGUIFontSize } 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 = defaultGUIFontSize } if presentCount <= 32 { return size } switch { case presentCount >= 40: size -= 6 case presentCount >= 36: size -= 4 case presentCount >= 33: size -= 2 } if size < minGUIFontSize { size = minGUIFontSize } 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: defaultGUIFontSize, RefreshIntervalValue: 2, RefreshIntervalUnit: "sec", HistoryExportIncludeTime: true, HistoryExportIncludeWorld: true, HistoryExportIncludeJoinLeave: true, } } if cfg.GUI.FontSize <= 0 { cfg.GUI.FontSize = defaultGUIFontSize } 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 = defaultGUIFontSize } 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 = defaultGUIFontSize } 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)) }