// go-sh-manymanuals TODO package main import ( "flag" "fmt" "log" "os" "path/filepath" "strings" "github.com/charmbracelet/bubbles/spinner" "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/rkoesters/xdg" "github.com/sahilm/fuzzy" "golang.org/x/term" ) // filenameFilterMode searches by PDF file name. const filenameFilterMode = "filename" // contentFilterMode searches by PDF contents. const contentFilterMode = "content" // 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 { input textinput.Model // Fuzzy search interface filterMode string // The filtering mode ("filename", "content") datasheets []datasheet // All datasheets under cwd datasheetNames []string // All datasheet names (caching) datasheetsStyle lipgloss.Style // Style to use for listing datasheets filteredDatasheets []string // Filtered view on all datasheets loadDatasheetSpinner spinner.Model // Spinner to show while loading datasheets datasheetsLoaded bool // Whether or not the datasheets are loaded or not datasheetViewport viewport.Model // Viewport for the PDF content datasheetViewportStyle lipgloss.Style // Style to show while showing datasheet viewport indexSpinner spinner.Model // Spinner to show that content indexes are being loaded querySpinner spinner.Model // Spinner to show that content is being queried } // 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 "" } // toggleFilterMode toggles the filter mode. func (m *model) toggleFilterMode() { if m.filterMode == filenameFilterMode { m.filterMode = contentFilterMode } else { m.filterMode = filenameFilterMode } } // 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(width int, height int) model { input := textinput.New() input.Focus() datasheetViewport := viewport.New(width/2+20, height/2) datasheetViewportStyle := lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("63")). Padding(2) // NOTE: disable the default keymap, we don't want to be able to // scroll on this viewport or control it in any view. it's // read-only datasheetViewport.KeyMap = viewport.KeyMap{} loadDatasheetSpinner := spinner.New() loadDatasheetSpinner.Spinner = spinner.Dot loadDatasheetSpinner.Style = lipgloss.NewStyle(). Foreground(lipgloss.Color("205")) datasheetsStyle := lipgloss.NewStyle(). Width(width/2 - 30). Height(height/2 - 30). MarginRight(2). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("63")). Padding(2) m := model{ input: input, filterMode: filenameFilterMode, datasheetsLoaded: false, loadDatasheetSpinner: loadDatasheetSpinner, datasheetViewport: datasheetViewport, datasheetViewportStyle: datasheetViewportStyle, datasheetsStyle: datasheetsStyle, } return m } // loadedDatasheetsMsg signals to the UI that the datasheets have been loaded. type loadedDatasheetsMsg struct { datasheets []datasheet datasheetNames []string } // loadDatasheets loads the datasheets from disk. The UI shows a spinner while // this is happening so that nobody gets bored. func loadDatasheets(m model) loadedDatasheetsMsg { var datasheets []datasheet var datasheetNames []string // TODO: have this gradually update the UI as files are walked/parsed instead // of blocking until everything is done in one big go. the current loading // spinner will be there for a while on actually many manuals // 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) datasheet := datasheet{ filename: name, absPath: path, contents: contents, } datasheets = append(datasheets, datasheet) datasheetNames = append(datasheetNames, name) } return nil }) return loadedDatasheetsMsg{ datasheets: datasheets, datasheetNames: datasheetNames, } } // Init initialises the program. func (m model) Init() tea.Cmd { return tea.Batch( func() tea.Msg { return loadDatasheets(m) }, textinput.Blink, m.loadDatasheetSpinner.Tick, ) } // filterDatasheetContents filters datasheet by content from user input. func filterDatasheetContents(m model) []string { // TODO: implement indexing work beforehand // wait for datasheetsLoaded to go true // then iterate m.datasheets and index them // // for this function, wait until Enter is hit to fire the query, // run the query, fire the spinner and block any other queries // coming in. return the list hits that come back from the query return []string{} } // filterDatasheetNames filters datasheet names based on user input. func filterDatasheetNames(m model) []string { search := m.input.Value() if !(len(search) >= minCharsUntilFilter) { return m.datasheetNames } var matchedDatasheets []string matches := fuzzy.Find(search, m.datasheetNames) for _, match := range matches { matchedDatasheets = append(matchedDatasheets, match.Str) } if len(matches) > 0 { return matchedDatasheets } return m.datasheetNames } // Update updates the program state. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( cmd tea.Cmd cmds []tea.Cmd ) m.input, cmd = m.input.Update(msg) cmds = append(cmds, cmd) m.datasheetViewport, cmd = m.datasheetViewport.Update(msg) cmds = append(cmds, cmd) m.loadDatasheetSpinner, cmd = m.loadDatasheetSpinner.Update(msg) cmds = append(cmds, cmd) if m.input.Focused() && m.datasheetsLoaded { if m.filterMode == filenameFilterMode { m.filteredDatasheets = filterDatasheetNames(m) } else { m.filteredDatasheets = filterDatasheetContents(m) } // 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.filteredDatasheets[len(m.filteredDatasheets)-1] viewportText := m.datasheetFromName(lastDatasheet) m.datasheetViewport.SetContent(viewportText) } switch msg := msg.(type) { case loadedDatasheetsMsg: m.datasheets = msg.datasheets m.datasheetNames = msg.datasheetNames m.filteredDatasheets = msg.datasheetNames selectedDatasheet := msg.datasheets[len(msg.datasheets)-1] m.datasheetViewport.SetContent(selectedDatasheet.contents) m.datasheetsLoaded = true case tea.WindowSizeMsg: // TODO: handle terminal resizing // resize listing / viewport case tea.KeyMsg: switch msg.String() { case "ctrl+c": return m, tea.Quit case "tab": m.toggleFilterMode() case "o": selectedDatasheet := m.datasheets[len(m.datasheets)-1] // TODO: handle error _ = xdg.Open(selectedDatasheet.absPath) } } return m, tea.Batch(cmds...) } // View outputs the program state for viewing. func (m model) View() string { body := strings.Builder{} var sheets string if !m.datasheetsLoaded { sheets = fmt.Sprintf("%s gathering datasheets...", m.loadDatasheetSpinner.View()) } else { // TODO: paginate / trim view to last 10 or something? sheets = strings.Join(m.filteredDatasheets, "\n") } // TODO: style further with lipgloss, e.g. borders, margins, etc. panes := lipgloss.JoinHorizontal( lipgloss.Left, m.datasheetsStyle.Render(sheets), m.datasheetViewportStyle.Render(m.datasheetViewport.View()), ) body.WriteString(panes) body.WriteString("\n" + m.input.View()) mode := "filter: " if m.filterMode == filenameFilterMode { mode += "filename" } else { // TODO make this mode actually work once we figure out bleve mode += "content (FIXME)" } body.WriteString("\n" + mode) help := "[ctrl-c]: quit | [tab]: filter mode | [o]: open" body.WriteString("\n" + help) 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 { log.Fatal(err) } defer f.Close() width, height, err := term.GetSize(0) if err != nil { log.Fatal(err) } p := tea.NewProgram( initialModel(width, height), tea.WithAltScreen(), tea.WithMouseCellMotion(), ) if err := p.Start(); err != nil { fmt.Printf("oops, something went wrong: %v", err) os.Exit(1) } }