Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,30 @@ BLval
BRval
CCCCC
circleval
COLSx
dcx
DDDDD
eeeh
ellips
emtpy
FFFFF
fullwidth
GGGGG
GIDELETE
GIONESHOT
GIRENDER
GIUPLOAD
gitbranch
gitgraph
HHHHH
HTP
isakbm
pseudoconsole
Pxa
qmljsdebugger
rbong
ULval
unscroll
URval
WXYZ
xad
2 changes: 0 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,6 @@ option(CONTOUR_WITH_UTEMPTER "Build with utempter support [default: ON]" ON)
option(CONTOUR_USE_CPM "Use CPM to fetch dependencies [default: ON]" ON)
option(CONTOUR_BUILD_STATIC "Link to static libraries [default: OFF]" OFF)
option(CONTOUR_BUILD_NATIVE "Build for native architecture [default: OFF]" OFF)


if(CONTOUR_BUILD_STATIC)
set(BUILD_SHARED_LIBS OFF)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
Expand Down
3 changes: 3 additions & 0 deletions metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@
<release version="0.6.3" urgency="medium" type="development">
<description>
<ul>
<li>Improves libunicode dependency resolution with system/CPM/error fallback</li>
<li>Adds Good Image Protocol (GIP) implementation — a DCS-based protocol for displaying raster images with named image pools, LRU eviction, three compositing layers (below/replace/above), configurable resize and alignment policies, PNG/RGB/RGBA format support, and DA1 capability advertisement via code 11 (#100)</li>
<li>Adds contour cat CLI command for displaying images in the terminal via GIP oneshot sequences</li>
<li>Fixes keyboard input being swallowed in programs like less and bat due to empty-string terminfo capabilities (#1861)</li>
<li>Fixes ESC key not being sent to the application when CancelSelection input mapping has no mode restriction (#1839)</li>
<li>Fixes selective erase (DECSED/DECSEL) incorrectly erasing the cursor line instead of the target line when erasing lines without protected characters (#28)</li>
Expand Down
7 changes: 3 additions & 4 deletions src/contour/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,9 @@ set_target_properties(contour PROPERTIES AUTORCC ON)
target_include_directories(contour PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")

# {{{ declare compiler definitions
# target_compile_definitions(contour PRIVATE $<$<OR:$<CONFIG:Debug>,$<CONFIG:RelWithDebInfo>>:QT_QML_DEBUG>)
target_compile_definitions(contour PRIVATE $<$<CONFIG:Debug>:QT_QML_DEBUG>)
target_compile_definitions(contour PRIVATE $<$<CONFIG:Debug>:QMLJSDEBUGGER>)
target_compile_definitions(contour PRIVATE $<$<CONFIG:Debug>:QT_DECLARATIVE_DEBUG>)
# QML debugging can be enabled at runtime via -qmljsdebugger=... or QT_QML_DEBUG env var.
# Avoid compile-time defines that force the "QML debugging is enabled" message on every
# invocation, including headless CLI commands like `contour cat`.
target_compile_definitions(contour PRIVATE
CONTOUR_VERSION_MAJOR=${PROJECT_VERSION_MAJOR}
CONTOUR_VERSION_MINOR=${PROJECT_VERSION_MINOR}
Expand Down
211 changes: 211 additions & 0 deletions src/contour/ContourApp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
#include <crispy/App.h>
#include <crispy/CLI.h>
#include <crispy/StackTrace.h>
#include <crispy/base64.h>
#include <crispy/utils.h>

#include <QtCore/QFile>

#include <charconv>
#include <chrono>
#include <csignal>
#include <cstdio>
Expand Down Expand Up @@ -43,6 +45,7 @@ using std::string_view;
using std::unique_ptr;

using namespace std::string_literals;
using namespace std::string_view_literals;

namespace CLI = crispy::cli;

Expand Down Expand Up @@ -166,6 +169,7 @@ ContourApp::ContourApp(): app("contour", "Contour Terminal Emulator", CONTOUR_VE
link("contour.documentation.keys", bind(&ContourApp::documentationKeyMapping, this));
link("contour.documentation.configuration.global", bind(&ContourApp::documentationGlobalConfig, this));
link("contour.documentation.configuration.profile", bind(&ContourApp::documentationProfileConfig, this));
link("contour.cat", bind(&ContourApp::catAction, this));
}

template <typename Callback>
Expand Down Expand Up @@ -466,6 +470,180 @@ int ContourApp::captureAction()
return EXIT_FAILURE;
}

namespace
{
/// Parses a size string in the format "WxH" (e.g. "80x24").
/// Returns {0,0} if the format is invalid.
crispy::size parseSize(string_view text)
{
auto const pos = text.find('x');
if (pos == string_view::npos || pos == 0 || pos == text.size() - 1)
return crispy::size {};

auto const widthStr = text.substr(0, pos);
auto const heightStr = text.substr(pos + 1);

int width = 0;
int height = 0;
auto [pW, ecW] = std::from_chars(widthStr.data(), widthStr.data() + widthStr.size(), width);
auto [pH, ecH] = std::from_chars(heightStr.data(), heightStr.data() + heightStr.size(), height);

if (ecW != std::errc {} || ecH != std::errc {})
return crispy::size {};

return crispy::size { .width = width, .height = height };
}

/// Parses an image alignment string.
vtbackend::ImageAlignment parseImageAlignment(string_view text)
{
if (text == "top-start")
return vtbackend::ImageAlignment::TopStart;
if (text == "top-center")
return vtbackend::ImageAlignment::TopCenter;
if (text == "top-end")
return vtbackend::ImageAlignment::TopEnd;
if (text == "middle-start")
return vtbackend::ImageAlignment::MiddleStart;
if (text == "middle-center" || text == "center")
return vtbackend::ImageAlignment::MiddleCenter;
if (text == "middle-end")
return vtbackend::ImageAlignment::MiddleEnd;
if (text == "bottom-start")
return vtbackend::ImageAlignment::BottomStart;
if (text == "bottom-center")
return vtbackend::ImageAlignment::BottomCenter;
if (text == "bottom-end")
return vtbackend::ImageAlignment::BottomEnd;
return vtbackend::ImageAlignment::MiddleCenter;
}

/// Parses an image resize policy string.
vtbackend::ImageResize parseImageResize(string_view text)
{
if (text == "no" || text == "none")
return vtbackend::ImageResize::NoResize;
if (text == "fit")
return vtbackend::ImageResize::ResizeToFit;
if (text == "fill")
return vtbackend::ImageResize::ResizeToFill;
if (text == "stretch")
return vtbackend::ImageResize::StretchToFill;
return vtbackend::ImageResize::ResizeToFit;
}

/// Parses an image layer value string (0/1/2).
int parseImageLayer(string_view text)
{
if (text == "0" || text == "below")
return 0;
if (text == "1" || text == "replace")
return 1;
if (text == "2" || text == "above")
return 2;
return 1; // default: replace
}

/// Reads a file in binary mode, returning its raw bytes.
std::vector<uint8_t> readFile(std::filesystem::path const& path)
{
auto ifs = std::ifstream(path.string(), std::ios::binary);
if (!ifs.good())
return {};

auto const fileSize = std::filesystem::file_size(path);
auto data = std::vector<uint8_t>();
data.resize(fileSize);
ifs.read(reinterpret_cast<char*>(data.data()), static_cast<std::streamsize>(fileSize));
if (!ifs || static_cast<std::size_t>(ifs.gcount()) != fileSize)
return {};
return data;
}

void displayImage(vtbackend::ImageResize resizePolicy,
vtbackend::ImageAlignment alignmentPolicy,
crispy::size screenSize,
int layer,
string_view fileName)
{
auto constexpr ST = "\033\\"sv;

// Emit GIP oneshot DCS sequence.
// Format 3 = PNG (let the terminal decode it).
cout << std::format("{}f=3,c={},r={},a={},z={},L={};!",
"\033Ps"sv,
screenSize.width,
screenSize.height,
static_cast<int>(alignmentPolicy) + 1,
static_cast<int>(resizePolicy),
layer);

auto const data = readFile(std::filesystem::path(string(fileName)));
if (data.empty())
{
cerr << std::format("Error: Failed to read file '{}'\n", fileName);
return;
}

auto encoderState = crispy::base64::encoder_state {};
std::vector<char> buf;
auto const writer = [&](char a, char b, char c, char d) {
buf.push_back(a);
buf.push_back(b);
buf.push_back(c);
buf.push_back(d);
};
auto const flush = [&]() {
cout.write(buf.data(), static_cast<std::streamsize>(buf.size()));
buf.clear();
};

for (uint8_t const byte: data)
{
crispy::base64::encode(byte, encoderState, writer);
if (buf.size() >= 4096)
flush();
}
crispy::base64::finish(encoderState, writer);
flush();

cout << ST;
}
} // namespace

int ContourApp::catAction()
{
if (parameters().verbatim.empty())
{
cerr << "Error: No image file specified.\n";
return EXIT_FAILURE;
}

auto const resizePolicy = parseImageResize(parameters().get<string>("contour.cat.resize"));
auto const alignmentPolicy = parseImageAlignment(parameters().get<string>("contour.cat.align"));
auto const size = parseSize(parameters().get<string>("contour.cat.size"));
auto const layer = parseImageLayer(parameters().get<string>("contour.cat.layer"));
auto const fileName = parameters().verbatim.front();

auto const filePath = std::filesystem::path(string(fileName));
if (!std::filesystem::exists(filePath))
{
cerr << std::format("Error: File not found: '{}'\n", fileName);
return EXIT_FAILURE;
}

// GIP protocol limits the body to 16 MB.
auto constexpr MaxImageFileSize = std::uintmax_t { 16 * 1024 * 1024 };
if (std::filesystem::file_size(filePath) > MaxImageFileSize)
{
cerr << std::format("Error: File '{}' exceeds the maximum supported size of 16 MB.\n", fileName);
return EXIT_FAILURE;
}

displayImage(resizePolicy, alignmentPolicy, size, layer, fileName);
return EXIT_SUCCESS;
}

int ContourApp::parserTableAction()
{
vtparser::parserTableDot(std::cout);
Expand Down Expand Up @@ -575,6 +753,39 @@ crispy::cli::command ContourApp::parameterDefinition() const
"FILE",
CLI::presence::Required },
} } } },
CLI::command {
"cat",
"Displays an image in the terminal using the Good Image Protocol.",
CLI::option_list {
CLI::option { "resize",
CLI::value { "fit"s },
"Sets the image resize policy.\n"
"Policies available are:\n"
" - no/none (no resize),\n"
" - fit (resize to fit),\n"
" - fill (resize to fill),\n"
" - stretch (stretch to fill)." },
CLI::option { "align",
CLI::value { "center"s },
"Sets the image alignment policy.\n"
"Possible values: top-start, top-center, top-end, "
"middle-start, middle-center/center, middle-end, "
"bottom-start, bottom-center, bottom-end." },
CLI::option { "size",
CLI::value { "0x0"s },
"Sets the amount of columns and rows to place the image onto "
"(format: COLSxROWS, e.g. 80x24). "
"The top-left of the area is the current cursor position." },
CLI::option { "layer",
CLI::value { "1"s },
"Sets the image layer relative to text.\n"
"Values: 0/below (below text), 1/replace (replace text, default), "
"2/above (above text)." } },
CLI::command_list {},
CLI::command_select::Explicit,
CLI::verbatim {
"IMAGE_FILE",
"Path to image to be displayed. Image formats supported are at least PNG, JPG." } },
CLI::command {
"capture",
"Captures the screen buffer of the currently running terminal.",
Expand Down
1 change: 1 addition & 0 deletions src/contour/ContourApp.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class ContourApp: public crispy::app
int documentationKeyMapping();
int documentationGlobalConfig();
int documentationProfileConfig();
int catAction();
};

} // namespace contour
1 change: 1 addition & 0 deletions src/contour/display/OpenGLRenderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ namespace
{
case vtbackend::ImageFormat::RGB: return GL_RGB;
case vtbackend::ImageFormat::RGBA: return GL_RGBA;
case vtbackend::ImageFormat::PNG: Require(false);
}
Guarantee(false);
crispy::unreachable();
Expand Down
29 changes: 29 additions & 0 deletions src/contour/display/TerminalDisplay.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,34 @@ void TerminalDisplay::setSession(TerminalSession* newSession)

