1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2025-07-09 07:00:21 +02:00
aerc/app/terminal.go
Karel Balej 5919e9dfda term: allow setting custom title for the terminal tab
Add a template for the terminal tab title and define two
terminal-specific properties: Title which expands to whatever the
underlying application requests via OSC and defaults to the command name
and Bell which is a boolean indicating whether the application has
emitted bell, the flag is cleared when the corresponding tab gains focus
and is not set at all if it currently has it.

This can among other things be used to highlight the terminal tab when
the underlying process prints the bell, such as by setting the template
as such:

	tab-title-terminal={{if .Bell}}[BELL] {{end}}{{.Title}}

Implements: https://todo.sr.ht/~rjarry/aerc/138
Changelog-added: A tab-title-terminal setting for customizing the
 title of tabs in which the terminal widget is run.
Requested-by: Drew DeVault <drew@ddevault.org>
Signed-off-by: Karel Balej <balejk@matfyz.cz>
Acked-by: Robin Jarry <robin@jarry.cc>
2025-05-05 13:49:04 +02:00

224 lines
4.3 KiB
Go

package app
import (
"bytes"
"os/exec"
"sync/atomic"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
"git.sr.ht/~rockorager/vaxis/widgets/term"
)
type HasTerminal interface {
Terminal() *Terminal
}
type Terminal struct {
// Bell was rung in the terminal since it last had focus.
bell bool
closed int32
visible int32 // visible if >0
cmd *exec.Cmd
ctx *ui.Context
focus bool
vterm *term.Model
running bool
tab *ui.Tab
// The window title set by the application running in the terminal.
title string
OnClose func(err error)
OnEvent func(event vaxis.Event) bool
OnStart func()
OnTitle func(title string)
}
func NewTerminal(cmd *exec.Cmd) (*Terminal, error) {
term := &Terminal{
cmd: cmd,
vterm: term.New(),
visible: 1,
}
term.vterm.OSC8 = config.General.EnableOSC8
term.vterm.TERM = config.General.Term
return term, nil
}
func (term *Terminal) Close() {
term.closeErr(nil)
}
// TODO: replace with atomic.Bool when min go version will have it (1.19+)
const closed int32 = 1
func (term *Terminal) isClosed() bool {
return atomic.LoadInt32(&term.closed) == closed
}
func (term *Terminal) closeErr(err error) {
if atomic.SwapInt32(&term.closed, closed) == closed {
return
}
if term.vterm != nil {
// Stop receiving events
term.vterm.Detach()
term.vterm.Close()
if term.ctx != nil {
term.ctx.HideCursor()
}
}
if term.OnClose != nil {
term.OnClose(err)
}
ui.Invalidate()
}
func (term *Terminal) Destroy() {
// If we destroy, we don't want to call the OnClose callback
term.OnClose = nil
term.closeErr(nil)
}
func (term *Terminal) Invalidate() {
ui.Invalidate()
}
func (term *Terminal) Draw(ctx *ui.Context) {
if ctx.Width() == 0 || ctx.Height() == 0 {
return
}
term.ctx = ctx
if !term.running && term.cmd != nil {
term.vterm.Attach(term.HandleEvent)
w, h := ctx.Window().Size()
if err := term.vterm.StartWithSize(term.cmd, w, h); err != nil {
log.Errorf("error running terminal: %v", err)
term.closeErr(err)
return
}
term.running = true
if term.OnStart != nil {
term.OnStart()
}
}
term.vterm.Draw(ctx.Window())
}
func (term *Terminal) Show(visible bool) {
if visible {
atomic.StoreInt32(&term.visible, 1)
} else {
atomic.StoreInt32(&term.visible, 0)
}
}
func (term *Terminal) Terminal() *Terminal {
return term
}
func (term *Terminal) MouseEvent(localX int, localY int, event vaxis.Event) {
ev, ok := event.(vaxis.Mouse)
if !ok {
return
}
if term.OnEvent != nil {
term.OnEvent(ev)
}
if term.isClosed() {
return
}
ev.Row = localY
ev.Col = localX
term.vterm.Update(ev)
}
func (term *Terminal) Focus(focus bool) {
if term.isClosed() {
return
}
term.focus = focus
term.bell = false
if term.focus {
term.vterm.Focus()
} else {
term.vterm.Blur()
}
term.setTitle()
ui.Invalidate()
}
// HandleEvent is used to watch the underlying terminal events
func (t *Terminal) HandleEvent(ev vaxis.Event) {
if t.isClosed() {
return
}
switch ev := ev.(type) {
case vaxis.Redraw:
if atomic.LoadInt32(&t.visible) > 0 {
ui.Invalidate()
}
case term.EventTitle:
t.title = string(ev)
t.setTitle()
ui.Invalidate()
if t.OnTitle != nil {
t.OnTitle(string(ev))
}
case term.EventClosed:
t.Close()
ui.Invalidate()
case term.EventBell:
if !t.focus {
t.bell = true
t.setTitle()
ui.Invalidate()
}
aerc.Beep()
}
}
func (term *Terminal) Event(event vaxis.Event) bool {
if term.OnEvent != nil {
if term.OnEvent(event) {
return true
}
}
if term.isClosed() {
return false
}
term.vterm.Update(event)
return true
}
// Renders the terminal tab title based on the template, falls back to the
// unformatted title in case of errors.
func (t *Terminal) RenderTitle() string {
title := t.title
if title == "" {
title = t.cmd.Path
}
data := state.NewDataSetter()
data.SetTitle(title)
data.SetBell(t.bell)
var buf bytes.Buffer
if err := config.Ui.TabTitleTerminal.Execute(&buf, data); err != nil {
PushError("failed to render terminal tab title: " + err.Error())
return title
}
return buf.String()
}
func (t *Terminal) SetTab(tab *ui.Tab) {
t.tab = tab
}
func (t *Terminal) setTitle() {
if t.tab != nil {
t.tab.SetTitle(t.RenderTitle())
}
}