package code import ( "fmt" "log/slog" "runtime" "slices" "strings" "github.com/egoist/mygo/ui" "github.com/ZacharyZhang-NY/MujicaUI/core" "github.com/ZacharyZhang-NY/MujicaUI/data" "github.com/ZacharyZhang-NY/MujicaUI/icons" "github.com/ZacharyZhang-NY/MujicaUI/internal" "github.com/ZacharyZhang-NY/MujicaUI/theme" ) // CodeViewerOptions configure a CodeViewer. type CodeViewerOptions struct { // Language picks the highlighting; empty is plain. Language string // Label names the view for assistive technology. Label string Disabled bool } // CodeViewerState is a CodeViewer's current line, selection, folds and place. type CodeViewerState struct { // Selection holds the selected line indices. Selection ui.Selection[int] // Folded holds the first lines of folded blocks. Folded ui.Selection[int] rows ui.ListState cursor int shown []int inBlock []bool ends []int Data *string n int lang string folds int started bool } // Line returns the current line index, or -1. func (s *CodeViewerState) Line() int { if s.cursor < 0 || s.cursor >= len(s.shown) { return -1 } return s.shown[s.cursor] } // Visible returns the first and last lines in view. func (s *CodeViewerState) Visible() (first, last int) { a, b := s.rows.Visible() if len(s.shown) == 0 || a < 0 { return 0, -1 } return s.shown[min(a, len(s.shown)-1)], s.shown[min(max(b, a), len(s.shown)-1)] } // ScrollTo brings line to the top. func (s *CodeViewerState) ScrollTo(line int) { row, _ := slices.BinarySearch(s.shown, line) s.rows.ScrollTo(row, ui.Start) } // GoTo makes line current, unfolding and scrolling to it. func (s *CodeViewerState) GoTo(line int) { for start := range s.Folded.All() { if start < line && line <= s.ends[start] { s.Folded.Remove(start) } } s.folds = -1 s.Selection.Clear() s.Selection.Add(line) s.started, s.cursor = true, -2-line // resolved to a row once the rows are known } // CodeViewer is a read-only, foldable, virtualized code view. func CodeViewer(c *ui.Context, lines []string, s *CodeViewerState, opts CodeViewerOptions) core.ChoiceResult { if s == nil { panic("mujica: CodeViewer needs a state") } if !s.started { s.started, s.cursor = true, -1 } codeViewerPrepare(lines, s, opts.Language) k := core.Tokens(c) d := core.Density(c) var res core.ChoiceResult res.Element = CodePanel(c).Disabled(opts.Disabled) res.Element.Children(func() { if len(lines) == 0 { CodeCaption(c, core.Msg(c, "code_viewer.empty")).Padding(d.Unit() * 4) return } digits := len(fmt.Sprint(len(lines))) gutter := codeTextWidth(c, strings.Repeat("9", digits)) s.rows.Selected = &s.cursor s.rows.Selection = &s.Selection s.rows.Key = func(i int) any { return s.shown[i] } s.rows.Label = func(i int) string { return fmt.Sprintf("%d %s", s.shown[i]+1, lines[s.shown[i]]) } restore := data.DataSelectTheme(c, 0) list := ui.List(c, &s.rows, len(s.shown), func(i int) { li := s.shown[i] ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2, 0, 0).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { CodeSelectMark(c, i == s.cursor) if s.ends[li] > li { label := core.Msg(c, "code_viewer.fold") if s.Folded.Has(li) { label = core.Msg(c, "code_viewer.unfold") } chev := "chevron-down" if s.Folded.Has(li) { chev = "chevron-right" } if core.IconAction(c, icons.Must(chev), fmt.Sprintf(label, li+1)).Clicked() && !res.Element.IsDisabled() { codeViewerToggle(s, li) } } else { ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0) } CodeMono(c, fmt.Sprint(li+1)).Width(gutter).TextAlign(ui.End).TextColor(k.TextMuted).Shrink(0) codeViewerLine(c, k, opts.Language, lines[li], s.inBlock[li]) }) }) restore() list.Grow(1).MinHeight(d.ControlHeight() * 3).Label(opts.Label) internal.Ring(list, k.Focus, theme.ControlRadius) if list.Changed() { res = core.NewChoiceResult(res.Element, true) slog.Debug("mujica: code_viewer line", "line", s.Line()) } codeViewerKeys(c, list, lines, s) }) return res } // codeViewerKeys copies the selection and folds with Left and Right. func codeViewerKeys(c *ui.Context, list *ui.Element, lines []string, s *CodeViewerState) { if list.IsDisabled() { return } copyMods := ui.Cmd if runtime.GOOS != "darwin" { copyMods = ui.Ctrl } line := s.Line() switch { case list.Shortcut(copyMods, ui.KeyC): picked := slices.Sorted(s.Selection.All()) if len(picked) == 0 && line >= 0 { picked = []int{line} } out := make([]string, len(picked)) for i, l := range picked { out[i] = lines[l] } c.WriteClipboard(strings.Join(out, "\n")) slog.Debug("mujica: code_viewer copy", "lines", len(out)) case list.Shortcut(0, ui.KeyLeft) && line >= 0 && s.ends[line] > line && !s.Folded.Has(line): codeViewerToggle(s, line) case list.Shortcut(0, ui.KeyRight) && line >= 0 && s.Folded.Has(line): codeViewerToggle(s, line) } } func codeViewerToggle(s *CodeViewerState, line int) { if s.Folded.Has(line) { s.Folded.Remove(line) } else { s.Folded.Add(line) } s.folds = -1 slog.Debug("mujica: code_viewer fold", "line", line, "folded", s.Folded.Has(line)) } // codeViewerLine draws one line's syntax tokens. func codeViewerLine(c *ui.Context, k theme.Tokens, lang, line string, inBlock bool) { toks, _ := internal.Highlight(lang, line, inBlock) spans := make([]ui.Span, len(toks)) for i, t := range toks { spans[i] = ui.Span{Text: t.Text, Color: CodeTokenColor(k, t.Kind), Italic: t.Kind == internal.TokComment} } ui.RichText(c, spans...).Font(theme.MonoFont).FontSize(core.FontSize(c, CodeSize)).SingleLine().Grow(1).MinWidth(0) } // codeViewerPrepare rescans code and folds when they change. func codeViewerPrepare(lines []string, s *CodeViewerState, lang string) { var first *string if len(lines) > 0 { first = &lines[0] } if s.Data != first || s.n != len(lines) || s.lang != lang { s.Data, s.n, s.lang, s.folds = first, len(lines), lang, -1 s.inBlock = slices.Grow(s.inBlock[:0], len(lines))[:len(lines)] in := false for i, l := range lines { s.inBlock[i] = in _, in = internal.Highlight(lang, l, in) } s.ends = codeViewerFolds(lines, s.ends) } if s.folds == s.Folded.Len() { return } s.folds = s.Folded.Len() s.shown = s.shown[:0] for i := 0; i < len(lines); i++ { s.shown = append(s.shown, i) if s.Folded.Has(i) { i = s.ends[i] } } if s.cursor <= -2 { s.cursor = max(slices.Index(s.shown, -2-s.cursor), 0) s.rows.ScrollIntoView(s.cursor) } s.cursor = min(s.cursor, len(s.shown)-1) } // codeViewerFolds returns each line's indented block end. func codeViewerFolds(lines []string, ends []int) []int { ends = slices.Grow(ends[:0], len(lines))[:len(lines)] indent := func(l string) int { if strings.TrimSpace(l) == "" { return -1 } return len(l) - len(strings.TrimLeft(l, " \t")) } type open struct{ line, indent int } var stack []open last := -1 for i, l := range lines { ends[i] = i in := indent(l) if in < 0 { continue } for len(stack) > 0 && stack[len(stack)-1].indent >= in { top := stack[len(stack)-1] stack = stack[:len(stack)-1] ends[top.line] = last } stack = append(stack, open{i, in}) last = i } for _, o := range stack { ends[o.line] = last } return ends }