Some checks failed
gobuild / build (push) Failing after 7s
Add Volume() to Player interface and mpvPlayer implementation to expose current volume level. Poll volume periodically and render a vertical bar next to the metadata display during playback. Update UI state and tests to support the new volume indicator.
657 lines
17 KiB
Go
657 lines
17 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/bubbles/list"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/gmgauthier/gostations/internal/config"
|
|
"github.com/gmgauthier/gostations/internal/data"
|
|
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
|
"github.com/gmgauthier/gostations/internal/radio"
|
|
)
|
|
|
|
var (
|
|
// hintKeyStyle gives trigger keys a colorful background + foreground so they pop in the hint row.
|
|
hintKeyStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("15")).
|
|
Background(lipgloss.Color("63")).
|
|
Bold(true)
|
|
|
|
// hintTextStyle dims the descriptive labels next to the keys.
|
|
hintTextStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("245"))
|
|
|
|
// hintBarStyle renders the entire hint row as a full-width bar with subtle background.
|
|
// The key badges (with their own bg) will stand out on top of this bar.
|
|
hintBarStyle = lipgloss.NewStyle().
|
|
Background(lipgloss.Color("236")).
|
|
Foreground(lipgloss.Color("245")).
|
|
PaddingLeft(2)
|
|
)
|
|
|
|
// item wraps a station for the bubbles list.
|
|
type item struct {
|
|
station radio.Station
|
|
isFavorite bool
|
|
}
|
|
|
|
func (i item) Title() string { return i.station.Name }
|
|
func (i item) Description() string {
|
|
return fmt.Sprintf("%s • %s kbps • %s", i.station.Codec, i.station.Bitrate, truncate(i.station.Url, 50))
|
|
}
|
|
func (i item) FilterValue() string {
|
|
return i.station.Name + " " + i.station.Tags + " " + i.station.Codec + " " + i.station.Url
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|
|
|
|
// listDelegate for nice rendering.
|
|
type listDelegate struct{}
|
|
|
|
func (d listDelegate) Height() int { return 2 }
|
|
func (d listDelegate) Spacing() int { return 1 }
|
|
func (d listDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
|
|
func (d listDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
|
|
i, ok := listItem.(item)
|
|
if !ok {
|
|
return
|
|
}
|
|
title := i.station.Name
|
|
if i.isFavorite {
|
|
title = "★ " + title
|
|
}
|
|
if m.Index() == index {
|
|
title = lipgloss.NewStyle().Bold(true).Render("▶ " + title)
|
|
} else {
|
|
title = " " + title
|
|
}
|
|
desc := fmt.Sprintf(" %s • %s kbps • %s", i.station.Codec, i.station.Bitrate, truncate(i.station.Url, 45))
|
|
fmt.Fprintf(w, "%s\n%s", title, desc)
|
|
}
|
|
|
|
// App is the root model. Supports list selection + playback view (winamp-ish).
|
|
type App struct {
|
|
list list.Model
|
|
quitting bool
|
|
currentSearchTerm string
|
|
favs *data.Favorites
|
|
width int
|
|
height int
|
|
|
|
// player and playback state (two-stage UI)
|
|
player playerpkg.Player
|
|
playing bool
|
|
playingItem item
|
|
nowPlaying string // streamed metadata title
|
|
paused bool
|
|
muted bool
|
|
currentVolume int
|
|
}
|
|
|
|
func NewApp(initial []radio.Station) *App {
|
|
favs, err := data.NewFavorites()
|
|
if err != nil {
|
|
log.Printf("warning: could not load favorites: %v", err)
|
|
}
|
|
favSet := map[string]bool{}
|
|
if favs != nil {
|
|
for _, s := range favs.List() {
|
|
favSet[s.Url] = true
|
|
}
|
|
}
|
|
|
|
items := make([]list.Item, len(initial))
|
|
for i, s := range initial {
|
|
isFav := favSet[s.Url]
|
|
items[i] = item{station: s, isFavorite: isFav}
|
|
}
|
|
|
|
// Heuristic: if every provided initial station is a favorite, this was a "favorites" initial load
|
|
isFavoritesInitial := len(initial) > 0
|
|
if isFavoritesInitial {
|
|
for _, s := range initial {
|
|
if !favSet[s.Url] {
|
|
isFavoritesInitial = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
l := list.New(items, listDelegate{}, 60, 20)
|
|
title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
|
|
if isFavoritesInitial {
|
|
title = "GoStations - Your Favorites"
|
|
}
|
|
l.Title = title
|
|
l.SetShowStatusBar(false)
|
|
l.SetFilteringEnabled(true)
|
|
l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
|
|
|
// Use a custom substring/AND filter instead of the default fuzzy (which matches
|
|
// any combination of individual letters, e.g. "WFMT" matching stations with W or F or M or T).
|
|
// This makes "/WFMT" find stations containing the substring "wfmt" (case-insensitive),
|
|
// and multi-word searches require all words to appear somewhere in the target.
|
|
l.Filter = func(term string, targets []string) []list.Rank {
|
|
term = strings.TrimSpace(term)
|
|
if term == "" {
|
|
ranks := make([]list.Rank, len(targets))
|
|
for i := range targets {
|
|
ranks[i] = list.Rank{Index: i}
|
|
}
|
|
return ranks
|
|
}
|
|
words := strings.Fields(strings.ToLower(term))
|
|
var ranks []list.Rank
|
|
for i, t := range targets {
|
|
tl := strings.ToLower(t)
|
|
matches := true
|
|
for _, w := range words {
|
|
if !strings.Contains(tl, w) {
|
|
matches = false
|
|
break
|
|
}
|
|
}
|
|
if matches {
|
|
ranks = append(ranks, list.Rank{Index: i})
|
|
}
|
|
}
|
|
return ranks
|
|
}
|
|
|
|
p := newPlayerForTUI()
|
|
return &App{
|
|
list: l,
|
|
favs: favs,
|
|
width: 80,
|
|
height: 24,
|
|
player: p,
|
|
}
|
|
}
|
|
|
|
// newPlayerForTUI creates the appropriate Player for interactive TUI use.
|
|
// Prefers mpv + IPC for background + controls + metadata; falls back to legacy
|
|
// (which will be non-interactive in TUI context).
|
|
func newPlayerForTUI() playerpkg.Player {
|
|
pname := "mpv"
|
|
if v, err := config.Get("player.command"); err == nil && v != "" {
|
|
pname = v
|
|
}
|
|
var base []string
|
|
if v, err := config.Get("player.options"); err == nil && v != "" {
|
|
base = strings.Fields(v) // split e.g. "--no-video --volume=50"
|
|
}
|
|
if strings.Contains(pname, "mpv") {
|
|
return playerpkg.NewMpv(pname, base...)
|
|
}
|
|
return playerpkg.NewLegacy(pname, base...)
|
|
}
|
|
|
|
func (a *App) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
if a.playing {
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
if a.player != nil {
|
|
_ = a.player.Stop()
|
|
}
|
|
a.quitting = true
|
|
return a, tea.Quit
|
|
case "s", "S", "x", "X":
|
|
// stop playback and return to list view
|
|
if a.player != nil {
|
|
_ = a.player.Stop()
|
|
}
|
|
a.playing = false
|
|
a.nowPlaying = ""
|
|
a.paused = false
|
|
a.muted = false
|
|
return a, nil
|
|
case " ", "p", "P":
|
|
if a.player != nil {
|
|
if a.paused {
|
|
_ = a.player.Resume()
|
|
a.paused = false
|
|
} else {
|
|
_ = a.player.Pause()
|
|
a.paused = true
|
|
}
|
|
}
|
|
return a, nil
|
|
case "m", "M":
|
|
if a.player != nil {
|
|
if a.muted {
|
|
_ = a.player.Unmute()
|
|
a.muted = false
|
|
} else {
|
|
_ = a.player.Mute()
|
|
a.muted = true
|
|
}
|
|
}
|
|
return a, nil
|
|
case "left", "h", "H":
|
|
if a.player != nil {
|
|
_ = a.player.Prev()
|
|
}
|
|
return a, nil
|
|
case "right", "l", "L":
|
|
if a.player != nil {
|
|
_ = a.player.Next()
|
|
}
|
|
return a, nil
|
|
case "up", "down":
|
|
if a.player != nil {
|
|
if msg.String() == "up" {
|
|
_ = a.player.VolumeUp()
|
|
} else {
|
|
_ = a.player.VolumeDown()
|
|
}
|
|
}
|
|
return a, nil
|
|
default:
|
|
// swallow other keys in playback (don't leak to list)
|
|
return a, nil
|
|
}
|
|
}
|
|
// not in playback: normal list key handling
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
a.quitting = true
|
|
return a, tea.Quit
|
|
case "enter":
|
|
if a.list.FilterState() == list.Filtering || a.list.FilterState() == list.FilterApplied {
|
|
// User pressed enter while in filter: treat the filter term as a new lookup/search
|
|
term := strings.TrimSpace(a.list.FilterValue())
|
|
if term != "" {
|
|
a.currentSearchTerm = term
|
|
a.list.Title = fmt.Sprintf(`Searching for "%s"...`, term)
|
|
return a, searchCmd(term)
|
|
}
|
|
}
|
|
if i, ok := a.list.SelectedItem().(item); ok {
|
|
// Transition to playback view (two-stage TUI).
|
|
a.playing = true
|
|
a.playingItem = i
|
|
a.nowPlaying = i.station.Name
|
|
a.paused = false
|
|
a.muted = false
|
|
a.currentVolume = 70 // default, will be updated by poll/observe
|
|
if a.player != nil {
|
|
if v := a.player.Volume(); v > 0 {
|
|
a.currentVolume = v
|
|
}
|
|
// launch in goroutine so TUI doesn't block even if using legacy player
|
|
// (for mpv+IPC this returns immediately anyway)
|
|
go func() { _ = a.player.Play(i.station.Url) }()
|
|
}
|
|
// start polling for streamed metadata and volume (for the vertical bar)
|
|
return a, tea.Batch(
|
|
metadataPollCmd(a.player),
|
|
volumePollCmd(a.player),
|
|
)
|
|
}
|
|
return a, nil
|
|
case "f":
|
|
// Toggle favorite on the currently selected item
|
|
if sel, ok := a.list.SelectedItem().(item); ok {
|
|
masterIdx := a.list.GlobalIndex()
|
|
if a.favs == nil {
|
|
var ferr error
|
|
a.favs, ferr = data.NewFavorites()
|
|
if ferr != nil {
|
|
statusCmd := a.list.NewStatusMessage("Favorites unavailable: " + ferr.Error())
|
|
return a, statusCmd
|
|
}
|
|
}
|
|
url := sel.station.Url
|
|
wasFav := sel.isFavorite
|
|
if wasFav {
|
|
a.favs.Remove(url)
|
|
} else {
|
|
a.favs.Add(sel.station)
|
|
}
|
|
if saveErr := a.favs.Save(); saveErr != nil {
|
|
statusCmd := a.list.NewStatusMessage("Failed to save favorites: " + saveErr.Error())
|
|
return a, statusCmd
|
|
}
|
|
sel.isFavorite = !wasFav
|
|
setCmd := a.list.SetItem(masterIdx, sel)
|
|
status := "Removed from favorites"
|
|
if sel.isFavorite {
|
|
status = "Added to favorites ★"
|
|
}
|
|
statusCmd := a.list.NewStatusMessage(status)
|
|
return a, tea.Batch(setCmd, statusCmd)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// Auto-start filter on first alphanumeric character (better UX than requiring / first)
|
|
s := msg.String()
|
|
if len(s) == 1 {
|
|
r := rune(s[0])
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
|
if a.list.FilterState() != list.Filtering {
|
|
// Simulate pressing the filter key to activate, then feed the char
|
|
slash := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
|
|
var c1 tea.Cmd
|
|
a.list, c1 = a.list.Update(slash)
|
|
var c2 tea.Cmd
|
|
a.list, c2 = a.list.Update(msg)
|
|
return a, tea.Batch(c1, c2)
|
|
}
|
|
}
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
a.width = msg.Width
|
|
a.height = msg.Height
|
|
// Reserve space for the hint row we append below the list in View().
|
|
a.list.SetSize(msg.Width-4, msg.Height-5)
|
|
case metadataMsg:
|
|
if msg.title != "" && a.playing {
|
|
a.nowPlaying = msg.title
|
|
}
|
|
// continue polling while in playback
|
|
if a.playing && a.player != nil {
|
|
return a, metadataPollCmd(a.player)
|
|
}
|
|
return a, nil
|
|
case volumeMsg:
|
|
if a.playing {
|
|
a.currentVolume = msg.volume
|
|
}
|
|
if a.playing && a.player != nil {
|
|
return a, volumePollCmd(a.player)
|
|
}
|
|
return a, nil
|
|
case searchResultsMsg:
|
|
if msg.err != nil {
|
|
a.list.Title = fmt.Sprintf("Search error: %v (press / to search again)", msg.err)
|
|
return a, nil
|
|
}
|
|
// Rebuild items (preserve favorites)
|
|
var favs *data.Favorites
|
|
var ferr error
|
|
if a.favs != nil {
|
|
favs = a.favs
|
|
// reload to get latest persisted state (in case of external edits)
|
|
favs, ferr = data.NewFavorites()
|
|
if ferr != nil {
|
|
favs = a.favs // fallback to in-memory
|
|
}
|
|
} else {
|
|
favs, ferr = data.NewFavorites()
|
|
}
|
|
a.favs = favs
|
|
favSet := map[string]bool{}
|
|
if favs != nil {
|
|
for _, fs := range favs.List() {
|
|
favSet[fs.Url] = true
|
|
}
|
|
}
|
|
newItems := make([]list.Item, len(msg.stations))
|
|
for i, s := range msg.stations {
|
|
newItems[i] = item{station: s, isFavorite: favSet[s.Url]}
|
|
}
|
|
setCmd := a.list.SetItems(newItems)
|
|
title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
|
|
if a.currentSearchTerm != "" {
|
|
title = fmt.Sprintf("GoStations - Results for %q (%d)", a.currentSearchTerm, len(newItems))
|
|
}
|
|
a.list.Title = title
|
|
a.list.ResetFilter()
|
|
return a, setCmd
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
a.list, cmd = a.list.Update(msg)
|
|
return a, cmd
|
|
}
|
|
|
|
func (a *App) View() string {
|
|
if a.quitting {
|
|
return "Thanks for using GoStations!\n"
|
|
}
|
|
if a.playing {
|
|
// playback view (no list, custom winamp-style + optional adapted hint)
|
|
return "\n" + a.renderPlayback() + "\n" + a.renderHint() + "\n"
|
|
}
|
|
return a.list.View() + "\n" + a.renderHint() + "\n"
|
|
}
|
|
|
|
// renderHint builds the terse, colorful bottom hint row as a full-width bar.
|
|
// The bar has a subtle background so the whole row looks like a distinct footer.
|
|
// Trigger keys use a colorful bg/fg badge (on top of the bar bg).
|
|
func (a *App) renderHint() string {
|
|
k := func(key string) string {
|
|
return hintKeyStyle.Render("[" + key + "]")
|
|
}
|
|
t := hintTextStyle.Render
|
|
|
|
var content string
|
|
if a.playing {
|
|
content = k("S") + t(" Stop/List ") + k("SPACE") + t(" Pause ") + k("M") + t(" Mute ") + k("←→") + t(" Skip ") + k("↑↓") + t(" Vol ") + k("Q") + t(" Quit")
|
|
} else {
|
|
content = k("/") + t(" Filter (") + k("ENTER") + t(" Search / Play) ") +
|
|
k("Q") + t(" Quit ") +
|
|
k("f") + t(" Favorite Toggle")
|
|
}
|
|
|
|
w := a.width
|
|
if w <= 0 {
|
|
w = 80
|
|
}
|
|
|
|
// Apply dynamic width + the bar background. PaddingLeft gives margin so text
|
|
// doesn't hug the left edge (roughly aligns with list content inset).
|
|
return hintBarStyle.Width(w).Render(content)
|
|
}
|
|
|
|
// renderPlayback draws a classic winamp-ish playback screen with metadata viewer
|
|
// and a row of control "buttons". Called when a.playing.
|
|
func (a *App) renderPlayback() string {
|
|
w := a.width
|
|
if w < 20 {
|
|
w = 60
|
|
}
|
|
boxW := min(w-4, 70)
|
|
|
|
box := lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("63")).
|
|
Padding(1, 2).
|
|
Width(boxW)
|
|
|
|
dispW := min(boxW-10, 50) // leave room for vertical vol bar (~2) + separator
|
|
display := lipgloss.NewStyle().
|
|
Background(lipgloss.Color("235")).
|
|
Foreground(lipgloss.Color("46")). // classic green lcd
|
|
Width(dispW).
|
|
Height(5).
|
|
Padding(1, 1).
|
|
Align(lipgloss.Left)
|
|
|
|
// build the metadata content
|
|
metaLines := []string{
|
|
lipgloss.NewStyle().Bold(true).Render("NOW PLAYING"),
|
|
"",
|
|
truncate(a.playingItem.station.Name, dispW-2),
|
|
}
|
|
if a.nowPlaying != "" {
|
|
metaLines = append(metaLines, truncate(a.nowPlaying, dispW-2))
|
|
} else {
|
|
metaLines = append(metaLines, "(waiting for stream metadata...)")
|
|
}
|
|
metaLines = append(metaLines, truncate(a.playingItem.station.Url, dispW-4))
|
|
|
|
metadata := display.Render(strings.Join(metaLines, "\n"))
|
|
|
|
// vertical volume bar to the right of the metadata display
|
|
volBar := renderVolumeBar(a.currentVolume, 5, 2)
|
|
|
|
// place side-by-side (top aligned). Add a small separator space.
|
|
viewer := lipgloss.JoinHorizontal(lipgloss.Top, metadata, " ", volBar)
|
|
|
|
// button row (text buttons, stateful)
|
|
playBtn := "[ > ]"
|
|
if a.paused {
|
|
playBtn = "[|| ]"
|
|
}
|
|
muteBtn := "[M]"
|
|
if a.muted {
|
|
muteBtn = "[M*]"
|
|
}
|
|
btnRow := fmt.Sprintf("%s %s %s %s %s %s %s",
|
|
"[<<]", "[>>]", muteBtn, playBtn, "[VOL-]", "[VOL+]", "[ X ]")
|
|
|
|
help := lipgloss.NewStyle().Faint(true).Render("left/right or h/l: skip | ↑↓: volume | space/p: pause | m: mute | s/x: stop & list")
|
|
|
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("GoStations"),
|
|
"",
|
|
viewer,
|
|
"",
|
|
btnRow,
|
|
help,
|
|
)
|
|
|
|
return box.Render(inner)
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// renderVolumeBar draws a vertical volume indicator bar.
|
|
// height matches the metadata display (e.g. 5).
|
|
// background is dark gray ("236"), filled indicator uses the green ("46") from the lcd display.
|
|
func renderVolumeBar(vol int, height, width int) string {
|
|
if height <= 0 {
|
|
height = 5
|
|
}
|
|
if width <= 0 {
|
|
width = 2
|
|
}
|
|
if vol < 0 {
|
|
vol = 0
|
|
}
|
|
if vol > 100 {
|
|
vol = 100
|
|
}
|
|
filled := int(math.Round(float64(vol) * float64(height) / 100.0))
|
|
|
|
darkGray := lipgloss.Color("236")
|
|
green := lipgloss.Color("46")
|
|
|
|
var lines []string
|
|
for i := 0; i < height; i++ {
|
|
// i=0 is top (high volume), fill from bottom up
|
|
isFilled := i >= (height - filled)
|
|
style := lipgloss.NewStyle().
|
|
Width(width).
|
|
Background(darkGray)
|
|
if isFilled {
|
|
style = style.Background(green)
|
|
}
|
|
// use block char for the indicator
|
|
seg := "█"
|
|
if !isFilled {
|
|
seg = " "
|
|
}
|
|
lines = append(lines, style.Render(strings.Repeat(seg, width)))
|
|
}
|
|
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
}
|
|
|
|
// searchCmd performs an async station search (used for in-TUI lookups via the filter box).
|
|
func searchCmd(name string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
stations, err := radio.Search(context.Background(), name, "", "", "", false)
|
|
return searchResultsMsg{stations: stations, err: err}
|
|
}
|
|
}
|
|
|
|
// searchResultsMsg is sent when a background search completes.
|
|
type searchResultsMsg struct {
|
|
stations []radio.Station
|
|
err error
|
|
}
|
|
|
|
// metadataMsg carries an update to the now-playing stream title from the player.
|
|
type metadataMsg struct {
|
|
title string
|
|
}
|
|
|
|
// volumeMsg carries volume level update (0-100).
|
|
type volumeMsg struct {
|
|
volume int
|
|
}
|
|
|
|
// metadataPollCmd returns a repeating-ish poll that checks the player's
|
|
// Metadata() and emits updates. (Simple, works whether player is mpvIPC or stub.)
|
|
func metadataPollCmd(p playerpkg.Player) tea.Cmd {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return tea.Tick(800*time.Millisecond, func(t time.Time) tea.Msg {
|
|
if title := p.Metadata(); title != "" {
|
|
return metadataMsg{title: title}
|
|
}
|
|
return nil // no change; next tick will try again
|
|
})
|
|
}
|
|
|
|
// volumePollCmd polls the player's Volume() for the vertical bar.
|
|
func volumePollCmd(p playerpkg.Player) tea.Cmd {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return tea.Tick(600*time.Millisecond, func(t time.Time) tea.Msg {
|
|
v := p.Volume()
|
|
if v < 0 {
|
|
v = 0
|
|
}
|
|
if v > 100 {
|
|
v = 100
|
|
}
|
|
return volumeMsg{volume: v}
|
|
})
|
|
}
|
|
|
|
// Run starts the TUI (alt screen).
|
|
func Run(initial []radio.Station) error {
|
|
p := tea.NewProgram(NewApp(initial), tea.WithAltScreen())
|
|
_, err := p.Run()
|
|
return err
|
|
}
|
|
|
|
// Short is a small helper (duplicated from old for TUI list desc; can be shared later).
|
|
func Short(s string, i int) string {
|
|
runes := []rune(s)
|
|
if len(runes) > i {
|
|
return string(runes[:i])
|
|
}
|
|
return s
|
|
}
|