Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ABCDEFGHIX

Check warning on line 1 in .github/actions/spelling/expect.txt

View workflow job for this annotation

GitHub Actions / Check spelling

Skipping `.github/actions/spelling/expect.txt` because it seems to have more noise (45) than unique words (0) (total: 45 / 0). (noisy-file)
ABCXDEFGHI
ABCXYDEFGH
ABCXZEFGHI
Expand All @@ -14,8 +14,9 @@
DECPCCM
DECRQDE
DECRST
DECSET

Check warning on line 17 in .github/actions/spelling/expect.txt

View workflow job for this annotation

GitHub Actions / Check spelling

`DECSET` is ignored by check-spelling because another more general variant is also in expect. (ignored-expect-variant)
Decset

Check warning on line 18 in .github/actions/spelling/expect.txt

View workflow job for this annotation

GitHub Actions / Check spelling

`Decset` is ignored by check-spelling because another more general variant is also in expect. (ignored-expect-variant)
ecmascript
ellips
emantic
emtpy
Expand Down
38 changes: 36 additions & 2 deletions docs/demo/hint-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ The `hint_action` parameter controls what happens when you select a match:
| `Open` | Open the match — URLs open in the browser, files open in the configured editor |
| `Paste` | Paste the matched text into the terminal input |
| `CopyAndPaste` | Copy to clipboard **and** paste into the terminal |
| `Select` | Copy the matched text to clipboard (visual mode pre-selection is planned) |
| `Select` | Enter vi visual mode with the match range pre-selected for manual adjustment |

## Configuration

Expand All @@ -109,7 +109,8 @@ input_mapping:

The `patterns` parameter accepts:

- A pattern name: `url`, `filepath`, `githash`, `ipv4`, `ipv6`
- A single pattern name: `url`, `filepath`, `githash`, `ipv4`, `ipv6`
- Multiple pattern names separated by `|`: `filepath|githash`
- `all` or empty: scan for all builtin patterns at once

### Colors
Expand All @@ -135,6 +136,39 @@ color_schemes:
background_alpha: 0.8
```

### Custom Patterns

You can define your own hint patterns in the profile configuration. Each pattern
needs a `name` (used to reference it in key bindings) and a `regex`
([ECMAScript syntax](https://en.cppreference.com/w/cpp/regex/ecmascript)):

```yaml
profiles:
main:
hint_patterns:
- name: uuid
regex: '\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b'
- name: docker_id
regex: '\b[0-9a-f]{12,64}\b'
```

Custom patterns with the same name as a builtin override the builtin definition.
You can then reference custom patterns in key bindings just like builtins:

```yaml
input_mapping:
- { mods: [Control, Shift], key: I, action: HintMode, patterns: uuid, hint_action: Copy }
```

### Path Resolution

When copying or opening file paths, Contour resolves relative paths to absolute
using the terminal's current working directory. For example, copying a hint for
`src/main.cpp` gives you `/home/user/project/src/main.cpp` in the clipboard, and
opening it passes the full path to your editor or file manager.

Home-relative paths (`~/...`) are expanded using the `HOME` environment variable.

## Tips and Tricks

- **Shell integration matters** — Ensure your shell reports the current working
Expand Down
26 changes: 26 additions & 0 deletions src/contour/Config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ void YAMLConfigReader::loadFromEntry(YAML::Node const& node, std::string const&
}

loadFromEntry(child, "hyperlink_decoration", where.hyperlinkDecoration);
loadFromEntry(child, "hint_patterns", where.hintPatterns);
}
}

Expand Down Expand Up @@ -1178,6 +1179,31 @@ void YAMLConfigReader::loadFromEntry(YAML::Node const& node, std::string const&
}
}

void YAMLConfigReader::loadFromEntry(YAML::Node const& node,
std::string const& entry,
std::vector<HintPatternConfig>& where)
{
auto const child = node[entry];
if (child && child.IsSequence())
{
where.clear();
for (auto const& item: child)
{
auto patternConfig = HintPatternConfig {};
if (auto const nameNode = item["name"])
patternConfig.name = nameNode.as<std::string>();
if (auto const regexNode = item["regex"])
patternConfig.regex = regexNode.as<std::string>();
if (!patternConfig.name.empty() && !patternConfig.regex.empty())
where.push_back(std::move(patternConfig));
else
logger()("Skipping hint pattern with missing name or regex: name='{}', regex='{}'",
patternConfig.name.empty() ? "<unnamed>" : patternConfig.name,
patternConfig.regex);
}
}
}

void YAMLConfigReader::loadFromEntry(YAML::Node const& node, std::string const& entry, MouseConfig& where)
{
auto const child = node[entry];
Expand Down
48 changes: 48 additions & 0 deletions src/contour/Config.h
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,13 @@ struct HyperlinkDecorationConfig
vtrasterizer::Decorator hover { vtrasterizer::Decorator::Underline };
};

/// A user-defined hint pattern for hint mode scanning.
struct HintPatternConfig
{
std::string name; ///< Pattern identifier (e.g. "uuid", "docker_id").
std::string regex; ///< ECMAScript regex to match against visible text.
};

struct PermissionsConfig
{
Permission captureBuffer { Permission::Ask };
Expand Down Expand Up @@ -457,6 +464,7 @@ struct TerminalProfile
ConfigEntry<BackgroundConfig, documentation::Background> background {};
ConfigEntry<ColorConfig, documentation::Colors> colors { SimpleColorConfig {} };
ConfigEntry<HyperlinkDecorationConfig, documentation::HyperlinkDecoration> hyperlinkDecoration {};
ConfigEntry<std::vector<HintPatternConfig>, documentation::HintPatterns> hintPatterns {};

ConfigEntry<std::string, documentation::WMClass> wmClass { CONTOUR_APP_ID };
ConfigEntry<bool, documentation::OptionKeyAsAlt> optionKeyAsAlt { false };
Expand Down Expand Up @@ -1047,6 +1055,9 @@ struct YAMLConfigReader
void loadFromEntry(YAML::Node const& node, std::string const& entry, BackgroundConfig& where);
void loadFromEntry(YAML::Node const& node, std::string const& entry, HyperlinkDecorationConfig& where);
void loadFromEntry(YAML::Node const& node, std::string const& entry, PermissionsConfig& where);
void loadFromEntry(YAML::Node const& node,
std::string const& entry,
std::vector<HintPatternConfig>& where);
void loadFromEntry(YAML::Node const& node, std::string const& entry, vtrasterizer::BoxDrawingRenderer::ArcStyle& where);
void loadFromEntry(YAML::Node const& node, std::string const& entry, vtrasterizer::BoxDrawingRenderer::GitDrawingsStyle& where);
void loadFromEntry(YAML::Node const& node, std::string const& entry, vtrasterizer::BoxDrawingRenderer::BrailleStyle& where);
Expand Down Expand Up @@ -1228,6 +1239,43 @@ struct Writer
return format(doc, v.hostname);
}

/// Serializes hint patterns: outputs the doc template (pure comment) followed by any user entries.
[[nodiscard]] std::string format(std::string_view doc, std::vector<HintPatternConfig> const& patterns)
{
// doc is already processed by process() (comment placeholders replaced, indentation applied).
auto result = std::string { doc };
if (!patterns.empty())
{
// Derive indentation from the first non-empty line in doc.
auto indent = std::string {};
for (auto const ch: doc)
{
if (ch == '\n')
{
indent.clear();
continue;
}
if (ch == ' ' || ch == '\t')
indent.push_back(ch);
else
break;
}
result += indent + "hint_patterns:\n";
for (auto const& p: patterns)
{
// Escape single quotes for YAML single-quoted scalars by doubling them.
auto escapedRegex = p.regex;
for (auto pos = escapedRegex.find('\''); pos != std::string::npos;
pos = escapedRegex.find('\'', pos + 2))
escapedRegex.insert(pos, 1, '\'');

result += std::format(
"{} - name: {}\n{} regex: '{}'\n", indent, p.name, indent, escapedRegex);
}
}
return result;
}

[[nodiscard]] std::string static format(vtbackend::CellRGBColor const& v)
{
if (std::holds_alternative<vtbackend::RGBColor>(v))
Expand Down
18 changes: 18 additions & 0 deletions src/contour/ConfigDocumentation.h
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,10 @@ constexpr StringLiteral InputMappingsConfig {
"{comment} - FollowHyperlink Follows the hyperlink that is exposed via OSC 8 under the current "
"cursor "
"position.\n"
"{comment} - HintMode Activates hint mode to scan for patterns (URLs, file paths, etc.).\n"
"{comment} Parameters: patterns (url, filepath, githash, ipv4, ipv6, all, or "
"names joined with |),\n"
"{comment} hint_action (Copy, Open, Paste, CopyAndPaste, Select).\n"
"{comment} - IncreaseFontSize Increases the font size by 1 pixel.\n"
"{comment} - IncreaseOpacity Increases the default-background opacity by 5%.\n"
"{comment} - MoveTabToLeft Moves the current tab to the left.\n"
Expand Down Expand Up @@ -1052,6 +1056,19 @@ constexpr StringLiteral HintMatchConfig {
" background_alpha: {}\n"
};

constexpr StringLiteral HintPatternsConfig {
"\n"
"{comment} Custom hint patterns for hint mode.\n"
"{comment} Each entry defines a named regex pattern that hint mode will scan for.\n"
"{comment} User-defined patterns with the same name as a builtin pattern override the builtin.\n"
"{comment} Built-in patterns: url, filepath, githash, ipv4, ipv6.\n"
"{comment} Example:\n"
"{comment} hint_patterns:\n"
"{comment} - name: uuid\n"
"{comment} regex: "
"'\\b[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}\\b'\n"
};

constexpr StringLiteral IndicatorStatusLineConfig {
"\n"
"{comment} Defines the colors to be used for the Indicator status line.\n"
Expand Down Expand Up @@ -2041,6 +2058,7 @@ using WordHighlightCurrent = DocumentationEntry<WordHighlightCurrentConfig, Dumm
using WordHighlight = DocumentationEntry<WordHighlightConfig, Dummy>;
using HintLabel = DocumentationEntry<HintLabelConfig, Dummy>;
using HintMatch = DocumentationEntry<HintMatchConfig, Dummy>;
using HintPatterns = DocumentationEntry<HintPatternsConfig, Dummy>;
using IndicatorStatusLine = DocumentationEntry<IndicatorStatusLineConfig, Dummy>;
using InputMethodEditor = DocumentationEntry<InputMethodEditorConfig, Dummy>;
using InputMethodEditorSupport = DocumentationEntry<InputMethodEditorSupportConfig, Dummy>;
Expand Down
52 changes: 44 additions & 8 deletions src/contour/TerminalSession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
#include <format>
#include <fstream>
#include <limits>
#include <regex>

#if defined(__OpenBSD__)
#include <pthread_np.h>
Expand Down Expand Up @@ -1358,19 +1359,54 @@ bool TerminalSession::operator()(actions::HintMode const& action)
{
sessionLog()("Activating hint mode with patterns: '{}', action: {}", action.patterns, action.hintAction);

// Resolve patterns: if empty or "all", use all builtin patterns.
// Start with builtin patterns.
auto patterns = vtbackend::HintModeHandler::builtinPatterns();

// Merge user-configured patterns: override builtins with same name, append new ones.
for (auto const& userPattern: profile().hintPatterns.value())
{
try
{
auto compiled = vtbackend::HintPattern {
.name = userPattern.name,
.regex = std::regex(userPattern.regex,
std::regex_constants::ECMAScript | std::regex_constants::optimize),
.validator = {},
.transformer = {},
};
auto const it =
std::ranges::find_if(patterns, [&](auto const& p) { return p.name == userPattern.name; });
if (it != patterns.end())
*it = std::move(compiled); // Override builtin with same name.
else
patterns.push_back(std::move(compiled)); // Append new user pattern.
}
catch (std::regex_error const& e)
{
sessionLog()("Skipping hint pattern '{}': invalid regex '{}': {}",
userPattern.name,
userPattern.regex,
e.what());
}
}

// Filter by requested pattern name(s) if specified.
if (!action.patterns.empty() && action.patterns != "all")
{
// Filter to only the named pattern(s).
auto filtered = std::vector<vtbackend::HintPattern>();
for (auto& p: patterns)
auto const requestedNames = crispy::split(std::string_view(action.patterns), '|');
auto const nameMatches = [&](auto const& p) {
return std::ranges::find(requestedNames, std::string_view(p.name)) != requestedNames.end();
};

if (std::ranges::any_of(patterns, nameMatches))
{
std::erase_if(patterns, [&](auto const& p) { return !nameMatches(p); });
sessionLog()("Filtered to {} hint pattern(s) matching '{}'", patterns.size(), action.patterns);
}
else
{
if (p.name == action.patterns)
filtered.push_back(std::move(p));
sessionLog()("No hint patterns matched '{}', falling back to all patterns", action.patterns);
}
if (!filtered.empty())
patterns = std::move(filtered);
}

auto const hintAction = action.hintAction;
Expand Down
23 changes: 15 additions & 8 deletions src/vtbackend/HintModeHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ void HintModeHandler::rescanLines(vector<string> const& visibleLines, PageSize p
utf8ByteOffsetToCodepointIndex(lineText, match.position() + match.length());
auto const endCol = ColumnOffset::cast_from(endCodepointIndex - 1);

auto const actionText = pattern.transformer ? pattern.transformer(matchStr) : matchStr;

_allMatches.push_back(HintMatch {
.label = {},
.matchedText = matchStr,
.matchedText = actionText,
.start = CellLocation { .line = lineOffset, .column = startCol },
.end = CellLocation { .line = lineOffset, .column = endCol },
});
Expand Down Expand Up @@ -183,10 +185,10 @@ bool HintModeHandler::processInput(char32_t ch)
// Auto-select when exactly one match remains.
if (_filteredMatches.size() == 1 && _filteredMatches[0].label == _filter)
{
auto const matchedText = _filteredMatches[0].matchedText;
auto match = std::move(_filteredMatches[0]);
auto const action = _action;
deactivate();
_executor.onHintSelected(matchedText, action);
_executor.onHintSelected(match.matchedText, action, match.start, match.end);
return true;
}

Expand Down Expand Up @@ -238,19 +240,23 @@ vector<HintPattern> HintModeHandler::builtinPatterns()
HintPattern { .name = "url",
.regex = regex(R"(https?://[^\s<>\"'\])\}]+)",
regex_constants::ECMAScript | regex_constants::optimize),
.validator = {} },
.validator = {},
.transformer = {} },
HintPattern { .name = "filepath",
.regex = regex(R"((?:~?/[\w./-]+|\.{1,2}/[\w./-]+|[\w][\w.-]*/[\w./-]+))",
regex_constants::ECMAScript | regex_constants::optimize),
.validator = {} },
.validator = {},
.transformer = {} },
HintPattern {
.name = "githash",
.regex = regex(R"(\b[0-9a-f]{7,40}\b)", regex_constants::ECMAScript | regex_constants::optimize),
.validator = {} },
.validator = {},
.transformer = {} },
HintPattern { .name = "ipv4",
.regex = regex(R"(\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?\b)",
regex_constants::ECMAScript | regex_constants::optimize),
.validator = {} },
.validator = {},
.transformer = {} },
HintPattern {
.name = "ipv6",
.regex =
Expand All @@ -261,7 +267,8 @@ vector<HintPattern> HintModeHandler::builtinPatterns()
R"(|\b(?:[0-9a-fA-F]{1,4}:)+:(?![0-9a-fA-F:]))"
R"())",
regex_constants::ECMAScript | regex_constants::optimize),
.validator = {} },
.validator = {},
.transformer = {} },
};
return cached;
}
Expand Down
16 changes: 15 additions & 1 deletion src/vtbackend/HintModeHandler.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ struct HintPattern
/// Optional post-match validator. When set, only matches for which
/// this returns true are kept. Used e.g. to check filesystem existence.
std::function<bool(std::string const&)> validator;
/// Optional post-match transformer. When set, the matched text is rewritten
/// before being stored in HintMatch. Used e.g. to resolve relative paths
/// to absolute paths. The overlay still shows the original terminal text.
std::function<std::string(std::string const&)> transformer;
};

/// A single match found during hint scanning, with its label and grid positions.
Expand All @@ -52,7 +56,17 @@ class HintModeHandler
virtual ~Executor() = default;

/// Called when a hint has been selected by the user.
virtual void onHintSelected(std::string const& matchedText, HintAction action) = 0;
///
/// @param matchedText The text that was matched by the hint pattern.
/// @param action The action to perform on the match.
/// @param start The start position of the match, relative to the visible viewport
/// area (line 0 = first visible line).
/// @param end The end position of the match (inclusive), relative to the visible
/// viewport area.
virtual void onHintSelected(std::string const& matchedText,
HintAction action,
CellLocation start,
CellLocation end) = 0;

/// Called when hint mode is entered.
virtual void onHintModeEntered() = 0;
Expand Down
Loading
Loading