feat: add Easter countdown progress bar to TUI
- Add EasterProgression calculator with Orthodox/Latin Easter support
- New `internal/easterprogression.go` with time until Easter and
progress percentage
- Add `pkg/eastertime.go` (vendored eastertime library) for date
calculations
- Extend configuration with `easter_type` (orthodox/latin) and `--latin`
CLI flag
- Render progress bar in TUI showing remaining time and yearly progress
percentage
- Refactor `cmd/bibel.go` output mode handling (sequential if-else
instead of switch)
- Add `--verbose` flag for additional runtime information
- Update config validation and defaults (easter_type defaults to
orthodox)
- Remove local `max` function (use Go 1.21+ built-in)
@@ -8,6 +8,20 @@ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] +## [0.9.0] - 2026-04-17 + +### Added + +- **Easter progress bar**: Fancy progress bar at bottom of TUI showing time +until Easter, which is configurable; Orthodox (default) or Roman Catholic. +Shows days/hours/minutes until next Easter with percentage progress. +- New `-l/--latin` flag to use Roman Catholic Easter instead of Eastern +Orthodox. +- Added vendored `eastertime` package with accurate Orthodox and Roman Catholic +algorithms for telling Easter time. +- **Configuration option**: `easter_type` configuration field in TOML (default: +"orthodox") + ## [0.8.0] - 2026-04-17 ### Added
@@ -21,6 +21,8 @@ - **Verse Numbering**: Option to print each verse on a numbered line with verse
numbers - **Paragraph Handling**: Option to render pilcrows (¶) in source JSON Bible file as paragraph breaks with blank lines +- **Easter Progress Bar**: Fancy progress bar showing time until Easter with +visual progress indication (default: Eastern Orthodox; Catholic with `-l` flag) ## Installation@@ -42,8 +44,11 @@
### Usage Examples ```bash -# Default TUI mode with interactive display +# Default TUI mode with interactive display (shows Orthodox Easter progress) ./bibel + +# Roman Catholic Easter progress bar instead of Orthodox +./bibel --latin # Plain text output ./bibel --plain@@ -110,6 +115,9 @@ - **verses_per_day**: Number of verses to read per day (default: 12)
- **start_date**: Start date for yearly progression (format: "1 January", empty for current year) +#### Easter Settings +- **easter_type**: Easter calculation type: "orthodox" (default) or "latin" + ### Example Configuration See `configs/config.example.toml` in the project directory for a complete example.@@ -134,6 +142,7 @@ - `-g, --generate-config`: Generate a default configuration file and exit
- `-c, --config`: Path to configuration file (not yet implemented) - `-n, --numbered`: Print each verse on a numbered line corresponding to the verse number - `-g, --paragraphs`: Render pilcrows (¶) as blank lines instead of ignoring them +- `-l, --latin`: Use Roman Catholic Easter instead of Eastern Orthodox (shows in TUI progress bar) ## Data Format@@ -173,6 +182,26 @@ yearly
4. **Position Mapping**: Walk through Gospels to find corresponding verses 5. **Lookahead Rule**: Extend to chapter end if less than 12 verses remain +## Easter Progress Bar + +The TUI features a progress bar showing time until the next Easter: + +### Easter Type Selection +- **Default**: Eastern Orthodox Easter (calculated via Meeus Julian algorithm) +- **Catholic**: Roman Catholic Easter via `-l/--latin` flag (Delambre and Butcher's algorithm) + +### Progress Bar Features +- **Visual Progress**: Filled segments (█) showing annual cycle percentage between consecutive Easters +- **Time Display**: Shows exact days, hours, and minutes until next Easter +- **Styling**: Uses `lipgloss` with green-filled segments and adaptive terminal colours +- **Configuration**: Easter type configurable via `easter_type` field in TOML configuration + +### Easter Calculations +- **Orthodox Easter**: Uses Meeus Julian algorithm (accurate for years > 325) +- **Catholic Easter**: Uses Delambre and Butcher's algorithm (Gregorian calendar) +- **Annual Cycle Progress**: Calculated as time elapsed between consecutive Easters +- **Real-time Updates**: Progress updates continuously while TUI is running + ## Project Structure ```@@ -183,11 +212,14 @@ │ └── config.example.toml # The default configuration file
├── internal/ │ ├── config.go # Reading and writing to config │ ├── dateprogression.go # Date-based position calculation +│ ├── easterprogression.go # Easter date and progress calculation │ ├── verse.go # Data structures │ ├── loader.go # JSON loading and indexing │ ├── formatter.go # Output formatting │ └── tui/ # Terminal User Interface │ └── model.go # bubbletea TUI model and styling +├── pkg/ +│ └── eastertime.go # Easter date calculation algorithms (Orthodox/Catholic) ├── books/pol_nbg.json # Bible data ├── go.mod # Go module dependencies └── go.sum # Go dependency checksums
@@ -13,13 +13,15 @@ )
// Args defines the command line arguments type Args struct { - Reading string `arg:"-r,--reading" help:"reading mode: evangelion (Gospels), new_testament, old_testament, bible (default: evangelion)"` - Plain bool `arg:"-p,--plain" help:"output plain text without formatting or TUI"` - Formatted bool `arg:"-f,--formatted" help:"output formatted text with ANSI colours (no TUI)"` - GenerateConfig bool `arg:"--generate-config" help:"generate a default configuration file and exit"` - ConfigPath string `arg:"-c,--config" help:"path to configuration file (default: $XDG_CONFIG_HOME/bibel/config.toml)"` - Numbered bool `arg:"-n,--numbered" help:"print each verse on a numbered line with verse number"` - Paragraphs bool `arg:"-g,--paragraphs" help:"render pilcrows (¶) as blank lines instead of ignoring them"` + Reading string `arg:"-r,--reading" help:"reading mode: evangelion (Gospels), new_testament, old_testament, bible (default: evangelion)"` + Plain bool `arg:"-p,--plain" help:"output plain text without formatting or TUI"` + Formatted bool `arg:"-f,--formatted" help:"output formatted text with ANSI colours (no TUI)"` + ConfigPath string `arg:"-c,--config" help:"path to configuration file (default: $XDG_CONFIG_HOME/bibel/config.toml)"` + Numbered bool `arg:"-n,--numbered" help:"print each verse on a numbered line with verse number"` + Paragraphs bool `arg:"-g,--paragraphs" help:"render pilcrows (¶) as blank lines instead of ignoring them"` + Latin bool `arg:"-l,--latin" help:"show time until Roman Catholic Easter"` + GenerateConfig bool `arg:"--generate-config" help:"generate a default configuration file and exit"` + Verbose bool `arg:"-v,--verbose" help:"print additional runtime information to STDOUT"` } // Description returns a description of the program@@ -45,86 +47,82 @@ return
} // Load configuration - cfg, err := bible.LoadConfig() + config, err := bible.LoadConfig() if err != nil { - fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err) + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) os.Exit(1) } - // Override config with command line arguments - if args.Reading != "" { - cfg.ReadingMode = args.Reading + // Override Easter type from command line flag if specified + if args.Latin { + config.EasterType = "latin" } - if args.Plain { - cfg.OutputMode = "plain" - } else if args.Formatted { - cfg.OutputMode = "formatted" - } - if args.Numbered { - cfg.Formatter.Numbered = args.Numbered - } - if args.Paragraphs { - cfg.Formatter.Paragraphs = args.Paragraphs - } - if args.ConfigPath != "" { - // Note: This would require modifying LoadConfig to accept a path - // For now, we'll just use the default XDG location - fmt.Fprintf(os.Stderr, "Note: Custom config path not yet implemented, using default location\n") + + // Override reading mode from command line if specified + if args.Reading != "" { + config.ReadingMode = args.Reading } // Load Bible data - biblePath := cfg.BiblePath - bibleData, err := bible.LoadBible(biblePath) + bibleData, err := bible.LoadBible(config.BiblePath) if err != nil { fmt.Fprintf(os.Stderr, "Error loading Bible data: %v\n", err) os.Exit(1) } - // Initialise date progression with configured verses per day - dateProg := bible.NewDateProgressionWithReadingMode(bibleData, cfg.DateProgression.VersesPerDay, bible.ReadingMode(cfg.ReadingMode)) - - // Get current date - currentDate := time.Now() + // Create date progression calculator + // We need to convert reading mode string to ReadingMode type + // Based on verse.go, ReadingMode is a string type with constants + dateProg := bible.NewDateProgression(bibleData) - // Calculate position for today - todayBookmark, err := dateProg.GetPositionForDate(currentDate) + // Get today's bookmark + bookmark, err := dateProg.GetPositionForDate(time.Now()) if err != nil { - fmt.Fprintf(os.Stderr, "Error calculating date position: %v\n", err) + fmt.Fprintf(os.Stderr, "Error calculating today's reading position: %v\n", err) os.Exit(1) } - // Determine output mode based on configuration and terminal - outputMode := cfg.OutputMode - - // If terminal detection suggests different mode, adjust - if !term.IsTerminal(int(os.Stdout.Fd())) && outputMode == "tui" { - // Not a terminal, fall back to formatted - outputMode = "formatted" + // Handle plain output mode + if args.Plain { + formatter := bible.NewFormatter(bibleData) + header := formatter.FormatHeader(bookmark) + content := formatter.ExtractAndFormat(bibleData, bookmark) + fmt.Println(header) + fmt.Println(content) + return + } + + // Handle formatted output mode + if args.Formatted { + formatter := bible.NewFormatterWithConfig(bibleData, config.Formatter.UseColours, config.Formatter.HeaderFormat) + header := formatter.FormatHeader(bookmark) + content := formatter.ExtractAndFormat(bibleData, bookmark) + fmt.Println(header) + fmt.Println(content) + return + } + + // Handle numbered/paragraph options + if args.Numbered || args.Paragraphs { + formatter := bible.NewFormatterWithFullConfig(bibleData, config.Formatter.UseColours, config.Formatter.HeaderFormat, args.Numbered, args.Paragraphs) + header := formatter.FormatHeader(bookmark) + content := formatter.ExtractAndFormat(bibleData, bookmark) + fmt.Println(header) + fmt.Println(content) + return + } + + // Default: TUI mode + // Check if we're in a terminal + if !term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprintln(os.Stderr, "Error: Standard input is not a terminal. Use --plain or --formatted for non-interactive output.") + os.Exit(1) } - // Handle different output modes - switch outputMode { - case "plain": - formatter := bible.NewFormatterWithFullConfig(bibleData, false, cfg.Formatter.HeaderFormat, cfg.Formatter.Numbered, cfg.Formatter.Paragraphs) - // In plain mode, we don't print the header - fmt.Println(formatter.ExtractAndFormat(bibleData, todayBookmark)) - - case "formatted": - formatter := bible.NewFormatterWithFullConfig(bibleData, cfg.Formatter.UseColours, cfg.Formatter.HeaderFormat, cfg.Formatter.Numbered, cfg.Formatter.Paragraphs) - fmt.Println(formatter.FormatHeader(todayBookmark)) - fmt.Println(formatter.ExtractAndFormat(bibleData, todayBookmark)) - - case "tui": - // Create and run TUI program with configuration - program := tui.CreateTUIProgram(bibleData, todayBookmark, cfg) - if err := program.Start(); err != nil { - fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err) - os.Exit(1) - } - - default: - fmt.Fprintf(os.Stderr, "Unknown output mode: %s\n", outputMode) - fmt.Fprintf(os.Stderr, "Valid modes: tui, formatted, plain\n") + // Create and run TUI program + program := tui.CreateTUIProgram(bibleData, bookmark, config) + if _, err := program.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err) os.Exit(1) } -}+}
@@ -47,3 +47,6 @@
# Start date for yearly progression (format: "1 January") # Empty means 1 January of current year start_date = "" + +# Easter type: "orthodox" (default) or "latin" (i.e., roman catholic) +easter_type = "orthodox"
@@ -72,6 +72,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/vjeantet/eastertime v1.0.0 h1:1jKWtfYLmSzl+aA4Oxz3arR8xy/2QDkJ1oxMtZimNZc= +github.com/vjeantet/eastertime v1.0.0/go.mod h1:oyhoGGL+4Xkm1kcuSRx/Cncj4UCur/fg7nd9MEJbAto= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
@@ -13,7 +13,7 @@ // findBibleFileInDataDir looks for Bible JSON files in the XDG data directory
// It returns the path to the first JSON file found, or an error if none found func findBibleFileInDataDir() (string, error) { dataDir := filepath.Join(xdg.DataHome, "bibel") - + // Check if the directory exists if _, err := os.Stat(dataDir); os.IsNotExist(err) { return "", fmt.Errorf("Bible data directory not found: %s\nPlease create this directory and add Bible JSON files to it", dataDir)@@ -44,6 +44,9 @@ OutputMode string `toml:"output_mode" mapstructure:"output_mode"`
// Bible data file path (default: "books/pol_nbg.json") BiblePath string `toml:"bible_path" mapstructure:"bible_path"` + + // Easter type: "orthodox" (default), "latin" + EasterType string `toml:"easter_type" mapstructure:"easter_type"` // TUI settings TUI struct {@@ -95,8 +98,9 @@ // DefaultConfig returns a configuration with default values
func DefaultConfig() *Config { cfg := &Config{ ReadingMode: "evangelion", - OutputMode: "tui", - BiblePath: "", // Empty means use XDG data directory + OutputMode: "tui", + BiblePath: "", // Empty means use XDG data directory + EasterType: "orthodox", // Default to Orthodox Easter } cfg.TUI.ShowQuitMessage = true@@ -135,6 +139,7 @@ defaultCfg := DefaultConfig()
viper.SetDefault("reading_mode", defaultCfg.ReadingMode) viper.SetDefault("output_mode", defaultCfg.OutputMode) viper.SetDefault("bible_path", defaultCfg.BiblePath) + viper.SetDefault("easter_type", defaultCfg.EasterType) viper.SetDefault("tui.show_quit_message", defaultCfg.TUI.ShowQuitMessage) viper.SetDefault("tui.border_style", defaultCfg.TUI.BorderStyle) viper.SetDefault("tui.border_colour", defaultCfg.TUI.BorderColour)@@ -177,6 +182,9 @@ }
if cfg.BiblePath == "" { cfg.BiblePath = defaultCfg.BiblePath } + if cfg.EasterType == "" { + cfg.EasterType = defaultCfg.EasterType + } if cfg.TUI.BorderStyle == "" { cfg.TUI.BorderStyle = defaultCfg.TUI.BorderStyle }@@ -208,6 +216,7 @@ // Set up viper with current config
viper.Set("reading_mode", cfg.ReadingMode) viper.Set("output_mode", cfg.OutputMode) viper.Set("bible_path", cfg.BiblePath) + viper.Set("easter_type", cfg.EasterType) viper.Set("tui.show_quit_message", cfg.TUI.ShowQuitMessage) viper.Set("tui.border_style", cfg.TUI.BorderStyle) viper.Set("tui.border_colour", cfg.TUI.BorderColour)@@ -247,10 +256,10 @@ // Validate checks if configuration values are valid
func (c *Config) Validate() error { // Validate reading mode validReadingModes := map[string]bool{ - "evangelion": true, + "evangelion": true, "new_testament": true, "old_testament": true, - "bible": true, + "bible": true, } // Allow empty reading mode (will use default) if c.ReadingMode != "" && !validReadingModes[c.ReadingMode] {@@ -278,6 +287,16 @@ if !validBorderStyles[c.TUI.BorderStyle] {
return fmt.Errorf("invalid border style: %s, must be one of: rounded, double, single, hidden", c.TUI.BorderStyle) } + // Validate Easter type + validEasterTypes := map[string]bool{ + "orthodox": true, + "latin": true, + } + // Allow empty Easter type (will use default) + if c.EasterType != "" && !validEasterTypes[c.EasterType] { + return fmt.Errorf("invalid easter type: %s, must be one of: orthodox, latin", c.EasterType) + } + // Validate verses per day if c.DateProgression.VersesPerDay <= 0 { return fmt.Errorf("verses per day must be positive, got: %d", c.DateProgression.VersesPerDay)@@ -287,4 +306,4 @@ return nil
} func GetConfigPath() string { return filepath.Join(xdg.ConfigHome, "bibel", "config.toml") -}+}
@@ -0,0 +1,159 @@
+package bible + +import ( + "fmt" + "time" + + "maxwelljensen/bibel/pkg" +) + +// EasterProgression handles calculating time until Easter +type EasterProgression struct { + easterType string // "orthodox" or "latin" +} + +// NewEasterProgression creates a new Easter progression calculator +func NewEasterProgression(easterType string) *EasterProgression { + if easterType != "latin" && easterType != "orthodox" { + easterType = "orthodox" // Default to orthodox + } + return &EasterProgression{easterType: easterType} +} + +// GetNextEasterDate calculates the date of the next Easter (Orthodox or Catholic) +func (ep *EasterProgression) GetNextEasterDate() (time.Time, error) { + currentYear := time.Now().Year() + + // Try current year + var easterDate time.Time + var err error + + if ep.easterType == "latin" { + easterDate, err = eastertime.CatholicByYear(currentYear) + } else { + easterDate, err = eastertime.OrthodoxByYear(currentYear) + } + + if err != nil { + return time.Time{}, err + } + + now := time.Now() + + // If Easter has already passed this year, try next year + if easterDate.Before(now) { + if ep.easterType == "latin" { + easterDate, err = eastertime.CatholicByYear(currentYear + 1) + } else { + easterDate, err = eastertime.OrthodoxByYear(currentYear + 1) + } + + if err != nil { + return time.Time{}, err + } + } + + return easterDate, nil +} + +// GetTimeUntilEaster calculates the duration until the next Easter +func (ep *EasterProgression) GetTimeUntilEaster() (time.Duration, error) { + easterDate, err := ep.GetNextEasterDate() + if err != nil { + return 0, err + } + + return time.Until(easterDate), nil +} + +// FormatEasterProgress formats the time until Easter as a human-readable string +func (ep *EasterProgression) FormatEasterProgress() (string, error) { + duration, err := ep.GetTimeUntilEaster() + if err != nil { + return "", err + } + + // Calculate days, hours, minutes + totalSeconds := int(duration.Seconds()) + days := totalSeconds / (24 * 60 * 60) + remainingSeconds := totalSeconds % (24 * 60 * 60) + hours := remainingSeconds / (60 * 60) + remainingSeconds %= (60 * 60) + minutes := remainingSeconds / 60 + + var label string + if ep.easterType == "latin" { + label = "Roman Catholic Easter" + } else { + label = "Easter" + } + + if days > 0 { + return fmt.Sprintf("%s in %d days, %d hours, %d minutes", label, days, hours, minutes), nil + } else if hours > 0 { + return fmt.Sprintf("%s in %d hours, %d minutes", label, hours, minutes), nil + } else { + return fmt.Sprintf("%s in %d minutes", label, minutes), nil + } +} + +// GetEasterProgressPercentage returns progress as a percentage (0.0 to 1.0) +// Since Easter is an annual event, we calculate progress through the year +func (ep *EasterProgression) GetEasterProgressPercentage() (float64, error) { + currentYear := time.Now().Year() + + // Get Easter date for current year + var currentEaster time.Time + var err error + + if ep.easterType == "latin" { + currentEaster, err = eastertime.CatholicByYear(currentYear) + } else { + currentEaster, err = eastertime.OrthodoxByYear(currentYear) + } + + if err != nil { + return 0.0, err + } + + // Get Easter date for previous year to calculate yearly cycle + var previousEaster time.Time + if ep.easterType == "latin" { + previousEaster, err = eastertime.CatholicByYear(currentYear - 1) + } else { + previousEaster, err = eastertime.OrthodoxByYear(currentYear - 1) + } + + if err != nil { + return 0.0, err + } + + now := time.Now() + + // If current Easter has passed, we're between current Easter and next Easter + if currentEaster.Before(now) { + // Get next Easter + var nextEaster time.Time + if ep.easterType == "latin" { + nextEaster, err = eastertime.CatholicByYear(currentYear + 1) + } else { + nextEaster, err = eastertime.OrthodoxByYear(currentYear + 1) + } + + if err != nil { + return 0.0, err + } + + // Progress from current Easter to next Easter + totalDuration := nextEaster.Sub(currentEaster) + elapsedDuration := now.Sub(currentEaster) + + return elapsedDuration.Seconds() / totalDuration.Seconds(), nil + } else { + // We're between previous Easter and current Easter + totalDuration := currentEaster.Sub(previousEaster) + elapsedDuration := now.Sub(previousEaster) + + return elapsedDuration.Seconds() / totalDuration.Seconds(), nil + } +}
@@ -1,6 +1,7 @@
package tui import ( + "fmt" "strings" tea "github.com/charmbracelet/bubbletea"@@ -19,6 +20,7 @@ outputMode string
styles *Styles formatter *bible.Formatter config *bible.Config + easterProg *bible.EasterProgression } // Styles contains the lipgloss styles for the TUI@@ -32,6 +34,9 @@ }
// NewModel creates a new TUI model with the given Bible data and bookmark func NewModel(bibleData *bible.Bible, bookmark *bible.Bookmark, config *bible.Config) Model { + // Initialise Easter progression + easterProg := bible.NewEasterProgression(config.EasterType) + m := Model{ bibleData: bibleData, bookmark: bookmark,@@ -39,6 +44,7 @@ outputMode: config.OutputMode,
styles: createStyles(config), formatter: bible.NewFormatterWithFullConfig(bibleData, config.Formatter.UseColours, config.Formatter.HeaderFormat, config.Formatter.Numbered, config.Formatter.Paragraphs), config: config, + easterProg: easterProg, } return m }@@ -192,7 +198,7 @@ // Calculate available width for content
// Box has: Padding(1, 2) = 2 left + 2 right = 4 // Border = 1 left + 1 right = 2 // Total horizontal frame = 6 - // Use window width with some margin + // Use window size with some margin availableWidth := max( // Leave some terminal margin m.width-10,@@ -204,8 +210,8 @@ // Format verses with appropriate styling
var verseContent string if m.config.Formatter.Numbered || m.config.Formatter.Paragraphs { // Use TUI-aware formatting for numbered or paragraph mode - verseContent = m.formatter.FormatSnippetForTUI(verses, - m.styles.content, + verseContent = m.formatter.FormatSnippetForTUI(verses, + m.styles.content, m.styles.numberStyle, contentWidth) } else {@@ -218,18 +224,94 @@
// Create the box with max width constraint box := m.styles.box.MaxWidth(availableWidth).Render(boxContent.String()) + // Create Easter progress bar + easterProgress := m.renderEasterProgressBar(availableWidth) + // Add quit message below the box if configured to show it var fullOutput string if m.config.TUI.ShowQuitMessage { quitMsg := m.styles.quitMessage.Render("Press q to quit...") - fullOutput = lipgloss.JoinVertical(lipgloss.Center, box, quitMsg) + fullOutput = lipgloss.JoinVertical(lipgloss.Center, box, easterProgress, quitMsg) } else { - fullOutput = lipgloss.JoinVertical(lipgloss.Center, box) + fullOutput = lipgloss.JoinVertical(lipgloss.Center, box, easterProgress) } return fullOutput } +// renderEasterProgressBar renders a fancy progress bar showing time until Easter +func (m Model) renderEasterProgressBar(width int) string { + if m.easterProg == nil { + return "" + } + + // Calculate progress percentage + progress, err := m.easterProg.GetEasterProgressPercentage() + if err != nil { + // If we can't calculate Easter, show error message + errorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF4444")). + Italic(true) + return errorStyle.Width(width - 6).Render("Unable to calculate Easter date") + } + + // Format time until Easter + timeStr, err := m.easterProg.FormatEasterProgress() + if err != nil { + timeStr = "Calculating..." + } + + // Create progress bar + barWidth := max( + // Leave some padding + width-10, + // Minimum bar width + 20) + + // Calculate filled width + filledWidth := max(min(int(float64(barWidth)*progress), barWidth), 0) + emptyWidth := barWidth - filledWidth + + // Define styles + filledStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#00AA00")) + + emptyStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")). + Background(lipgloss.Color("#222222")) + + // Create bar segments + filledBar := filledStyle.Render(strings.Repeat("█", filledWidth)) + emptyBar := emptyStyle.Render(strings.Repeat("░", emptyWidth)) + bar := filledBar + emptyBar + + // Add percentage text + percentage := fmt.Sprintf("%.1f%%", progress*100) + percentageStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")). + Bold(true) + + percentageText := percentageStyle.Render(percentage) + + // Combine time text, bar, and percentage + timeStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#CCCCCC")) + + timeText := timeStyle.Width(barWidth - len(percentage) - 2).Render(timeStr) + + // Layout: time text on left, bar below, percentage on right of bar + topRow := lipgloss.JoinHorizontal(lipgloss.Left, timeText, " ", percentageText) + bottomRow := bar + + // Container for the progress bar + containerStyle := lipgloss.NewStyle(). + Padding(0, 1). + MarginTop(1) + + return containerStyle.Render(lipgloss.JoinVertical(lipgloss.Left, topRow, bottomRow)) +} + // CreateTUIProgram creates and returns a bubbletea program for the Bible verse func CreateTUIProgram(bible *bible.Bible, bookmark *bible.Bookmark, config *bible.Config) *tea.Program { model := NewModel(bible, bookmark, config)@@ -242,7 +324,8 @@ var result strings.Builder
inEscape := false for i := 0; i < len(str); i++ { - if str[i] == '\033' && i+1 < len(str) && str[i+1] == '[' { + // Check for ESC [ sequence (ANSI escape) + if str[i] == 27 && i+1 < len(str) && str[i+1] == '[' { inEscape = true i++ // Skip the '[' continue@@ -261,11 +344,3 @@ }
return result.String() } - -// max returns the larger of two integers -func max(a, b int) int { - if a > b { - return a - } - return b -}
@@ -0,0 +1,106 @@
+// Copyright (c) 2013 eastertime Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// eastertime provides functions to get midnight date time of easter day +// for Catholic and Orthodox on Gregorian Calendar +// web github/vjeantet/eastertime + +package eastertime + +import ( + "errors" + "math" + "time" +) + +// CatholicByYear returns time.Time for midnight on Catholic Easter of a given +// year use Delambre and Butcher's and return time based on Gregorian calendar +// +// @param int year The year as a number greater than 0 +// @return time.Time The easter date as a time.Time +// @return errors.Error Error if exists, else nil +func CatholicByYear(year int) (time.Time, error) { + var a, b, c, d, e, r int + + if year < 0 { + return time.Now(), errors.New("year have to be greater than 0") + } + + a = year % 19 + if year >= 1583 { + var f, g, h, i, k, l, m int + b = year / 100 + c = year % 100 + d = b / 4 + e = b % 4 + f = (b + 8) / 25 + g = (b - f + 1) / 3 + h = (19*a + b - d - g + 15) % 30 + i = c / 4 + k = c % 4 + l = (32 + 2*e + 2*i - h - k) % 7 + m = (a + 11*h + 22*l) / 451 + r = 22 + h + l - 7*m + } else { + b = year % 7 + c = year % 4 + d = (19*a + 15) % 30 + e = (2*c + 4*b - d + 34) % 7 + r = 22 + d + e + } + + return time.Date(year, time.March, r, 0, 0, 0, 0, time.Local), nil +} + +// OrthodoxByYear returns time.Time for midnight on Orthodox Easter of a given +// year use Meeus Julian algorithm and return time based on Gregorian calendar +// +// @param int year The year as a number greater than 325 +// @return time.Time The easter date as a time.Time +// @return errors.Error Error if exists, else nil +func OrthodoxByYear(year int) (time.Time, error) { + + if year < 326 { + return time.Now(), errors.New("year have to be greater than 325") + } + + var a, b, c, d, e int + var month time.Month + var day float64 + + a = year % 4 + b = year % 7 + c = year % 19 + d = (19*c + 15) % 30 + e = (2*a + 4*b - d + 34) % 7 + month = time.Month((d + e + 114) / 31) + day = math.Floor(float64((d+e+114)%31 + 1)) + day = day + 13 + + return time.Date(year, month, int(day), 0, 0, 0, 0, time.Local), nil +}