diff --git a/cmd/vrc_osc_gui/history_view_windows.go b/cmd/vrc_osc_gui/history_view_windows.go new file mode 100644 index 0000000..301a771 --- /dev/null +++ b/cmd/vrc_osc_gui/history_view_windows.go @@ -0,0 +1,779 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "syscall" + "time" + "unsafe" +) + +type historyCalendarCell struct { + Date time.Time + Rect winRect + Count int + Selected bool + InRange bool +} + +type historyDetailRow struct { + Key string + Rect winRect + WorldLabel string + Start time.Time + End time.Time + Users []string + Lines []string + Selected bool + Current bool +} + +type historyEventRecord struct { + At time.Time + Kind string + Name string + Raw string +} + +type historyVisitRecord struct { + Key string + WorldLabel string + WorldID string + InstanceID string + Start time.Time + End time.Time + Current bool +} + +type historyQuery struct { + Pattern *regexp.Regexp + From time.Time + To time.Time + HasFrom bool + HasTo bool +} + +func ensureHistoryState(app *guiApp) { + if app == nil { + return + } + if app.historySelectedKeys == nil { + app.historySelectedKeys = map[string]bool{} + } + if app.historySelectedDate.IsZero() { + if latest := latestHistoryDate(); !latest.IsZero() { + app.historySelectedDate = latest + } else { + app.historySelectedDate = time.Now() + } + } + if app.historyMonth.IsZero() { + app.historyMonth = time.Date(app.historySelectedDate.Year(), app.historySelectedDate.Month(), 1, 0, 0, 0, 0, app.historySelectedDate.Location()) + } + app.historySelectedDate = clampHistoryDate(app.historySelectedDate, historyQueryFromApp(app)) + app.historyMonth = time.Date(app.historySelectedDate.Year(), app.historySelectedDate.Month(), 1, 0, 0, 0, 0, app.historySelectedDate.Location()) +} + +func historyQueryFromApp(app *guiApp) historyQuery { + q := historyQuery{} + if app == nil { + return q + } + if s := strings.TrimSpace(app.historyRegex); s != "" { + if re, err := regexp.Compile(s); err == nil { + q.Pattern = re + } + } + if s := strings.TrimSpace(app.historyFromDate); s != "" { + if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { + q.From = t + q.HasFrom = true + } + } + if s := strings.TrimSpace(app.historyToDate); s != "" { + if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { + q.To = t.AddDate(0, 0, 1) + q.HasTo = true + } + } + return q +} + +func historyInQueryRange(day time.Time, q historyQuery) bool { + start, end := historyDayBounds(day) + if q.HasFrom && end.Before(q.From) { + return false + } + if q.HasTo && !start.Before(q.To) { + return false + } + return true +} + +func historyDayBounds(day time.Time) (time.Time, time.Time) { + loc := day.Location() + if loc == nil { + loc = time.Local + } + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) + return start, start.AddDate(0, 0, 1) +} + +func clampHistoryDate(day time.Time, q historyQuery) time.Time { + if day.IsZero() { + return day + } + start, end := historyDayBounds(day) + if q.HasFrom && start.Before(q.From) { + return q.From + } + if q.HasTo && !start.Before(q.To) { + return q.To.AddDate(0, 0, -1) + } + if q.HasFrom && q.HasTo { + if end.Before(q.From) { + return q.From + } + } + return day +} + +func latestHistoryDate() time.Time { + visits := historyVisitSource() + var latest time.Time + for _, v := range visits { + at := v.End + if at.IsZero() { + at = v.Start + } + if at.After(latest) { + latest = at + } + } + if latest.IsZero() { + if world, since, ok := currentWorldVisitInfo(); ok && strings.TrimSpace(world) != "" && !since.IsZero() { + return since + } + } + return latest +} + +func historyVisitSource() []historyVisitRecord { + records := make([]historyVisitRecord, 0, len(readWorldHistory())+1) + for _, visit := range readWorldHistory() { + records = append(records, historyVisitRecord{ + Key: historyVisitKey(visit.WorldLabel, visit.StartedAt, visit.EndedAt, visit.WorldID, visit.InstanceID, false), + WorldLabel: visit.WorldLabel, + WorldID: visit.WorldID, + InstanceID: visit.InstanceID, + Start: visit.StartedAt, + End: visit.EndedAt, + }) + } + if label, since, ok := currentWorldVisitInfo(); ok && strings.TrimSpace(label) != "" && !since.IsZero() { + records = append(records, historyVisitRecord{ + Key: historyVisitKey(label, since, time.Time{}, "", "", true), + WorldLabel: label, + Start: since, + Current: true, + }) + } + sort.SliceStable(records, func(i, j int) bool { + ti := records[i].End + if ti.IsZero() { + ti = records[i].Start + } + tj := records[j].End + if tj.IsZero() { + tj = records[j].Start + } + if ti.Equal(tj) { + return strings.ToLower(records[i].WorldLabel) < strings.ToLower(records[j].WorldLabel) + } + return ti.After(tj) + }) + return records +} + +func historyVisitKey(label string, start, end time.Time, worldID, instanceID string, current bool) string { + key := strings.TrimSpace(label) + if worldID != "" { + key = worldID + if instanceID != "" { + key += ":" + instanceID + } + } + if !start.IsZero() { + key += "|" + start.UTC().Format(time.RFC3339Nano) + } + if !end.IsZero() { + key += "|" + end.UTC().Format(time.RFC3339Nano) + } + if current { + key += "|current" + } + return key +} + +func historyEventsFromLog() []historyEventRecord { + p := filepath.Join(runtimeDir(), "join_leave.log") + b, err := os.ReadFile(p) + if err != nil || len(b) == 0 { + return nil + } + out := make([]historyEventRecord, 0, 128) + 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 + } + out = append(out, historyEventRecord{ + At: currentAt, + Kind: m[1], + Name: strings.TrimSpace(m[2]), + Raw: line, + }) + } + return out +} + +func historyVisitsForDay(app *guiApp, day time.Time) []historyDetailRow { + q := historyQueryFromApp(app) + if !historyInQueryRange(day, q) { + return nil + } + dayStart, dayEnd := historyDayBounds(day) + events := historyEventsFromLog() + visits := historyVisitSource() + rows := make([]historyDetailRow, 0, len(visits)) + for _, visit := range visits { + visitStart := visit.Start + if visitStart.IsZero() { + continue + } + visitEnd := visit.End + if visitEnd.IsZero() { + visitEnd = time.Now() + } + if !visitStart.Before(dayEnd) || !visitEnd.After(dayStart) { + continue + } + segmentStart := visitStart + if segmentStart.Before(dayStart) { + segmentStart = dayStart + } + segmentEnd := visitEnd + if segmentEnd.After(dayEnd) { + segmentEnd = dayEnd + } + lines, users, matched := historyVisitLinesForSegment(visit.WorldLabel, segmentStart, segmentEnd, events, q) + if q.Pattern != nil && !matched { + continue + } + rows = append(rows, historyDetailRow{ + Key: visit.Key, + WorldLabel: visit.WorldLabel, + Start: segmentStart, + End: segmentEnd, + Users: users, + Lines: lines, + Current: visit.Current, + }) + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].Start.Equal(rows[j].Start) { + return strings.ToLower(rows[i].WorldLabel) < strings.ToLower(rows[j].WorldLabel) + } + return rows[i].Start.Before(rows[j].Start) + }) + return rows +} + +func historyVisitLinesForSegment(worldLabel string, start, end time.Time, events []historyEventRecord, q historyQuery) ([]string, []string, bool) { + if end.Before(start) { + end = start + } + lines := make([]string, 0, 16) + users := make([]string, 0, 8) + seenUsers := map[string]struct{}{} + matched := false + if q.Pattern != nil && q.Pattern.MatchString(worldLabel) { + matched = true + } + for _, ev := range events { + if ev.At.IsZero() || ev.At.Before(start) || !ev.At.Before(end) { + continue + } + raw := fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name) + if q.Pattern != nil { + if !(q.Pattern.MatchString(worldLabel) || q.Pattern.MatchString(ev.Name) || q.Pattern.MatchString(raw)) { + continue + } + } + matched = true + lines = append(lines, raw) + if _, ok := seenUsers[ev.Name]; !ok { + seenUsers[ev.Name] = struct{}{} + users = append(users, ev.Name) + } + } + if q.Pattern == nil { + matched = true + } + sort.Strings(users) + return lines, users, matched +} + +func historyCalendarCellsForMonth(app *guiApp) []historyCalendarCell { + ensureHistoryState(app) + q := historyQueryFromApp(app) + monthStart := app.historyMonth + if monthStart.IsZero() { + monthStart = time.Now() + } + monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) + counts := map[string]int{} + daysInMonth := monthStart.AddDate(0, 1, -1).Day() + for day := 1; day <= daysInMonth; day++ { + date := time.Date(monthStart.Year(), monthStart.Month(), day, 0, 0, 0, 0, monthStart.Location()) + counts[date.Format("2006-01-02")] = len(historyVisitsForDay(app, date)) + } + firstWeekday := int(monthStart.Weekday()) + out := make([]historyCalendarCell, 0, 42) + for i := 0; i < firstWeekday; i++ { + out = append(out, historyCalendarCell{}) + } + for day := 1; day <= daysInMonth; day++ { + date := time.Date(monthStart.Year(), monthStart.Month(), day, 0, 0, 0, 0, monthStart.Location()) + cell := historyCalendarCell{ + Date: date, + Count: counts[date.Format("2006-01-02")], + InRange: historyInQueryRange(date, q), + } + if sameDay(date, app.historySelectedDate) { + cell.Selected = true + } + out = append(out, cell) + } + for len(out)%7 != 0 { + out = append(out, historyCalendarCell{}) + } + return out +} + +func sameDay(a, b time.Time) bool { + if a.IsZero() || b.IsZero() { + return false + } + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd +} + +func exportHistoryDay(app *guiApp, day time.Time, selected map[string]bool) (string, error) { + rows := historyVisitsForDay(app, day) + if len(rows) == 0 { + return "", nil + } + if len(selected) > 0 { + filtered := rows[:0] + for _, row := range rows { + if selected[row.Key] { + filtered = append(filtered, row) + } + } + rows = append([]historyDetailRow(nil), filtered...) + } + if len(rows) == 0 { + return "", nil + } + var b strings.Builder + b.WriteString("Date: ") + b.WriteString(day.Format("2006-01-02")) + b.WriteString("\n") + if q := historyQueryFromApp(app); q.Pattern != nil { + b.WriteString("Filter: ") + b.WriteString(strings.TrimSpace(app.historyRegex)) + b.WriteString("\n") + } + b.WriteString("\n") + for _, row := range rows { + b.WriteString("\nWorld: ") + b.WriteString(strings.TrimSpace(row.WorldLabel)) + b.WriteString("\n") + b.WriteString("Time: ") + b.WriteString(row.Start.Format("15:04")) + b.WriteString(" - ") + b.WriteString(row.End.Format("15:04")) + b.WriteString("\n") + if len(row.Users) > 0 { + b.WriteString("Users: ") + b.WriteString(strings.Join(row.Users, ", ")) + b.WriteString("\n") + } + if len(row.Lines) == 0 { + b.WriteString("(no matches)\n") + continue + } + for _, line := range row.Lines { + b.WriteString(line) + b.WriteString("\n") + } + } + path := filepath.Join(runtimeDir(), "visit_"+day.Format("20060102")+".log") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + return "", err + } + return path, nil +} + +func paintHistoryCalendarPane(hwnd uintptr, app *guiApp) uintptr { + if app == nil { + return 0 + } + ensureHistoryState(app) + 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, 18, 18, "履歴カレンダー", app.joinBoldHFont, clrAccentGreen) + monthLabel := app.historyMonth.Format("2006年01月") + drawPaneText(hdc, 18, 42, monthLabel, app.hFont, clrText) + + app.historyPrevMonthRect = winRect{Left: rc.Right - 108, Top: 14, Right: rc.Right - 58, Bottom: 42} + app.historyNextMonthRect = winRect{Left: rc.Right - 54, Top: 14, Right: rc.Right - 14, Bottom: 42} + drawSettingsBox(hdc, app.historyPrevMonthRect, 0x0038281a, clrPaneBorder) + drawSettingsBox(hdc, app.historyNextMonthRect, 0x0038281a, clrPaneBorder) + drawCenteredPaneText(hdc, app.historyPrevMonthRect, "<", app.hFont, clrText) + drawCenteredPaneText(hdc, app.historyNextMonthRect, ">", app.hFont, clrText) + + weekdays := []string{"日", "月", "火", "水", "木", "金", "土"} + cellW := (rc.Right - 24) / 7 + if cellW < 48 { + cellW = 48 + } + cellH := int32(56) + startX := int32(12) + startY := int32(80) + for i, wd := range weekdays { + x := startX + int32(i)*cellW + drawPaneText(hdc, x+6, startY-18, wd, app.hFont, clrMutedText) + } + + cells := historyCalendarCellsForMonth(app) + app.historyCalendarCells = app.historyCalendarCells[:0] + for i, cell := range cells { + if cell.Date.IsZero() { + continue + } + col := i % 7 + row := i / 7 + rect := winRect{ + Left: startX + int32(col)*cellW, + Top: startY + int32(row)*cellH, + Right: startX + int32(col+1)*cellW - 4, + Bottom: startY + int32(row+1)*cellH - 4, + } + cell.Rect = rect + app.historyCalendarCells = append(app.historyCalendarCells, cell) + fill := uint32(0x00261a11) + border := uint32(clrPaneBorder) + textColor := uint32(clrText) + if !cell.InRange { + fill = 0x001b140d + textColor = uint32(clrMutedText) + } + if cell.Count > 0 { + fill = 0x00321f12 + border = clrAccentGreen + } + if cell.Selected { + fill = 0x003d2a14 + border = clrAccentBlue + } + drawSettingsBox(hdc, rect, fill, border) + drawPaneText(hdc, rect.Left+6, rect.Top+6, fmt.Sprintf("%d", cell.Date.Day()), app.hFont, textColor) + if cell.Count > 0 { + drawPaneText(hdc, rect.Right-24, rect.Bottom-18, fmt.Sprintf("%d", cell.Count), app.hFont, clrAccentGreen) + } + } + + if app.historyStatus != "" { + drawPaneText(hdc, 18, rc.Bottom-26, app.historyStatus, app.hFont, clrMutedText) + } + return 0 +} + +func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr { + if app == nil { + return 0 + } + ensureHistoryState(app) + 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) + + rows := historyVisitsForDay(app, app.historySelectedDate) + app.historyDetailRows = app.historyDetailRows[:0] + + title := app.historySelectedDate.Format("2006年01月02日") + drawPaneText(hdc, 18, 18, title, app.joinBoldHFont, clrAccentGreen) + summary := fmt.Sprintf("%d件", len(rows)) + drawPaneText(hdc, 18, 44, summary, app.hFont, clrMutedText) + if strings.TrimSpace(app.historyRegex) != "" { + drawPaneText(hdc, 96, 44, "grep: "+app.historyRegex, app.hFont, clrMutedText) + } + if strings.TrimSpace(app.historyFromDate) != "" || strings.TrimSpace(app.historyToDate) != "" { + rangeLabel := strings.TrimSpace(app.historyFromDate) + if rangeLabel == "" { + rangeLabel = "..." + } + rangeLabel += " - " + if to := strings.TrimSpace(app.historyToDate); to != "" { + rangeLabel += to + } else { + rangeLabel += "..." + } + drawPaneText(hdc, 260, 44, rangeLabel, app.hFont, clrMutedText) + } + + app.historyExportRect = winRect{Left: rc.Right - 132, Top: 14, Right: rc.Right - 16, Bottom: 42} + drawSettingsBox(hdc, app.historyExportRect, 0x0038281a, clrAccentGreen) + drawCenteredPaneText(hdc, app.historyExportRect, "Export", app.hFont, clrText) + if app.historyStatus != "" { + drawPaneText(hdc, 18, rc.Bottom-26, app.historyStatus, app.hFont, clrMutedText) + } + + pageHeight := rc.Bottom - rc.Top + contentHeight := historyDetailContentHeight(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 { + rowHeight := historyDetailRowHeight(row) + row.Rect = winRect{Left: 14, Top: y, Right: rc.Right - 14, Bottom: y + rowHeight - 4} + app.historyDetailRows = append(app.historyDetailRows, row) + if row.Rect.Bottom < 72 { + y += rowHeight + continue + } + if row.Rect.Top > rc.Bottom-40 { + break + } + selected := app.historySelectedKeys != nil && app.historySelectedKeys[row.Key] + fill := uint32(0x00271b12) + border := uint32(clrPaneBorder) + if selected { + fill = 0x00331f11 + border = clrAccentBlue + } else if row.Current { + border = clrAccentGreen + } + drawSettingsBox(hdc, row.Rect, fill, border) + drawPaneText(hdc, row.Rect.Left+12, y+6, row.Start.Format("15:04")+" - "+row.End.Format("15:04"), app.hFont, clrMutedText) + drawPaneText(hdc, row.Rect.Left+122, y+6, humanDurationLabel(row.Start, row.End, row.Current), app.hFont, clrMutedText) + label := ellipsizeTextToWidth(hdc, row.WorldLabel, row.Rect.Right-row.Rect.Left-150, app.joinBoldHFont) + if label == "" { + label = "(unknown)" + } + drawPaneText(hdc, row.Rect.Left+12, y+24, label, app.joinBoldHFont, clrText) + if len(row.Users) > 0 { + users := "Users: " + strings.Join(row.Users, ", ") + drawPaneText(hdc, row.Rect.Left+12, y+44, ellipsizeTextToWidth(hdc, users, row.Rect.Right-row.Rect.Left-24, app.hFont), app.hFont, clrMutedText) + } + lineY := y + 62 + showLines := row.Lines + if len(showLines) > 3 { + showLines = append([]string(nil), showLines[:3]...) + showLines = append(showLines, fmt.Sprintf("... +%d more", len(row.Lines)-3)) + } + for _, line := range showLines { + drawPaneText(hdc, row.Rect.Left+18, lineY, ellipsizeTextToWidth(hdc, line, row.Rect.Right-row.Rect.Left-40, app.hFont), app.hFont, clrText) + lineY += 18 + } + y += rowHeight + } + return 0 +} + +func historyDetailContentHeight(hwnd uintptr, app *guiApp) int32 { + if app == nil || getClientRectProc == nil { + return 0 + } + var rc winRect + getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc))) + rows := historyVisitsForDay(app, app.historySelectedDate) + height := int32(90) + for _, row := range rows { + height += historyDetailRowHeight(row) + 6 + } + if height < 120 { + height = 120 + } + return height +} + +func historyDetailRowHeight(row historyDetailRow) int32 { + lines := len(row.Lines) + if lines > 3 { + lines = 4 + } + height := int32(72 + lines*18) + if len(row.Users) > 0 { + height += 18 + } + if height < 84 { + height = 84 + } + if height > 160 { + height = 160 + } + return height +} + +func handleHistoryPaneClick(app *guiApp, hwnd uintptr, x, y int32, setWindowText *syscall.LazyProc) bool { + if app == nil { + return false + } + ensureHistoryState(app) + switch hwnd { + case app.leftHwnd: + if pointInRect(x, y, app.historyPrevMonthRect) { + historyShiftMonth(app, -1) + return true + } + if pointInRect(x, y, app.historyNextMonthRect) { + historyShiftMonth(app, 1) + return true + } + for _, cell := range app.historyCalendarCells { + if cell.Date.IsZero() || !pointInRect(x, y, cell.Rect) { + continue + } + historySelectDate(app, cell.Date) + return true + } + case app.rightHwnd: + if pointInRect(x, y, app.historyExportRect) { + path, err := exportHistoryDay(app, app.historySelectedDate, app.historySelectedKeys) + if err != nil { + app.historyStatus = "export failed: " + err.Error() + } else if path != "" { + app.historyStatus = "exported: " + filepath.Base(path) + } else { + app.historyStatus = "exported: nothing selected" + } + invalidateHistoryPanes(app) + return true + } + for _, row := range app.historyDetailRows { + if row.Key == "" || !pointInRect(x, y, row.Rect) { + continue + } + if app.historySelectedKeys == nil { + app.historySelectedKeys = map[string]bool{} + } + if app.historySelectedKeys[row.Key] { + delete(app.historySelectedKeys, row.Key) + } else { + app.historySelectedKeys[row.Key] = true + } + invalidateHistoryPanes(app) + return true + } + } + return false +} + +func historyShiftMonth(app *guiApp, delta int) { + if app == nil || delta == 0 { + return + } + ensureHistoryState(app) + base := app.historyMonth + if base.IsZero() { + base = app.historySelectedDate + } + if base.IsZero() { + base = time.Now() + } + nextMonth := time.Date(base.Year(), base.Month(), 1, 0, 0, 0, 0, base.Location()).AddDate(0, delta, 0) + if nextMonth.IsZero() { + return + } + day := app.historySelectedDate.Day() + lastDay := nextMonth.AddDate(0, 1, -1).Day() + if day > lastDay { + day = lastDay + } + nextDate := time.Date(nextMonth.Year(), nextMonth.Month(), day, 0, 0, 0, 0, nextMonth.Location()) + historySelectDate(app, nextDate) + invalidateHistoryPanes(app) +} + +func historySelectDate(app *guiApp, day time.Time) { + if app == nil || day.IsZero() { + return + } + ensureHistoryState(app) + q := historyQueryFromApp(app) + day = clampHistoryDate(day, q) + if day.IsZero() { + return + } + app.historySelectedDate = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()) + app.historyMonth = time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()) + app.historySelectedKeys = map[string]bool{} + app.historyStatus = "" + app.historyScrollPos = 0 + invalidateHistoryPanes(app) +} + +func invalidateHistoryPanes(app *guiApp) { + if invalidateRectProc == nil { + return + } + if app != nil { + if app.leftHwnd != 0 { + invalidateRectProc.Call(app.leftHwnd, 0, 1) + } + if app.rightHwnd != 0 { + invalidateRectProc.Call(app.rightHwnd, 0, 1) + } + } +} diff --git a/cmd/vrc_osc_gui/window_windows.go b/cmd/vrc_osc_gui/window_windows.go index 219ac48..fa8b25d 100644 --- a/cmd/vrc_osc_gui/window_windows.go +++ b/cmd/vrc_osc_gui/window_windows.go @@ -5,9 +5,11 @@ package main import ( "encoding/json" "fmt" + "io" "os" "path/filepath" "regexp" + "runtime/debug" "sort" "strconv" "strings" @@ -15,7 +17,7 @@ import ( "time" "unsafe" - "vrc_osc_go/internal/app" + appPkg "vrc_osc_go/internal/app" "vrc_osc_go/internal/config" ) @@ -25,29 +27,95 @@ const ( wsChild = 0x40000000 wsPopup = 0x80000000 wsClipChildren = 0x02000000 + wsClipSiblings = 0x04000000 wsBorder = 0x00800000 + wsVScroll = 0x00200000 esMultiline = 0x0004 esReadonly = 0x0800 esAutovscroll = 0x0040 esAutohscroll = 0x0080 wmCreateGUI = 0x0001 wmDestroyGUI = 0x0002 + wmPaint = 0x000F + wmEraseBkgnd = 0x0014 + wmSize = 0x0005 + wmVScroll = 0x0115 + wmMouseWheel = 0x020A + wmCloseGUI = 0x0010 + wmLButtonDown = 0x0201 + wmLButtonUpGUI = 0x0202 + wmNCLButtonDown = 0x00A1 wmTimerGUI = 0x0113 wmCommandGUI = 0x0111 wmSetFont = 0x0030 + 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" + paneClass = "VRC_OSC_PANE" idStatus = 2001 idLog = 2002 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 ) 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 @@ -65,26 +133,89 @@ var setWindowPosProc *syscall.LazyProc var sendMessageProc *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 type guiApp struct { - hwnd uintptr - leftHwnd uintptr - rightHwnd uintptr - settingsPaneHwnd uintptr - tabJoin uintptr - tabTranslate uintptr - tabSettings uintptr - btnTopMost uintptr - btnFontDown uintptr - btnFontUp uintptr - activeTab string - topMost bool - lastTopMost bool - fontSize int - hFont uintptr - lastSettingsAction time.Time - lastInstanceCount int + 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 + historyExportRect winRect + 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 { @@ -102,36 +233,87 @@ func runtimeDir() string { return filepath.Join(filepath.Dir(exe), "runtime") } -func guiLog(text string) { - _ = app.AppendRuntimeLog("GUI", text) +type initCommonControlsEx struct { + dwSize uint32 + dwICC uint32 } -func runNativeGUI() error { +type toolbarButton struct { + iBitmap int32 + idCommand int32 + fsState byte + fsStyle byte + bReserved [2]byte + dwData uintptr + iString uintptr +} + +type worldState struct { + location string + worldID string + instanceID string + worldName string + pendingWorldName string + initialized bool +} + +func guiLog(text string) { + _ = appPkg.AppendRuntimeLog("GUI", text) +} + +func runNativeGUI(initialTab string) error { user32 := syscall.NewLazyDLL("user32.dll") kernel32 := syscall.NewLazyDLL("kernel32.dll") gdi32 := syscall.NewLazyDLL("gdi32.dll") + comctl32 := syscall.NewLazyDLL("comctl32.dll") registerClass := user32.NewProc("RegisterClassW") createWindowEx := user32.NewProc("CreateWindowExW") defWindowProc := user32.NewProc("DefWindowProcW") showWindowProc = user32.NewProc("ShowWindow") - updateWindow := user32.NewProc("UpdateWindow") 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") - setTimer := user32.NewProc("SetTimer") + releaseCapture := user32.NewProc("ReleaseCapture") + setTimerProc = user32.NewProc("SetTimer") loadCursor := user32.NewProc("LoadCursorW") setWindowText := user32.NewProc("SetWindowTextW") getWindowRect := user32.NewProc("GetWindowRect") isWindowVisible := user32.NewProc("IsWindowVisible") isIconic := user32.NewProc("IsIconic") - getSystemMetrics := user32.NewProc("GetSystemMetrics") getModuleHandle := kernel32.NewProc("GetModuleHandleW") - loadIcon := user32.NewProc("LoadIconW") + loadImage := user32.NewProc("LoadImageW") + setForegroundWindow := user32.NewProc("SetForegroundWindow") + bringWindowToTop := user32.NewProc("BringWindowToTop") type wndClass struct { style uint32 @@ -155,51 +337,193 @@ func runNativeGUI() error { } 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) - icon, _, _ := loadIcon.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" + } guiCfg := loadGUISettings() app.topMost = guiCfg.TopMost + app.autoUnmuteOnLeave = guiCfg.AutoUnmuteOnSelfLeave if guiCfg.FontSize > 0 { app.fontSize = guiCfg.FontSize } else { app.fontSize = 18 } + app.refreshIntervalValue = guiCfg.RefreshIntervalValue + if app.refreshIntervalValue <= 0 { + app.refreshIntervalValue = 2 + } + app.refreshIntervalUnit = strings.ToLower(strings.TrimSpace(guiCfg.RefreshIntervalUnit)) + if app.refreshIntervalUnit != "min" { + app.refreshIntervalUnit = "sec" + } + app.historyRegex = strings.TrimSpace(guiCfg.HistoryRegex) + app.historyFromDate = strings.TrimSpace(guiCfg.HistoryFromDate) + app.historyToDate = strings.TrimSpace(guiCfg.HistoryToDate) app.hFont = createAppFont(app.fontSize) - wndProc := syscall.NewCallback(func(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr { + 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 wmLButtonUpGUI: + if (hwnd == app.settingsPaneHwnd || hwnd == app.leftHwnd) && app.activeTab == "settings" { + if handleSettingsPaneClick(&app, int32(lParam&0xffff), int32((lParam>>16)&0xffff), 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), 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") - leftClass, _ := syscall.UTF16PtrFromString("STATIC") - rightClass, _ := syscall.UTF16PtrFromString("STATIC") leftTitle, _ := syscall.UTF16PtrFromString("") rightTitle, _ := syscall.UTF16PtrFromString("") - joinTitle, _ := syscall.UTF16PtrFromString("Join Log") - translateTitle, _ := syscall.UTF16PtrFromString("Translate") - settingsTitle, _ := syscall.UTF16PtrFromString("Settings") topMostTitle, _ := syscall.UTF16PtrFromString("Top most") + autoUnmuteTitle, _ := syscall.UTF16PtrFromString("Leave unmute") fontDownTitle, _ := syscall.UTF16PtrFromString("A-") fontUpTitle, _ := syscall.UTF16PtrFromString("A+") - app.tabJoin, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(joinTitle)), wsVisible|wsChild, 370, 20, 90, 28, hwnd, 3001, hInstance, 0) - app.tabTranslate, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(translateTitle)), wsVisible|wsChild, 470, 20, 90, 28, hwnd, 3002, hInstance, 0) - app.tabSettings, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(settingsTitle)), wsVisible|wsChild, 570, 20, 90, 28, hwnd, 3003, hInstance, 0) - app.leftHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(leftClass)), uintptr(unsafe.Pointer(leftTitle)), wsVisible|wsChild, 20, 20, 330, 300, hwnd, idStatus, hInstance, 0) - app.rightHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(rightTitle)), wsVisible|wsChild|wsClipChildren, 370, 60, 330, 260, hwnd, idLog, hInstance, 0) + 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: tbstateEnabled, 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(rightClass)), uintptr(unsafe.Pointer(settingsPaneTitle)), wsVisible|wsChild|wsClipChildren, 370, 60, 330, 260, hwnd, idLog+1, hInstance, 0) - app.btnTopMost, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(topMostTitle)), wsVisible|wsChild, 390, 90, 120, 30, hwnd, 3005, hInstance, 0) - app.btnFontDown, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontDownTitle)), wsVisible|wsChild, 390, 135, 50, 30, hwnd, 3006, hInstance, 0) - app.btnFontUp, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontUpTitle)), wsVisible|wsChild, 445, 135, 50, 30, hwnd, 3007, hInstance, 0) - app.activeTab = "join" - setTimer.Call(hwnd, timerRefresh, 2000, 0) + app.settingsPaneHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(paneClassName)), uintptr(unsafe.Pointer(settingsPaneTitle)), paneStyle, leftPaneWidth, contentTop, settingsPaneWidth, 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) + applyRefreshTimer(hwnd, &app) applyFont(&app) - refreshGUI(setWindowText, &app) + refreshGUI(setWindowText, &app, true) applyTopMost(hwnd, &app) - ensureWindowVisible("startup", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics) + ensureWindowVisible("startup", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetricsProc) logWindowState("after-ensure", hwnd, getWindowRect, isWindowVisible, isIconic) return 0 case wmCommandGUI: @@ -213,6 +537,10 @@ func runNativeGUI() error { needsFullRefresh = true case 3003: app.activeTab = "settings" + needsFullRefresh = true + case 3004: + app.activeTab = "history" + needsFullRefresh = true case 3005: if !allowRapidSettingsAction(&app) { return 0 @@ -220,6 +548,12 @@ func runNativeGUI() error { app.topMost = !app.topMost saveGUISettings(&app) applyTopMost(hwnd, &app) + case 3008: + if !allowRapidSettingsAction(&app) { + return 0 + } + app.autoUnmuteOnLeave = !app.autoUnmuteOnLeave + saveGUISettings(&app) case 3006: if !allowRapidSettingsAction(&app) { return 0 @@ -250,20 +584,73 @@ func runNativeGUI() error { } } if needsFullRefresh { - refreshGUI(setWindowText, &app) + refreshGUI(setWindowText, &app, false) refreshSettingsControls(showWindowProc, setWindowPosProc, &app) } else { refreshCommandUI(setWindowText, &app) refreshSettingsControls(showWindowProc, setWindowPosProc, &app) } return 0 + case wmLButtonDown: + x := int32(lParam & 0xffff) + y := int32((lParam >> 16) & 0xffff) + if y >= 0 && y < headerHeight { + var rc winRect + width := int32(windowWidth) + if getClientRectProc != nil { + getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc))) + if rc.Right > 0 { + width = rc.Right + } + } + if x >= width-42 && x <= width-22 && y >= 12 && y <= 32 { + guiLog("close button ignored to keep GUI visible") + return 0 + } + releaseCapture.Call() + sendMessageProc.Call(hwnd, wmNCLButtonDown, htCaption, 0) + return 0 + } + case wmLButtonUpGUI: + x := int32(lParam & 0xffff) + y := int32((lParam >> 16) & 0xffff) + if y >= headerHeight && y < contentTop { + nextTab := app.activeTab + switch { + case x < 120: + nextTab = "join" + case x < 240: + nextTab = "history" + case x < 360: + nextTab = "translate" + case x < 480: + nextTab = "settings" + } + if nextTab == app.activeTab { + return 0 + } + if !allowRapidTabAction(&app) { + return 0 + } + app.activeTab = nextTab + refreshGUI(setWindowText, &app, false) + refreshSettingsControls(showWindowProc, setWindowPosProc, &app) + if invalidateRectProc != nil { + invalidateRectProc.Call(hwnd, 0, 1) + } + return 0 + } case wmTimerGUI: - refreshGUI(setWindowText, &app) + refreshGUI(setWindowText, &app, true) refreshSettingsControls(showWindowProc, setWindowPosProc, &app) return 0 case wmDestroyGUI: + guiLog("wmDestroy received") postQuitMessage.Call(0) return 0 + case wmCloseGUI: + guiLog("wmClose ignored to keep GUI visible") + return 0 } ret, _, _ := defWindowProc.Call(hwnd, uintptr(message), wParam, lParam) return ret @@ -276,7 +663,7 @@ func runNativeGUI() error { hInstance: hInstance, hIcon: icon, hCursor: cursor, - hbrBackground: 6, + hbrBackground: mainBgBrush, lpszClassName: className, }))) if atom == 0 { @@ -285,7 +672,7 @@ func runNativeGUI() error { } guiLog("stage=create window") - app.hwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), wsOverlappedWindow|wsVisible, 200, 120, 760, 380, 0, 0, hInstance, 0) + 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") @@ -296,15 +683,27 @@ func runNativeGUI() error { guiLog("stage=show window") prevVisible, _, _ := showWindowProc.Call(app.hwnd, 1) guiLog(fmt.Sprintf("stage=show window ret=%d", prevVisible)) - updateWindow.Call(app.hwnd) - ensureWindowVisible("startup", app.hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics) + 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 { + 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))) @@ -342,18 +741,63 @@ func logWindowState(stage string, hwnd uintptr, getWindowRect, isWindowVisible, )) } -func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) { - current, instanceCount := currentUsersFromJoinLeave() - guiLog(fmt.Sprintf("refreshGUI activeTab=%s current=%d", app.activeTab, instanceCount)) - if instanceCount == 0 && app.lastInstanceCount > 0 { - instanceCount = app.lastInstanceCount +func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp, reloadData bool) { + if app == nil { + return } - if instanceCount > 0 { - app.lastInstanceCount = instanceCount + current := app.currentUsers + instanceCount := app.currentUserCount + worldLabel := app.currentWorld + if reloadData { + 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)) } if app.leftHwnd != 0 { - t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current)) - setWindowText.Call(app.leftHwnd, uintptr(unsafe.Pointer(t))) + if app.activeTab == "join" || app.activeTab == "history" || app.activeTab == "settings" { + 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 { + invalidateRectProc.Call(app.leftHwnd, 0, 1) + } + } else { + showWindowProc.Call(app.leftHwnd, swHideControl) + } } if app.btnTopMost != 0 { label := "Top most: OFF" @@ -363,38 +807,67 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) { 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" { + state := readRuntimeSnapshot() + state["top_most"] = app.topMost + state["font_size"] = app.fontSize + state["auto_unmute_on_self_leave"] = app.autoUnmuteOnLeave if app.rightHwnd != 0 { showWindowProc.Call(app.rightHwnd, swHideControl) + if invalidateRectProc != nil { + invalidateRectProc.Call(app.rightHwnd, 0, 1) + } } if app.settingsPaneHwnd != 0 { - showWindowProc.Call(app.settingsPaneHwnd, swShowControl) - setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags) + showWindowProc.Call(app.settingsPaneHwnd, swHideControl) } - for _, hwnd := range []uintptr{app.btnTopMost, app.btnFontDown, app.btnFontUp} { + for _, hwnd := range []uintptr{app.btnTopMost, app.btnAutoUnmute, app.btnFontDown, app.btnFontUp} { if hwnd != 0 { - showWindowProc.Call(hwnd, swShowControl) + showWindowProc.Call(hwnd, swHideControl) } } + if invalidateRectProc != nil && app.hwnd != 0 { + invalidateRectProc.Call(app.hwnd, 0, 1) + } return } if app.rightHwnd != 0 { state := readRuntimeSnapshot() state["top_most"] = app.topMost state["font_size"] = app.fontSize - rightText := formatRightPane(app.activeTab, state, currentWorldLabel(), instanceCount) - showWindowProc.Call(app.rightHwnd, swShowControl) - t, _ := syscall.UTF16PtrFromString(rightText) - setWindowText.Call(app.rightHwnd, uintptr(unsafe.Pointer(t))) + 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 { + 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.btnFontDown, app.btnFontUp} { + 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) { @@ -409,6 +882,14 @@ func refreshCommandUI(setWindowText *syscall.LazyProc, app *guiApp) { 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) { @@ -416,22 +897,18 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, return } if app.activeTab == "settings" { - if app.settingsPaneHwnd != 0 { - showWindowProc.Call(app.settingsPaneHwnd, swShowControl) - setWindowPosProc.Call(app.settingsPaneHwnd, 0, 370, 60, 330, 260, swpVisibleFlags) - } - for _, hwnd := range []uintptr{app.btnTopMost, app.btnFontDown, app.btnFontUp} { + for _, hwnd := range []uintptr{app.btnTopMost, app.btnAutoUnmute, app.btnFontDown, app.btnFontUp} { if hwnd != 0 { - showWindowProc.Call(hwnd, swShowControl) + showWindowProc.Call(hwnd, swHideControl) } } - guiLog("refreshSettingsControls visible") + guiLog("refreshSettingsControls custom") return } if app.settingsPaneHwnd != 0 { showWindowProc.Call(app.settingsPaneHwnd, swHideControl) } - for _, hwnd := range []uintptr{app.btnTopMost, app.btnFontDown, app.btnFontUp} { + for _, hwnd := range []uintptr{app.btnTopMost, app.btnAutoUnmute, app.btnFontDown, app.btnFontUp} { if hwnd != 0 { showWindowProc.Call(hwnd, swHideControl) } @@ -508,14 +985,21 @@ func applyTopMost(hwnd uintptr, app *guiApp) { } func createAppFont(size int) uintptr { + return createAppFontWithWeight(size, 400) +} + +func createAppFontWithWeight(size int, weight int32) uintptr { if createFontProc == nil { - return 0 + return defaultGUIFont } h, _, _ := createFontProc.Call( - uintptr(-size), 0, 0, 0, 400, 0, 0, 0, + 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 } @@ -523,13 +1007,1750 @@ func applyFont(app *guiApp) { if sendMessageProc == nil || app.hFont == 0 { return } - targets := []uintptr{app.leftHwnd, app.rightHwnd, app.tabJoin, app.tabTranslate, app.tabSettings} + targets := []uintptr{app.rightHwnd, app.navBarHwnd} for _, hwnd := range targets { if hwnd == 0 { continue } sendMessageProc.Call(hwnd, wmSetFont, app.hFont, 1) } + applyJoinLogFont(app, app.lastInstanceCount) +} + +func applyJoinLogFont(app *guiApp, presentCount int) { + if app == nil || app.leftHwnd == 0 { + return + } + size := joinLogFontSize(app.fontSize, presentCount) + if size <= 0 { + size = app.fontSize + } + if size <= 0 { + size = 18 + } + if app.joinHFont != 0 && app.joinBoldHFont != 0 && app.joinHeadlineHFont != 0 && + app.joinFontSize == size && app.joinBoldFontSize == size && app.joinHeadlineSize == size+2 { + return + } + newFont := createAppFont(size) + if newFont == 0 { + newFont = defaultGUIFont + } + if newFont == 0 { + return + } + boldFont := createAppFontWithWeight(size, 700) + headlineSize := size + 2 + if headlineSize > 30 { + headlineSize = 30 + } + headlineFont := createAppFontWithWeight(headlineSize, 700) + oldFont := app.joinHFont + oldBoldFont := app.joinBoldHFont + oldHeadlineFont := app.joinHeadlineHFont + app.joinHFont = newFont + app.joinFontSize = size + app.joinBoldHFont = boldFont + app.joinBoldFontSize = size + app.joinHeadlineHFont = headlineFont + app.joinHeadlineSize = headlineSize + if oldFont != 0 && oldFont != app.hFont && oldFont != defaultGUIFont && deleteObjectProc != nil { + deleteObjectProc.Call(oldFont) + } + if oldBoldFont != 0 && oldBoldFont != oldFont && oldBoldFont != app.hFont && oldBoldFont != defaultGUIFont && deleteObjectProc != nil { + deleteObjectProc.Call(oldBoldFont) + } + if oldHeadlineFont != 0 && oldHeadlineFont != oldFont && oldHeadlineFont != oldBoldFont && oldHeadlineFont != app.hFont && oldHeadlineFont != defaultGUIFont && deleteObjectProc != nil { + deleteObjectProc.Call(oldHeadlineFont) + } +} + +func updateJoinPaneLayout(app *guiApp, presentCount int) { + if app == nil || app.leftHwnd == 0 { + return + } + lines := joinPaneLineCount(app.leftPaneText) + if lines < 1 { + lines = 1 + } + contentHeight := joinPaneContentHeight(app.fontSize, lines) + if contentHeight < leftPaneHeight { + contentHeight = leftPaneHeight + } + availableHeight := maxJoinPaneContentHeight() + fontSize := fitJoinLogFontSize(app.fontSize, lines, availableHeight) + applyJoinLogFontSize(app, fontSize) + contentHeight = joinPaneContentHeight(fontSize, lines) + if contentHeight < leftPaneHeight { + contentHeight = leftPaneHeight + } + if availableHeight > 0 && contentHeight > availableHeight { + contentHeight = availableHeight + } + if contentHeight != app.lastLayoutHeight { + resizeGUIForJoinContent(app, contentHeight) + app.lastLayoutHeight = contentHeight + } + if invalidateRectProc != nil { + invalidateRectProc.Call(app.leftHwnd, 0, 1) + } +} + +func applyJoinLogFontSize(app *guiApp, size int) { + if app == nil || app.leftHwnd == 0 { + return + } + if size <= 0 { + size = 18 + } + if app.joinHFont != 0 && app.joinBoldHFont != 0 && app.joinFontSize == size && app.joinBoldFontSize == size { + return + } + newFont := createAppFont(size) + if newFont == 0 { + newFont = defaultGUIFont + } + if newFont == 0 { + return + } + boldFont := createAppFontWithWeight(size, 700) + oldFont := app.joinHFont + oldBoldFont := app.joinBoldHFont + app.joinHFont = newFont + app.joinFontSize = size + app.joinBoldHFont = boldFont + app.joinBoldFontSize = size + if oldFont != 0 && oldFont != app.hFont && oldFont != defaultGUIFont && deleteObjectProc != nil { + deleteObjectProc.Call(oldFont) + } + if oldBoldFont != 0 && oldBoldFont != oldFont && oldBoldFont != app.hFont && oldBoldFont != defaultGUIFont && deleteObjectProc != nil { + deleteObjectProc.Call(oldBoldFont) + } +} + +func fitJoinLogFontSize(baseSize, lineCount, availableHeight int) int { + size := baseSize + if size <= 0 { + size = 18 + } + if lineCount <= 0 { + lineCount = 1 + } + if availableHeight <= 0 { + availableHeight = 300 + } + for size > 8 { + if joinPaneContentHeight(size, lineCount) <= availableHeight { + break + } + size -= 2 + } + if size < 8 { + size = 8 + } + return size +} + +func joinPaneLineCount(text string) int { + trimmed := strings.TrimRight(text, "\r\n") + if trimmed == "" { + return 1 + } + return len(strings.Split(trimmed, "\n")) +} + +func joinPaneContentHeight(fontSize, lineCount int) int { + lineHeight := joinPaneLineHeight(fontSize) + height := lineCount*lineHeight + 16 + if height < leftPaneHeight { + height = leftPaneHeight + } + return height +} + +func joinPaneLineHeight(fontSize int) int { + lineHeight := fontSize + 3 + if lineHeight < 12 { + lineHeight = 12 + } + return lineHeight +} + +func maxJoinPaneContentHeight() int { + if getSystemMetricsProc == nil { + return 0 + } + vh, _, _ := getSystemMetricsProc.Call(uintptr(smCYVirtualScreen)) + if vh <= 0 { + return 0 + } + return int(vh) - contentTop - 56 +} + +func resizeGUIForJoinContent(app *guiApp, contentHeight int) { + if app == nil || setWindowPosProc == nil || app.hwnd == 0 { + return + } + if contentHeight < leftPaneHeight { + contentHeight = leftPaneHeight + } + resizeFlags := hwndNoMove | hwndNoZOrder | hwndNoActivate + windowHeight := contentTop + contentHeight + bottomBarHeight + setWindowPosProc.Call(app.hwnd, 0, 0, 0, windowWidth, uintptr(windowHeight), resizeFlags) + for _, item := range []struct { + hwnd uintptr + width int + }{ + {app.leftHwnd, leftPaneWidth}, + {app.rightHwnd, rightPaneWidth}, + {app.settingsPaneHwnd, settingsPaneWidth}, + } { + if item.hwnd != 0 { + setWindowPosProc.Call(item.hwnd, 0, 0, 0, uintptr(item.width), uintptr(contentHeight), resizeFlags) + } + } +} + +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, tabColor(app.activeTab == "translate")) + drawPaneText(hdc, 394, headerHeight+18, "設定", app.hFont, tabColor(app.activeTab == "settings")) + drawLine(hdc, activeLeft, contentTop-3, activeLeft+120, contentTop-3, clrAccentBlue) + drawLine(hdc, 0, contentTop, rc.Right, contentTop, clrPaneBorder) + + paintFooter(hdc, rc, app) + return 0 +} + +func paintFooter(hdc uintptr, rc winRect, app *guiApp) { + if app == nil { + return + } + statusTop := rc.Bottom - bottomBarHeight + fillSolid(hdc, winRect{Left: 0, Top: statusTop, Right: rc.Right, Bottom: rc.Bottom}, 0x00281709) + drawLine(hdc, 0, statusTop, rc.Right, statusTop, clrPaneBorder) + + world := strings.TrimSpace(app.currentWorld) + if world == "" { + world = "(unknown)" + } + users := app.currentUserCount + if users == 0 { + users = countPresent(app.currentUsers) + } + state := readRuntimeSnapshot() + muted := stateBool(state, "discord_muted") + + 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 + } + 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) + + state := readRuntimeSnapshot() + 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) + + y := int32(382) + if updatedAt, _ := state["updated_at"].(string); strings.TrimSpace(updatedAt) != "" { + drawPaneText(hdc, 24, y, "Updated: "+updatedAt, app.hFont, clrMutedText) + y += 26 + } + drawPaneText(hdc, 24, y, "OCR: "+onOffLine(stateString(state, "ocr")), app.hFont, clrText) + y += 26 + drawPaneText(hdc, 24, y, "Translate: "+onOffLine(stateString(state, "translate")), app.hFont, clrText) + 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 handleSettingsPaneClick(app *guiApp, x, y int32, setWindowText *syscall.LazyProc) bool { + if app == nil || !allowRapidSettingsAction(app) { + return true + } + switch { + case pointInRect(x, y, settingsTopMostRect()): + app.topMost = !app.topMost + saveGUISettings(app) + applyTopMost(app.hwnd, app) + case pointInRect(x, y, settingsAutoUnmuteRect()): + app.autoUnmuteOnLeave = !app.autoUnmuteOnLeave + saveGUISettings(app) + case pointInRect(x, y, settingsRefreshDownRect()): + if adjustRefreshInterval(app, -1) { + saveGUISettings(app) + applyRefreshTimer(app.hwnd, app) + } + case pointInRect(x, y, settingsRefreshUpRect()): + if adjustRefreshInterval(app, 1) { + saveGUISettings(app) + applyRefreshTimer(app.hwnd, app) + } + case pointInRect(x, y, settingsRefreshUnitRect()): + if toggleRefreshIntervalUnit(app) { + saveGUISettings(app) + applyRefreshTimer(app.hwnd, app) + } + case pointInRect(x, y, settingsFontDownRect()): + if app.fontSize > 10 { + app.fontSize -= 2 + app.hFont = createAppFont(app.fontSize) + applyFont(app) + saveGUISettings(app) + } + case pointInRect(x, y, settingsFontUpRect()): + if app.fontSize < 30 { + app.fontSize += 2 + app.hFont = createAppFont(app.fontSize) + applyFont(app) + saveGUISettings(app) + } + default: + return false + } + refreshCommandUI(setWindowText, app) + if invalidateRectProc != nil { + if app.settingsPaneHwnd != 0 { + invalidateRectProc.Call(app.settingsPaneHwnd, 0, 1) + } + if app.rightHwnd != 0 { + invalidateRectProc.Call(app.rightHwnd, 0, 1) + } + if app.leftHwnd != 0 { + invalidateRectProc.Call(app.leftHwnd, 0, 1) + } + if app.hwnd != 0 { + invalidateRectProc.Call(app.hwnd, 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))) + users := app.currentUsers + if len(users) == 0 { + users, _ = currentUsersFromJoinLeave() + } + events := eventRows(users) + 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) + + users := app.currentUsers + if len(users) == 0 { + users, _ = currentUsersFromJoinLeave() + } + events := eventRows(users) + 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 events { + 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 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 +} + +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 len(rows) == 0 { + rows = historyRows() + app.historyRows = rows + } + 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 len(rows) == 0 { + rows = historyRows() + app.historyRows = rows + } + 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") + b, err := os.ReadFile(p) + if err != nil || len(b) == 0 { + return nil + } + var snap guiWorldHistoryFile + if err := json.Unmarshal(b, &snap); err != nil { + var visits []guiWorldVisitRecord + if err := json.Unmarshal(b, &visits); err != nil { + return nil + } + return visits + } + return snap.Visits +} + +func currentWorldVisitInfo() (string, time.Time, bool) { + if tailLabel, tailSince := currentWorldVisitFromVRChatLog(); tailLabel != "" && !tailSince.IsZero() { + return tailLabel, tailSince, true + } + 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 + } + return "", time.Time{}, false +} + +func currentWorldVisitFromVRChatLog() (string, time.Time) { + logPath, err := findLatestVRChatLog() + if err != nil { + return "", time.Time{} + } + b, err := readFileTail(logPath, guiLogTailBytes) + if err != nil || len(b) == 0 { + return "", time.Time{} + } + text := appPkg.DecodeVRChatLog(b) + if strings.TrimSpace(text) == "" { + return "", time.Time{} + } + state := &worldState{} + latestLabel := "" + latestTime := time.Time{} + for _, raw := range strings.Split(text, "\n") { + line := strings.TrimSpace(strings.TrimRight(raw, "\r")) + if line == "" { + continue + } + at := parseLogTime(line) + if changed, label := updateWorldState(state, line); changed { + if shouldPreferWorldCandidate(label, latestLabel) { + latestLabel = label + latestTime = at + } + continue + } + candidate := "" + if strings.Contains(line, "Memory Usage: after world loaded") { + if m := worldPattern.FindStringSubmatch(line); len(m) == 2 { + candidate = strings.TrimSpace(m[1]) + } + } + if candidate == "" { + if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 { + candidate = strings.TrimSpace(m[1]) + } + } + if candidate == "" { + if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 { + candidate = strings.TrimSpace(m[1]) + } + } + if candidate == "" { + continue + } + if !shouldPreferWorldCandidate(candidate, latestLabel) { + continue + } + if at.IsZero() || latestTime.IsZero() || !at.Before(latestTime) { + latestLabel = candidate + if !at.IsZero() { + latestTime = at + } + } + } + return latestLabel, latestTime +} + +func shouldPreferWorldCandidate(candidate, current string) bool { + candidate = strings.TrimSpace(candidate) + current = strings.TrimSpace(current) + if candidate == "" { + return false + } + if strings.HasPrefix(strings.ToLower(candidate), "wrld_") && current != "" && !strings.HasPrefix(strings.ToLower(current), "wrld_") { + return false + } + return true +} + +func humanDurationLabel(started, ended time.Time, current bool) string { + if started.IsZero() { + return "0 min" + } + if ended.IsZero() { + ended = time.Now() + } + if ended.Before(started) { + ended = started + } + d := ended.Sub(started) + if current { + return formatDurationShort(d) + " active" + } + return formatDurationShort(d) +} + +func formatDurationShort(d time.Duration) string { + if d < time.Minute { + return "<1 min" + } + minutes := int(d.Minutes()) + hours := minutes / 60 + mins := minutes % 60 + switch { + case hours > 0 && mins > 0: + return fmt.Sprintf("%dh%dm", hours, mins) + case hours > 0: + return fmt.Sprintf("%dh", hours) + default: + return fmt.Sprintf("%dm", mins) + } +} + +func joinLogFontSize(baseSize, presentCount int) int { + size := baseSize + if size <= 0 { + size = 18 + } + if presentCount <= 32 { + return size + } + switch { + case presentCount >= 40: + size -= 6 + case presentCount >= 36: + size -= 4 + case presentCount >= 33: + size -= 2 + } + if size < 8 { + size = 8 + } + return size } func allowRapidSettingsAction(app *guiApp) bool { @@ -545,7 +2766,30 @@ func allowRapidSettingsAction(app *guiApp) bool { 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 := currentUsersFromVRChatLog(); ok { + guiLog(fmt.Sprintf("currentUsers source=vrc_log count=%d total=%d", countPresent(out), len(out))) + return out, countPresent(out) + } + + if out, ok := currentUsersFromJoinLeaveLog(); ok { + guiLog(fmt.Sprintf("currentUsers source=join_leave count=%d total=%d", countPresent(out), len(out))) + return out, countPresent(out) + } + if snap := readGuestSnapshot(); len(snap) > 0 { out := make([]userState, 0, len(snap)) for _, s := range snap { @@ -569,37 +2813,35 @@ func currentUsersFromJoinLeave() ([]userState, int) { return out, countPresent(out) } - if out, ok := currentUsersFromJoinLeaveLog(); ok { - guiLog(fmt.Sprintf("currentUsers source=join_leave count=%d total=%d", countPresent(out), len(out))) - return out, countPresent(out) - } - - if out, ok := currentUsersFromVRChatLog(); ok { - guiLog(fmt.Sprintf("currentUsers source=vrc_log count=%d total=%d", countPresent(out), len(out))) - return out, countPresent(out) - } - guiLog("currentUsers source=none") return nil, 0 } func currentUsersFromJoinLeaveLog() ([]userState, bool) { p := filepath.Join(runtimeDir(), "join_leave.log") - b, err := os.ReadFile(p) + b, err := readFileTail(p, guiLogTailBytes) if err != nil || len(b) == 0 { return nil, false } users := map[string]*userState{} + var entryAt time.Time lines := strings.Split(string(b), "\n") for _, raw := range lines { line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "VRC JOIN/LEAVE") { continue } - at, kind, name := parseJoinLeaveLine(line) - if name == "" { + if at, ok := parseRuntimeTime(line); ok && strings.Contains(line, "VRC JOIN/LEAVE") { + entryAt = at continue } + m := joinLeaveBodyPattern.FindStringSubmatch(line) + if len(m) != 4 { + continue + } + kind := m[1] + name := strings.TrimSpace(m[2]) + at := entryAt u := users[name] if u == nil { u = &userState{Name: name} @@ -626,11 +2868,11 @@ func currentUsersFromVRChatLog() ([]userState, bool) { if err != nil { return nil, false } - b, err := os.ReadFile(logPath) + b, err := readFileTail(logPath, guiLogTailBytes) if err != nil || len(b) == 0 { return nil, false } - text := app.DecodeVRChatLog(b) + text := appPkg.DecodeVRChatLog(b) if strings.TrimSpace(text) == "" { return nil, false } @@ -724,6 +2966,9 @@ func findLatestVRChatLog() (string, error) { if err != nil { continue } + if info.Size() == 0 { + continue + } if info.ModTime().After(latestMod) { latestMod = info.ModTime() latest = filepath.Join(dir, name) @@ -735,100 +2980,66 @@ func findLatestVRChatLog() (string, error) { return latest, nil } -func readGuestSnapshot() []app.GuestStatus { +func readGuestSnapshot() []appPkg.GuestStatus { p := filepath.Join(runtimeDir(), "guest_snapshot.json") - b, err := os.ReadFile(p) + b, err := readFileTail(p, guiLogTailBytes) if err != nil { return nil } - var out []app.GuestStatus + var out []appPkg.GuestStatus if err := json.Unmarshal(b, &out); err != nil { return nil } return out } -func formatGuestPane(title string, items []userState) string { +func buildGuestPaneText(worldLabel string, items []userState) string { var b strings.Builder - selfName := currentSelfName() - var selfJoin string - guiLog("formatGuestPane start") - b.WriteString(title) - b.WriteString("\r\n") - b.WriteString(strings.Repeat("=", len(title))) - b.WriteString("\r\n") - if len(items) == 0 { - b.WriteString("Current in room: 0") - b.WriteString("\r\n") - b.WriteString("(none)") - return b.String() + headline := strings.TrimSpace(worldLabel) + if headline == "" { + headline = "(unknown)" } - b.WriteString("Current in room: ") - b.WriteString(strconv.Itoa(countPresent(items))) - b.WriteString("\r\n\r\n") + 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)) - left := make([]userState, 0, len(items)) for _, item := range items { if item.Present { present = append(present, item) - } else { - left = append(left, item) - } - if selfName != "" && strings.EqualFold(strings.TrimSpace(item.Name), selfName) && !item.LastJoin.IsZero() { - selfJoin = item.LastJoin.Format("15:04:05") } } - if selfJoin != "" { - b.WriteString("You joined at ") - b.WriteString(selfJoin) - b.WriteString(" (self=") - b.WriteString(selfName) - b.WriteString(")") - b.WriteString("\r\n\r\n") - } - if len(present) > 0 { - b.WriteString("Present now") - b.WriteString("\r\n") - for _, item := range present { - b.WriteString("・ ") - b.WriteString(item.Name) - if !item.LastJoin.IsZero() { - b.WriteString(" ") - b.WriteString(item.LastJoin.Format("15:04:05")) - } - b.WriteString("\r\n") + 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") } - if len(left) > 0 { - b.WriteString("Left already") - b.WriteString("\r\n") - sort.SliceStable(left, func(i, j int) bool { - if left[i].LastLeave.Equal(left[j].LastLeave) { - return strings.ToLower(left[i].Name) < strings.ToLower(left[j].Name) - } - return left[i].LastLeave.After(left[j].LastLeave) - }) - for _, item := range left { - b.WriteString("・ ") - b.WriteString(item.Name) - if !item.LastLeave.IsZero() { - b.WriteString(" ") - b.WriteString(timeAgo(item.LastLeave)) - } - b.WriteString("\r\n") - } - } - text := strings.TrimRight(b.String(), "\r\n") - guiLog("formatGuestPane done") - if err := app.AppendDesktopJoinLog(title, currentWorldLabel(), text); err != nil { - _ = app.AppendRuntimeLog("GUI JOIN LOG WRITE FAIL", err.Error()) - } else { - _ = app.AppendRuntimeLog("GUI JOIN LOG WRITE OK", "written desktop join.txt") + return 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 != "" { @@ -849,11 +3060,15 @@ func currentSelfName() string { func loadGUISettings() config.GUIConfig { cfg, err := config.Load("") if err != nil || cfg == nil { - return config.GUIConfig{TopMost: false, FontSize: 18} + return config.GUIConfig{TopMost: false, FontSize: 18, RefreshIntervalValue: 2, RefreshIntervalUnit: "sec"} } if cfg.GUI.FontSize <= 0 { cfg.GUI.FontSize = 18 } + cfg.GUI.RefreshIntervalValue, cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalSettings(cfg.GUI.RefreshIntervalValue, cfg.GUI.RefreshIntervalUnit) + cfg.GUI.HistoryRegex = strings.TrimSpace(cfg.GUI.HistoryRegex) + cfg.GUI.HistoryFromDate = strings.TrimSpace(cfg.GUI.HistoryFromDate) + cfg.GUI.HistoryToDate = strings.TrimSpace(cfg.GUI.HistoryToDate) return cfg.GUI } @@ -862,8 +3077,14 @@ func saveGUISettings(app *guiApp) { return } guiCfg := config.GUIConfig{ - TopMost: app.topMost, - FontSize: app.fontSize, + 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), } if err := config.SaveGUI("", guiCfg); err != nil { guiLog("saveGUISettings failed: " + err.Error()) @@ -872,104 +3093,254 @@ func saveGUISettings(app *guiApp) { guiLog("saveGUISettings ok") } -func formatRightPane(tab string, state map[string]any, worldLabel string, currentUsers int) string { +func formatRightPane(tab string, state map[string]any, worldLabel string, currentUsers int, current []userState, historyCount int) string { var b strings.Builder switch tab { - case "discord": - b.WriteString("Discord") case "translate": - b.WriteString("Translate") - case "settings": - b.WriteString("Settings") - default: - b.WriteString("Current World") - } - b.WriteString("\r\n") - titleLine := "Current World" - switch tab { - case "translate": - titleLine = "Translate" - case "settings": - titleLine = "Settings" - case "discord": - titleLine = "Discord" - } - b.WriteString(strings.Repeat("=", len(titleLine))) - b.WriteString("\r\n") - ocr, _ := state["ocr"].(string) - translate, _ := state["translate"].(string) - fontSize, _ := state["font_size"].(int) - switch tab { - case "translate": - b.WriteString("Translate: ") - if strings.TrimSpace(translate) == "" { - b.WriteString("OFF") - } else { - b.WriteString("ON") - } + b.WriteString("\u7ffb\u8a33") + b.WriteString("\r\n================") b.WriteString("\r\nOCR: ") - if strings.TrimSpace(ocr) == "" { - b.WriteString("OFF") - } else { - b.WriteString("ON") - } - b.WriteString("\r\nWorld: ") - b.WriteString(worldLabel) + 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("Top most: ") - if stateBool(state, "top_most") { - b.WriteString("ON") - } else { - b.WriteString("OFF") - } + b.WriteString("\u8a2d\u5b9a") + b.WriteString("\r\n========") + b.WriteString("\r\nTop most: ") + b.WriteString(onOffBool(stateBool(state, "top_most"))) b.WriteString("\r\nFont size: ") + fontSize, _ := state["font_size"].(int) if fontSize == 0 { fontSize = 18 } b.WriteString(strconv.Itoa(fontSize)) - default: - b.WriteString("World: ") - b.WriteString(worldLabel) - b.WriteString("\r\nUsers: ") - b.WriteString(fmt.Sprintf("%d", currentUsers)) - b.WriteString("\r\nOCR: ") - if strings.TrimSpace(ocr) == "" { - b.WriteString("OFF") - } else { - b.WriteString("ON") - } - b.WriteString("\r\nTranslate: ") - if strings.TrimSpace(translate) == "" { - b.WriteString("OFF") - } else { - b.WriteString("ON") - } + b.WriteString("\r\nLeave unmute: ") + b.WriteString(onOffBool(stateBool(state, "auto_unmute_on_self_leave"))) + return b.String() + case "history": + b.WriteString("ワールド訪問履歴") + b.WriteString("\r\n========") + b.WriteString("\r\nvisits: ") + b.WriteString(strconv.Itoa(historyCount)) + return b.String() } + + b.WriteString("\u5165\u9000\u5ba4\u30ed\u30b0") + b.WriteString("\r\n========") + events := formatJoinLeaveEvents(current) + if len(events) == 0 { + b.WriteString("\r\nnone") + return b.String() + } + b.WriteString("\r\n") + for _, line := range events { + b.WriteString(line) + b.WriteString("\r\n") + } + return strings.TrimRight(b.String(), "\r\n") +} + +func formatSettingsPane(state map[string]any) string { + var b strings.Builder + b.WriteString("\u8a2d\u5b9a") + b.WriteString("\r\n========") + b.WriteString("\r\nTop most: ") + b.WriteString(onOffBool(stateBool(state, "top_most"))) + b.WriteString("\r\nFont size: ") + fontSize, _ := state["font_size"].(int) + if fontSize == 0 { + fontSize = 18 + } + b.WriteString(strconv.Itoa(fontSize)) + b.WriteString("\r\nLeave unmute: ") + b.WriteString(onOffBool(stateBool(state, "auto_unmute_on_self_leave"))) + if updatedAt, _ := state["updated_at"].(string); strings.TrimSpace(updatedAt) != "" { + b.WriteString("\r\nUpdated: ") + b.WriteString(updatedAt) + } + b.WriteString("\r\nOCR: ") + b.WriteString(onOffLine(stateString(state, "ocr"))) + b.WriteString("\r\nTranslate: ") + b.WriteString(onOffLine(stateString(state, "translate"))) 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 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 := os.ReadFile(p) - if err != nil { - return "" - } - lines := strings.Split(string(b), "\n") - var latest string - 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]) + 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]) + } } } - return latest + if strings.TrimSpace(latest) != "" { + return latest + } + state := readRuntimeSnapshot() + if world := strings.TrimSpace(stateString(state, "world")); world != "" { + return world + } + return "" +} + +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 { @@ -983,9 +3354,40 @@ func readRuntimeSnapshot() 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 := os.ReadFile(p) + b, err := readFileTail(p, guiLogTailBytes) if err != nil { return time.Time{} } @@ -1060,13 +3462,13 @@ func timeAgo(at time.Time) string { } d := time.Since(at) if d < time.Minute { - return "0分前" + return "1\u5206\u524d" } if d < time.Hour { - return fmt.Sprintf("%d分前", int(d.Minutes())) + return fmt.Sprintf("%d\u5206\u524d", int(d.Minutes())) } if d < 24*time.Hour { - return fmt.Sprintf("%d時間前", int(d.Hours())) + return fmt.Sprintf("%d\u6642\u9593\u524d", int(d.Hours())) } - return fmt.Sprintf("%d日前", int(d.Hours()/24)) + return fmt.Sprintf("%d\u65e5\u524d", int(d.Hours()/24)) } diff --git a/internal/config/config.go b/internal/config/config.go index 310a35f..c98b7b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "vrc_osc_go/internal/common" ) @@ -32,14 +33,20 @@ type VrcLogConfig struct { } type GUIConfig struct { - TopMost bool - FontSize int + TopMost bool + FontSize int + AutoUnmuteOnSelfLeave bool + RefreshIntervalValue int + RefreshIntervalUnit string + HistoryRegex string + HistoryFromDate string + HistoryToDate string } func Load(path string) (*Config, error) { cfg := &Config{ OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}, - GUI: GUIConfig{FontSize: 18}, + GUI: GUIConfig{FontSize: 18, RefreshIntervalValue: 2, RefreshIntervalUnit: "sec"}, } if path == "" { path = filepath.Join(common.RootDir(), "config", "config.toml") @@ -114,9 +121,29 @@ func Load(path string) (*Config, error) { if n, err := strconv.Atoi(val); err == nil && n > 0 { cfg.GUI.FontSize = n } + case "auto_unmute_on_self_leave": + cfg.GUI.AutoUnmuteOnSelfLeave = parseBool(val, cfg.GUI.AutoUnmuteOnSelfLeave) + case "refresh_interval_value": + if n, err := strconv.Atoi(val); err == nil && n > 0 { + cfg.GUI.RefreshIntervalValue = n + } + case "refresh_interval_unit": + cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(val) + case "history_regex": + cfg.GUI.HistoryRegex = val + case "history_from": + cfg.GUI.HistoryFromDate = normalizeHistoryDate(val) + case "history_to": + cfg.GUI.HistoryToDate = normalizeHistoryDate(val) } } } + if cfg.GUI.RefreshIntervalValue <= 0 { + cfg.GUI.RefreshIntervalValue = 2 + } + cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(cfg.GUI.RefreshIntervalUnit) + cfg.GUI.HistoryFromDate = normalizeHistoryDate(cfg.GUI.HistoryFromDate) + cfg.GUI.HistoryToDate = normalizeHistoryDate(cfg.GUI.HistoryToDate) if cfg.VrcLog.GuestFile != "" { if !filepath.IsAbs(cfg.VrcLog.GuestFile) { cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile) @@ -222,13 +249,56 @@ func upsertGUISection(existing string, gui GUIConfig) string { } func appendGUISection(out *[]string, gui GUIConfig) { + gui.RefreshIntervalUnit = normalizeRefreshIntervalUnit(gui.RefreshIntervalUnit) + if gui.RefreshIntervalValue <= 0 { + gui.RefreshIntervalValue = 2 + } *out = append(*out, "[gui]", fmt.Sprintf("top_most = %t", gui.TopMost), fmt.Sprintf("font_size = %d", gui.FontSize), + fmt.Sprintf("auto_unmute_on_self_leave = %t", gui.AutoUnmuteOnSelfLeave), + fmt.Sprintf("refresh_interval_value = %d", gui.RefreshIntervalValue), + fmt.Sprintf("refresh_interval_unit = %q", gui.RefreshIntervalUnit), + fmt.Sprintf("history_regex = %q", gui.HistoryRegex), + fmt.Sprintf("history_from = %q", gui.HistoryFromDate), + fmt.Sprintf("history_to = %q", gui.HistoryToDate), ) } +func normalizeRefreshIntervalUnit(unit string) string { + switch strings.ToLower(strings.TrimSpace(unit)) { + case "min", "minute", "minutes": + return "min" + default: + return "sec" + } +} + +func (g GUIConfig) RefreshInterval() time.Duration { + value := g.RefreshIntervalValue + if value <= 0 { + value = 2 + } + switch normalizeRefreshIntervalUnit(g.RefreshIntervalUnit) { + case "min": + return time.Duration(value) * time.Minute + default: + return time.Duration(value) * time.Second + } +} + +func normalizeHistoryDate(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if _, err := time.Parse("2006-01-02", value); err != nil { + return "" + } + return value +} + func loadLines(path string) ([]string, error) { data, err := os.ReadFile(path) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 22f1cac..3c453b4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -35,14 +35,14 @@ func TestLoadOSCValues(t *testing.T) { func TestLoadGUIValues(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") - if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\n"), 0o600); err != nil { + if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\nauto_unmute_on_self_leave = true\nrefresh_interval_value = 5\nrefresh_interval_unit = \"min\"\nhistory_regex = \"[wip]\"\nhistory_from = \"2026-01-01\"\nhistory_to = \"2026-01-31\"\n"), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load returned error: %v", err) } - if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 { + if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 5 || cfg.GUI.RefreshIntervalUnit != "min" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" { t.Fatalf("unexpected gui values: %+v", cfg.GUI) } } @@ -54,14 +54,14 @@ func TestSaveGUIUpdatesSection(t *testing.T) { if err := os.WriteFile(path, initial, 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } - if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26}); err != nil { + if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26, AutoUnmuteOnSelfLeave: true, RefreshIntervalValue: 3, RefreshIntervalUnit: "sec", HistoryRegex: "[wip]", HistoryFromDate: "2026-01-01", HistoryToDate: "2026-01-31"}); err != nil { t.Fatalf("SaveGUI returned error: %v", err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load returned error: %v", err) } - if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 { + if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 3 || cfg.GUI.RefreshIntervalUnit != "sec" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" { t.Fatalf("unexpected saved gui values: %+v", cfg.GUI) } data, err := os.ReadFile(path)