package main import ( "context" "fmt" "log" "os" "runtime/debug" "strings" "sync" "git.yetaga.in/alazyreader/library/book" "git.yetaga.in/alazyreader/library/config" "git.yetaga.in/alazyreader/library/database" "git.yetaga.in/alazyreader/library/importer" "git.yetaga.in/alazyreader/library/ui" "github.com/gdamore/tcell" "github.com/kelseyhightower/envconfig" ) // State holds the UI state keys=>value map and manages access to the map with a mutex type State struct { m sync.Mutex stateMap map[string]interface{} } // key, present func (s *State) Get(key string) interface{} { s.m.Lock() defer s.m.Unlock() if s.stateMap == nil { s.stateMap = make(map[string]interface{}) } k, ok := s.stateMap[key] if !ok { return nil } return k } // key, value func (s *State) Set(key string, value interface{}) { s.m.Lock() defer s.m.Unlock() if s.stateMap == nil { s.stateMap = make(map[string]interface{}) } s.stateMap[key] = value } func max(a, b int) int { if a > b { return a } return b } // UI states const ( IN_MENU = iota IN_BOOK IN_IMPORT ) func main() { var c config.Config err := envconfig.Process("library", &c) if err != nil { log.Fatalln(err) } // create state state := State{} // set up DB connection if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" { if c.DBPass != "" { // obscure password c.DBPass = c.DBPass[0:max(3, len(c.DBPass))] + strings.Repeat("*", max(0, len(c.DBPass)-3)) } log.Fatalf("vars: %+v", c) } lib, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName) if err != nil { log.Fatalln(err) } err = lib.PrepareDatabase(context.Background()) if err != nil { log.Fatalln(err) } _, _, err = lib.RunMigrations(context.Background()) if err != nil { log.Fatalln(err) } books, err := lib.GetAllBooks(context.Background()) if err != nil { log.Fatalln(err) } state.Set("library", books) screen, err := tcell.NewScreen() if err != nil { log.Fatalln(err) } err = screen.Init() if err != nil { log.Fatalln(err) } // cleanup our screen and log if we panic and crash out somewhere defer func() { if r := recover(); r != nil { if screen != nil { screen.Fini() } fmt.Println("fatal panic;", r) if c.Debug { fmt.Println("stacktrace: \n" + string(debug.Stack())) } return } }() // book list and options menu (left column) l := ui.NewList(Titles(state.Get("library").([]book.Book)), 0) menu := ui.NewBox( "library", []string{"˄˅ select", "⏎ edit", "(n)ew", "(i)mport", "(q)uit"}, ui.Contents{{ Offsets: ui.Offsets{Top: 1, Left: 2, Bottom: -2, Right: -2}, Container: l, }}, ui.StyleActive, false, ) activeBookDetails := ui.NewBookDetails(&book.Book{}) // book display (right column) activeBook := ui.NewBox( "book", []string{"˄˅ select", "⏎ edit", "(esc) close"}, ui.Contents{{ Offsets: ui.Offsets{Top: 1, Left: 2, Bottom: 0, Right: 0}, Container: activeBookDetails, }}, ui.StyleInactive, false, ) // parent container container := ui.NewContainer( ui.Contents{ {Container: menu, Offsets: ui.Offsets{Percent: 1}}, {Container: activeBook, Offsets: ui.Offsets{Percent: 2}}, }, ui.LayoutHorizontalPercent, ) // import pop-up wd, _ := os.Getwd() fileSelector := ui.NewEditableTextLine(wd) fileSelector.ResetCursor(false) fileSelector.SetStyle(ui.StyleActive.Underline(true)) popup := ui.NewBox( "import csv file", []string{"⏎ submit", "(esc)close"}, ui.Contents{ {Container: fileSelector, Offsets: ui.Offsets{Top: 2, Left: 2}}, }, ui.StyleActive, false, ) popup.SetVisible(false) // error pop-up errorMessage := ui.NewEditableTextLine("") errorPopup := ui.NewBox( "error", []string{"⏎ close"}, ui.Contents{ {Container: errorMessage, Offsets: ui.Offsets{Top: 1, Left: 1}}, }, ui.StyleActive.Bold(true).Foreground(tcell.ColorRed), false, ) errorPopup.SetVisible(false) // init screen.Clear() w, h := screen.Size() container.SetSize(0, 0, h, w) container.Draw(screen) screen.Sync() // init UI state state.Set("ui_state", IN_MENU) screen.PostEvent(NewEventLoadBook(l.SelectedID())) // UI loop for { e := screen.PollEvent() switch v := e.(type) { case *tcell.EventError: fmt.Fprintf(os.Stderr, "%v", v) screen.Beep() case *tcell.EventKey: // input handling curr := state.Get("ui_state").(int) if curr == IN_MENU { if v.Key() == tcell.KeyUp && l.Selected() > 0 { l.SetSelected(l.Selected() - 1) screen.PostEvent(NewEventLoadBook(l.SelectedID())) } if v.Key() == tcell.KeyDown && l.Selected() < len(l.ListMembers())-1 { l.SetSelected(l.Selected() + 1) screen.PostEvent(NewEventLoadBook(l.SelectedID())) } if v.Key() == tcell.KeyEnter { screen.PostEvent(NewEventEnterBook()) } if v.Rune() == 'i' { screen.PostEvent(NewEventOpenImport()) } if v.Rune() == 'q' { screen.PostEvent(NewEventQuit()) } } else if curr == IN_BOOK { if v.Key() == tcell.KeyEsc { screen.PostEvent(NewEventExitBook()) } } else if curr == IN_IMPORT { if v.Key() == tcell.KeyEsc { fileSelector.SetText(wd) fileSelector.ResetCursor(false) screen.PostEvent(NewEventCloseImport()) } if v.Key() == tcell.KeyBackspace || v.Key() == tcell.KeyBackspace2 { fileSelector.DeleteAtCursor() } else if v.Key() == tcell.KeyRight { fileSelector.MoveCursor(1) } else if v.Key() == tcell.KeyLeft { fileSelector.MoveCursor(-1) } else if v.Key() == tcell.KeyEnter { screen.PostEvent(NewEventAttemptImport(fileSelector.Text())) } else if v.Rune() != 0 { fileSelector.InsertAtCursor(v.Rune()) } } case *tcell.EventResize: // screen redraw w, h := screen.Size() container.SetSize(0, 0, h, w) case *EventBookUpdate: // TK case *EventEnterBook: activeBook.SetStyle(ui.StyleActive) menu.SetStyle(ui.StyleInactive) state.Set("ui_state", IN_BOOK) case *EventExitBook: state.Set("ui_state", IN_MENU) activeBook.SetStyle(ui.StyleInactive) menu.SetStyle(ui.StyleActive) case *EventLoadBook: activeBookDetails.SetBook(GetBookByID(v.ID, books)) case *EventOpenImport: state.Set("ui_state", IN_IMPORT) menu.SetStyle(ui.StyleInactive) popup.SetVisible(true) popup.SetSize(6, 3, 5, 80) case *EventAttemptImport: // this will block other events, but it shouldn't take too long... f, err := os.Open(v.filename) if err != nil { screen.PostEvent(NewEventError(err)) continue } books, err := importer.CSVToBooks(f) if err != nil { screen.PostEvent(NewEventError(err)) continue } for b := range books { err = lib.AddBook(context.Background(), &books[b]) if err != nil { screen.PostEvent(NewEventError(err)) } } screen.PostEvent(NewEventCloseImport()) allbooks, err := lib.GetAllBooks(context.Background()) if err != nil { screen.PostEvent(NewEventError(err)) } state.Set("library", allbooks) state.Set("ui_state", IN_MENU) case *EventCloseImport: state.Set("ui_state", IN_MENU) screen.HideCursor() menu.SetStyle(ui.StyleActive) popup.SetVisible(false) case *EventError: errorMessage.SetText(v.err.Error()) errorPopup.SetVisible(true) case *EventQuit: screen.Fini() fmt.Printf("Thank you for playing Wing Commander!\n\n") return case *tcell.EventInterrupt: case *tcell.EventMouse: case *tcell.EventTime: default: } // repaint l.SetMembers(Titles(state.Get("library").([]book.Book))) container.Draw(screen) popup.Draw(screen) errorPopup.Draw(screen) screen.Show() } } func Titles(lb []book.Book) []ui.ListKeyValue { r := []ui.ListKeyValue{} for i := range lb { r = append(r, ui.ListKeyValue{ Key: lb[i].ID, Value: lb[i].Title, }) } return r } func GetBookByID(id int, lb []book.Book) *book.Book { for i := range lb { if lb[i].ID == id { return &lb[i] } } return &book.Book{} }