mirror of
https://github.com/m1ngsama/TUT.git
synced 2025-12-24 10:51:46 +00:00
feat: Add inline link display and vim-style quick navigation
Major improvements to link handling and navigation: Features: - Display links inline with numbered indicators [0], [1], etc. - Quick navigation: type number + Enter to jump to link - Fast follow: press 'f' + number to open link directly - Visual improvements: links shown with underline and highlight - Remove separate link list at bottom for better readability Technical changes: - Add InlineLink structure to track link positions in text - Implement wrap_text_with_links() for intelligent text wrapping - Add GOTO_LINK and FOLLOW_LINK_NUM actions - Implement LINK input mode for 'f' command - Character-by-character rendering for proper link highlighting - Update help documentation with new navigation methods Usage examples: - 3<Enter> : Jump to link 3 - f5 or 5f : Open link 5 directly - Tab/Enter : Traditional navigation still works All comments converted to standard Unix style (English).
This commit is contained in:
parent
354133b500
commit
ea71b0ca02
9 changed files with 540 additions and 42 deletions
76
LINK_NAVIGATION.md
Normal file
76
LINK_NAVIGATION.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Quick Link Navigation Guide
|
||||||
|
|
||||||
|
The browser now supports vim-style quick navigation to links!
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. Visual Link Numbers
|
||||||
|
All links are displayed inline in the text with numbers like `[0]`, `[1]`, `[2]`, etc.
|
||||||
|
- Links are shown with yellow color and underline
|
||||||
|
- Active link (selected with Tab) has yellow background
|
||||||
|
|
||||||
|
### 2. Quick Navigation Methods
|
||||||
|
|
||||||
|
#### Method 1: Number + Enter
|
||||||
|
Type a number and press Enter to jump to that link:
|
||||||
|
```
|
||||||
|
3<Enter> - Jump to link [3]
|
||||||
|
10<Enter> - Jump to link [10]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Method 2: 'f' command (follow)
|
||||||
|
Press `f` followed by a number to immediately open that link:
|
||||||
|
```
|
||||||
|
f3 - Open link [3] directly
|
||||||
|
f10 - Open link [10] directly
|
||||||
|
```
|
||||||
|
|
||||||
|
Or type the number first:
|
||||||
|
```
|
||||||
|
3f - Open link [3] directly
|
||||||
|
10f - Open link [10] directly
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Method 3: Traditional Tab navigation (still works)
|
||||||
|
```
|
||||||
|
Tab - Next link
|
||||||
|
Shift-Tab/T - Previous link
|
||||||
|
Enter - Follow current highlighted link
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Given a page with these links:
|
||||||
|
- "Google[0]"
|
||||||
|
- "GitHub[1]"
|
||||||
|
- "Wikipedia[2]"
|
||||||
|
|
||||||
|
You can:
|
||||||
|
- Press `1<Enter>` to select GitHub link
|
||||||
|
- Press `f2` to immediately open Wikipedia
|
||||||
|
- Press `Tab` twice then `Enter` to open Wikipedia
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test with a real website
|
||||||
|
./tut https://example.com
|
||||||
|
|
||||||
|
# View help
|
||||||
|
./tut
|
||||||
|
# Press ? for help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Bindings Summary
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| `[N]<Enter>` | Jump to link N |
|
||||||
|
| `f[N]` or `[N]f` | Open link N directly |
|
||||||
|
| `Tab` | Next link |
|
||||||
|
| `Shift-Tab` / `T` | Previous link |
|
||||||
|
| `Enter` | Follow current link |
|
||||||
|
| `h` | Go back |
|
||||||
|
| `l` | Go forward |
|
||||||
|
|
||||||
|
All standard vim navigation keys (j/k, gg/G, /, n/N) still work as before!
|
||||||
134
src/browser.cpp
134
src/browser.cpp
|
|
@ -138,34 +138,93 @@ public:
|
||||||
int line_idx = scroll_pos + i;
|
int line_idx = scroll_pos + i;
|
||||||
const auto& line = rendered_lines[line_idx];
|
const auto& line = rendered_lines[line_idx];
|
||||||
|
|
||||||
if (line.is_link && line.link_index == current_link) {
|
// Check if this line contains the active link
|
||||||
attron(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
bool has_active_link = (line.is_link && line.link_index == current_link);
|
||||||
} else {
|
|
||||||
attron(COLOR_PAIR(line.color_pair));
|
// Check if this line is in search results
|
||||||
if (line.is_bold) {
|
bool in_search_results = !search_term.empty() &&
|
||||||
attron(A_BOLD);
|
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end();
|
||||||
|
|
||||||
|
// If line has link ranges, render character by character with proper highlighting
|
||||||
|
if (!line.link_ranges.empty()) {
|
||||||
|
int col = 0;
|
||||||
|
for (size_t char_idx = 0; char_idx < line.text.length(); ++char_idx) {
|
||||||
|
// Check if this character is within any link range
|
||||||
|
bool is_in_link = false;
|
||||||
|
|
||||||
|
for (const auto& [start, end] : line.link_ranges) {
|
||||||
|
if (char_idx >= start && char_idx < end) {
|
||||||
|
is_in_link = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply appropriate color
|
||||||
|
if (is_in_link && has_active_link) {
|
||||||
|
attron(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||||
|
} else if (is_in_link) {
|
||||||
|
attron(COLOR_PAIR(COLOR_LINK));
|
||||||
|
attron(A_UNDERLINE);
|
||||||
|
} else {
|
||||||
|
attron(COLOR_PAIR(line.color_pair));
|
||||||
|
if (line.is_bold) {
|
||||||
|
attron(A_BOLD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_search_results) {
|
||||||
|
attron(A_REVERSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
mvaddch(i, col, line.text[char_idx]);
|
||||||
|
|
||||||
|
if (in_search_results) {
|
||||||
|
attroff(A_REVERSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_in_link && has_active_link) {
|
||||||
|
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||||
|
} else if (is_in_link) {
|
||||||
|
attroff(A_UNDERLINE);
|
||||||
|
attroff(COLOR_PAIR(COLOR_LINK));
|
||||||
|
} else {
|
||||||
|
if (line.is_bold) {
|
||||||
|
attroff(A_BOLD);
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(line.color_pair));
|
||||||
|
}
|
||||||
|
|
||||||
|
col++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!search_term.empty() &&
|
|
||||||
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) {
|
|
||||||
attron(A_REVERSE);
|
|
||||||
}
|
|
||||||
|
|
||||||
mvprintw(i, 0, "%s", line.text.c_str());
|
|
||||||
|
|
||||||
if (!search_term.empty() &&
|
|
||||||
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) {
|
|
||||||
attroff(A_REVERSE);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.is_link && line.link_index == current_link) {
|
|
||||||
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
|
||||||
} else {
|
} else {
|
||||||
if (line.is_bold) {
|
// No inline links, render normally
|
||||||
attroff(A_BOLD);
|
if (has_active_link) {
|
||||||
|
attron(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||||
|
} else {
|
||||||
|
attron(COLOR_PAIR(line.color_pair));
|
||||||
|
if (line.is_bold) {
|
||||||
|
attron(A_BOLD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_search_results) {
|
||||||
|
attron(A_REVERSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
mvprintw(i, 0, "%s", line.text.c_str());
|
||||||
|
|
||||||
|
if (in_search_results) {
|
||||||
|
attroff(A_REVERSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has_active_link) {
|
||||||
|
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||||
|
} else {
|
||||||
|
if (line.is_bold) {
|
||||||
|
attroff(A_BOLD);
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(line.color_pair));
|
||||||
}
|
}
|
||||||
attroff(COLOR_PAIR(line.color_pair));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,6 +288,26 @@ public:
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case Action::GOTO_LINK:
|
||||||
|
// Jump to specific link by number
|
||||||
|
if (result.number >= 0 && result.number < static_cast<int>(current_doc.links.size())) {
|
||||||
|
current_link = result.number;
|
||||||
|
scroll_to_link(current_link);
|
||||||
|
status_message = "Link " + std::to_string(result.number);
|
||||||
|
} else {
|
||||||
|
status_message = "Invalid link number: " + std::to_string(result.number);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Action::FOLLOW_LINK_NUM:
|
||||||
|
// Follow specific link by number directly
|
||||||
|
if (result.number >= 0 && result.number < static_cast<int>(current_doc.links.size())) {
|
||||||
|
load_page(current_doc.links[result.number].url);
|
||||||
|
} else {
|
||||||
|
status_message = "Invalid link number: " + std::to_string(result.number);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case Action::GO_BACK:
|
case Action::GO_BACK:
|
||||||
if (history_pos > 0) {
|
if (history_pos > 0) {
|
||||||
history_pos--;
|
history_pos--;
|
||||||
|
|
@ -332,9 +411,12 @@ public:
|
||||||
<< "<p>G: Go to bottom</p>"
|
<< "<p>G: Go to bottom</p>"
|
||||||
<< "<p>[number]G: Go to line number</p>"
|
<< "<p>[number]G: Go to line number</p>"
|
||||||
<< "<h2>Links</h2>"
|
<< "<h2>Links</h2>"
|
||||||
|
<< "<p>Links are displayed inline with numbers like [0], [1], etc.</p>"
|
||||||
<< "<p>Tab: Next link</p>"
|
<< "<p>Tab: Next link</p>"
|
||||||
<< "<p>Shift-Tab or T: Previous link</p>"
|
<< "<p>Shift-Tab or T: Previous link</p>"
|
||||||
<< "<p>Enter: Follow link</p>"
|
<< "<p>Enter: Follow current link</p>"
|
||||||
|
<< "<p>[number]Enter: Jump to link number N</p>"
|
||||||
|
<< "<p>f[number]: Follow link number N directly</p>"
|
||||||
<< "<p>h: Go back</p>"
|
<< "<p>h: Go back</p>"
|
||||||
<< "<p>l: Go forward</p>"
|
<< "<p>l: Go forward</p>"
|
||||||
<< "<h2>Search</h2>"
|
<< "<h2>Search</h2>"
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,70 @@ public:
|
||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从HTML中提取文本,同时保留内联链接位置信息
|
||||||
|
std::string extract_text_with_links(const std::string& html,
|
||||||
|
std::vector<Link>& all_links,
|
||||||
|
std::vector<InlineLink>& inline_links) {
|
||||||
|
std::string result;
|
||||||
|
std::regex link_regex(R"(<a\s+[^>]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)</a>)",
|
||||||
|
std::regex::icase);
|
||||||
|
|
||||||
|
size_t last_pos = 0;
|
||||||
|
auto begin = std::sregex_iterator(html.begin(), html.end(), link_regex);
|
||||||
|
auto end = std::sregex_iterator();
|
||||||
|
|
||||||
|
// 处理所有链接
|
||||||
|
for (std::sregex_iterator i = begin; i != end; ++i) {
|
||||||
|
std::smatch match = *i;
|
||||||
|
|
||||||
|
// 添加链接前的文本
|
||||||
|
std::string before_link = html.substr(last_pos, match.position() - last_pos);
|
||||||
|
std::string before_text = decode_html_entities(remove_tags(before_link));
|
||||||
|
result += before_text;
|
||||||
|
|
||||||
|
// 提取链接信息
|
||||||
|
std::string link_url = match[1].str();
|
||||||
|
std::string link_text = decode_html_entities(remove_tags(match[2].str()));
|
||||||
|
|
||||||
|
// 跳过空链接或锚点链接
|
||||||
|
if (link_url.empty() || link_url[0] == '#' || link_text.empty()) {
|
||||||
|
result += link_text;
|
||||||
|
last_pos = match.position() + match.length();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到这个链接在全局链接列表中的索引
|
||||||
|
int link_index = -1;
|
||||||
|
for (size_t j = 0; j < all_links.size(); ++j) {
|
||||||
|
if (all_links[j].url == link_url && all_links[j].text == link_text) {
|
||||||
|
link_index = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link_index != -1) {
|
||||||
|
// 记录内联链接位置
|
||||||
|
InlineLink inline_link;
|
||||||
|
inline_link.text = link_text;
|
||||||
|
inline_link.url = link_url;
|
||||||
|
inline_link.start_pos = result.length();
|
||||||
|
inline_link.end_pos = result.length() + link_text.length();
|
||||||
|
inline_link.link_index = link_index;
|
||||||
|
inline_links.push_back(inline_link);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加链接文本
|
||||||
|
result += link_text;
|
||||||
|
last_pos = match.position() + match.length();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加最后一段文本
|
||||||
|
std::string remaining = html.substr(last_pos);
|
||||||
|
result += decode_html_entities(remove_tags(remaining));
|
||||||
|
|
||||||
|
return trim(result);
|
||||||
|
}
|
||||||
|
|
||||||
// 清理空白字符
|
// 清理空白字符
|
||||||
std::string trim(const std::string& str) {
|
std::string trim(const std::string& str) {
|
||||||
auto start = str.begin();
|
auto start = str.begin();
|
||||||
|
|
@ -237,14 +301,13 @@ ParsedDocument HtmlParser::parse(const std::string& html, const std::string& bas
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析段落
|
// 解析段落 (保留内联链接)
|
||||||
auto paragraphs = pImpl->extract_all_tags(main_content, "p");
|
auto paragraphs = pImpl->extract_all_tags(main_content, "p");
|
||||||
for (const auto& para : paragraphs) {
|
for (const auto& para : paragraphs) {
|
||||||
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(para)));
|
ContentElement elem;
|
||||||
if (!text.empty() && text.length() > 1) {
|
elem.type = ElementType::PARAGRAPH;
|
||||||
ContentElement elem;
|
elem.text = pImpl->extract_text_with_links(para, doc.links, elem.inline_links);
|
||||||
elem.type = ElementType::PARAGRAPH;
|
if (!elem.text.empty() && elem.text.length() > 1) {
|
||||||
elem.text = text;
|
|
||||||
doc.elements.push_back(elem);
|
doc.elements.push_back(elem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,20 @@ struct Link {
|
||||||
int position;
|
int position;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct InlineLink {
|
||||||
|
std::string text;
|
||||||
|
std::string url;
|
||||||
|
size_t start_pos; // Position in the text where link starts
|
||||||
|
size_t end_pos; // Position in the text where link ends
|
||||||
|
int link_index; // Index in the document's links array
|
||||||
|
};
|
||||||
|
|
||||||
struct ContentElement {
|
struct ContentElement {
|
||||||
ElementType type;
|
ElementType type;
|
||||||
std::string text;
|
std::string text;
|
||||||
std::string url;
|
std::string url;
|
||||||
int level;
|
int level;
|
||||||
|
std::vector<InlineLink> inline_links; // Links within this element's text
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ParsedDocument {
|
struct ParsedDocument {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ public:
|
||||||
result.has_count = false;
|
result.has_count = false;
|
||||||
result.count = 1;
|
result.count = 1;
|
||||||
|
|
||||||
|
// Handle digit input for count or 'f' command
|
||||||
if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) {
|
if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) {
|
||||||
count_buffer += static_cast<char>(ch);
|
count_buffer += static_cast<char>(ch);
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -31,33 +32,38 @@ public:
|
||||||
if (!count_buffer.empty()) {
|
if (!count_buffer.empty()) {
|
||||||
result.has_count = true;
|
result.has_count = true;
|
||||||
result.count = std::stoi(count_buffer);
|
result.count = std::stoi(count_buffer);
|
||||||
count_buffer.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (ch) {
|
switch (ch) {
|
||||||
case 'j':
|
case 'j':
|
||||||
case KEY_DOWN:
|
case KEY_DOWN:
|
||||||
result.action = Action::SCROLL_DOWN;
|
result.action = Action::SCROLL_DOWN;
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case 'k':
|
case 'k':
|
||||||
case KEY_UP:
|
case KEY_UP:
|
||||||
result.action = Action::SCROLL_UP;
|
result.action = Action::SCROLL_UP;
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case 'h':
|
case 'h':
|
||||||
case KEY_LEFT:
|
case KEY_LEFT:
|
||||||
result.action = Action::GO_BACK;
|
result.action = Action::GO_BACK;
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case 'l':
|
case 'l':
|
||||||
case KEY_RIGHT:
|
case KEY_RIGHT:
|
||||||
result.action = Action::GO_FORWARD;
|
result.action = Action::GO_FORWARD;
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
case ' ':
|
case ' ':
|
||||||
result.action = Action::SCROLL_PAGE_DOWN;
|
result.action = Action::SCROLL_PAGE_DOWN;
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case 21:
|
case 21:
|
||||||
case 'b':
|
case 'b':
|
||||||
result.action = Action::SCROLL_PAGE_UP;
|
result.action = Action::SCROLL_PAGE_UP;
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case 'g':
|
case 'g':
|
||||||
buffer += 'g';
|
buffer += 'g';
|
||||||
|
|
@ -65,6 +71,7 @@ public:
|
||||||
result.action = Action::GOTO_TOP;
|
result.action = Action::GOTO_TOP;
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
}
|
}
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case 'G':
|
case 'G':
|
||||||
if (result.has_count) {
|
if (result.has_count) {
|
||||||
|
|
@ -73,27 +80,52 @@ public:
|
||||||
} else {
|
} else {
|
||||||
result.action = Action::GOTO_BOTTOM;
|
result.action = Action::GOTO_BOTTOM;
|
||||||
}
|
}
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case '/':
|
case '/':
|
||||||
mode = InputMode::SEARCH;
|
mode = InputMode::SEARCH;
|
||||||
buffer = "/";
|
buffer = "/";
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case 'n':
|
case 'n':
|
||||||
result.action = Action::SEARCH_NEXT;
|
result.action = Action::SEARCH_NEXT;
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case 'N':
|
case 'N':
|
||||||
result.action = Action::SEARCH_PREV;
|
result.action = Action::SEARCH_PREV;
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case '\t':
|
case '\t':
|
||||||
result.action = Action::NEXT_LINK;
|
result.action = Action::NEXT_LINK;
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case KEY_BTAB:
|
case KEY_BTAB:
|
||||||
case 'T':
|
case 'T':
|
||||||
result.action = Action::PREV_LINK;
|
result.action = Action::PREV_LINK;
|
||||||
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case '\n':
|
case '\n':
|
||||||
case '\r':
|
case '\r':
|
||||||
result.action = Action::FOLLOW_LINK;
|
// If count buffer has a number, jump to that link
|
||||||
|
if (result.has_count) {
|
||||||
|
result.action = Action::GOTO_LINK;
|
||||||
|
result.number = result.count;
|
||||||
|
} else {
|
||||||
|
result.action = Action::FOLLOW_LINK;
|
||||||
|
}
|
||||||
|
count_buffer.clear();
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
// 'f' command: follow link by number
|
||||||
|
if (result.has_count) {
|
||||||
|
result.action = Action::FOLLOW_LINK_NUM;
|
||||||
|
result.number = result.count;
|
||||||
|
count_buffer.clear();
|
||||||
|
} else {
|
||||||
|
// Enter link follow mode, wait for number
|
||||||
|
mode = InputMode::LINK;
|
||||||
|
buffer = "f";
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case ':':
|
case ':':
|
||||||
mode = InputMode::COMMAND;
|
mode = InputMode::COMMAND;
|
||||||
|
|
@ -190,6 +222,41 @@ public:
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InputResult process_link_mode(int ch) {
|
||||||
|
InputResult result;
|
||||||
|
result.action = Action::NONE;
|
||||||
|
|
||||||
|
if (std::isdigit(ch)) {
|
||||||
|
buffer += static_cast<char>(ch);
|
||||||
|
} else if (ch == '\n' || ch == '\r') {
|
||||||
|
// Follow the link number entered
|
||||||
|
if (buffer.length() > 1) {
|
||||||
|
try {
|
||||||
|
int link_num = std::stoi(buffer.substr(1));
|
||||||
|
result.action = Action::FOLLOW_LINK_NUM;
|
||||||
|
result.number = link_num;
|
||||||
|
} catch (...) {
|
||||||
|
set_status("Invalid link number");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mode = InputMode::NORMAL;
|
||||||
|
buffer.clear();
|
||||||
|
} else if (ch == 27) {
|
||||||
|
// ESC cancels
|
||||||
|
mode = InputMode::NORMAL;
|
||||||
|
buffer.clear();
|
||||||
|
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||||
|
if (buffer.length() > 1) {
|
||||||
|
buffer.pop_back();
|
||||||
|
} else {
|
||||||
|
mode = InputMode::NORMAL;
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
|
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
|
||||||
|
|
@ -204,6 +271,8 @@ InputResult InputHandler::handle_key(int ch) {
|
||||||
return pImpl->process_command_mode(ch);
|
return pImpl->process_command_mode(ch);
|
||||||
case InputMode::SEARCH:
|
case InputMode::SEARCH:
|
||||||
return pImpl->process_search_mode(ch);
|
return pImpl->process_search_mode(ch);
|
||||||
|
case InputMode::LINK:
|
||||||
|
return pImpl->process_link_mode(ch);
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ enum class Action {
|
||||||
NEXT_LINK,
|
NEXT_LINK,
|
||||||
PREV_LINK,
|
PREV_LINK,
|
||||||
FOLLOW_LINK,
|
FOLLOW_LINK,
|
||||||
|
GOTO_LINK, // Jump to specific link by number
|
||||||
|
FOLLOW_LINK_NUM, // Follow specific link by number (f command)
|
||||||
GO_BACK,
|
GO_BACK,
|
||||||
GO_FORWARD,
|
GO_FORWARD,
|
||||||
OPEN_URL,
|
OPEN_URL,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@ class TextRenderer::Impl {
|
||||||
public:
|
public:
|
||||||
RenderConfig config;
|
RenderConfig config;
|
||||||
|
|
||||||
|
struct LinkPosition {
|
||||||
|
int link_index;
|
||||||
|
size_t start;
|
||||||
|
size_t end;
|
||||||
|
};
|
||||||
|
|
||||||
std::vector<std::string> wrap_text(const std::string& text, int width) {
|
std::vector<std::string> wrap_text(const std::string& text, int width) {
|
||||||
std::vector<std::string> lines;
|
std::vector<std::string> lines;
|
||||||
if (text.empty()) {
|
if (text.empty()) {
|
||||||
|
|
@ -50,6 +56,151 @@ public:
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrap text with links, tracking link positions and adding link numbers
|
||||||
|
std::vector<std::pair<std::string, std::vector<LinkPosition>>>
|
||||||
|
wrap_text_with_links(const std::string& original_text, int width,
|
||||||
|
const std::vector<InlineLink>& inline_links) {
|
||||||
|
std::vector<std::pair<std::string, std::vector<LinkPosition>>> result;
|
||||||
|
|
||||||
|
// If no links, use simple wrapping
|
||||||
|
if (inline_links.empty()) {
|
||||||
|
auto wrapped = wrap_text(original_text, width);
|
||||||
|
for (const auto& line : wrapped) {
|
||||||
|
result.push_back({line, {}});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build modified text with link numbers inserted
|
||||||
|
std::string text;
|
||||||
|
std::vector<InlineLink> modified_links;
|
||||||
|
size_t text_pos = 0;
|
||||||
|
|
||||||
|
for (const auto& link : inline_links) {
|
||||||
|
// Add text before link
|
||||||
|
if (link.start_pos > text_pos) {
|
||||||
|
text += original_text.substr(text_pos, link.start_pos - text_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add link text with number indicator
|
||||||
|
size_t link_start_in_modified = text.length();
|
||||||
|
std::string link_text = original_text.substr(link.start_pos, link.end_pos - link.start_pos);
|
||||||
|
std::string link_indicator = "[" + std::to_string(link.link_index) + "]";
|
||||||
|
text += link_text + link_indicator;
|
||||||
|
|
||||||
|
// Store modified link position (including the indicator)
|
||||||
|
InlineLink mod_link = link;
|
||||||
|
mod_link.start_pos = link_start_in_modified;
|
||||||
|
mod_link.end_pos = text.length();
|
||||||
|
modified_links.push_back(mod_link);
|
||||||
|
|
||||||
|
text_pos = link.end_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining text after last link
|
||||||
|
if (text_pos < original_text.length()) {
|
||||||
|
text += original_text.substr(text_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split text into words
|
||||||
|
std::vector<std::string> words;
|
||||||
|
std::vector<size_t> word_positions;
|
||||||
|
|
||||||
|
size_t pos = 0;
|
||||||
|
while (pos < text.length()) {
|
||||||
|
// Skip whitespace
|
||||||
|
while (pos < text.length() && std::isspace(text[pos])) {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
if (pos >= text.length()) break;
|
||||||
|
|
||||||
|
// Extract word
|
||||||
|
size_t word_start = pos;
|
||||||
|
while (pos < text.length() && !std::isspace(text[pos])) {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
words.push_back(text.substr(word_start, pos - word_start));
|
||||||
|
word_positions.push_back(word_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lines
|
||||||
|
std::string current_line;
|
||||||
|
std::vector<LinkPosition> current_links;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < words.size(); ++i) {
|
||||||
|
const auto& word = words[i];
|
||||||
|
size_t word_pos = word_positions[i];
|
||||||
|
|
||||||
|
bool can_fit = current_line.empty()
|
||||||
|
? word.length() <= static_cast<size_t>(width)
|
||||||
|
: current_line.length() + 1 + word.length() <= static_cast<size_t>(width);
|
||||||
|
|
||||||
|
if (!can_fit && !current_line.empty()) {
|
||||||
|
// Save current line
|
||||||
|
result.push_back({current_line, current_links});
|
||||||
|
current_line.clear();
|
||||||
|
current_links.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add word to current line
|
||||||
|
if (!current_line.empty()) {
|
||||||
|
current_line += " ";
|
||||||
|
}
|
||||||
|
size_t word_start_in_line = current_line.length();
|
||||||
|
current_line += word;
|
||||||
|
|
||||||
|
// Check if this word overlaps with any links
|
||||||
|
for (const auto& link : modified_links) {
|
||||||
|
size_t word_end = word_pos + word.length();
|
||||||
|
|
||||||
|
// Check for overlap
|
||||||
|
if (word_pos < link.end_pos && word_end > link.start_pos) {
|
||||||
|
// Calculate link position in current line
|
||||||
|
size_t link_start_in_line = word_start_in_line;
|
||||||
|
if (link.start_pos > word_pos) {
|
||||||
|
link_start_in_line += (link.start_pos - word_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t link_end_in_line = word_start_in_line + word.length();
|
||||||
|
if (link.end_pos < word_end) {
|
||||||
|
link_end_in_line -= (word_end - link.end_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if link already added
|
||||||
|
bool already_added = false;
|
||||||
|
for (auto& existing : current_links) {
|
||||||
|
if (existing.link_index == link.link_index) {
|
||||||
|
// Extend existing link range
|
||||||
|
existing.end = link_end_in_line;
|
||||||
|
already_added = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!already_added) {
|
||||||
|
LinkPosition lp;
|
||||||
|
lp.link_index = link.link_index;
|
||||||
|
lp.start = link_start_in_line;
|
||||||
|
lp.end = link_end_in_line;
|
||||||
|
current_links.push_back(lp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last line
|
||||||
|
if (!current_line.empty()) {
|
||||||
|
result.push_back({current_line, current_links});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.empty()) {
|
||||||
|
result.push_back({"", {}});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
std::string add_indent(const std::string& text, int indent) {
|
std::string add_indent(const std::string& text, int indent) {
|
||||||
return std::string(indent, ' ') + text;
|
return std::string(indent, ' ') + text;
|
||||||
}
|
}
|
||||||
|
|
@ -167,18 +318,38 @@ std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int sc
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto wrapped_lines = pImpl->wrap_text(elem.text, content_width - prefix.length());
|
auto wrapped_with_links = pImpl->wrap_text_with_links(elem.text,
|
||||||
for (size_t i = 0; i < wrapped_lines.size(); ++i) {
|
content_width - prefix.length(),
|
||||||
|
elem.inline_links);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < wrapped_with_links.size(); ++i) {
|
||||||
|
const auto& [line_text, link_positions] = wrapped_with_links[i];
|
||||||
RenderedLine line;
|
RenderedLine line;
|
||||||
|
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
line.text = std::string(margin, ' ') + prefix + wrapped_lines[i];
|
line.text = std::string(margin, ' ') + prefix + line_text;
|
||||||
} else {
|
} else {
|
||||||
line.text = std::string(margin + prefix.length(), ' ') + wrapped_lines[i];
|
line.text = std::string(margin + prefix.length(), ' ') + line_text;
|
||||||
}
|
}
|
||||||
|
|
||||||
line.color_pair = color;
|
line.color_pair = color;
|
||||||
line.is_bold = bold;
|
line.is_bold = bold;
|
||||||
line.is_link = false;
|
|
||||||
line.link_index = -1;
|
// Store link information
|
||||||
|
if (!link_positions.empty()) {
|
||||||
|
line.is_link = true;
|
||||||
|
line.link_index = link_positions[0].link_index; // Primary link for Tab navigation
|
||||||
|
|
||||||
|
// Adjust link positions for margin and prefix
|
||||||
|
size_t offset = (i == 0) ? (margin + prefix.length()) : (margin + prefix.length());
|
||||||
|
for (const auto& lp : link_positions) {
|
||||||
|
line.link_ranges.push_back({lp.start + offset, lp.end + offset});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line.is_link = false;
|
||||||
|
line.link_index = -1;
|
||||||
|
}
|
||||||
|
|
||||||
lines.push_back(line);
|
lines.push_back(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,7 +369,8 @@ std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int sc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!doc.links.empty() && pImpl->config.show_link_indicators) {
|
// Don't show separate links section if inline links are displayed
|
||||||
|
if (!doc.links.empty() && !pImpl->config.show_link_indicators) {
|
||||||
RenderedLine separator;
|
RenderedLine separator;
|
||||||
std::string sepline(content_width, '-');
|
std::string sepline(content_width, '-');
|
||||||
separator.text = std::string(margin, ' ') + sepline;
|
separator.text = std::string(margin, ' ') + sepline;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ struct RenderedLine {
|
||||||
bool is_bold;
|
bool is_bold;
|
||||||
bool is_link;
|
bool is_link;
|
||||||
int link_index;
|
int link_index;
|
||||||
|
std::vector<std::pair<size_t, size_t>> link_ranges; // (start, end) positions of links in this line
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RenderConfig {
|
struct RenderConfig {
|
||||||
|
|
@ -19,7 +20,7 @@ struct RenderConfig {
|
||||||
int margin_left = 0;
|
int margin_left = 0;
|
||||||
bool center_content = true;
|
bool center_content = true;
|
||||||
int paragraph_spacing = 1;
|
int paragraph_spacing = 1;
|
||||||
bool show_link_indicators = true;
|
bool show_link_indicators = false; // Set to false to show inline links by default
|
||||||
};
|
};
|
||||||
|
|
||||||
class TextRenderer {
|
class TextRenderer {
|
||||||
|
|
|
||||||
24
test_inline_links.html
Normal file
24
test_inline_links.html
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Test Inline Links</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Test Page for Inline Links</h1>
|
||||||
|
|
||||||
|
<p>This is a paragraph with an <a href="https://example.com">inline link</a> in the middle of the text. You should be able to see the link highlighted directly in the text.</p>
|
||||||
|
|
||||||
|
<p>Here is another paragraph with multiple links: <a href="https://google.com">Google</a> and <a href="https://github.com">GitHub</a> are both popular websites.</p>
|
||||||
|
|
||||||
|
<p>This paragraph has a longer link text: <a href="https://en.wikipedia.org">Wikipedia is a free online encyclopedia</a> that anyone can edit.</p>
|
||||||
|
|
||||||
|
<h2>More Examples</h2>
|
||||||
|
|
||||||
|
<p>Press Tab to navigate between links, and Enter to follow them. The links should be <a href="https://example.com/test1">highlighted</a> directly in the text, not listed separately at the bottom.</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>List item with <a href="https://news.ycombinator.com">Hacker News</a></li>
|
||||||
|
<li>Another item with <a href="https://reddit.com">Reddit</a></li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue