mirror of https://github.com/veonik/squirssi
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
339 lines
8.5 KiB
339 lines
8.5 KiB
// import "code.dopame.me/veonik/squirssi" |
|
package squirssi |
|
|
|
import ( |
|
"fmt" |
|
"sync" |
|
"time" |
|
|
|
"code.dopame.me/veonik/squircy3/event" |
|
"code.dopame.me/veonik/squircy3/irc" |
|
"code.dopame.me/veonik/squircy3/vm" |
|
ui "github.com/gizak/termui/v3" |
|
tb "github.com/nsf/termbox-go" |
|
"github.com/sirupsen/logrus" |
|
|
|
"code.dopame.me/veonik/squirssi/colors" |
|
"code.dopame.me/veonik/squirssi/widget" |
|
) |
|
|
|
// A Server handles user interaction and displaying screen elements. |
|
type Server struct { |
|
*logrus.Logger |
|
outputLogHook *logFileWriterHook |
|
|
|
screenWidth, screenHeight int |
|
pageSize int |
|
|
|
mainWindow *ui.Grid |
|
statusBar *widget.StatusBarPane |
|
inputTextBox *widget.ModedTextInput |
|
chatPane *widget.ChatPane |
|
userListPane *widget.UserList |
|
|
|
events *event.Dispatcher |
|
irc *irc.Manager |
|
vm *vm.VM |
|
|
|
currentNick string |
|
|
|
windows *WindowManager |
|
history *HistoryManager |
|
tabber *TabCompleter |
|
|
|
mu sync.RWMutex |
|
done chan struct{} |
|
|
|
interrupt Interrupter |
|
|
|
debounce bool |
|
} |
|
|
|
// NewServer creates a new server. |
|
func NewServer(ev *event.Dispatcher, irc *irc.Manager, jsvm *vm.VM) (*Server, error) { |
|
srv := &Server{ |
|
Logger: logrus.StandardLogger(), |
|
outputLogHook: newLogFileWriterHook(), |
|
|
|
events: ev, |
|
irc: irc, |
|
vm: jsvm, |
|
|
|
windows: NewWindowManager(ev), |
|
history: NewHistoryManager(), |
|
tabber: NewTabCompleter(), |
|
|
|
done: make(chan struct{}), |
|
} |
|
srv.initUI() |
|
srv.Logger.SetOutput(srv.windows.Index(0)) |
|
srv.Logger.SetFormatter(&statusFormatter{}) |
|
srv.Logger.AddHook(srv.outputLogHook) |
|
return srv, nil |
|
} |
|
|
|
type Interrupter func() |
|
|
|
func (srv *Server) OnInterrupt(fn Interrupter) *Server { |
|
srv.mu.Lock() |
|
defer srv.mu.Unlock() |
|
srv.interrupt = fn |
|
return srv |
|
} |
|
|
|
func (srv *Server) IRCDoAsync(fn func(conn *irc.Connection) error) { |
|
go func() { |
|
err := srv.irc.Do(fn) |
|
if err != nil { |
|
logrus.Errorln("irc command failed:", err) |
|
} |
|
}() |
|
} |
|
|
|
func (srv *Server) CurrentNick() string { |
|
srv.mu.RLock() |
|
defer srv.mu.RUnlock() |
|
return srv.currentNick |
|
} |
|
|
|
func (srv *Server) setCurrentNick(newNick string) { |
|
srv.mu.Lock() |
|
defer srv.mu.Unlock() |
|
srv.currentNick = newNick |
|
} |
|
|
|
func (srv *Server) initUI() { |
|
ui.StyleParserColorMap["gray"] = colors.Grey35 |
|
ui.StyleParserColorMap["grey"] = colors.Grey35 |
|
ui.StyleParserColorMap["gray82"] = colors.Grey82 |
|
ui.StyleParserColorMap["grey82"] = colors.Grey82 |
|
ui.StyleParserColorMap["gray100"] = colors.Grey100 |
|
ui.StyleParserColorMap["grey100"] = colors.Grey100 |
|
ui.StyleParserColorMap["red4"] = colors.Red4 |
|
ui.StyleParserColorMap["dodgerblue3"] = colors.DodgerBlue3 |
|
ui.StyleParserColorMap["orange"] = colors.Orange1 |
|
|
|
srv.userListPane = widget.NewUserList() |
|
srv.userListPane.Rows = []string{} |
|
srv.userListPane.Border = true |
|
srv.userListPane.BorderRight = false |
|
srv.userListPane.BorderLeft = false |
|
srv.userListPane.BorderTop = true |
|
srv.userListPane.BorderBottom = true |
|
srv.userListPane.BorderStyle.Fg = colors.Grey42 |
|
srv.userListPane.Title = "Users" |
|
srv.userListPane.PaddingRight = 0 |
|
srv.userListPane.TitleStyle.Fg = colors.Grey100 |
|
|
|
srv.chatPane = widget.NewChatPane() |
|
srv.chatPane.Rows = []string{} |
|
srv.chatPane.BorderStyle.Fg = colors.DodgerBlue1 |
|
srv.chatPane.Border = true |
|
srv.chatPane.PaddingLeft = 1 |
|
srv.chatPane.PaddingRight = 1 |
|
srv.chatPane.WrapText = true |
|
srv.chatPane.TitleStyle.Fg = colors.Grey100 |
|
srv.chatPane.SubTitleStyle.Fg = colors.White |
|
srv.chatPane.ModeStyle.Fg = colors.Grey42 |
|
|
|
srv.statusBar = widget.NewStatusBarPane() |
|
srv.statusBar.ActiveTabStyle.Fg = colors.DodgerBlue1 |
|
srv.statusBar.NoticeStyle = ui.NewStyle(colors.White, colors.DodgerBlue1) |
|
srv.statusBar.ActivityStyle = ui.NewStyle(ui.ColorBlack, ui.ColorWhite) |
|
srv.statusBar.Border = true |
|
srv.statusBar.BorderTop = true |
|
srv.statusBar.BorderLeft = false |
|
srv.statusBar.BorderRight = false |
|
srv.statusBar.BorderBottom = false |
|
srv.statusBar.BorderStyle.Fg = colors.DodgerBlue1 |
|
|
|
srv.inputTextBox = widget.NewModedTextInput() |
|
srv.inputTextBox.Border = false |
|
|
|
srv.mainWindow = ui.NewGrid() |
|
} |
|
|
|
// Close ends the UI session, returning control of stdout. |
|
func (srv *Server) Close() { |
|
srv.mu.Lock() |
|
defer srv.mu.Unlock() |
|
select { |
|
case <-srv.done: |
|
// already closing |
|
return |
|
default: |
|
ui.Close() |
|
close(srv.done) |
|
} |
|
} |
|
|
|
// Update refreshes the state of the UI but stops short of rendering. |
|
func (srv *Server) Update() { |
|
srv.mu.Lock() |
|
defer srv.mu.Unlock() |
|
srv.statusBar.ActiveTabIndex = srv.windows.ActiveIndex() |
|
win := srv.windows.Active() |
|
if win == nil { |
|
return |
|
} |
|
win.Touch() |
|
srv.statusBar.TabNames, srv.statusBar.TabsWithActivity = srv.windows.TabNames() |
|
srv.chatPane.SelectedRow = win.CurrentLine() |
|
srv.chatPane.Rows = win.Lines() |
|
srv.chatPane.Title = win.Title() |
|
|
|
if ch, ok := win.(*Channel); ok { |
|
srv.chatPane.SubTitle = ch.Topic() |
|
srv.chatPane.ModeText = ch.Modes() |
|
} else { |
|
srv.chatPane.SubTitle = "" |
|
srv.chatPane.ModeText = "" |
|
} |
|
srv.chatPane.LeftPadding = win.padding() + 7 |
|
if srv.statusBar.ActiveTabIndex == 0 { |
|
srv.chatPane.ModeText = srv.currentNick |
|
} |
|
srv.mainWindow.Items = nil |
|
if v, ok := win.(WindowWithUserList); ok { |
|
srv.userListPane.Rows = v.UserList() |
|
suff := "s" |
|
if len(srv.userListPane.Rows) == 1 { |
|
suff = "" |
|
} |
|
srv.userListPane.Title = fmt.Sprintf("%d user%s", len(srv.userListPane.Rows), suff) |
|
srv.mainWindow.Set( |
|
ui.NewCol(.85, srv.chatPane), |
|
ui.NewCol(.15, srv.userListPane), |
|
) |
|
} else { |
|
srv.mainWindow.Set( |
|
ui.NewCol(1, srv.chatPane), |
|
) |
|
} |
|
} |
|
|
|
type screenElement int |
|
|
|
const ( |
|
InputTextBox screenElement = iota |
|
StatusBar |
|
MainWindow |
|
) |
|
|
|
// RenderOnly renders select screen elements rather than the whole screen. |
|
func (srv *Server) RenderOnly(items ...screenElement) { |
|
srv.mu.RLock() |
|
defer srv.mu.RUnlock() |
|
var its []ui.Drawable |
|
for _, it := range items { |
|
switch it { |
|
case InputTextBox: |
|
its = append(its, srv.inputTextBox) |
|
case StatusBar: |
|
its = append(its, srv.statusBar) |
|
case MainWindow: |
|
its = append(its, srv.mainWindow) |
|
} |
|
} |
|
ui.Render(its...) |
|
} |
|
|
|
// Render renders the current state to the screen. |
|
func (srv *Server) Render() { |
|
srv.mu.Lock() |
|
defer srv.mu.Unlock() |
|
if srv.debounce { |
|
return |
|
} |
|
srv.debounce = true |
|
go func() { |
|
<-time.After(1 * time.Millisecond) |
|
srv.doRender(false) |
|
}() |
|
} |
|
|
|
func (srv *Server) doRender(force bool) { |
|
srv.mu.Lock() |
|
defer srv.mu.Unlock() |
|
ui.Render(srv.mainWindow, srv.statusBar, srv.inputTextBox) |
|
srv.pageSize = srv.chatPane.Inner.Dy() |
|
if !force { |
|
srv.debounce = false |
|
} |
|
} |
|
|
|
func (srv *Server) resize(w, h int) { |
|
srv.mu.Lock() |
|
defer srv.mu.Unlock() |
|
srv.screenHeight = h |
|
srv.screenWidth = w |
|
// guess the page size for now, will be corrected after the first render. |
|
srv.pageSize = h - 8 |
|
srv.statusBar.SetRect(0, srv.screenHeight-2, srv.screenWidth, srv.screenHeight) |
|
srv.inputTextBox.SetRect(0, srv.screenHeight-srv.statusBar.Dy()-1, srv.screenWidth, srv.screenHeight-srv.statusBar.Dy()) |
|
srv.mainWindow.SetRect(0, 0, srv.screenWidth, srv.screenHeight-srv.statusBar.Dy()-srv.inputTextBox.Dy()) |
|
} |
|
|
|
// From https://github.com/gizak/termui/issues/255 |
|
func DisableMouseInput() { |
|
tb.SetInputMode(tb.InputAlt) |
|
} |
|
|
|
// Start begins the UI event loop and does the initial render. |
|
func (srv *Server) Start() error { |
|
if err := ui.Init(); err != nil { |
|
return err |
|
} |
|
srv.outputLogHook.Start() |
|
DisableMouseInput() |
|
w, h := ui.TerminalDimensions() |
|
bindUIHandlers(srv, srv.events) |
|
bindIRCHandlers(srv, srv.events) |
|
srv.inputTextBox.Reset() |
|
srv.resize(w, h) |
|
srv.Update() |
|
srv.Render() |
|
|
|
go srv.startUIEventLoop() |
|
|
|
return nil |
|
} |
|
|
|
func (srv *Server) startUIEventLoop() { |
|
uiEvents := ui.PollEvents() |
|
|
|
for { |
|
select { |
|
case <-srv.done: |
|
// srv.Close() was called, no need to continue |
|
return |
|
case e := <-uiEvents: |
|
switch e.Type { |
|
case ui.KeyboardEvent: |
|
// handle keyboard input outside of the event emitter to avoid |
|
// too long a delay between keypress and the UI reacting. |
|
onUIKeyPress(srv, e.ID) |
|
srv.events.Emit("ui.KEYPRESS", map[string]interface{}{ |
|
"key": e.ID, |
|
}) |
|
case ui.ResizeEvent: |
|
resize, ok := e.Payload.(ui.Resize) |
|
if !ok { |
|
panic(fmt.Sprintf("received termui Resize event but Payload was unexpected type %T", e.Payload)) |
|
} |
|
srv.resize(resize.Width, resize.Height) |
|
srv.events.Emit("ui.RESIZE", map[string]interface{}{ |
|
"width": resize.Width, |
|
"height": resize.Height, |
|
}) |
|
// todo: its unclear why, but after much frustration, i've |
|
// decided i'm ok with the hack: when the screen is resized |
|
// in tmux, it needs two renders to fully display properly. |
|
// so every time there is a resize, force a render and emit a |
|
// good old fashioned ui.DIRTY event. |
|
srv.doRender(true) |
|
srv.events.Emit("ui.DIRTY", nil) |
|
} |
|
} |
|
} |
|
}
|
|
|