diff --git a/cmd/vrc_osc_gui/window_windows.go b/cmd/vrc_osc_gui/window_windows.go index edc1a4c..0a70f53 100644 --- a/cmd/vrc_osc_gui/window_windows.go +++ b/cmd/vrc_osc_gui/window_windows.go @@ -16,12 +16,15 @@ import ( "unsafe" "vrc_osc_go/internal/app" + "vrc_osc_go/internal/config" ) const ( wsOverlappedWindow = 0x00CF0000 wsVisible = 0x10000000 wsChild = 0x40000000 + wsPopup = 0x80000000 + wsClipChildren = 0x02000000 wsBorder = 0x00800000 esMultiline = 0x0004 esReadonly = 0x0800 @@ -31,6 +34,7 @@ const ( wmDestroyGUI = 0x0002 wmTimerGUI = 0x0113 wmCommandGUI = 0x0111 + wmSetFont = 0x0030 idStatus = 2001 idLog = 2002 timerRefresh = 1 @@ -38,22 +42,57 @@ const ( 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 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 hwndTopMostFlag uintptr = ^uintptr(0) - 1 +var hwndNoMove uintptr = 0x0002 +var hwndNoSize uintptr = 0x0001 +var hwndNoActivate uintptr = 0x0010 +var hwndShowWindow uintptr = 0x0040 +var swRestore uintptr = 9 +var swHideControl uintptr = 0 +var swShowControl uintptr = 5 +var swpVisibleFlags uintptr = hwndNoActivate | hwndShowWindow +var swpFlags uintptr = hwndNoMove | hwndNoSize | hwndNoActivate +var swpShowFlags uintptr = hwndNoMove | hwndNoSize | hwndShowWindow +var smXVirtualScreen int32 = 76 +var smYVirtualScreen int32 = 77 +var smCXVirtualScreen int32 = 78 +var smCYVirtualScreen int32 = 79 +var setWindowPosProc *syscall.LazyProc +var sendMessageProc *syscall.LazyProc +var createFontProc *syscall.LazyProc +var deleteObjectProc *syscall.LazyProc +var showWindowProc *syscall.LazyProc type guiApp struct { - hwnd uintptr - leftHwnd uintptr - rightHwnd uintptr - tabJoin uintptr - tabDiscord uintptr - tabTranslate uintptr - activeTab string - lastInstanceCount int + hwnd uintptr + leftHwnd uintptr + rightHwnd uintptr + settingsPaneHwnd uintptr + tabJoin uintptr + tabTranslate uintptr + tabSettings uintptr + btnActiveWindow uintptr + btnTopMost uintptr + btnFontDown uintptr + btnFontUp uintptr + activeTab string + activeWindow bool + lastActiveWindow bool + topMost bool + lastTopMost bool + fontSize int + hFont uintptr + lastSettingsAction time.Time + lastInstanceCount int } type userState struct { - Name string - Present bool - LastJoin time.Time + Name string + Present bool + LastJoin time.Time LastLeave time.Time } @@ -72,18 +111,30 @@ func guiLog(text string) { func runNativeGUI() error { user32 := syscall.NewLazyDLL("user32.dll") kernel32 := syscall.NewLazyDLL("kernel32.dll") + gdi32 := syscall.NewLazyDLL("gdi32.dll") registerClass := user32.NewProc("RegisterClassW") createWindowEx := user32.NewProc("CreateWindowExW") defWindowProc := user32.NewProc("DefWindowProcW") - showWindow := user32.NewProc("ShowWindow") + showWindowProc = user32.NewProc("ShowWindow") updateWindow := user32.NewProc("UpdateWindow") + setWindowPosProc = user32.NewProc("SetWindowPos") + sendMessageProc = user32.NewProc("SendMessageW") + createFontProc = gdi32.NewProc("CreateFontW") + deleteObjectProc = gdi32.NewProc("DeleteObject") getMessage := user32.NewProc("GetMessageW") translateMessage := user32.NewProc("TranslateMessage") dispatchMessage := user32.NewProc("DispatchMessageW") postQuitMessage := user32.NewProc("PostQuitMessage") + setForegroundWindow := user32.NewProc("SetForegroundWindow") + bringWindowToTop := user32.NewProc("BringWindowToTop") setTimer := user32.NewProc("SetTimer") loadCursor := user32.NewProc("LoadCursorW") setWindowText := user32.NewProc("SetWindowTextW") + getWindowRect := user32.NewProc("GetWindowRect") + isWindowVisible := user32.NewProc("IsWindowVisible") + isIconic := user32.NewProc("IsIconic") + getForegroundWindow := user32.NewProc("GetForegroundWindow") + getSystemMetrics := user32.NewProc("GetSystemMetrics") getModuleHandle := kernel32.NewProc("GetModuleHandleW") loadIcon := user32.NewProc("LoadIconW") @@ -116,39 +167,108 @@ func runNativeGUI() error { guiLog("stage=prepare window") var app guiApp + guiCfg := loadGUISettings() + app.activeWindow = guiCfg.ActiveWindow + app.topMost = guiCfg.TopMost + if guiCfg.FontSize > 0 { + app.fontSize = guiCfg.FontSize + } else { + app.fontSize = 18 + } + app.hFont = createAppFont(app.fontSize) wndProc := syscall.NewCallback(func(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr { switch message { case wmCreateGUI: buttonClass, _ := syscall.UTF16PtrFromString("BUTTON") - leftClass, _ := syscall.UTF16PtrFromString("EDIT") - rightClass, _ := syscall.UTF16PtrFromString("EDIT") + leftClass, _ := syscall.UTF16PtrFromString("STATIC") + rightClass, _ := syscall.UTF16PtrFromString("STATIC") leftTitle, _ := syscall.UTF16PtrFromString("") rightTitle, _ := syscall.UTF16PtrFromString("") joinTitle, _ := syscall.UTF16PtrFromString("Join Log") - discordTitle, _ := syscall.UTF16PtrFromString("Discord") translateTitle, _ := syscall.UTF16PtrFromString("Translate") + settingsTitle, _ := syscall.UTF16PtrFromString("Settings") + activeWindowTitle, _ := syscall.UTF16PtrFromString("Active window") + topMostTitle, _ := syscall.UTF16PtrFromString("Top most") + fontDownTitle, _ := syscall.UTF16PtrFromString("A-") + fontUpTitle, _ := syscall.UTF16PtrFromString("A+") app.tabJoin, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(joinTitle)), wsVisible|wsChild, 370, 20, 90, 28, hwnd, 3001, hInstance, 0) - app.tabDiscord, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(discordTitle)), wsVisible|wsChild, 470, 20, 90, 28, hwnd, 3002, hInstance, 0) - app.tabTranslate, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(translateTitle)), wsVisible|wsChild, 570, 20, 90, 28, hwnd, 3003, hInstance, 0) - app.leftHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(leftClass)), uintptr(unsafe.Pointer(leftTitle)), wsVisible|wsChild|wsBorder|esMultiline|esReadonly|esAutovscroll|esAutohscroll, 20, 20, 330, 300, hwnd, idStatus, hInstance, 0) - app.rightHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(rightTitle)), wsVisible|wsChild|wsBorder|esMultiline|esReadonly|esAutovscroll|esAutohscroll, 370, 60, 330, 260, hwnd, idLog, hInstance, 0) + app.tabTranslate, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(translateTitle)), wsVisible|wsChild, 470, 20, 90, 28, hwnd, 3002, hInstance, 0) + app.tabSettings, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(settingsTitle)), wsVisible|wsChild, 570, 20, 90, 28, hwnd, 3003, hInstance, 0) + app.leftHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(leftClass)), uintptr(unsafe.Pointer(leftTitle)), wsVisible|wsChild, 20, 20, 330, 300, hwnd, idStatus, hInstance, 0) + app.rightHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(rightTitle)), wsVisible|wsChild|wsClipChildren, 370, 60, 330, 260, hwnd, idLog, hInstance, 0) + settingsPaneTitle, _ := syscall.UTF16PtrFromString("") + app.settingsPaneHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(settingsPaneTitle)), wsVisible|wsChild|wsClipChildren, 370, 60, 330, 260, hwnd, idLog+1, hInstance, 0) + app.btnActiveWindow, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(activeWindowTitle)), wsVisible|wsChild, 390, 90, 130, 30, hwnd, 3004, hInstance, 0) + app.btnTopMost, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(topMostTitle)), wsVisible|wsChild, 390, 130, 120, 30, hwnd, 3005, hInstance, 0) + app.btnFontDown, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontDownTitle)), wsVisible|wsChild, 390, 175, 50, 30, hwnd, 3006, hInstance, 0) + app.btnFontUp, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontUpTitle)), wsVisible|wsChild, 445, 175, 50, 30, hwnd, 3007, hInstance, 0) app.activeTab = "join" + app.activeWindow = true + app.lastActiveWindow = false setTimer.Call(hwnd, timerRefresh, 2000, 0) + applyFont(&app) refreshGUI(setWindowText, &app) + applyActiveWindow(hwnd, &app, showWindowProc, setForegroundWindow, bringWindowToTop, setWindowPosProc, getForegroundWindow, getSystemMetrics, getWindowRect, isWindowVisible, isIconic) + ensureWindowVisible("startup", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics) + logWindowState("after-ensure", hwnd, getWindowRect, isWindowVisible, isIconic) return 0 case wmCommandGUI: switch uint16(wParam & 0xffff) { case 3001: app.activeTab = "join" case 3002: - app.activeTab = "discord" - case 3003: app.activeTab = "translate" + case 3003: + app.activeTab = "settings" + case 3004: + if !allowRapidSettingsAction(&app) { + return 0 + } + app.activeWindow = !app.activeWindow + saveGUISettings(&app) + applyActiveWindow(hwnd, &app, showWindowProc, setForegroundWindow, bringWindowToTop, setWindowPosProc, getForegroundWindow, getSystemMetrics, getWindowRect, isWindowVisible, isIconic) + case 3005: + if !allowRapidSettingsAction(&app) { + return 0 + } + app.topMost = !app.topMost + saveGUISettings(&app) + applyTopMost(hwnd, &app) + case 3006: + if !allowRapidSettingsAction(&app) { + return 0 + } + if app.fontSize > 10 { + app.fontSize -= 2 + oldFont := app.hFont + app.hFont = createAppFont(app.fontSize) + applyFont(&app) + if oldFont != 0 && deleteObjectProc != nil { + deleteObjectProc.Call(oldFont) + } + saveGUISettings(&app) + } + case 3007: + if !allowRapidSettingsAction(&app) { + return 0 + } + if app.fontSize < 30 { + app.fontSize += 2 + oldFont := app.hFont + app.hFont = createAppFont(app.fontSize) + applyFont(&app) + if oldFont != 0 && deleteObjectProc != nil { + deleteObjectProc.Call(oldFont) + } + saveGUISettings(&app) + } } refreshGUI(setWindowText, &app) + refreshSettingsControls(showWindowProc, setWindowPosProc, &app) return 0 case wmTimerGUI: refreshGUI(setWindowText, &app) + refreshSettingsControls(showWindowProc, setWindowPosProc, &app) return 0 case wmDestroyGUI: postQuitMessage.Call(0) @@ -174,15 +294,20 @@ func runNativeGUI() error { } guiLog("stage=create window") - app.hwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), wsOverlappedWindow, 200, 120, 760, 380, 0, 0, hInstance, 0) + app.hwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), wsOverlappedWindow|wsVisible, 200, 120, 760, 380, 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") - showWindow.Call(app.hwnd, 5) + prevVisible, _, _ := showWindowProc.Call(app.hwnd, 1) + guiLog(fmt.Sprintf("stage=show window ret=%d", prevVisible)) updateWindow.Call(app.hwnd) + ensureWindowVisible("startup", app.hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics) + logWindowState("after-show", app.hwnd, getWindowRect, isWindowVisible, isIconic) guiLog("stage=message loop") var m msg @@ -197,8 +322,38 @@ func runNativeGUI() error { 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) { current, instanceCount := currentUsersFromJoinLeave() + guiLog(fmt.Sprintf("refreshGUI activeTab=%s current=%d", app.activeTab, instanceCount)) if instanceCount == 0 && app.lastInstanceCount > 0 { instanceCount = app.lastInstanceCount } @@ -209,59 +364,264 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) { t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current)) setWindowText.Call(app.leftHwnd, uintptr(unsafe.Pointer(t))) } + if app.btnActiveWindow != 0 { + label := "Active window: OFF" + if app.activeWindow { + label = "Active window: ON" + } + t, _ := syscall.UTF16PtrFromString(label) + setWindowText.Call(app.btnActiveWindow, uintptr(unsafe.Pointer(t))) + } + 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.activeTab == "settings" { + if app.rightHwnd != 0 { + showWindowProc.Call(app.rightHwnd, swHideControl) + } + if app.settingsPaneHwnd != 0 { + showWindowProc.Call(app.settingsPaneHwnd, swShowControl) + setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags) + } + for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} { + if hwnd != 0 { + showWindowProc.Call(hwnd, swShowControl) + } + } + return + } if app.rightHwnd != 0 { - t, _ := syscall.UTF16PtrFromString(formatRightPane(app.activeTab, readRuntimeSnapshot(), currentWorldLabel(), instanceCount)) + state := readRuntimeSnapshot() + state["active_window"] = app.activeWindow + state["top_most"] = app.topMost + state["font_size"] = app.fontSize + rightText := formatRightPane(app.activeTab, state, currentWorldLabel(), instanceCount) + showWindowProc.Call(app.rightHwnd, swShowControl) + t, _ := syscall.UTF16PtrFromString(rightText) setWindowText.Call(app.rightHwnd, uintptr(unsafe.Pointer(t))) } + if app.settingsPaneHwnd != 0 { + showWindowProc.Call(app.settingsPaneHwnd, swHideControl) + } + for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} { + if hwnd != 0 { + showWindowProc.Call(hwnd, swHideControl) + } + } } -func currentUsersFromJoinLeave() ([]userState, int) { - since := currentWorldStartTime() - users := map[string]*userState{} - lastLoggedCount := 0 - for _, s := range readGuestSnapshot() { - if !s.Present { - continue +func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, app *guiApp) { + if showWindowProc == nil || setWindowPosProc == nil || app == nil { + return + } + if app.activeTab == "settings" { + if app.settingsPaneHwnd != 0 { + showWindowProc.Call(app.settingsPaneHwnd, swShowControl) + setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags) } - cp := s - users[s.Name] = &userState{ - Name: s.Name, - Present: true, - LastJoin: cp.LastJoin, + for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} { + if hwnd != 0 { + showWindowProc.Call(hwnd, swShowControl) + } + } + guiLog("refreshSettingsControls visible") + return + } + if app.settingsPaneHwnd != 0 { + showWindowProc.Call(app.settingsPaneHwnd, swHideControl) + } + for _, hwnd := range []uintptr{app.btnActiveWindow, app.btnTopMost, app.btnFontDown, app.btnFontUp} { + if hwnd != 0 { + showWindowProc.Call(hwnd, swHideControl) } } + guiLog("refreshSettingsControls hidden") +} - p := filepath.Join(runtimeDir(), "join_leave.log") - b, err := os.ReadFile(p) - if err != nil { - out := make([]userState, 0, len(users)) - for _, u := range users { - out = append(out, *u) +func applyActiveWindow(hwnd uintptr, app *guiApp, showWindowProc, setForegroundWindow, bringWindowToTop, setWindowPosProc, getForegroundWindow, getSystemMetrics, getWindowRect, isWindowVisible, isIconic *syscall.LazyProc) { + if hwnd == 0 || app.activeWindow == app.lastActiveWindow { + return + } + if app.activeWindow { + showWindowProc.Call(hwnd, swRestore) + setWindowPosProc.Call(hwnd, 0, 0, 0, 0, 0, swpShowFlags) + bringWindowToTop.Call(hwnd) + setForegroundWindow.Call(hwnd) + foreground, _, _ := getForegroundWindow.Call() + guiLog(fmt.Sprintf("applyActiveWindow on foreground=%d target=%d", foreground, hwnd)) + ensureWindowVisible("active-window", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics) + } else { + guiLog("applyActiveWindow off") + } + app.lastActiveWindow = app.activeWindow +} + +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 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, 0, 0, 0, 0, 0, swpFlags) + } + app.lastTopMost = app.topMost +} + +func createAppFont(size int) uintptr { + if createFontProc == nil { + return 0 + } + h, _, _ := createFontProc.Call( + uintptr(-size), 0, 0, 0, 400, 0, 0, 0, + 128, 0, 0, 0, 0, + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Yu Gothic UI"))), + ) + return h +} + +func applyFont(app *guiApp) { + if sendMessageProc == nil || app.hFont == 0 { + return + } + targets := []uintptr{app.leftHwnd, app.rightHwnd, app.tabJoin, app.tabTranslate, app.tabSettings} + for _, hwnd := range targets { + if hwnd == 0 { + continue + } + sendMessageProc.Call(hwnd, wmSetFont, app.hFont, 1) + } +} + +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 currentUsersFromJoinLeave() ([]userState, int) { + if snap := readGuestSnapshot(); len(snap) > 0 { + 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 } - return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + 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) }) + guiLog(fmt.Sprintf("currentUsers source=snapshot count=%d total=%d", countPresent(out), len(out))) return out, countPresent(out) } - for _, raw := range strings.Split(string(b), "\n") { + if out, ok := currentUsersFromJoinLeaveLog(); ok { + guiLog(fmt.Sprintf("currentUsers source=join_leave 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 currentUsersFromJoinLeaveLog() ([]userState, bool) { + p := filepath.Join(runtimeDir(), "join_leave.log") + b, err := os.ReadFile(p) + if err != nil || len(b) == 0 { + return nil, false + } + users := map[string]*userState{} + lines := strings.Split(string(b), "\n") + for _, raw := range lines { line := strings.TrimSpace(raw) - if line == "" { + if line == "" || strings.HasPrefix(line, "VRC JOIN/LEAVE") { continue } - if c := parseJoinLeaveCount(line); c > 0 { - lastLoggedCount = c - } at, kind, name := parseJoinLeaveLine(line) if name == "" { continue } - if !since.IsZero() && at.Before(since) { - continue - } u := users[name] if u == nil { u = &userState{Name: name} @@ -270,39 +630,74 @@ func currentUsersFromJoinLeave() ([]userState, int) { switch kind { case "join": u.Present = true - if at.After(u.LastJoin) { + if !at.IsZero() { u.LastJoin = at } case "leave": u.Present = false - if at.After(u.LastLeave) { + if !at.IsZero() { u.LastLeave = at } } } + return sortUserStates(users), len(users) > 0 +} - // Keep any snapshot-marked present users that the join/leave delta can't rebuild. - for _, snap := range readGuestSnapshot() { - if !snap.Present { +func currentUsersFromVRChatLog() ([]userState, bool) { + logPath, err := findLatestVRChatLog() + if err != nil { + return nil, false + } + b, err := os.ReadFile(logPath) + if err != nil || len(b) == 0 { + return nil, false + } + text := app.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 } - u := users[snap.Name] - if u == nil { - users[snap.Name] = &userState{ - Name: snap.Name, - Present: true, - LastJoin: snap.LastJoin, + 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 !u.Present { - u.Present = true - if u.LastJoin.IsZero() { - u.LastJoin = snap.LastJoin + 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 sortUserStates(users map[string]*userState) []userState { out := make([]userState, 0, len(users)) for _, u := range users { out = append(out, *u) @@ -311,13 +706,55 @@ func currentUsersFromJoinLeave() ([]userState, int) { if out[i].Present != out[j].Present { return out[i].Present } - return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + 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) }) - present := countPresent(out) - if lastLoggedCount > present { - present = lastLoggedCount + return out +} + +func parseLogTime(line string) time.Time { + m := vrcLogTimePattern.FindStringSubmatch(line) + if len(m) != 2 { + return time.Time{} } - return out, present + 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.ModTime().After(latestMod) { + latestMod = info.ModTime() + latest = filepath.Join(dir, name) + } + } + if latest == "" { + return "", os.ErrNotExist + } + return latest, nil } func readGuestSnapshot() []app.GuestStatus { @@ -335,6 +772,9 @@ func readGuestSnapshot() []app.GuestStatus { func formatGuestPane(title string, items []userState) string { var b strings.Builder + selfName := currentSelfName() + var selfJoin string + guiLog("formatGuestPane start") b.WriteString(title) b.WriteString("\r\n") b.WriteString(strings.Repeat("=", len(title))) @@ -348,21 +788,113 @@ func formatGuestPane(title string, items []userState) string { b.WriteString("Current in room: ") b.WriteString(strconv.Itoa(countPresent(items))) b.WriteString("\r\n\r\n") + present := make([]userState, 0, len(items)) + left := make([]userState, 0, len(items)) for _, item := range items { - b.WriteString("・ ") - b.WriteString(item.Name) if item.Present { - b.WriteString(" [present]") + present = append(present, item) } else { + left = append(left, item) + } + if selfName != "" && strings.EqualFold(strings.TrimSpace(item.Name), selfName) && !item.LastJoin.IsZero() { + selfJoin = item.LastJoin.Format("15:04:05") + } + } + if selfJoin != "" { + b.WriteString("You joined at ") + b.WriteString(selfJoin) + b.WriteString(" (self=") + b.WriteString(selfName) + b.WriteString(")") + b.WriteString("\r\n\r\n") + } + if len(present) > 0 { + b.WriteString("Present now") + b.WriteString("\r\n") + for _, item := range present { + b.WriteString("・ ") + b.WriteString(item.Name) + b.WriteString(" [present]") + if !item.LastJoin.IsZero() { + b.WriteString(" ") + b.WriteString(item.LastJoin.Format("15:04:05")) + } + b.WriteString("\r\n") + } + b.WriteString("\r\n") + } + if len(left) > 0 { + b.WriteString("Left already") + b.WriteString("\r\n") + sort.SliceStable(left, func(i, j int) bool { + if left[i].LastLeave.Equal(left[j].LastLeave) { + return strings.ToLower(left[i].Name) < strings.ToLower(left[j].Name) + } + return left[i].LastLeave.After(left[j].LastLeave) + }) + for _, item := range left { + b.WriteString("・ ") + b.WriteString(item.Name) b.WriteString(" [gray]") if !item.LastLeave.IsZero() { b.WriteString(" ") b.WriteString(timeAgo(item.LastLeave)) } + b.WriteString("\r\n") } - b.WriteString("\r\n") } - return strings.TrimRight(b.String(), "\r\n") + text := strings.TrimRight(b.String(), "\r\n") + guiLog("formatGuestPane done") + if err := app.AppendDesktopJoinLog(title, currentWorldLabel(), text); err != nil { + _ = app.AppendRuntimeLog("GUI JOIN LOG WRITE FAIL", err.Error()) + } else { + _ = app.AppendRuntimeLog("GUI JOIN LOG WRITE OK", "written desktop join.txt") + } + 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{ActiveWindow: true, FontSize: 18} + } + if cfg.GUI.FontSize <= 0 { + cfg.GUI.FontSize = 18 + } + return cfg.GUI +} + +func saveGUISettings(app *guiApp) { + if app == nil { + return + } + guiCfg := config.GUIConfig{ + ActiveWindow: app.activeWindow, + TopMost: app.topMost, + FontSize: app.fontSize, + } + if err := config.SaveGUI("", guiCfg); err != nil { + guiLog("saveGUISettings failed: " + err.Error()) + return + } + guiLog("saveGUISettings ok") } func formatRightPane(tab string, state map[string]any, worldLabel string, currentUsers int) string { @@ -372,52 +904,86 @@ func formatRightPane(tab string, state map[string]any, worldLabel string, curren b.WriteString("Discord") case "translate": b.WriteString("Translate") + case "settings": + b.WriteString("Settings") default: b.WriteString("Current World") } b.WriteString("\r\n") titleLine := "Current World" switch tab { - case "discord": - titleLine = "Discord" case "translate": titleLine = "Translate" + case "settings": + titleLine = "Settings" + case "discord": + titleLine = "Discord" } b.WriteString(strings.Repeat("=", len(titleLine))) b.WriteString("\r\n") - discordMuted, _ := state["discord_muted"].(bool) ocr, _ := state["ocr"].(string) translate, _ := state["translate"].(string) + fontSize, _ := state["font_size"].(int) switch tab { - case "discord": - b.WriteString("Muted: ") - b.WriteString(fmt.Sprintf("%v", discordMuted)) - b.WriteString("\r\nWorld: ") - b.WriteString(worldLabel) - b.WriteString("\r\nUsers: ") - b.WriteString(fmt.Sprintf("%d", currentUsers)) case "translate": b.WriteString("Translate: ") - b.WriteString(translate) + if strings.TrimSpace(translate) == "" { + b.WriteString("OFF") + } else { + b.WriteString("ON") + } b.WriteString("\r\nOCR: ") - b.WriteString(ocr) + if strings.TrimSpace(ocr) == "" { + b.WriteString("OFF") + } else { + b.WriteString("ON") + } b.WriteString("\r\nWorld: ") b.WriteString(worldLabel) + case "settings": + b.WriteString("Active window: ") + if stateBool(state, "active_window") { + b.WriteString("ON") + } else { + b.WriteString("OFF") + } + b.WriteString("\r\nTop most: ") + if stateBool(state, "top_most") { + b.WriteString("ON") + } else { + b.WriteString("OFF") + } + b.WriteString("\r\nFont size: ") + if fontSize == 0 { + fontSize = 18 + } + b.WriteString(strconv.Itoa(fontSize)) default: b.WriteString("World: ") b.WriteString(worldLabel) b.WriteString("\r\nUsers: ") b.WriteString(fmt.Sprintf("%d", currentUsers)) - b.WriteString("\r\nDiscord muted: ") - b.WriteString(fmt.Sprintf("%v", discordMuted)) b.WriteString("\r\nOCR: ") - b.WriteString(ocr) + if strings.TrimSpace(ocr) == "" { + b.WriteString("OFF") + } else { + b.WriteString("ON") + } b.WriteString("\r\nTranslate: ") - b.WriteString(translate) + if strings.TrimSpace(translate) == "" { + b.WriteString("OFF") + } else { + b.WriteString("ON") + } } return b.String() } +func stateBool(state map[string]any, key string) bool { + v, _ := state[key].(bool) + return v +} + func currentWorldLabel() string { p := filepath.Join(runtimeDir(), "runtime.log") b, err := os.ReadFile(p) @@ -457,7 +1023,13 @@ func currentWorldStartTime() time.Time { var latest time.Time for _, raw := range strings.Split(string(b), "\n") { line := strings.TrimSpace(raw) - if line == "" || !strings.Contains(line, "VRC WORLD") { + 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) {