package main import ( "astronaut/styles" "astronaut/ui" "bufio" "io" "strings" "sync" "github.com/gdamore/tcell/v2" ) // Wrapped represents a line that has been wrapped. type Wrapped struct { Index int Type LineType Text string Wrapped bool } // Text represents a text document. type Text struct { mediatype string view *ui.View cfg DisplayConfig rc io.ReadCloser mu sync.RWMutex lines []Line // parsed lines tabindex []int // list of tabbable lines processed int // number of processed lines wrapped []Wrapped // wrapped lines pos []int // list of line positions nwrapped int // number of lines which have been wrapped title string // document title focus int // focused link action func(string) } // NewText returns a new text document with the given media type. func NewText(view *ui.View, tab *Tab, rc io.ReadCloser, mediatype string) *Text { return &Text{ mediatype: mediatype, view: view, cfg: tab.browser.config.Display, rc: rc, focus: -1, } } // NewParsedGemText returns a new, already parsed Gemini text document. func NewParsedGemText(view *ui.View, tab *Tab, rc io.ReadCloser) *Text { defer rc.Close() text := NewText(view, tab, rc, "text/gemini") text.Parse() return text } // Parse parses the text document. func (t *Text) Parse() error { switch t.mediatype { case "text/gemini": return t.parseGemtext() default: return t.parsePlaintext() } } // Close stops the parsing of the text document. func (t *Text) Close() error { return t.rc.Close() } func (t *Text) parseGemtext() error { // Add an empty newline t.appendLine(Line{}) scanner := bufio.NewScanner(t.rc) parser := NewParser() var graphic bool loop: for scanner.Scan() { line := parser.ParseLine(scanner.Text()) switch line.Type { case LinePreformattingStart: // Trim whitespace line.Text = strings.TrimSpace(line.Text) // The presence of alt text indicates that the preformatted text // is a graphic (i.e., not accessible). graphic = len(line.Text) != 0 if !t.cfg.AltText { // Hide alt text continue loop } case LinePreformattingEnd: graphic = false continue loop case LinePreformattedText: if graphic && !t.cfg.Graphics { // Hide graphics continue loop } } t.appendLine(line) } return scanner.Err() } func (t *Text) parsePlaintext() error { scanner := bufio.NewScanner(t.rc) for scanner.Scan() { t.appendLine(Line{ Type: LinePreformattedText, Text: scanner.Text(), }) } return scanner.Err() } func (t *Text) appendLine(line Line) { t.mu.Lock() defer t.mu.Unlock() t.lines = append(t.lines, line) t.view.Invalidate() } // Title returns the title of the text document. func (t *Text) Title() string { return t.title } // Update updates the text document. func (t *Text) Update() { t.mu.RLock() defer t.mu.RUnlock() const padding = 4 width, height := t.view.Size() width -= padding * 2 // Process new lines // Processing happens only once per line for ; t.processed < len(t.lines); t.processed++ { line := t.lines[t.processed] // Set title if len(t.title) == 0 && line.Type == LineHeading1 { t.title = line.Text t.view.Invalidate() } // Populate tab index if line.Tabindex != 0 { t.tabindex = append(t.tabindex, t.processed) } } if t.nwrapped < len(t.lines) { vy := t.view.ScrollY() y := len(t.wrapped) // If the new lines are within the view area if y >= vy && y < vy+height { // Invalidate the view t.view.Invalidate() } } // Wrap newly added lines // Wrapping happens every time the screen is resized for ; t.nwrapped < len(t.lines); t.nwrapped++ { line := t.lines[t.nwrapped] text := line.Text // Store line position t.pos = append(t.pos, len(t.wrapped)) if line.Type == LinePreformattedText { // Don't wrap t.wrapped = append(t.wrapped, Wrapped{ Index: t.nwrapped, Type: LinePreformattedText, Text: text, }) continue } if line.Type == LineLink && len(text) == 0 { text = line.URL } wrapped := ui.WordWrap(text, width) for i, text := range wrapped { t.wrapped = append(t.wrapped, Wrapped{ Index: t.nwrapped, Type: line.Type, Text: text, Wrapped: i != 0, }) } } } // Draw draws the text document. func (t *Text) Draw() { t.mu.RLock() defer t.mu.RUnlock() t.view.Fill(' ', styles.Default) const padding = 4 w, h := t.view.Size() w -= padding * 2 vy := t.view.ScrollY() for y := vy; y < vy+h && y < len(t.wrapped); y++ { var style tcell.Style var prefix string var pstyle tcell.Style x := padding line := t.wrapped[y] switch line.Type { case LineHeading1: style = styles.Heading1 if !line.Wrapped { prefix = " # " pstyle = style } case LineHeading2: style = styles.Heading2 if !line.Wrapped { prefix = " ## " pstyle = style } case LineHeading3: style = styles.Heading3 if !line.Wrapped { prefix = "### " pstyle = style } case LineQuote: style = styles.Quote prefix = " > " pstyle = style case LineLink: style = styles.Link if line.Index == t.focus { style = style.Reverse(true) } if !line.Wrapped { prefix = " => " pstyle = styles.Link.Underline(false) } case LineListItem: style = styles.ListItem if !line.Wrapped { prefix = " • " pstyle = style } case LineText: style = styles.Text case LinePreformattedText: // Don't use padding w, _ := t.view.Size() x = 0 style = styles.Pre for i := 0; i < w; i++ { t.view.SetContent(x+i, y, ' ', nil, style) } } if len(prefix) != 0 { t.view.DrawText(0, y, prefix, pstyle) } t.view.DrawText(x, y, line.Text, style) } t.view.SetContentSize(w, len(t.wrapped)+h-1, true) } // Invalidate invalidates the text document, forcing a redraw of all the text. func (t *Text) Invalidate() { t.wrapped = nil t.nwrapped = 0 t.pos = nil } // LinkAt reports whether there is a link at y, returning the link index if so. func (t *Text) LinkAt(y int) (int, bool) { if y >= len(t.wrapped) { return -1, false } line := t.wrapped[y] if line.Type != LineLink { return -1, false } return line.Index, true } // LinkURL returns the URL of the link at the given index. func (t *Text) LinkURL(idx int) string { return t.lines[idx].URL }