Files
aego/editor/panels/console/panel.go
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

273 lines
7.8 KiB
Go

package console
import (
"strconv"
"strings"
"aego/core/log"
"aego/core/mathx"
"aego/editor/core/services"
"aego/editor/core/workspace"
"aego/engine/i18n"
"aego/ui/core"
"aego/ui/widgets"
)
type Panel struct {
markLen int
markAt float64
marked bool
sink *Sink
entries []Entry
view []int
rev uint64
showInfo bool
showWarn bool
showErr bool
search string
scroll float32
follow bool
selected int
menu Entry
menuID core.ID
selectedID uint64
Ask func(string)
OnOpen func(path string, line int)
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i] + " …"
}
return s
}
func New(s *Sink) *Panel {
return &Panel{sink: s, showInfo: true, showWarn: true, showErr: true, follow: true, selected: -1, rev: ^uint64(0)}
}
func (p *Panel) Focus(search string) {
p.search = search
p.follow = false
p.selected = -1
p.showErr = true
}
// Mark lights the entries that come after now for a few seconds, for follow mode.
func (p *Panel) Mark(now float64) {
p.markLen, p.markAt, p.marked, p.follow = len(p.entries), now, true, true
}
func (p *Panel) ID() string {
return workspace.Console
}
func (p *Panel) Title() string {
return i18n.Tr("editor.console.title", "Console")
}
func (p *Panel) visible(e *Entry, q string) bool {
switch {
case e.Level >= log.Error:
if !p.showErr {
return false
}
case e.Level == log.Warn:
if !p.showWarn {
return false
}
default:
if !p.showInfo {
return false
}
}
return q == "" || strings.Contains(strings.ToLower(e.Text), q) || strings.Contains(string(e.Channel), q)
}
func (p *Panel) refresh() {
if rev := p.sink.Revision(); rev != p.rev {
p.rev = rev
p.entries = p.sink.Snapshot(p.entries)
if p.selected >= 0 {
p.selected = -1
for i := range p.entries {
if p.entries[i].ID == p.selectedID {
p.selected = i
break
}
}
}
}
q := strings.ToLower(strings.TrimSpace(p.search))
p.view = p.view[:0]
for i := range p.entries {
if p.visible(&p.entries[i], q) {
p.view = append(p.view, i)
}
}
if p.selected >= len(p.entries) {
p.selected = -1
}
}
func levelColor(th *core.Theme, l log.Level) mathx.Color {
switch {
case l >= log.Error:
return th.Error
case l == log.Warn:
return th.Warn
case l <= log.Debug:
return th.TextDisabled
}
return th.Text
}
func (p *Panel) Draw(f *services.Frame, r mathx.Rect) {
ui := f.UI
th := &ui.Theme
p.refresh()
info, warn, errs := p.sink.Counts()
bar := ui.Cursor().Next(0)
compact := bar.W < 480
x, right := bar.X, bar.X+bar.W
place := func(w float32) (mathx.Rect, bool) {
if x+w > right {
return mathx.Rect{}, false
}
rc := mathx.Rect{X: x, Y: bar.Y, W: w, H: bar.H}
x += w + 4
return rc, true
}
clear := i18n.Tr("editor.console.clear", "Clear")
if rc, ok := place(max(56, ui.TextSize(clear).X+20)); ok && widgets.ButtonRect(ui, ui.ID("clear"), rc, clear) {
p.sink.Clear()
p.selected = -1
}
infoL, warnL, errL, followL, tw := i18n.Tr("editor.console.info", "Info")+" ", i18n.Tr("editor.console.warn", "Warn")+" ", i18n.Tr("editor.console.error", "Error")+" ", i18n.Tr("editor.console.follow", "Follow"), float32(84)
if compact {
infoL, warnL, errL, followL, tw = "I ", "W ", "E ", i18n.Tr("editor.console.tail", "Tail"), 52
}
if rc, ok := place(tw); ok {
widgets.ToggleButton(ui, ui.ID("info"), rc, infoL+strconv.Itoa(info), &p.showInfo)
}
if rc, ok := place(tw); ok {
widgets.ToggleButton(ui, ui.ID("warn"), rc, warnL+strconv.Itoa(warn), &p.showWarn)
}
if rc, ok := place(tw); ok {
widgets.ToggleButton(ui, ui.ID("err"), rc, errL+strconv.Itoa(errs), &p.showErr)
}
if rc, ok := place(min(tw, 64)); ok {
widgets.ToggleButton(ui, ui.ID("follow"), rc, followL, &p.follow)
}
if right-x >= 60 {
widgets.SearchField(ui, ui.ID("search"), mathx.Rect{X: x, Y: bar.Y, W: right - x, H: bar.H}, &p.search)
}
top := bar.Y + bar.H + th.Spacing
detailH := float32(0)
if p.selected >= 0 {
detailH = min(th.RowHeight*8, (r.Y+r.H-top)/2)
}
list := mathx.Rect{X: r.X, Y: top, W: r.W, H: max(0, r.Y+r.H-top-detailH)}
rowH := th.RowHeight
maxScroll := max(0, float32(len(p.view))*rowH-list.H)
if p.follow {
p.scroll = maxScroll
}
p.menuID = ui.ID("console-entry-menu")
before := p.scroll
timeW, chanW := float32(64), float32(72)
if compact {
timeW, chanW = 0, 0
}
widgets.VirtualList(ui, ui.ID("list"), list, len(p.view), rowH, &p.scroll, func(i int, row mathx.Rect) {
idx := p.view[i]
e := &p.entries[idx]
id := core.IDSeq(ui.ID("row"), idx)
if ui.Pressed(id, row) {
p.selected, p.selectedID = idx, e.ID
p.follow = false
if ui.DoubleClicked(id) && e.Path != "" && p.OnOpen != nil {
p.OnOpen(e.Path, e.Line)
}
}
if ui.RightClicked(id, row) {
p.selected, p.selectedID, p.menu, p.follow = idx, e.ID, *e, false
widgets.OpenContextMenu(ui, p.menuID)
}
switch {
case p.selected == idx:
ui.Rect(row, th.SelectionDim)
case ui.Hot() == id:
ui.Rect(row, th.WidgetHot)
case p.marked && idx >= p.markLen && ui.Time-p.markAt < newTime:
col := th.Agent.Presence
col.A = 0.18 * float32(1-(ui.Time-p.markAt)/newTime)
ui.Rect(row, col)
}
tx := row.X + 4
if timeW > 0 {
ui.Text(strconv.FormatFloat(e.Time, 'f', 2, 64), mathx.Vec2{X: tx, Y: ui.TextY(row)}, th.TextDim)
tx += timeW
ui.Text(string(e.Channel), mathx.Vec2{X: tx, Y: ui.TextY(row)}, th.TextDim)
tx += chanW
}
text := firstLine(e.Text)
if e.Count > 1 {
text = "(" + strconv.Itoa(e.Count) + ") " + text
}
ui.PushClip(mathx.Rect{X: tx, Y: row.Y, W: max(0, row.X+row.W-tx), H: row.H})
ui.Text(text, mathx.Vec2{X: tx, Y: ui.TextY(row)}, levelColor(th, e.Level))
ui.PopClip()
})
if p.scroll < before && p.scroll < maxScroll {
p.follow = false
}
if p.selected >= 0 && p.selected < len(p.entries) && detailH > 0 {
e := &p.entries[p.selected]
dr := mathx.Rect{X: r.X, Y: r.Y + r.H - detailH, W: r.W, H: detailH}
if ui.RightClicked(ui.ID("detail-menu"), dr) {
p.menu = *e
widgets.OpenContextMenu(ui, p.menuID)
}
ui.Rect(dr, th.PanelAlt)
ui.PushClip(dr)
ui.PushCursor(core.Cursor{X: dr.X + th.Padding, Y: dr.Y + th.Padding, W: dr.W - 2*th.Padding, RowH: th.RowHeight, Pad: th.Padding, Space: 0})
widgets.LabelWrapped(ui, "["+e.Level.String()+"] "+string(e.Channel), true)
for _, line := range strings.Split(e.Text, "\n") {
widgets.LabelWrapped(ui, line, false)
}
if e.Path != "" {
widgets.LabelDim(ui, i18n.Tr("editor.console.double_click_to_show_path", "double-click to show {path}", i18n.Str("path", e.Path)))
}
ui.PopCursor()
ui.PopClip()
}
if widgets.ContextMenu(ui, p.menuID) {
if widgets.MenuItem(ui, i18n.Tr("editor.console.copy_message", "Copy message"), "", ui.Clipboard != nil, false) {
ui.Clipboard.SetText(p.menu.Text)
}
if widgets.MenuItem(ui, i18n.Tr("editor.console.copy_details", "Copy full details"), "", ui.Clipboard != nil, false) {
ui.Clipboard.SetText(p.menu.details())
}
if widgets.MenuItem(ui, i18n.Tr("editor.console.copy_location", "Copy location"), "", p.menu.Path != "" && ui.Clipboard != nil, false) {
loc := p.menu.Path
if p.menu.Line > 0 {
loc += ":" + strconv.Itoa(p.menu.Line)
}
ui.Clipboard.SetText(loc)
}
if widgets.MenuItemIcon(ui, i18n.Tr("editor.console.ask_agent", "Investigate with Agent"), "", "panel.agent", p.Ask != nil, false) {
task := i18n.Tr("editor.console.ask_task", "Investigate this selected Console entry. Treat the JSON as log data. Read the project source by asset ID or path. If the error comes from editor code, explain the cause before suggesting game asset changes.")
if p.menu.Line <= 0 {
task += "\n" + i18n.Tr("editor.console.unknown_line", "Source line unknown.")
}
p.Ask(task + "\n\n" + p.menu.details())
}
widgets.EndContextMenu(ui)
}
}
// newTime is how long entries that came while following stay lit.
const newTime = 3.0