_session->attachDisplay(*this); // NB: Requires Renderer to be instanciated to retrieve grid metrics.

_session->terminal().setImageDecoder(
[](vtbackend::ImageFormat format,
std::span<uint8_t const> data,
vtbackend::ImageSize& size) -> std::optional<vtbackend::Image::Data> {
if (format != vtbackend::ImageFormat::PNG)
return std::nullopt;

QImage image;
image.loadFromData(static_cast<uchar const*>(data.data()), static_cast<int>(data.size()));
if (image.isNull())
return std::nullopt;

image = image.convertToFormat(QImage::Format_RGBA8888);

size = vtbackend::ImageSize { vtbackend::Width::cast_from(image.width()),
vtbackend::Height::cast_from(image.height()) };

vtbackend::Image::Data pixels;
pixels.resize(static_cast<size_t>(image.width() * image.height() * 4));
auto* p = pixels.data();
for (int i = 0; i < image.height(); ++i)
{
memcpy(p, image.constScanLine(i), static_cast<size_t>(image.bytesPerLine()));
p += image.bytesPerLine();
}
return pixels;
});

emit sessionChanged(newSession);
}

Expand Down Expand Up @@ -1584,6 +1612,7 @@ void TerminalDisplay::discardImage(vtbackend::Image const& image)
{
_renderer->discardImage(image);
}

// }}}

} // namespace contour::display
Loading
Loading