main 3d41bc5

app.go RawHistoryFolder

1// Package app wires the capture adapters to the core workflow.
2package app
3
4import (
5	"context"
6	"errors"
7	"fmt"
8	"io"
9	"time"
10
11	"github.com/bnema/nefercap/internal/adapters/cli"
12	"github.com/bnema/nefercap/internal/adapters/clipboard"
13	"github.com/bnema/nefercap/internal/adapters/config"
14	"github.com/bnema/nefercap/internal/adapters/flash"
15	"github.com/bnema/nefercap/internal/adapters/png"
16	"github.com/bnema/nefercap/internal/adapters/wayland"
17	"github.com/bnema/nefercap/internal/core"
18	"github.com/bnema/nefercap/internal/logging"
19	"github.com/bnema/nefercap/internal/ports"
20)
21
22const discoveryTimeout = 5 * time.Second
23
24// Run owns the source connection and all capture work for one invocation.
25func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) {
26	if handled, controlErr := controlCommand(ctx, options.Command, output); handled {
27		return controlErr
28	}
29	discovery, cancel := context.WithTimeout(ctx, discoveryTimeout)
30	source, err := wayland.New(discovery, "")
31	if err != nil {
32		cancel()
33		return discoveryError(ctx, "connect Wayland", err)
34	}
35	defer func() {
36		if closeErr := source.Close(); closeErr != nil {
37			err = errors.Join(err, closeErr)
38		}
39	}()
40	outputs, err := source.Outputs(discovery)
41	cancel()
42	if err != nil {
43		return discoveryError(ctx, "list outputs", err)
44	}
45	if options.Command == cli.Outputs {
46		for _, item := range outputs {
47			if _, err := fmt.Fprintf(output, "%s\t%dx%d\tscale=%d\n", item.Name, item.Width, item.Height, item.Scale); err != nil {
48				return err
49			}
50		}
51		return nil
52	}
53	var selection ports.Selection
54	interactive := options.Command == cli.Shot || options.Command == cli.Rec || options.Command == cli.AllInOne
55	if interactive {
56		// Fail on a bad config before anything is shown.
57		settings, loadErr := config.Load()
58		if loadErr != nil {
59			return fmt.Errorf("load config: %w", loadErr)
60		}
61		if err := rejectCompetingSelector(ctx); err != nil {
62			return err
63		}
64		var accepted bool
65		selection, accepted, err = chooseInteractive(ctx, source, outputs, options, settings)
66		if err != nil || !accepted {
67			return err
68		}
69	} else {
70		target, err := resolveOutput(outputs, options.Output)
71		if err != nil {
72			return err
73		}
74		selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Clipboard: options.Clipboard, Video: options.Video, Duration: options.Duration}
75		switch options.Command {
76		case cli.Screenshot:
77			selection.Mode = ports.Screenshot
78		case cli.Record:
79			selection.Mode = ports.Record
80		default:
81			return fmt.Errorf("unsupported command %q", options.Command)
82		}
83	}
84	log := logging.For(ctx, "app")
85	log.Info().Str("mode", string(selection.Mode)).Msg("capture started")
86	var saved bool
87	if selection.Mode == ports.Record {
88		var outcome recordingOutcome
89		outcome, err = recordInteractive(ctx, output, source, outputs, selection)
90		saved = outcome.Saved
91	} else {
92		var writer ports.ScreenshotWriter = png.New()
93		var copied *clipboard.Writer
94		if selection.Clipboard {
95			copied = clipboard.NewWriter(writer, clipboard.New())
96			writer = copied
97		}
98		err = core.New(source, writer, nil).Run(ctx, selection)
99		saved = err == nil && selection.Path != ""
100		if copied != nil {
101			saved = copied.Saved
102		}
103	}
104	if saved && err == nil {
105		log.Info().Msg("capture complete")
106	}
107	err = finishCapture(output, selection.Path, saved, err)
108	if err == nil && interactive && selection.Mode == ports.Screenshot {
109		confirmScreenshot(ctx, source, outputs, selection.Target)
110	}
111	return err
112}
113
114// confirmScreenshot flashes the captured area once the screenshot is done. The
115// screenshot already succeeded, so a failed flash is only logged. One budget
116// bounds the workspace lookup and the flash, so a silent compositor cannot
117// keep the process alive.
118func confirmScreenshot(ctx context.Context, source *wayland.Source, outputs []ports.Output, target ports.Target) {
119	ctx, cancel := context.WithTimeout(ctx, flash.Limit)
120	defer cancel()
121	for _, o := range outputs {
122		if o.ID != target.OutputID {
123			continue
124		}
125		area := target.Region
126		if target.WorkspaceID != 0 {
127			area = recordedFrame(ctx, source, target)
128			if ctx.Err() != nil {
129				return // the budget went on the lookup
130			}
131		}
132		if err := flash.Show(ctx, o, area); err != nil {
133			log := logging.For(ctx, "app")
134			log.Warn().Err(err).Msg("screenshot confirmation not shown")
135		}
136		return
137	}
138}
139
140// finishCapture prints the path of a file that exists, whatever error the
141// capture ended with, so a reported failure never hides where the file is.
142// Nothing is printed when no file was saved. The original error and a failed
143// write are both returned.
144func finishCapture(output io.Writer, path string, saved bool, err error) error {
145	if !saved {
146		return err
147	}
148	if _, printErr := fmt.Fprintln(output, path); printErr != nil {
149		return errors.Join(err, printErr)
150	}
151	return err
152}
153
154// discoveryError preserves a user interrupt, but reports local timeouts and
155// actual protocol failures with the failing phase.
156func discoveryError(ctx context.Context, phase string, err error) error {
157	if ctx.Err() != nil && err == ctx.Err() {
158		return err
159	}
160	return fmt.Errorf("%s: %w", phase, err)
161}
162
163func resolveOutput(outputs []ports.Output, name string) (ports.Output, error) {
164	if name == "" {
165		if len(outputs) == 1 {
166			return outputs[0], nil
167		}
168		return ports.Output{}, fmt.Errorf("select an output with -output (use outputs to list names)")
169	}
170	for _, output := range outputs {
171		if output.Name == name {
172			return output, nil
173		}
174	}
175	return ports.Output{}, fmt.Errorf("%w: %q", ports.ErrOutputNotFound, name)
176}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close