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.

891 lines
24 KiB

package main
import (
_ "embed"
"fmt"
"log"
"os"
"strings"
"text/template"
"github.com/gdamore/tcell/v2"
"github.com/gobwas/glob"
"gopkg.in/ini.v1"
)
//go:embed toot.tmpl
var tootTemplate string
//go:embed user.tmpl
var userTemplate string
type Config struct {
General GeneralConfig
Style StyleConfig
Media MediaConfig
OpenPattern OpenPatternConfig
OpenCustom OpenCustomConfig
NotificationConfig NotificationConfig
Templates TemplatesConfig
}
type GeneralConfig struct {
AutoLoadNewer bool
AutoLoadSeconds int
DateTodayFormat string
DateFormat string
DateRelative int
MaxWidth int
StartTimeline TimelineType
NotificationFeed bool
QuoteReply bool
CharLimit int
ShortHints bool
ListPlacement ListPlacement
ListSplit ListSplit
HideNotificationText bool
ListProportion int
ContentProportion int
ShowIcons bool
}
type StyleConfig struct {
Background tcell.Color
Text tcell.Color
Subtle tcell.Color
WarningText tcell.Color
TextSpecial1 tcell.Color
TextSpecial2 tcell.Color
TopBarBackground tcell.Color
TopBarText tcell.Color
StatusBarBackground tcell.Color
StatusBarText tcell.Color
StatusBarViewBackground tcell.Color
StatusBarViewText tcell.Color
ListSelectedBackground tcell.Color
ListSelectedText tcell.Color
}
type MediaConfig struct {
ImageViewer string
ImageArgs []string
ImageTerminal bool
ImageSingle bool
ImageReverse bool
VideoViewer string
VideoArgs []string
VideoTerminal bool
VideoSingle bool
VideoReverse bool
AudioViewer string
AudioArgs []string
AudioTerminal bool
AudioSingle bool
AudioReverse bool
LinkViewer string
LinkArgs []string
LinkTerminal bool
}
type Pattern struct {
Pattern string
Open string
Compiled glob.Glob
Program string
Args []string
Terminal bool
}
type OpenPatternConfig struct {
Patterns []Pattern
}
type OpenCustom struct {
Index int
Name string
Program string
Args []string
Terminal bool
}
type OpenCustomConfig struct {
OpenCustoms []OpenCustom
}
type ListPlacement uint
const (
ListPlacementTop ListPlacement = iota
ListPlacementBottom
ListPlacementLeft
ListPlacementRight
)
type ListSplit uint
const (
ListRow ListSplit = iota
ListColumn
)
type NotificationType uint
const (
NotificationFollower = iota
NotificationFavorite
NotificationMention
NotificationBoost
NotificationPoll
NotificationPost
)
type NotificationConfig struct {
NotificationFollower bool
NotificationFavorite bool
NotificationMention bool
NotificationBoost bool
NotificationPoll bool
NotificationPost bool
}
type TemplatesConfig struct {
TootTemplate *template.Template
UserTemplate *template.Template
}
func parseColor(input string, def string, xrdb map[string]string) tcell.Color {
if input == "" {
return tcell.GetColor(def)
}
if strings.HasPrefix(input, "xrdb:") {
key := strings.TrimPrefix(input, "xrdb:")
if c, ok := xrdb[key]; ok {
return tcell.GetColor(c)
} else {
return tcell.GetColor(def)
}
}
return tcell.GetColor(input)
}
func parseStyle(cfg *ini.File) StyleConfig {
var xrdbColors map[string]string
xrdbMap, _ := GetXrdbColors()
prefix := cfg.Section("style").Key("xrdb-prefix").String()
if prefix == "" {
prefix = "guess"
}
if prefix == "guess" {
if m, ok := xrdbMap["*"]; ok {
xrdbColors = m
} else if m, ok := xrdbMap["URxvt"]; ok {
xrdbColors = m
} else if m, ok := xrdbMap["XTerm"]; ok {
xrdbColors = m
}
} else {
if m, ok := xrdbMap[prefix]; ok {
xrdbColors = m
}
}
style := StyleConfig{}
bg := cfg.Section("style").Key("background").String()
style.Background = parseColor(bg, "default", xrdbColors)
text := cfg.Section("style").Key("text").String()
style.Text = parseColor(text, "white", xrdbColors)
subtle := cfg.Section("style").Key("subtle").String()
style.Subtle = parseColor(subtle, "gray", xrdbColors)
warningText := cfg.Section("style").Key("warning-text").String()
style.WarningText = parseColor(warningText, "#f92672", xrdbColors)
textSpecial1 := cfg.Section("style").Key("text-special-one").String()
style.TextSpecial1 = parseColor(textSpecial1, "#ae81ff", xrdbColors)
textSpecial2 := cfg.Section("style").Key("text-special-two").String()
style.TextSpecial2 = parseColor(textSpecial2, "#a6e22e", xrdbColors)
topBarBackround := cfg.Section("style").Key("top-bar-background").String()
style.TopBarBackground = parseColor(topBarBackround, "#f92672", xrdbColors)
topBarText := cfg.Section("style").Key("top-bar-text").String()
style.TopBarText = parseColor(topBarText, "white", xrdbColors)
statusBarBackround := cfg.Section("style").Key("status-bar-background").String()
style.StatusBarBackground = parseColor(statusBarBackround, "#f92672", xrdbColors)
statusBarText := cfg.Section("style").Key("status-bar-text").String()
style.StatusBarText = parseColor(statusBarText, "white", xrdbColors)
statusBarViewBackround := cfg.Section("style").Key("status-bar-view-background").String()
style.StatusBarViewBackground = parseColor(statusBarViewBackround, "#ae81ff", xrdbColors)
statusBarViewText := cfg.Section("style").Key("status-bar-view-text").String()
style.StatusBarViewText = parseColor(statusBarViewText, "white", xrdbColors)
listSelectedBackground := cfg.Section("style").Key("list-selected-background").String()
style.ListSelectedBackground = parseColor(listSelectedBackground, "#f92672", xrdbColors)
listSelectedText := cfg.Section("style").Key("list-selected-text").String()
style.ListSelectedText = parseColor(listSelectedText, "white", xrdbColors)
return style
}
func parseGeneral(cfg *ini.File) GeneralConfig {
general := GeneralConfig{}
general.AutoLoadNewer = cfg.Section("media").Key("auto-load-newer").MustBool(true)
autoLoadSeconds, err := cfg.Section("general").Key("auto-load-seconds").Int()
if err != nil {
autoLoadSeconds = 60
}
general.AutoLoadSeconds = autoLoadSeconds
dateFormat := cfg.Section("general").Key("date-format").String()
if dateFormat == "" {
dateFormat = "2006-01-02 15:04"
}
general.DateFormat = dateFormat
dateTodayFormat := cfg.Section("general").Key("date-today-format").String()
if dateTodayFormat == "" {
dateTodayFormat = "15:04"
}
general.DateTodayFormat = dateTodayFormat
dateRelative, err := cfg.Section("general").Key("date-relative").Int()
if err != nil {
dateRelative = -1
}
general.DateRelative = dateRelative
tl := cfg.Section("general").Key("timeline").In("home", []string{"home", "direct", "local", "federated"})
switch tl {
case "home":
general.StartTimeline = TimelineHome
case "direct":
general.StartTimeline = TimelineDirect
case "local":
general.StartTimeline = TimelineLocal
case "federated":
general.StartTimeline = TimelineFederated
}
general.NotificationFeed = cfg.Section("general").Key("notification-feed").MustBool(true)
general.QuoteReply = cfg.Section("general").Key("quote-reply").MustBool(false)
general.CharLimit = cfg.Section("general").Key("char-limit").MustInt(500)
general.MaxWidth = cfg.Section("general").Key("max-width").MustInt(0)
general.ShortHints = cfg.Section("general").Key("short-hints").MustBool(false)
general.HideNotificationText = cfg.Section("general").Key("hide-notification-text").MustBool(false)
general.ShowIcons = cfg.Section("general").Key("show-icons").MustBool(true)
lp := cfg.Section("general").Key("list-placement").In("left", []string{"left", "right", "top", "bottom"})
switch lp {
case "left":
general.ListPlacement = ListPlacementLeft
case "right":
general.ListPlacement = ListPlacementRight
case "top":
general.ListPlacement = ListPlacementTop
case "bottom":
general.ListPlacement = ListPlacementBottom
}
ls := cfg.Section("general").Key("list-split").In("row", []string{"row", "column"})
switch ls {
case "row":
general.ListSplit = ListRow
case "column":
general.ListSplit = ListColumn
}
listProp := cfg.Section("general").Key("list-proportion").MustInt(1)
if listProp < 1 {
listProp = 1
}
contentProp := cfg.Section("general").Key("content-proportion").MustInt(2)
if contentProp < 1 {
contentProp = 1
}
general.ListProportion = listProp
general.ContentProportion = contentProp
return general
}
func parseMedia(cfg *ini.File) MediaConfig {
media := MediaConfig{}
imageViewerComponents := strings.Fields(cfg.Section("media").Key("image-viewer").String())
if len(imageViewerComponents) == 0 {
media.ImageViewer = "xdg-open"
media.ImageArgs = []string{}
} else {
media.ImageViewer = imageViewerComponents[0]
media.ImageArgs = imageViewerComponents[1:]
}
media.ImageTerminal = cfg.Section("media").Key("image-terminal").MustBool(false)
media.ImageSingle = cfg.Section("media").Key("image-single").MustBool(true)
media.ImageReverse = cfg.Section("media").Key("image-reverse").MustBool(false)
videoViewerComponents := strings.Fields(cfg.Section("media").Key("video-viewer").String())
if len(videoViewerComponents) == 0 {
media.VideoViewer = "xdg-open"
media.VideoArgs = []string{}
} else {
media.VideoViewer = videoViewerComponents[0]
media.VideoArgs = videoViewerComponents[1:]
}
media.VideoTerminal = cfg.Section("media").Key("video-terminal").MustBool(false)
media.VideoSingle = cfg.Section("media").Key("video-single").MustBool(true)
media.VideoReverse = cfg.Section("media").Key("video-reverse").MustBool(false)
audioViewerComponents := strings.Fields(cfg.Section("media").Key("audio-viewer").String())
if len(audioViewerComponents) == 0 {
media.AudioViewer = "xdg-open"
media.AudioArgs = []string{}
} else {
media.AudioViewer = audioViewerComponents[0]
media.AudioArgs = audioViewerComponents[1:]
}
media.AudioTerminal = cfg.Section("media").Key("audio-terminal").MustBool(false)
media.AudioSingle = cfg.Section("media").Key("audio-single").MustBool(true)
media.AudioReverse = cfg.Section("media").Key("audio-reverse").MustBool(false)
linkViewerComponents := strings.Fields(cfg.Section("media").Key("link-viewer").String())
if len(linkViewerComponents) == 0 {
media.LinkViewer = "xdg-open"
media.LinkArgs = []string{}
} else {
media.LinkViewer = linkViewerComponents[0]
media.LinkArgs = linkViewerComponents[1:]
}
media.LinkTerminal = cfg.Section("media").Key("link-terminal").MustBool(false)
return media
}
func ParseOpenPattern(cfg *ini.File) OpenPatternConfig {
om := OpenPatternConfig{}
keys := cfg.Section("open-pattern").KeyStrings()
pairs := make(map[string]Pattern)
for _, s := range keys {
parts := strings.Split(s, "-")
if len(parts) < 2 {
panic(fmt.Sprintf("Invalid key %s in config. Must end in -pattern, -use or -terminal", s))
}
last := parts[len(parts)-1]
if last != "pattern" && last != "use" && last != "terminal" {
panic(fmt.Sprintf("Invalid key %s in config. Must end in -pattern, -use or -terminal", s))
}
name := strings.Join(parts[:len(parts)-1], "-")
if _, ok := pairs[name]; !ok {
pairs[name] = Pattern{}
}
if last == "pattern" {
tmp := pairs[name]
tmp.Pattern = cfg.Section("open-pattern").Key(s).MustString("")
pairs[name] = tmp
}
if last == "use" {
tmp := pairs[name]
tmp.Open = cfg.Section("open-pattern").Key(s).MustString("")
pairs[name] = tmp
}
if last == "terminal" {
tmp := pairs[name]
tmp.Terminal = cfg.Section("open-pattern").Key(s).MustBool(false)
pairs[name] = tmp
}
}
for key := range pairs {
if pairs[key].Pattern == "" {
panic(fmt.Sprintf("Invalid value for key %s in config. Can't be empty", key+"-pattern"))
}
if pairs[key].Open == "" {
panic(fmt.Sprintf("Invalid value for key %s in config. Can't be empty", key+"-use"))
}
compiled, err := glob.Compile(pairs[key].Pattern)
if err != nil {
panic(fmt.Sprintf("Couldn't compile pattern for key %s in config. Error: %v", key+"-pattern", err))
}
tmp := pairs[key]
tmp.Compiled = compiled
comp := strings.Fields(tmp.Open)
tmp.Program = comp[0]
tmp.Args = comp[1:]
om.Patterns = append(om.Patterns, tmp)
}
return om
}
func ParseCustom(cfg *ini.File) OpenCustomConfig {
oc := OpenCustomConfig{}
for i := 1; i < 6; i++ {
name := cfg.Section("open-custom").Key(fmt.Sprintf("c%d-name", i)).MustString("")
use := cfg.Section("open-custom").Key(fmt.Sprintf("c%d-use", i)).MustString("")
terminal := cfg.Section("open-custom").Key(fmt.Sprintf("c%d-terminal", i)).MustBool(false)
if use == "" {
continue
}
comp := strings.Fields(use)
c := OpenCustom{}
c.Index = i
c.Name = name
c.Program = comp[0]
c.Args = comp[1:]
c.Terminal = terminal
oc.OpenCustoms = append(oc.OpenCustoms, c)
}
return oc
}
func ParseNotifications(cfg *ini.File) NotificationConfig {
nc := NotificationConfig{}
nc.NotificationFollower = cfg.Section("desktop-notification").Key("followers").MustBool(false)
nc.NotificationFavorite = cfg.Section("desktop-notification").Key("favorite").MustBool(false)
nc.NotificationMention = cfg.Section("desktop-notification").Key("mention").MustBool(false)
nc.NotificationBoost = cfg.Section("desktop-notification").Key("boost").MustBool(false)
nc.NotificationPoll = cfg.Section("desktop-notification").Key("poll").MustBool(false)
nc.NotificationPost = cfg.Section("desktop-notification").Key("posts").MustBool(false)
return nc
}
func ParseTemplates(cfg *ini.File) TemplatesConfig {
var tootTmpl *template.Template
tootTmplPath, exists, err := CheckConfig("toot.tmpl")
if err != nil {
log.Fatalln(
fmt.Sprintf("Couldn't access toot.tmpl. Error: %v", err),
)
}
if exists {
tootTmpl, err = template.New("toot.tmpl").Funcs(template.FuncMap{
"Color": ColorMark,
"Flags": TextFlags,
}).ParseFiles(tootTmplPath)
}
if !exists || err != nil {
tootTmpl, err = template.New("toot.tmpl").Funcs(template.FuncMap{
"Color": ColorMark,
"Flags": TextFlags,
}).Parse(tootTemplate)
}
if err != nil {
log.Fatalf("Couldn't parse toot.tmpl. Error: %v", err)
}
var userTmpl *template.Template
userTmplPath, exists, err := CheckConfig("user.tmpl")
if err != nil {
log.Fatalln(
fmt.Sprintf("Couldn't access user.tmpl. Error: %v", err),
)
}
if exists {
userTmpl, err = template.New("user.tmpl").Funcs(template.FuncMap{
"Color": ColorMark,
"Flags": TextFlags,
}).ParseFiles(userTmplPath)
}
if !exists || err != nil {
userTmpl, err = template.New("user.tmpl").Funcs(template.FuncMap{
"Color": ColorMark,
"Flags": TextFlags,
}).Parse(userTemplate)
}
if err != nil {
log.Fatalf("Couldn't parse user.tmpl. Error: %v", err)
}
return TemplatesConfig{
TootTemplate: tootTmpl,
UserTemplate: userTmpl,
}
}
func ParseConfig(filepath string) (Config, error) {
cfg, err := ini.LoadSources(ini.LoadOptions{
SpaceBeforeInlineComment: true,
}, filepath)
conf := Config{}
if err != nil {
return conf, err
}
conf.General = parseGeneral(cfg)
conf.Media = parseMedia(cfg)
conf.Style = parseStyle(cfg)
conf.OpenPattern = ParseOpenPattern(cfg)
conf.OpenCustom = ParseCustom(cfg)
conf.NotificationConfig = ParseNotifications(cfg)
conf.Templates = ParseTemplates(cfg)
return conf, nil
}
func CreateConfigDir() error {
cd, err := os.UserConfigDir()
if err != nil {
log.Fatalf("couldn't find $HOME. Err %v", err)
}
path := cd + "/tut"
return os.MkdirAll(path, os.ModePerm)
}
func CheckConfig(filename string) (path string, exists bool, err error) {
cd, err := os.UserConfigDir()
if err != nil {
log.Fatalf("couldn't find $HOME. Err %v", err)
}
dir := cd + "/tut/"
path = dir + filename
_, err = os.Stat(path)
if os.IsNotExist(err) {
return path, false, nil
} else if err != nil {
return path, true, err
}
return path, true, err
}
func CreateDefaultConfig(filepath string) error {
conf := `#
# Configuration file for tut
[general]
# If the program should check for new toots without user interaction.
# If you don't enable this the program will only look for new toots when
# you reach the bottom or top of your feed. With this enabled it will check
# for new toots every x second.
# default=true
auto-load-newer=true
# How many seconds between each pulling of new toots if you have enabled
# auto-load-newer.
# default=60
auto-load-seconds=60
# The date format to be used
# See https://godoc.org/time#Time.Format
# default=2006-01-02 15:04
date-format=2006-01-02 15:04
# Format for dates the same day
# default=15:04
date-today-format=15:04
# This displays relative dates instead
# for statuses that are one day or older
# the output is 1y2m1d (1 year 2 months and 1 day)
#
# The value is an integear
# -1 = don't use relative dates
# 0 = always use relative dates, except for dates < 1 day
# 1 - ∞ = number of days to use relative dates
#
# Example: date-relative=28 will display a relative date
# for toots that are between 1-28 days old. Otherwhise it
# will use the short or long format
#
# default=-1
date-relative=-1
# The timeline that opens up when you start tut
# Valid values: home, direct, local, federated
# default=home
timeline=home
# The max width of text before it wraps when displaying toots
# 0 = no restriction
# default=0
max-width=0
# If you want to display a list of notifications
# under your timeline feed
# default=true
notification-feed=true
# Where do you want the list of toots to be placed
# Valid values: left, right, top, bottom
# default=left
list-placement=left
# If you have notification-feed set to true you can
# display it under the main list of toots (row)
# or place it to the right of the main list of toots (column)
# default=row
list-split=row
# Hide notification text above list in column split
# default=false
hide-notification-text=false
# You can change the proportions of the list view
# in relation to the content view
# list-proportion=1 and content-proportoin=3
# will result in the content taking up 3 times more space
# Must be n > 0
# defaults:
# list-proportion=1
# content-proportion=2
list-proportion=1
content-proportion=2
# If you always want to quote original message when replying
# default=false
quote-reply=false
# If you're on an instance with a custom character limit you can set it here
# default=500
char-limit=500
# If you want to show icons in the list of toots
# default=true
show-icons=true
# If you've learnt all the shortcut keys you can remove the help text and
# only show the key in tui. So it gets less cluttered.
# default=false
short-hints=false
[media]
# Your image viewer
# default=xdg-open
image-viewer=xdg-open
# Open in the same terminal as toot
# for terminal based viewers
# default=false
image-terminal=false
# If image should open one by one e.g. "imv image.png" multiple times
# If set to false all images will open at the same time like this
# "imv image1.png image2.png image3.png".
# Not all image viewers support this, so try it first.
# default=true
image-single=true
# If you want to open the images in reverse order. In some image viewers
# this will display the images in the "right" order.
# default=false
image-reverse=false
# Your video viewer
# default=xdg-open
video-viewer=xdg-open
# Open in the same terminal as toot
# for terminal based players
# default=false
video-terminal=false
# If videos should open one by one. See above comment about image-single
# default=true
video-single=true
# If you want to open the videos in reverse order. In some video apps
# this will play the files in the "right" order.
# default=false
video-reverse=false
# Your audio viewer
# default=xdg-open
audio-viewer=xdg-open
# Open in the same terminal as toot
# for terminal based players
# default=false
audio-terminal=false
# If you want to play the audio files in reverse order. In some audio apps
# this will play the files in the "right" order.
# default=false
audio-reverse=false
# If audio files should open one by one. See above comment about image-single
# default=true
audio-single=true
# Your web browser
# default=xdg-open
link-viewer=xdg-open
# Open in the same terminal as toot
# for terminal based browser
# default=false
link-terminal=false
[open-custom]
# This sections allows you to set up to five custom programs to upen URLs with.
# If the url points to an image, you can set c1-name to img and c1-use to imv.
# If the program runs in a terminal and you want to run it in the same terminal
# as tut. Set cX-terminal to true.
# The name will show up in the UI, so keep it short so all five fits.
#
# c1-name=img
# c1-use=imv
# c1-terminal=false
#
# c2-name=
# c2-use=
# c2-terminal=false
#
# c3-name=
# c3-use=
# c3-terminal=false
#
# c4-name=
# c4-use=
# c4-terminal=false
#
# c5-name=
# c5-use=
# c5-terminal=false
[open-pattern]
# Here you can set your own glob patterns for opening matching URLs in the
# program you want them to open up in.
# You could for example open Youtube videos in your video player instead of
# your default browser.
#
# You must name the keys foo-pattern, foo-use and foo-terminal where foo-use is the
# program that will open up the URL.
# foo-pattern is the glob pattern. To see the syntax for glob pattern you can follow
# this URL https://github.com/gobwas/glob#syntax
# foo-terminal is for programs that runs in the terminal and you want it to open
# up in the same terminal as tut.
#
# Example for youtube.com and youtu.be to open up in mpv instead of the browser
#
# y1-pattern=*youtube.com/watch*
# y1-use=mpv
# y1-terminal=false
# y2-pattern=*youtu.be/*
# y2-use=mpv
# y2-terminal=false
[desktop-notification]
# Under this section you can turn on desktop notifications
# Notification when someone follows you
# default=false
followers=false
# Notification when someone favorites one of your toots
# default=false
favorite=false
# Notification when someone mentions you
# default=false
mention=false
# Notification when someone boosts one of your toots
# default=false
boost=false
# Notification of poll results
# default=false
poll=false
# New posts in current timeline
# default=false
posts=false
[style]
# All styles can be represented in their HEX value like #ffffff or
# with their name, so in this case white.
# The only special value is "default" which equals to transparent,
# so it will be the same color as your terminal. But this can lead
# to some artifacts left from a previous paint
# You can also use xrdb colors like this xrdb:color1
# The program will use colors prefixed with an * first then look
# for URxvt or XTerm if it can't find any color prefixed with an asterik.
# If you don't want tut to guess the prefix you can set the prefix yourself.
# If the xrdb color can't be found a preset color will be used.
# The xrdb prefix used for colors in .Xresources
# default=guess
xrdb-prefix=guess
# The background color used on most elements
# default=xrdb:background
background=xrdb:background
# The text color used on most of the text
# default=xrdb:foreground
text=xrdb:foreground
# The color to display sublte elements or subtle text. Like lines and help text
# default=xrdb:color14
subtle=xrdb:color14
# The color for errors or warnings
# default=xrdb:color1
warning-text=xrdb:color1
# This color is used to display username
# default=xrdb:color5
text-special-one=xrdb:color5
# This color is used to display username and keys
# default=xrdb:color2
text-special-two=xrdb:color2
# The color of the bar at the top
# default=xrdb:color5
top-bar-background=xrdb:color5
# The color of the text in the bar at the top
# default=xrdb:background
top-bar-text=xrdb:background
# The color of the bar at the bottom
# default=xrdb:color5
status-bar-background=xrdb:color5
# The color of the text in the bar at the bottom
# default=xrdb:foreground
status-bar-text=xrdb:foreground
# The color of the bar at the bottom in view mode
# default=xrdb:color4
status-bar-view-background=xrdb:color4
# The color of the text in the bar at the bottom in view mode
# default=xrdb:foreground
status-bar-view-text=xrdb:foreground
# Background of selected list items
# default=xrdb:color5
list-selected-background=xrdb:color5
# The text color of selected list items
# default=xrdb:background
list-selected-text=xrdb:background
`
f, err := os.Create(filepath)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(conf)
if err != nil {
return err
}
return nil
}