[feat] anchor CLI zoom to mouse and simplify view shortcuts

This commit is contained in:
dijunkun
2026-11-02 15:32:16 +08:00
parent 6de7165fb0
commit 30d409ff49
12 changed files with 177 additions and 80 deletions
@@ -58,6 +58,8 @@ struct Grid {
Rect Fit(int width, int height, int columns, int pixel_rows,
double pixel_aspect = 1);
Grid Layout(const DecodedFrame &, Viewport);
// Calculate crop and pointer geometry without decoded pixel storage.
Grid Layout(unsigned source_width, unsigned source_height, Viewport);
Grid Convert(const DecodedFrame &, Viewport);
std::optional<std::pair<float, float>> MapPointer(const Grid &, int column,
int row);
@@ -119,9 +121,11 @@ private:
bool pasting_ = false;
unsigned mouse_buttons_ = 0;
float pointer_x_ = .5f, pointer_y_ = .5f;
int mouse_column_ = -1, mouse_row_ = -1;
void Tap(ControllerSession &, int key, int modifier = 0);
bool LocalKey(int key, ControllerSession &, const Grid *);
void Navigate(char key, const Grid *);
void Zoom(double next_zoom, const Grid *);
void Mouse(int button, int x, int y, bool release, ControllerSession &,
const Grid *);
};
+58 -14
View File
@@ -45,8 +45,7 @@ bool Input::LocalKey(int key, ControllerSession &s, const Grid *grid) {
} else if (key == 'c') {
control = !control;
} else if (key == '+' || key == '=' || key == '-' || key == 'f' ||
key == 'd' || key == 'i' || key == 'j' || key == 'k' ||
key == 'l') {
key == 'i' || key == 'j' || key == 'k' || key == 'l') {
Navigate(char(key), grid);
} else if (!command_mode) {
return false;
@@ -58,20 +57,52 @@ bool Input::LocalKey(int key, ControllerSession &s, const Grid *grid) {
mouse_buttons_ = 0;
return true;
}
void Input::Zoom(double next_zoom, const Grid *grid) {
if (next_zoom == zoom)
return;
if (grid && mouse_column_ >= 0 && mouse_row_ >= 0) {
auto v = grid->viewport;
v.zoom = zoom;
v.native = native;
v.center_x = center_x;
v.center_y = center_y;
// Rebuild from the requested view so queued key/wheel repeats compose
// even when the terminal is still displaying an earlier frame.
const auto before = Layout(grid->source_width, grid->source_height, v);
if (const auto anchor = MapPointer(before, mouse_column_, mouse_row_)) {
v.zoom = next_zoom;
const auto after = Layout(grid->source_width, grid->source_height, v);
if (const auto moved = MapPointer(after, mouse_column_, mouse_row_)) {
const auto center = [](int origin, int size, unsigned extent,
double shift) {
const double half = size * .5 / extent;
return std::clamp((origin + size * .5 + shift * (extent - 1)) /
extent,
half, 1 - half);
};
// Use the actual, clamped crop center and the same source-pixel
// mapping as remote input. Letterboxing and status rows stay aligned.
center_x = center(after.source.x, after.source.width,
after.source_width,
double(anchor->first) - moved->first);
center_y = center(after.source.y, after.source.height,
after.source_height,
double(anchor->second) - moved->second);
}
}
}
zoom = next_zoom;
}
void Input::Navigate(char key, const Grid *grid) {
if (key == '+' || key == '=')
zoom = std::min(32.0, zoom * 2);
Zoom(std::min(32.0, zoom * 2), grid);
if (key == '-')
zoom = std::max(1.0, zoom / 2);
Zoom(std::max(1.0, zoom / 2), grid);
if (key == 'f') {
native = false;
zoom = 1;
center_x = center_y = .5;
}
if (key == 'd') {
native = true;
zoom = 1;
}
// Use the displayed crop as the pan step, including native pixel mode.
const double dx = grid && grid->source_width
? double(grid->source.width) / grid->source_width
@@ -99,14 +130,26 @@ void Input::Mouse(int b, int x, int y, bool release, ControllerSession &s,
const Grid *grid) {
++mouse_reports;
// Extra mouse buttons must not alias the three supported remote buttons.
if (!grid || command_mode || b < 0 || b >= 128)
if (b < 0 || b >= 128 || x <= 0 || y <= 0)
return;
auto pos = MapPointer(*grid, x - 1, y - 1);
// Track terminal coordinates even in local command mode. Re-map them for
// every zoom; a stationary mouse must follow subsequent view changes.
mouse_column_ = x - 1;
mouse_row_ = y - 1;
if (!grid || command_mode)
return;
auto pos = MapPointer(*grid, mouse_column_, mouse_row_);
if (!control) {
if (pos && !release && ((b & 64) || b == 0)) {
center_x = pos->first;
center_y = pos->second;
Navigate((b & 64) ? ((b & 1) ? '-' : '+') : 'd', grid);
if (pos && !release) {
if (b & 64) {
Navigate((b & 1) ? '-' : '+', grid);
} else if (b == 0) {
center_x = pos->first;
center_y = pos->second;
native = true;
zoom = 1;
++view_revision;
}
}
return;
}
@@ -248,6 +291,7 @@ void Input::Feed(const std::string &bytes, ControllerSession &s,
control = false;
s.SetControl(false);
mouse_buttons_ = 0;
mouse_column_ = mouse_row_ = -1;
continue;
}
int key = 0;
+24 -14
View File
@@ -25,21 +25,25 @@ static uint32_t Pixel(const DecodedFrame &f, int x, int y) {
(clamp((298 * yy - 100 * u - 208 * v + 128) >> 8) << 8) |
clamp((298 * yy + 516 * u + 128) >> 8);
}
static void Validate(const DecodedFrame &f, const Viewport &v) {
static void Validate(unsigned width, unsigned height, const Viewport &v) {
if (v.columns < 1 || v.rows < 1 || v.columns > 240 || v.rows > 100 ||
v.row_offset < 0 || v.row_offset > 100 ||
!std::isfinite(v.cell_aspect) || v.cell_aspect < .2 ||
v.cell_aspect > 2 || !std::isfinite(v.zoom) || v.zoom < 1 ||
v.zoom > 32 || !std::isfinite(v.center_x) || !std::isfinite(v.center_y) ||
v.center_x < 0 || v.center_x > 1 || v.center_y < 0 || v.center_y > 1 ||
!f.width || !f.height || f.width > 8192 || f.height > 8192 ||
(f.width & 1) || (f.height & 1) ||
size_t(f.width) * f.height > 16777216 ||
f.nv12.size() < size_t(f.width) * f.height * 3 / 2)
!width || !height || width > 8192 || height > 8192 || (width & 1) ||
(height & 1) || size_t(width) * height > 16777216)
throw std::invalid_argument("invalid frame or viewport");
}
static Rect Crop(const DecodedFrame &f, const Viewport &v) {
double width = f.width, height = f.height;
static void Validate(const DecodedFrame &f, const Viewport &v) {
Validate(f.width, f.height, v);
if (f.nv12.size() < size_t(f.width) * f.height * 3 / 2)
throw std::invalid_argument("invalid frame or viewport");
}
static Rect Crop(unsigned source_width, unsigned source_height,
const Viewport &v) {
double width = source_width, height = source_height;
if (v.native) {
const int sx = v.utf8 && v.detail == AnsiDetail::Quadrant ? 2 : 1;
const int sy = v.utf8 ? 2 : 1;
@@ -60,8 +64,10 @@ static Rect Crop(const DecodedFrame &f, const Viewport &v) {
}
int w = std::max(1, int(width / v.zoom)),
h = std::max(1, int(height / v.zoom));
int x = std::clamp(int(v.center_x * f.width - w * .5), 0, int(f.width) - w);
int y = std::clamp(int(v.center_y * f.height - h * .5), 0, int(f.height) - h);
int x = std::clamp(int(v.center_x * source_width - w * .5), 0,
int(source_width) - w);
int y = std::clamp(int(v.center_y * source_height - h * .5), 0,
int(source_height) - h);
return {x, y, w, h};
}
// Area resampling integrates the entire source footprint. Thin lines cannot
@@ -249,16 +255,20 @@ static void AppendNumber(std::string &out, uint32_t value) {
}
Grid Layout(const DecodedFrame &f, Viewport v) {
Validate(f, v);
return Layout(f.width, f.height, v);
}
Grid Layout(unsigned source_width, unsigned source_height, Viewport v) {
Validate(source_width, source_height, v);
const int sx = v.utf8 && v.detail == AnsiDetail::Quadrant ? 2 : 1,
sy = v.utf8 ? 2 : 1;
const int width = v.columns * sx, height = v.rows * sy;
Rect crop = Crop(f, v);
Rect crop = Crop(source_width, source_height, v);
Grid g{v,
Fit(crop.width, crop.height, width, height, v.cell_aspect * sy / sx),
{}};
g.source = crop;
g.source_width = f.width;
g.source_height = f.height;
g.source_width = source_width;
g.source_height = source_height;
g.samples_x = sx;
g.samples_y = sy;
return g;
@@ -553,7 +563,7 @@ PixelFrame ImagePixels(const DecodedFrame &f, const Viewport &requested) {
pixels.height =
std::clamp(v.pixel_height > 0 ? v.pixel_height : v.rows * 20, 2, 1080);
pixels.rgb.resize(size_t(pixels.width) * pixels.height * 3, 0);
const auto crop = Crop(f, v);
const auto crop = Crop(f.width, f.height, v);
const auto r = Fit(crop.width, crop.height, pixels.width, pixels.height,
double(v.columns) * v.cell_aspect * pixels.height /
(v.rows * pixels.width));
@@ -678,7 +688,7 @@ std::string Kitty(const DecodedFrame &f, const Viewport &requested,
std::clamp(v.pixel_width > 0 ? v.pixel_width : v.columns * 10, 2, 1920);
int h =
std::clamp(v.pixel_height > 0 ? v.pixel_height : v.rows * 20, 2, 1080);
const auto crop = Crop(f, v);
const auto crop = Crop(f.width, f.height, v);
std::string b64;
for (;;) {
auto r = Fit(crop.width, crop.height, w, h,
+45 -27
View File
@@ -79,41 +79,52 @@ std::vector<std::string> StatusLines(const StatusBar &bar, int columns) {
std::string keys, view;
if (inner >= 63) {
const size_t count = inner >= 99 ? 4 : inner >= 77 ? 3 : 2;
const size_t count = inner >= 99 ? 4 : inner >= 89 ? 3 : 2;
if (bar.command_mode) {
keys =
Columns("LOCAL",
count >= 3 ? std::vector<std::string>{"<Esc/Ctrl+C> Return",
"<c> Toggle control",
"<q> Quit"}
: std::vector<std::string>{"<Esc/Ctrl+C> Return",
"<c> Toggle; <q> Quit"},
inner, count);
keys = Columns(
"LOCAL",
count == 4
? std::vector<std::string>{"<Esc/Ctrl+C> Return",
"<c> Toggle control", "<q> Quit",
"<mouse> Set anchor"}
: count == 3
? std::vector<std::string>{"<Esc/Ctrl+C> Return",
"<c> Toggle control", "<q> Quit"}
: std::vector<std::string>{"<Esc/Ctrl+C> Return",
"<c> Toggle; <q> Quit"},
inner, count);
} else if (bar.control) {
keys = Columns("KEYS", {"<Ctrl+]> Commands", "Input -> remote"}, inner,
count);
} else {
keys = Columns("KEYS",
count >= 3
? std::vector<std::string>{"<c> Control", "<q> Quit",
"<Ctrl+]> Commands"}
: std::vector<std::string>{"<c> Control; <q> Quit",
"<Ctrl+]> Commands"},
inner, count);
keys = Columns(
"KEYS",
count == 4
? std::vector<std::string>{"<c> Control", "<q> Quit",
"<Ctrl+]> Commands", "<click> Detail"}
: count == 3
? std::vector<std::string>{"<c> Control; <q> Quit",
"<Ctrl+]> Commands", "<click> Detail"}
: std::vector<std::string>{"<c> Control; <q> Quit",
"<Ctrl+]> Commands"},
inner, count);
}
if (bar.control && !bar.command_mode) {
view = Columns("THEN", {"<c> Toggle control", "<q> Quit"}, inner, count);
} else {
const auto zoom = bar.command_mode ? "<+/-> Zoom at mouse"
: "<+/wheel/-> Zoom at mouse";
view = Columns(
"VIEW",
count == 4
? std::vector<std::string>{"<arrows/ijkl> Pan", "<+/-> Zoom",
"<f> Fit", "<d> Detail"}
? std::vector<std::string>{"<+/-> Zoom at mouse",
bar.command_mode ? "<=> Zoom in"
: "<wheel> Zoom",
"<arrows/ijkl> Pan", "<f> Full view"}
: count == 3
? std::vector<std::string>{"<arrows/ijkl> Pan", "<+/-> Zoom",
"<f> Fit; <d> Detail"}
: std::vector<std::string>{"<arrows> Pan; <+/-> Zoom",
"<f> Fit; <d> Detail"},
? std::vector<std::string>{zoom, "<arrows/ijkl> Pan",
"<f> Full view"}
: std::vector<std::string>{zoom, "<arrows> Pan; <f> Full"},
inner, count);
}
} else {
@@ -139,11 +150,18 @@ std::vector<std::string> StatusLines(const StatusBar &bar, int columns) {
? std::vector<std::string>{"<c> Toggle control | <q> Quit",
"<c> Toggle; <q> Quit", "<c> / <q>",
"<q>", "q"}
: std::vector<std::string>{"<arrows> Pan | <+/-> Zoom | <f> Fit | "
"<d> Detail",
"<+/-> Zoom | <f> Fit | <d> Detail",
"<f> Fit | <d> Detail", "<f> Fit", "<f>",
"f"},
: bar.command_mode
? std::vector<
std::string>{"<+/-> Zoom at mouse | <arrows> Pan | <f> Full",
"<+/-> Zoom at mouse | <f> Full",
"<+/-> Zoom | <f> Full", "<+/-> | <f>", "<+/->",
"+"}
: std::vector<std::string>{"<+/wheel/-> Zoom at mouse | <arrows> "
"Pan | <f> Full",
"<+/wheel/-> Zoom at mouse | <f> Full",
"<+/wheel/-> Zoom | <f> Full",
"<+/-> Zoom | <f> Full", "<+/-> | <f>",
"<+/->", "+"},
inner);
}
std::vector<std::string> lines{status, keys, view};