4581 lines
132 KiB
Go
4581 lines
132 KiB
Go
//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))
|
||
}
|