123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526 |
- package ui
- import (
- "fmt"
- "servitor/ansi"
- "servitor/config"
- "servitor/feed"
- "servitor/history"
- "servitor/mime"
- "servitor/pub"
- "servitor/splicer"
- "servitor/style"
- "os/exec"
- "strconv"
- "strings"
- "sync"
- )
- const (
- loading = iota
- normal
- command
- selection
- opening
- problem
- )
- const (
- enterKey byte = '\r'
- escapeKey byte = 27
- backspaceKey byte = 127
- )
- type Page struct {
- feed *feed.Feed
- frontier pub.Tangible
- loadingUp bool
- children pub.Container
- basepoint uint
- loadingDown bool
- }
- type State struct {
- m *sync.Mutex
- h history.History[*Page]
- width int
- height int
- output func(string)
- config *config.Config
- mode int
- buffer string
- }
- func (s *State) view() string {
- const cursor = "┃ "
- const parentConnector = " │\n"
- const childConnector = "\n"
- if s.mode == loading {
- return ansi.CenterVertically("", style.Color(" Loading…"), "", uint(s.height))
- }
- var top, center, bottom string
- for i := -s.config.Context; i <= s.config.Context; i++ {
- if !s.h.Current().feed.Contains(i) {
- continue
- }
- var serialized string
- if s.h.Current().feed.IsParent(i) {
- serialized = s.h.Current().feed.Get(i).Preview(s.width - 4)
- } else if s.h.Current().feed.IsChild(i) {
- serialized = "→ " + ansi.Indent(s.h.Current().feed.Get(i).Preview(s.width-8), " ", false)
- } else {
- serialized = s.h.Current().feed.Get(i).String(s.width - 4)
- }
- if i == 0 {
- center = ansi.Indent(serialized, cursor, true)
- if s.h.Current().feed.IsParent(i) {
- bottom = parentConnector
- } else {
- bottom = childConnector
- }
- continue
- }
- serialized = ansi.Indent(serialized, " ", true) + "\n"
- if s.h.Current().feed.IsParent(i) {
- serialized += parentConnector
- } else {
- serialized += childConnector
- }
- if i < 0 {
- top += serialized
- } else {
- bottom += serialized
- }
- }
- if s.h.Current().loadingUp && !s.h.Current().feed.Contains(-s.config.Context-1) {
- top = "\n " + style.Color("Loading…") + "\n\n" + top
- }
- if s.h.Current().loadingDown && !s.h.Current().feed.Contains(s.config.Context+1) {
- bottom += " " + style.Color("Loading…") + "\n"
- }
-
- top = strings.TrimSuffix(top, "\n")
- bottom = strings.TrimSuffix(bottom, "\n")
- output := ansi.CenterVertically(top, center, bottom, uint(s.height))
- var footer string
- switch s.mode {
- case normal:
- break
- case selection:
- footer = "Selecting " + s.buffer + " (press . to open internally, enter to open externally)"
- case command:
- footer = ":" + s.buffer
- case opening:
- footer = "Opening " + s.buffer + "\u2026"
- case problem:
- footer = s.buffer
- default:
- panic("encountered unrecognized mode")
- }
- if footer != "" {
- output = ansi.ReplaceLastLine(output, style.Highlight(ansi.SetLength(footer, s.width, "\u2026")))
- }
- return output
- }
- func (s *State) Update(input byte) {
- s.m.Lock()
- defer s.m.Unlock()
- if s.mode == loading {
- return
- }
- if input == escapeKey {
- s.buffer = ""
- s.mode = normal
- s.output(s.view())
- return
- }
- if input == backspaceKey {
- if len(s.buffer) == 0 {
- s.mode = normal
- s.output(s.view())
- return
- }
- s.buffer = s.buffer[:len(s.buffer)-1]
- if s.buffer == "" && s.mode == selection {
- s.mode = normal
- }
- s.output(s.view())
- return
- }
- if s.mode == command {
- if input == enterKey {
- if args := strings.SplitN(s.buffer, " ", 2); len(args) == 2 {
- err := s.subcommand(args[0], args[1])
- if err != nil {
- s.buffer = "Failed to run command: " + ansi.Squash(err.Error())
- s.mode = problem
- s.output(s.view())
- s.buffer = ""
- s.mode = normal
- }
- } else {
- s.buffer = ""
- s.mode = normal
- }
- return
- }
- s.buffer += string(input)
- s.output(s.view())
- return
- }
- if input == ':' {
- s.buffer = ""
- s.mode = command
- s.output(s.view())
- return
- }
- if input >= '0' && input <= '9' {
- if s.mode != selection {
- s.buffer = ""
- }
- s.buffer += string(input)
- s.mode = selection
- s.output(s.view())
- return
- }
- if s.mode == selection {
- if input == '.' || input == enterKey {
- number, err := strconv.Atoi(s.buffer)
- if err != nil {
- panic("buffer had a non-number while in selection mode")
- }
- link, mediaType, present := s.h.Current().feed.Current().SelectLink(number)
- if !present {
- s.buffer = ""
- s.mode = normal
- s.output(s.view())
- return
- }
- if input == '.' {
- s.openInternally(link)
- return
- }
- if input == enterKey {
- s.openExternally(link, mediaType)
- return
- }
- }
-
- s.mode = normal
- s.buffer = ""
- }
-
- switch input {
- case 'k':
- s.h.Current().feed.MoveUp()
- s.loadSurroundings()
- case 'j':
- s.h.Current().feed.MoveDown()
- s.loadSurroundings()
- case 'g':
- s.h.Current().feed.MoveToCenter()
- case 'h':
- s.h.Back()
- case 'l':
- s.h.Forward()
- case ' ':
- s.switchTo(s.h.Current().feed.Current())
- case 'c':
- unwrapped := s.h.Current().feed.Current()
- if activity, ok := unwrapped.(*pub.Activity); ok {
- unwrapped = activity.Target()
- }
- if post, ok := unwrapped.(*pub.Post); ok {
- creators := post.Creators()
- s.switchTo(creators)
- }
- case 'r':
- unwrapped := s.h.Current().feed.Current()
- if activity, ok := unwrapped.(*pub.Activity); ok {
- unwrapped = activity.Target()
- }
- if post, ok := unwrapped.(*pub.Post); ok {
- recipients := post.Recipients()
- s.switchTo(recipients)
- }
- case 'a':
- if activity, ok := s.h.Current().feed.Current().(*pub.Activity); ok {
- actor := activity.Actor()
- s.switchTo(actor)
- }
- case 'o':
- unwrapped := s.h.Current().feed.Current()
- if activity, ok := unwrapped.(*pub.Activity); ok {
- unwrapped = activity.Target()
- }
- if post, ok := unwrapped.(*pub.Post); ok {
- if link, mediaType, present := post.Media(); present {
- s.openExternally(link, mediaType)
- }
- }
- case 'p':
- if actor, ok := s.h.Current().feed.Current().(*pub.Actor); ok {
- if link, mediaType, present := actor.ProfilePic(); present {
- s.openExternally(link, mediaType)
- }
- }
- case 'b':
- if actor, ok := s.h.Current().feed.Current().(*pub.Actor); ok {
- if link, mediaType, present := actor.Banner(); present {
- s.openExternally(link, mediaType)
- }
- }
- }
- s.output(s.view())
- }
- func (s *State) switchTo(item any) {
- switch narrowed := item.(type) {
- case []pub.Tangible:
- if len(narrowed) == 0 {
- return
- }
- if len(narrowed) == 1 {
- s.h.Add(&Page{
- feed: feed.Create(narrowed[0]),
- children: narrowed[0].Children(),
- frontier: narrowed[0],
- })
- } else {
- s.h.Add(&Page{
- feed: feed.CreateAndAppend(narrowed),
- })
- }
- case pub.Tangible:
- s.h.Add(&Page{
- feed: feed.Create(narrowed),
- children: narrowed.Children(),
- frontier: narrowed,
- })
- case pub.Container:
- s.mode = loading
- s.buffer = ""
- s.output(s.view())
- children, nextCollection, newBasepoint := narrowed.Harvest(uint(s.config.Context), 0)
- s.h.Add(&Page{
- basepoint: newBasepoint,
- children: nextCollection,
- feed: feed.CreateAndAppend(children),
- })
- default:
- panic("can't switch to non-Tangible non-Container")
- }
- s.mode = normal
- s.buffer = ""
- s.loadSurroundings()
- }
- func (s *State) SetWidthHeight(width int, height int) {
- s.m.Lock()
- defer s.m.Unlock()
- if s.width == width && s.height == height {
- return
- }
- s.width = width
- s.height = height
- s.output(s.view())
- }
- func (s *State) loadSurroundings() {
- page := s.h.Current()
- context := s.config.Context
- if !page.loadingUp && !page.feed.Contains(-context) && page.frontier != nil {
- page.loadingUp = true
- go func() {
- parents, newFrontier := page.frontier.Parents(uint(context))
- s.m.Lock()
- page.feed.Prepend(parents)
- page.frontier = newFrontier
- page.loadingUp = false
- s.output(s.view())
- s.m.Unlock()
- }()
- }
- if !page.loadingDown && !page.feed.Contains(context) && page.children != nil {
- page.loadingDown = true
- go func() {
-
- children, nextCollection, newBasepoint := page.children.Harvest(uint(context), page.basepoint)
- s.m.Lock()
- page.feed.Append(children)
- page.children = nextCollection
- page.basepoint = newBasepoint
- page.loadingDown = false
- s.output(s.view())
- s.m.Unlock()
- }()
- }
- }
- func (s *State) openUserInput(input string) {
- s.mode = loading
- s.buffer = ""
- s.output(s.view())
- go func() {
- result := pub.FetchUserInput(input)
- s.m.Lock()
- s.switchTo(result)
- s.output(s.view())
- s.m.Unlock()
- }()
- }
- func (s *State) openInternally(input string) {
- s.mode = loading
- s.buffer = ""
- s.output(s.view())
- go func() {
- result := pub.New(input, nil)
- s.m.Lock()
- s.switchTo(result)
- s.output(s.view())
- s.m.Unlock()
- }()
- }
- func (s *State) openFeed(input string) {
- inputs, present := s.config.Feeds[input]
- if !present {
- s.mode = problem
- s.buffer = "Failed to open feed: " + input + " is not a known feed"
- s.output(s.view())
- s.mode = normal
- s.buffer = ""
- return
- }
- s.mode = loading
- s.buffer = ""
- s.output(s.view())
- go func() {
- result := splicer.NewSplicer(inputs)
- s.switchTo(result)
- s.output(s.view())
- }()
- }
- func NewState(config *config.Config, width int, height int, output func(string)) *State {
- s := &State{
- h: history.History[*Page]{},
- config: config,
- width: width,
- height: height,
- output: output,
- m: &sync.Mutex{},
- mode: loading,
- }
- return s
- }
- func (s *State) Subcommand(name, argument string) error {
- s.m.Lock()
- err := s.subcommand(name, argument)
- if err != nil {
-
- return err
- }
- s.m.Unlock()
- return nil
- }
- func (s *State) subcommand(name, argument string) error {
- switch name {
- case "open":
- s.openUserInput(argument)
- case "feed":
- s.openFeed(argument)
- default:
- return fmt.Errorf("unrecognized subcommand: %s", name)
- }
- return nil
- }
- func (s *State) openExternally(link string, mediaType *mime.MediaType) {
- s.mode = opening
- s.buffer = link
- s.output(s.view())
- command := make([]string, len(s.config.MediaHook))
- copy(command, s.config.MediaHook)
- foundPercentU := false
- for i, field := range command {
- if i == 0 {
- continue
- }
- switch field {
- case "%u":
- command[i] = link
- foundPercentU = true
- case "%m":
- command[i] = mediaType.Essence
- case "%s":
- command[i] = mediaType.Subtype
- case "%t":
- command[i] = mediaType.Supertype
- }
- }
- cmd := exec.Command(command[0], command[1:]...)
- if !foundPercentU {
- cmd.Stdin = strings.NewReader(link)
- }
- go func() {
- err := cmd.Run()
- s.m.Lock()
- defer s.m.Unlock()
- if s.mode != opening {
- return
- }
- if err != nil {
- s.mode = problem
- s.buffer = "Failed to open link: " + ansi.Squash(err.Error())
- s.output(s.view())
- s.mode = normal
- s.buffer = ""
- return
- }
- s.mode = normal
- s.buffer = ""
- s.output(s.view())
- }()
- }
|