aboutsummaryrefslogtreecommitdiff
path: root/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens')
-rw-r--r--src/screens/available_packages.rs38
-rw-r--r--src/screens/installed_packages.rs37
2 files changed, 43 insertions, 32 deletions
diff --git a/src/screens/available_packages.rs b/src/screens/available_packages.rs
index a96437b..b16eac1 100644
--- a/src/screens/available_packages.rs
+++ b/src/screens/available_packages.rs
@@ -7,8 +7,8 @@ use ratatui::{
Frame,
crossterm::event::KeyCode,
layout::{Alignment, Constraint, Layout, Rect},
- style::{Color, Style},
- text::{Line, Span, Text},
+ style::Color,
+ text::{Line, Span},
widgets::{Block, Borders, List, ListState, Paragraph},
};
@@ -136,16 +136,16 @@ impl AvailablePackagesScreen {
List::new(pkg_versions.iter().map(|v| version_to_variant(v)))
.style(Color::White)
.highlight_style(Theme::selected())
- .highlight_symbol("> "), // , // .block(
- // Block::default()
- // .borders(Borders::ALL)
- // .border_style(Theme::block())
- // .title("Variants"),
- // ),
+ .highlight_symbol("> "),
);
self.selected_version = Some(pkg_versions[0].clone());
}
+
+ fn select_package_index(&mut self, index: usize, repo: &Portage) {
+ self.pkg_list_state.select(Some(index));
+ self.build_pkg_version_list(repo);
+ }
}
impl Default for AvailablePackagesScreen {
@@ -258,6 +258,10 @@ impl Screen for AvailablePackagesScreen {
let text = if self.search_popup.visible {
"(esc) to quit search | (enter) to search".to_string()
+ } else if let Some((current, total)) = self.search_popup.match_status() {
+ format!(
+ "(q) to quit | (j) down | (k) up | (space) switch panel | (/) to search | Search {current}/{total} | (n) next | (N) previous"
+ )
} else {
"(q) to quit | (j) down | (k) up | (space) switch panel | (/) to search".to_string()
};
@@ -312,6 +316,18 @@ impl Screen for AvailablePackagesScreen {
KeyCode::Char(' ') => self.cycle_list(),
KeyCode::Char('/') => self.search_popup.toggle(),
+ KeyCode::Char('n') => {
+ if let Some(index) = self.search_popup.next_match() {
+ self.select_package_index(index, repo)
+ }
+ }
+
+ KeyCode::Char('N') => {
+ if let Some(index) = self.search_popup.previous_match() {
+ self.select_package_index(index, repo)
+ }
+ }
+
_ => match self.chosen_list {
CurrentList::PackageList => match key.code {
KeyCode::Char('j') => {
@@ -369,12 +385,6 @@ impl Screen for AvailablePackagesScreen {
.style(Color::White)
.highlight_style(Theme::selected())
.highlight_symbol("> ");
- // .block(
- // Block::default()
- // .borders(Borders::ALL)
- // .border_style(Theme::block())
- // .title("Available packages"),
- // );
self.pkg_list = Some(list);
self.loading_state = LoadingState::Complete;
diff --git a/src/screens/installed_packages.rs b/src/screens/installed_packages.rs
index b373cb4..24fab50 100644
--- a/src/screens/installed_packages.rs
+++ b/src/screens/installed_packages.rs
@@ -4,17 +4,17 @@ use crate::{
signal::{Event, Signal},
theme::Theme,
widgets::{
- helpers::search_popup_rect, package_metadata::render_package_metadata,
- search_popup::SearchPopup, use_flags::render_use_flags,
+ package_metadata::render_package_metadata, search_popup::SearchPopup,
+ use_flags::render_use_flags,
},
};
use ratatui::{
Frame,
crossterm::event::{KeyCode, KeyModifiers},
layout::{Constraint, Layout, Rect},
- style::{Color, Modifier, Style},
+ style::Color,
text::{Line, Span},
- widgets::{Block, Borders, Clear, List, ListState, Paragraph},
+ widgets::{Block, Borders, List, ListState, Paragraph},
};
#[derive(Debug)]
@@ -114,7 +114,6 @@ impl Screen for InstalledPackagesScreen {
frame,
split_vert[0],
pkg.iuse.iter().collect(),
- // TODO: Dont use clone here if possible
&pkg.enabled_use_flags,
);
render_package_metadata(
@@ -129,19 +128,13 @@ impl Screen for InstalledPackagesScreen {
let text = if self.search_popup.visible {
"(esc) to quit search | (enter) to search".to_string()pre { line-height: 125%; } td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } .highlight .hll { background-color: #49483e } .highlight .c { color: #959077 } /* Comment */ .highlight .err { color: #ed007e; background-color: #1e0010 } /* Error */ .highlight .esc { color: #f8f8f2 } /* Escape */ .highlight .g { color: #f8f8f2 } /* Generic */ .highlight .k { color: #66d9ef } /* Keyword */ .highlight .l { color: #ae81ff } /* Literal */ .highlight .n { color: #f8f8f2 } /* Name */ .highlight .o { color: #ff4689 } /* Operator */ .highlight .x { color: #f8f8f2 } /* Other */ .highlight .p { color: #f8f8f2 } /* Punctuation */ .highlight .ch { color: #959077 } /* Comment.Hashbang */ .highlight .cm { color: #959077 } /* Comment.Multiline */ .highlight .cp { color: #959077 } /* Comment.Preproc */ .highlight .cpf { color: #959077 } /* Comment.PreprocFile */ .highlight .c1 { color: #959077 } /* Comment.Single */ .highlight .cs { color: #959077 } /* Comment.Special */ .highlight .gd { color: #ff4689 } /* Generic.Deleted */ .highlight .ge { color: #f8f8f2; font-style: italic } /* Generic.Emph */ .highlight .ges { color: #f8f8f2; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ .highlight .gr { color: #f8f8f2 } /* Generic.Error */ .highlight .gh { color: #f8f8f2 } /* Generic.Heading */ .highlight .gi { color: #a6e22e } /* Generic.Inserted */ .highlight .go { color: #66d9ef } /* Generic.Output */ .highlight .gp { color: #ff4689; font-weight: bold } /* Generic.Prompt */ .highlight .gs { color: #f8f8f2; font-weight: bold } /* Generic.Strong */ .highlight .gu { color: #959077 } /* Generic.Subheading */ .highlight .gt { color: #f8f8f2 } /* Generic.Traceback */ .highlight .kc { color: #66d9ef } /* Keyword.Constant */ .highlight .kd { color: #66d9ef } /* Keyword.Declaration */ .highlight .kn { color: #ff4689 } /* Keyword.Namespace */ .highlight .kp { color: #66d9ef } /* Keyword.Pseudo */ .highlight .kr { color: #66d9ef } /* Keyword.Reserved */ .highlight .kt { color: #66d9ef } /* Keyword.Type */ .highlight .ld { color: #e6db74 } /* Literal.Date */ .highlight .m { color: #ae81ff } /* Literal.Number */ .highlight .s { color: #e6db74 } /* Literal.String */ .highlight .na { color: #a6e22e } /* Name.Attribute */ .highlight .nb { color: #f8f8f2 } /* Name.Builtin */ .highlight .nc { color: #a6e22e } /* Name.Class */ .highlight .no { color: #66d9ef } /* Name.Constant */ .highlight .nd { color: #a6e22e } /* Name.Decorator */ .highlight .ni { color: #f8f8f2 } /* Name.Entity */ .highlight .ne { color: #a6e22e } /* Name.Exception */ .highlight .nf { color: #a6e22e } /* Name.Function */ .highlight .nl { color: #f8f8f2 } /* Name.Label */ .highlight .nn { color: #f8f8f2 } /* Name.Namespace */ .highlight .nx { color: #a6e22e } /* Name.Other */ .highlight .py { color: #f8f8f2 } /* Name.Property */ .highlight .nt { color: #ff4689 } /* Name.Tag */ .highlight .nv { color: #f8f8f2 } /* Name.Variable */ .highlight .ow { color: #ff4689 } /* Operator.Word */ .highlight .pm { color: #f8f8f2 } /* Punctuation.Marker */ .highlight .w { color: #f8f8f2 } /* Text.Whitespace */ .highlight .mb { color: #ae81ff } /* Literal.Number.Bin */ .highlight .mf { color: #ae81ff } /* Literal.Number.Float */ .highlight .mh { color: #ae81ff } /* Literal.Number.Hex */ .highlight .mi { color: #ae81ff } /* Literal.Number.Integer */ .highlight .mo { color: #ae81ff } /* Literal.Number.Oct */ .highlight .sa { color: #e6db74 } /* Literal.String.Affix */ .highlight .sb { color: #e6db74 } /* Literal.String.Backtick */ .highlight .sc { color: #e6db74 } /* Literal.String.Char */ .highlight .dl { color: #e6db74 } /* Literal.String.Delimiter */ .highlight .sd { color: #e6db74 } /* Literal.String.Doc */ .highlight .s2 { color: #e6db74 } /* Literal.String.Double */ .highlight .se { color: #ae81ff } /* Literal.String.Escape */ .highlight .sh { color: #e6db74 } /* Literal.String.Heredoc */ .highlight .si { color: #e6db74 } /* Literal.String.Interpol */ .highlight .sx { color: #e6db74 } /* Literal.String.Other */ .highlight .sr { color: #e6db74 } /* Literal.String.Regex */ .highlight .s1 { color: #e6db74 } /* Literal.String.Single */ .highlight .ss { color: #e6db74 } /* Literal.String.Symbol */ .highlight .bp { color: #f8f8f2 } /* Name.Builtin.Pseudo */ .highlight .fm { color: #a6e22e } /* Name.Function.Magic */ .highlight .vc { color: #f8f8f2 } /* Name.Variable.Class */ .highlight .vg { color: #f8f8f2 } /* Name.Variable.Global */ .highlight .vi { color: #f8f8f2 } /* Name.Variable.Instance */ .highlight .vm { color: #f8f8f2 } /* Name.Variable.Magic */ .highlight .il { color: #ae81ff } /* Literal.Number.Integer.Long */
package thumbnailgen

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"os/exec"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"time"
)

type TimeFilter struct {
	Start float64
	End   float64
}

type Options struct {
	Interval      int
	Segments      int
	UseSegments   bool
	Format        string
	Scale         string
	EnableFilter  bool
	MaxThumbnails int
}

func NewDefaultOptions() Options {
	return Options{
		Interval:      5,
		Segments:      0,
		UseSegments:   false,
		Format:        "webp",
		Scale:         "-1:-1",
		EnableFilter:  true,
		MaxThumbnails: 0,
	}
}

func (options *Options) Apply(opts ...func(*Options)) *Options {
	for _, opt := range opts {
		opt(options)
	}

	return options
}

type videoStreamProbe struct {
	Streams []struct {
		CodecType string `json:"codec_type"`
		Duration  string `json:"duration"`
	} `json:"streams"`
}

func getThumbnailDuration(path string) (float64, error) {
	stderr := bytes.NewBuffer(nil)
	stdout := bytes.NewBuffer(nil)

	cmd := exec.Command(
		"ffprobe",
		"-v", "error",
		"-select_streams", "v:0",
		"-show_entries", "stream=codec_type,duration",
		"-of", "json",
		path,
	)
	cmd.Stdout = stdout
	cmd.Stderr = stderr

	if err := cmd.Run(); err != nil {
		return 0, fmt.Errorf(
			"probe video stream: %w: %s",
			err,
			strings.TrimSpace(stderr.String()),
		)
	}

	var probe videoStreamProbe
	if err := json.Unmarshal(stdout.Bytes(), &probe); err != nil {
		return 0, fmt.Errorf("decode video-stream probe: %w", err)
	}
	if len(probe.Streams) == 0 || probe.Streams[0].CodecType != "video" {
		return 0, errors.New("input has no video stream")
	}

	durationText := strings.TrimSpace(probe.Streams[0].Duration)
	if durationText == "" || durationText == "N/A" {
		return GetVideoLength(path) // container-duration fallback for animated WebP
	}

	duration, err := strconv.ParseFloat(durationText, 64)
	if err != nil {
		return 0, fmt.Errorf("parse video-stream duration %q: %w", durationText, err)
	}
	if duration <= 0 {
		return 0, fmt.Errorf("invalid video-stream duration %v", duration)
	}
	return duration, nil
}

func (opts *Options) GetThumbnail(path string) ([][]byte, int, error) {
	var filters []TimeFilter
	if opts.EnableFilter {
		f, err := GetFilter(path)
		if err != nil {
			return nil, 0, err
		}
		filters = f
	}

	length, err := getThumbnailDuration(path)
	if err != nil {
		return nil, 0, err
	}

	var interval int
	if opts.UseSegments {
		interval = int(length / float64(opts.Segments))
	} else {
		interval = opts.Interval
	}

	return GetThumbnailUnderlying(path, opts.MaxThumbnails, filters, length, interval, opts.EnableFilter, opts.Format, opts.Scale)
}

func GetThumbnailUnderlying(path string, maxThumbnails int, filters []TimeFilter, length float64, intervalSeconds int, enableFilter bool, format string, scale string) ([][]byte, int, error) {
	buf := bytes.NewBuffer(nil)
	framesExtracted := 0

	var out [][]byte
	if maxThumbnails > 0 {
		out = make([][]byte, maxThumbnails)
	} else {
		out = make([][]byte, 0)
	}

	var time float64 = 1
	for {
		if (maxThumbnails > 0 && framesExtracted >= maxThumbnails) || time >= length {
			break
		}

		if enableFilter {
			if ok, next := FrameLiesWithinFilter(time, filters); ok {
				time = next + 1
				continue
			}
		}

		err := GetImage(buf, path, int(time), format, scale)
		if err != nil {
			return nil, 0, err
		}

		b := bytes.Clone(buf.Bytes())[0:buf.Len()]

		if maxThumbnails > 0 {
			out[framesExtracted] = b
		} else {
			out = append(out, b)
		}

		time += float64(intervalSeconds)
		framesExtracted += 1
		buf.Reset()
	}
	return out, framesExtracted, nil
}

// Return true, if time is inside filter, also returns next time, when not in filter anymore.
// If False it returns input time
func FrameLiesWithinFilter(time float64, filters []TimeFilter) (bool, float64) {
	for _, filter := range filters {
		if time >= filter.Start && time <= filter.End {
			return true, filter.End
		}

	}
	return false, time
}

func GetImage(buf *bytes.Buffer, path string, timestamp int, format string, scale string) error {
	var t time.Time
	t = t.Add(time.Duration(timestamp) * time.Second)
	stderr := bytes.NewBuffer(nil)
	cmd := exec.Command(
		"ffmpeg",
		"-ss",
		t.Format("15:04:05"),
		"-i",
		path,
		"-vframes",
		"1",
		"-c:v",
		format,
		"-filter:v",
		fmt.Sprintf("scale=%s", scale),
		"-f",
		"image2pipe",
		"-",
	)
	cmd.Stdout = buf
	cmd.Stderr = stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("generate thumbnail at %s: %w: %s", t.Format("15:04:05"), err, strings.TrimSpace(stderr.String()))
	}
	return nil
}

func GetVideoLength(path string) (float64, error) {
	cmd := exec.Command("ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path)
	stderr := bytes.NewBuffer(nil)
	buf := bytes.NewBuffer(nil)
	cmd.Stdout = buf
	cmd.Stderr = stderr
	if err := cmd.Run(); err != nil {
		return 0, fmt.Errorf(
			"probe video duration: %w: %s",
			err,
			strings.TrimSpace(stderr.String()),
		)
	}
	return strconv.ParseFloat(strings.ReplaceAll(buf.String(), "\n", ""), 64)
}

func escapeFilterValue(value string) string {
	return strings.NewReplacer(
		"\\", "\\\\",
		"'", "\\'",
		":", "\\:",
		",", "\\,",
		";", "\\;",
		"[", "\\[",
		"]", "\\]",
	).Replace(value)
}

func GetFilter(path string) ([]TimeFilter, error) {
	buf := bytes.NewBuffer(nil)
	stderr := bytes.NewBuffer(nil)
	filter := fmt.Sprintf("movie=filename='%s',blackdetect[out0]", escapeFilterValue(path))

	cmd := exec.Command("ffprobe", "-f", "lavfi", "-i", filter, "-show_entries", "tags=lavfi.black_start,lavfi.black_end", "-of", "default=nw=1", "-v", "quiet")
	cmd.Stderr = stderr
	cmd.Stdout = buf
	if err := cmd.Run(); err != nil {
		return nil, fmt.Errorf("detect black frames: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	filterStr := buf.String()
	filters := strings.Split(filterStr, "\n")
	filters = slices.Compact(filters)
	i := 0
	reg := regexp.MustCompile(`([0-9]*\.?[0-9]+)`)
	blackFilters := make([]TimeFilter, len(filters)/2)
	for {
		if i >= len(filters) {
			break
		}
		start := reg.FindString(filters[i])
		var end string
		if i+1 >= len(filters) || filters[i+1] == "" {
			end = start
		} else {
			end = reg.FindString(filters[i+1])
		}

		if start == "" || end == "" {
			break
		}

		s, err := strconv.ParseFloat(start, 64)
		if err != nil {
			return nil, err
		}

		e, err := strconv.ParseFloat(end, 64)
		if err != nil {
			return nil, err
		}

		blackFilters[i/2] = TimeFilter{
			Start: s,
			End:   e,
		}
		i += 2
	}

	return blackFilters, nil
}

func GetFramerate(path string) (float64, error) {
	cmd := exec.Command("ffprobe", "-v", "0", "-of", "csv=p=0", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", path)
	buf := bytes.NewBuffer(nil)
	cmd.Stdout = buf
	err := cmd.Run()
	if err != nil {
		return 0, err
	}
	s := buf.String()
	s = strings.ReplaceAll(s, "\n", "")
	s = strings.TrimSpace(s)
	ss := strings.Split(s, "/")
	f1, err := strconv.Atoi(ss[0])
	if err != nil {
		return 0, err
	}

	f2, err := strconv.Atoi(ss[1])
	if err != nil {
		return 0, err
	}
	return float64(f1) / float64(f2), nil
}