//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) } } }