From 5a9b1b9709958b4ee9be85f8dab0f7e939aa6764 Mon Sep 17 00:00:00 2001 From: Gabe Cook Date: Fri, 24 Nov 2023 14:22:01 -0600 Subject: [PATCH] feat: Add support for non-color terminals Closes #35 --- cmd/play/cmd.go | 3 +- go.mod | 2 ++ go.sum | 4 +-- internal/movie/player.go | 28 ++++++++++++----- internal/movie/player_help.go | 56 +++++++++++++++++++++++++++++++++ internal/movie/player_styles.go | 8 +++++ internal/movie/player_test.go | 5 +-- internal/server/ssh.go | 6 +++- internal/server/telnet.go | 17 +++++----- internal/server/telnet/proxy.go | 44 +++++++++++++++++++++++--- internal/util/term.go | 26 +++++++++++++++ 11 files changed, 174 insertions(+), 25 deletions(-) create mode 100644 internal/movie/player_help.go create mode 100644 internal/util/term.go diff --git a/cmd/play/cmd.go b/cmd/play/cmd.go index 6f0b05a6..78724225 100644 --- a/cmd/play/cmd.go +++ b/cmd/play/cmd.go @@ -4,6 +4,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/gabe565/ascii-movie/internal/config" "github.com/gabe565/ascii-movie/internal/movie" + "github.com/muesli/termenv" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -38,7 +39,7 @@ func run(cmd *cobra.Command, args []string) (err error) { return err } - program := tea.NewProgram(movie.NewPlayer(&m, nil)) + program := tea.NewProgram(movie.NewPlayer(&m, nil, termenv.ColorProfile())) if _, err := program.Run(); err != nil { return err } diff --git a/go.mod b/go.mod index 5a1b2374..2739c246 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/gabe565/ascii-movie go 1.21.4 +replace github.com/charmbracelet/lipgloss => github.com/gabe565/lipgloss v0.0.0-20231124201931-3d7efac1ed1b + require ( github.com/charmbracelet/bubbles v0.16.1 github.com/charmbracelet/bubbletea v0.24.3-0.20231107170225-a6f07b8ba643 diff --git a/go.sum b/go.sum index 3ce6a967..77777954 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,6 @@ github.com/charmbracelet/bubbletea v0.24.3-0.20231107170225-a6f07b8ba643 h1:YvPB github.com/charmbracelet/bubbletea v0.24.3-0.20231107170225-a6f07b8ba643/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc= github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8= -github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= -github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/charmbracelet/log v0.2.5 h1:1yVvyKCKVV639RR4LIq1iy1Cs1AKxuNO+Hx2LJtk7Wc= github.com/charmbracelet/log v0.2.5/go.mod h1:nQGK8tvc4pS9cvVEH/pWJiZ50eUq1aoXUOjGpXvdD0k= github.com/charmbracelet/ssh v0.0.0-20230822194956-1a051f898e09 h1:ZDIQmTtohv0S/AAYE//w8mYTxCzqphhF1+4ACPDMiLU= @@ -30,6 +28,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabe565/lipgloss v0.0.0-20231124201931-3d7efac1ed1b h1:BZ+kR2CvKQxBWIJ0SAWx4O5QvhnnywR4RLjjYFGYyZw= +github.com/gabe565/lipgloss v0.0.0-20231124201931-3d7efac1ed1b/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= diff --git a/internal/movie/player.go b/internal/movie/player.go index dc893bde..4ec814b4 100644 --- a/internal/movie/player.go +++ b/internal/movie/player.go @@ -9,17 +9,25 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/gabe565/ascii-movie/internal/log_hooks" + "github.com/muesli/termenv" log "github.com/sirupsen/logrus" ) -func NewPlayer(m *Movie, logger *log.Entry) Player { +func NewPlayer(m *Movie, logger *log.Entry, profile termenv.Profile) Player { player := Player{ movie: m, + profile: profile, speed: 1, selectedOption: 3, activeOption: 4, + optionsStyle: &optionsStyle, + activeStyle: &activeStyle, optionViewStale: true, } + if profile == termenv.Ascii { + player.optionsStyle = &optionsStyleAnsi + player.activeStyle = &activeStyleAnsi + } player.play() if logger != nil { player.durationHook = log_hooks.NewDuration() @@ -28,7 +36,7 @@ func NewPlayer(m *Movie, logger *log.Entry) Player { player.keymap = newKeymap() helpModel := help.New() - player.helpViewCache = helpModel.ShortHelpView([]key.Binding{ + player.helpViewCache = RenderHelpWithProfile(profile, helpModel, []key.Binding{ player.keymap.quit, player.keymap.left, player.keymap.right, @@ -43,6 +51,7 @@ type Player struct { frame int log *log.Entry durationHook log_hooks.Duration + profile termenv.Profile speed float64 playCtx context.Context @@ -50,6 +59,8 @@ type Player struct { selectedOption int activeOption int + optionsStyle *lipgloss.Style + activeStyle *lipgloss.Style optionViewCache string optionViewStale bool @@ -184,10 +195,10 @@ func (p Player) View() string { p.optionViewCache = p.OptionsView() } - return appStyle.Render(lipgloss.JoinVertical( + return appStyle.RenderWithProfile(p.profile, lipgloss.JoinVertical( lipgloss.Center, - p.movie.screenStyle.Render(p.movie.Frames[p.frame].Data), - progressStyle.Render(p.movie.Frames[p.frame].Progress), + p.movie.screenStyle.RenderWithProfile(p.profile, p.movie.Frames[p.frame].Data), + progressStyle.RenderWithProfile(p.profile, p.movie.Frames[p.frame].Progress), p.optionViewCache, p.helpViewCache, )) @@ -195,6 +206,7 @@ func (p Player) View() string { func (p *Player) OptionsView() string { p.optionViewStale = false + options := make([]string, 0, len(playerOptions)) for i, option := range playerOptions { if option == OptionPause && !p.isPlaying() { @@ -202,11 +214,11 @@ func (p *Player) OptionsView() string { } var rendered string if i == p.selectedOption { - rendered = selectedStyle.Render(string(option)) + rendered = selectedStyle.RenderWithProfile(p.profile, string(option)) } else if i == p.activeOption { - rendered = activeStyle.Render(string(option)) + rendered = p.activeStyle.RenderWithProfile(p.profile, string(option)) } else { - rendered = optionsStyle.Render(string(option)) + rendered = p.optionsStyle.RenderWithProfile(p.profile, string(option)) } options = append(options, rendered) } diff --git a/internal/movie/player_help.go b/internal/movie/player_help.go new file mode 100644 index 00000000..2acf3466 --- /dev/null +++ b/internal/movie/player_help.go @@ -0,0 +1,56 @@ +package movie + +import ( + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +func RenderHelpWithProfile(p termenv.Profile, m help.Model, bindings []key.Binding) string { + if len(bindings) == 0 { + return "" + } + + var b strings.Builder + var totalWidth int + separator := m.Styles.ShortSeparator.Inline(true).RenderWithProfile(p, m.ShortSeparator) + + for i, kb := range bindings { + if !kb.Enabled() { + continue + } + + var sep string + if totalWidth > 0 && i < len(bindings) { + sep = separator + } + + str := sep + + m.Styles.ShortKey.Inline(true).RenderWithProfile(p, kb.Help().Key) + " " + + m.Styles.ShortDesc.Inline(true).RenderWithProfile(p, kb.Help().Desc) + + w := lipgloss.Width(str) + + // If adding this help item would go over the available width, stop + // drawing. + if m.Width > 0 && totalWidth+w > m.Width { + // Although if there's room for an ellipsis, print that. + tail := " " + m.Styles.Ellipsis.Inline(true).RenderWithProfile(p, m.Ellipsis) + tailWidth := lipgloss.Width(tail) + + if totalWidth+tailWidth < m.Width { + b.WriteString(tail) + } + + break + } + + totalWidth += w + b.WriteString(str) + } + + return b.String() +} diff --git a/internal/movie/player_styles.go b/internal/movie/player_styles.go index fad2e76a..e1cf6794 100644 --- a/internal/movie/player_styles.go +++ b/internal/movie/player_styles.go @@ -27,6 +27,11 @@ var ( BorderForeground(optionsColor). Background(optionsColor) + optionsStyleAnsi = optionsStyle.Copy(). + Padding(0, 2). + Margin(1). + Border(lipgloss.InnerHalfBlockBorder(), false) + activeColor = lipgloss.AdaptiveColor{Light: "8", Dark: "12"} activeStyle = optionsStyle.Copy(). Background(activeColor). @@ -34,6 +39,9 @@ var ( Foreground(lipgloss.AdaptiveColor{Light: "15"}). Bold(true) + activeStyleAnsi = activeStyle.Copy(). + BorderStyle(lipgloss.DoubleBorder()) + selectedColor = lipgloss.AdaptiveColor{Light: "12", Dark: "4"} selectedStyle = optionsStyle.Copy(). Background(selectedColor). diff --git a/internal/movie/player_test.go b/internal/movie/player_test.go index 24531219..e0a76525 100644 --- a/internal/movie/player_test.go +++ b/internal/movie/player_test.go @@ -3,6 +3,7 @@ package movie import ( "testing" + "github.com/muesli/termenv" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) @@ -10,7 +11,7 @@ import ( func TestNewPlayer(t *testing.T) { t.Run("simple", func(t *testing.T) { movie := NewMovie() - player := NewPlayer(&movie, log.WithField("test", t.Name())) + player := NewPlayer(&movie, log.WithField("test", t.Name()), termenv.ColorProfile()) assert.Equal(t, &movie, player.movie) assert.NotNil(t, player.log) assert.NotEmpty(t, player.durationHook) @@ -18,7 +19,7 @@ func TestNewPlayer(t *testing.T) { t.Run("no logger", func(t *testing.T) { movie := NewMovie() - player := NewPlayer(&movie, nil) + player := NewPlayer(&movie, nil, termenv.ColorProfile()) assert.Equal(t, &movie, player.movie) assert.Nil(t, player.log) assert.Empty(t, player.durationHook) diff --git a/internal/server/ssh.go b/internal/server/ssh.go index 749fbf88..909bd3f0 100644 --- a/internal/server/ssh.go +++ b/internal/server/ssh.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/wish" "github.com/charmbracelet/wish/bubbletea" "github.com/gabe565/ascii-movie/internal/movie" + "github.com/gabe565/ascii-movie/internal/util" "github.com/muesli/termenv" log "github.com/sirupsen/logrus" flag "github.com/spf13/pflag" @@ -112,7 +113,10 @@ func (s *SSHServer) Handler(m *movie.Movie) bubbletea.ProgramHandler { "user": session.User(), }) - player := movie.NewPlayer(m, logger) + pty, _, _ := session.Pty() + profile := util.Profile(pty.Term) + + player := movie.NewPlayer(m, logger, profile) program := tea.NewProgram( player, tea.WithInput(session), diff --git a/internal/server/telnet.go b/internal/server/telnet.go index 0eb6e981..edecaa63 100644 --- a/internal/server/telnet.go +++ b/internal/server/telnet.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/gabe565/ascii-movie/internal/movie" "github.com/gabe565/ascii-movie/internal/server/telnet" + "github.com/gabe565/ascii-movie/internal/util" flag "github.com/spf13/pflag" ) @@ -108,7 +109,15 @@ func (s *TelnetServer) Handler(ctx context.Context, conn net.Conn, m *movie.Movi ctx, cancel := context.WithCancel(ctx) defer cancel() - player := movie.NewPlayer(m, logger) + termCh := make(chan string) + go func() { + // Proxy input to program + _ = telnet.Proxy(conn, inW, termCh) + cancel() + }() + profile := util.Profile(<-termCh) + + player := movie.NewPlayer(m, logger, profile) program := tea.NewProgram( player, tea.WithInput(inR), @@ -140,12 +149,6 @@ func (s *TelnetServer) Handler(ctx context.Context, conn net.Conn, m *movie.Movi _, _ = io.Copy(io.Discard, outR) }() - go func() { - // Proxy input to program - _ = telnet.Proxy(conn, inW) - cancel() - }() - if _, err := program.Run(); err != nil && !errors.Is(err, tea.ErrProgramKilled) { logger.WithError(err).Error("Stream failed") } diff --git a/internal/server/telnet/proxy.go b/internal/server/telnet/proxy.go index 70b59d21..1dc466a3 100644 --- a/internal/server/telnet/proxy.go +++ b/internal/server/telnet/proxy.go @@ -11,9 +11,10 @@ import ( log "github.com/sirupsen/logrus" ) -func Proxy(conn net.Conn, proxy io.Writer) error { +func Proxy(conn net.Conn, proxy io.Writer, termCh chan string) error { reader := bufio.NewReaderSize(conn, 64) var wroteTelnetCommands bool + var wroteTermType bool // Gets Telnet to send option negotiation commands if explicit port was given. // Also clears the line in case the client isn't Telnet @@ -22,6 +23,15 @@ func Proxy(conn net.Conn, proxy io.Writer) error { return err } + go func() { + time.Sleep(250 * time.Millisecond) + if !wroteTermType { + wroteTermType = true + log.Trace("Did not get terminal type in time") + close(termCh) + } + }() + for { b, err := reader.ReadByte() if err != nil { @@ -33,10 +43,11 @@ func Proxy(conn net.Conn, proxy io.Writer) error { case Iac: // https://ibm.com/docs/zos/2.5.0?topic=problems-telnet-commands-options if conn != nil && !wroteTelnetCommands { - log.Trace("Writing Telnet commands") + log.Trace("Configuring Telnet") if _, err := Write(conn, Iac, Will, Echo, Iac, Will, SuppressGoAhead, + Iac, Do, TerminalType, ); err != nil { log.WithError(err).Error("Failed to write Telnet commands") } @@ -53,14 +64,39 @@ func Proxy(conn net.Conn, proxy io.Writer) error { if err := conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil { return err } - _, err := reader.ReadBytes(byte(Se)) + command, err := reader.ReadBytes(byte(Se)) if err != nil && !errors.Is(err, os.ErrDeadlineExceeded) { return err } if err := conn.SetReadDeadline(time.Time{}); err != nil { return err } - case Will, Wont, Do, Dont: + + if len(command) != 0 { + switch Operator(command[0]) { + case TerminalType: + if len(command) > 5 && !wroteTermType { + wroteTermType = true + term := string(command[2 : len(command)-2]) + log.Trace("Got terminal type") + termCh <- term + close(termCh) + } + } + } + case Will: + if b, err = reader.ReadByte(); err != nil { + return err + } + + switch Operator(b) { + case TerminalType: + log.Trace("Requesting terminal type") + if _, err := Write(conn, Iac, Subnegotiation, TerminalType, 1, Iac, Se); err != nil { + return err + } + } + case Wont, Do, Dont: if _, err := reader.Discard(1); err != nil { return err } diff --git a/internal/util/term.go b/internal/util/term.go new file mode 100644 index 00000000..1e5bdf35 --- /dev/null +++ b/internal/util/term.go @@ -0,0 +1,26 @@ +package util + +import ( + "strings" + + "github.com/muesli/termenv" +) + +func Profile(term string) termenv.Profile { + term = strings.ToLower(term) + switch { + case strings.Contains(term, "256color"): + return termenv.ANSI256 + case term == "", + strings.Contains(term, "color"), + strings.Contains(term, "xterm"), + strings.Contains(term, "ansi"), + strings.Contains(term, "tmux"), + strings.Contains(term, "screen"), + strings.Contains(term, "cygwin"), + strings.Contains(term, "rxvt"): + return termenv.ANSI + default: + return termenv.Ascii + } +}