// go-sh-manymanuals TODO package main import ( "flag" "fmt" "os" "path/filepath" "strings" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" pdf "github.com/johbar/go-poppler" "github.com/sahilm/fuzzy" ) // help is the command-line interface help output. const help = `go-sh-manymanuals: TODO TODO Options: -h output help ` // minCharsUntilFilter is the minimum amount of characters that are // required before the filtering logic commences actually filtering. const minCharsUntilFilter = 2 // helpFlag is the help flag for the command-line interface. var helpFlag bool // handleCliFlags handles command-line flags. func handleCliFlags() { flag.BoolVar(&helpFlag, "h", false, "output help") flag.Parse() } // readPDF reads the plain text contents of a PDF. This does not include the // formatting. Only the content you see when you view it through a PDF reader. // The library we're using makes use of the C bindings to the Poppler PDF // library https://poppler.freedesktop.org which appears to offer a good mix of // reliability and effectiveness. func readPDF(name string) (string, error) { doc, err := pdf.Open(name) if err != nil { return "", err } defer doc.Close() var txt string for i := 0; i < doc.GetNPages(); i++ { txt += doc.GetPage(i).Text() } return txt, nil } // model offers the core of the state for the entire UI. type model struct { filterInput textinput.Model // Fuzzy search interface datasheets []datasheet // All datasheets under cwd dataSheetsView []string // Filtered view on all datasheets dataSheetViewport viewport.Model // Viewport for the PDF content } // dataSheetNames lists all datasheet names. func (m model) dataSheetNames() []string { // TODO: cache this somewhere, it's called several times... in the model? var names []string for _, datasheet := range m.datasheets { names = append(names, datasheet.filename) } return names } // datasheetFromName retrieves a datasheet via a name. func (m model) datasheetFromName(name string) string { for _, d := range m.datasheets { if d.filename == name { return d.contents } } return "" } // datasheet represents a datasheet on disk. type datasheet struct { filename string // The name of the file absPath string // the absolute file path contents string // the contents of the PDF } // initialModel constucts an initial state for the UI. func initialModel() model { input := textinput.New() input.Focus() var ds []datasheet // TODO: handle error in interface? _ = filepath.Walk(".", func(path string, info os.FileInfo, err error) error { if err != nil { return err } name := info.Name() if strings.HasSuffix(name, "pdf") { // TODO: handle error in interface? // TODO: don't read them all up front while blocking? // we could run this in a goroutine somewhere // this currently slows down startup time contents, _ := readPDF(path) d := datasheet{ filename: name, absPath: path, contents: contents, } ds = append(ds, d) } return nil }) // TODO: set width/heigh to match terminal viewp := viewport.New(60, 30) viewp.SetContent(ds[len(ds)-1].contents) m := model{ filterInput: input, datasheets: ds, dataSheetViewport: viewp, } m.dataSheetsView = m.dataSheetNames() return m } // Init initialises the program. func (m model) Init() tea.Cmd { return textinput.Blink } // Update updates the program state. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( cmd tea.Cmd cmds []tea.Cmd ) if m.filterInput.Focused() { var matched []string search := m.filterInput.Value() if len(search) >= minCharsUntilFilter { matches := fuzzy.Find(search, m.dataSheetNames()) for _, match := range matches { matched = append(matched, match.Str) } if len(matches) > 0 { m.dataSheetsView = matched } else { m.dataSheetsView = m.dataSheetNames() } } else { m.dataSheetsView = m.dataSheetNames() } // TODO: implement cursor for scrolling up/down filtered // results so we can view the PDF contents as desired // it's currently just the last one (closest to input) lastDatasheet := m.dataSheetsView[len(m.dataSheetsView)-1] viewportText := m.datasheetFromName(lastDatasheet) m.dataSheetViewport.SetContent(viewportText) } // TODO: handle terminal resizing switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit } } m.filterInput, cmd = m.filterInput.Update(msg) cmds = append(cmds, cmd) m.dataSheetViewport, cmd = m.dataSheetViewport.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } // View outputs the program state for viewing. func (m model) View() string { body := strings.Builder{} // TODO: paginate / trim view to last 10 or something? sheets := strings.Join(m.dataSheetsView, "\n") // TODO: style further with lipgloss, e.g. borders, margins, etc. panes := lipgloss.JoinHorizontal(lipgloss.Left, sheets, m.dataSheetViewport.View()) body.WriteString(panes) body.WriteString("\n" + m.filterInput.View()) return body.String() } // main is the command-line entrypoint. func main() { handleCliFlags() if helpFlag { fmt.Printf(help) os.Exit(0) } f, err := tea.LogToFile("debug.log", "debug") if err != nil { fmt.Println("fatal:", err) os.Exit(1) } defer f.Close() p := tea.NewProgram( initialModel(), tea.WithAltScreen(), tea.WithMouseCellMotion(), ) if err := p.Start(); err != nil { fmt.Printf("oops, something went wrong: %v", err) os.Exit(1) } }