let there be a text viewer

This commit is contained in:
Lephenixnoir 2023-05-23 01:05:19 +02:00
commit 5c12cb5653
Signed by: Lephenixnoir
GPG Key ID: 1BBA026E13FC0495
17 changed files with 907 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
# Build files
/build-fx
/build-cg
/build-cg-push
/*.g1a
/*.g3a
# Python bytecode
__pycache__/
# Common IDE files
*.sublime-project
*.sublime-workspace
.vscode

26
CMakeLists.txt Normal file
View File

@ -0,0 +1,26 @@
# Configure with [fxsdk build-fx] or [fxsdk build-cg], which provide the
# toolchain file and module path of the fxSDK
cmake_minimum_required(VERSION 3.15)
project(CGTextViewer)
include(GenerateG3A)
include(Fxconv)
find_package(Gint 2.9 REQUIRED)
find_package(JustUI 1.3 REQUIRED)
set(SOURCES
src/main.c
src/vtext.c)
set(ASSETS
assets-cg/uf5x7
assets-cg/uf8x9)
fxconv_declare_assets(${ASSETS} WITH_METADATA)
add_executable(myaddin ${SOURCES} ${ASSETS})
target_compile_options(myaddin PRIVATE -Wall -Wextra -Os)
target_link_libraries(myaddin JustUI::JustUI Gint::Gint)
generate_g3a(TARGET myaddin OUTPUT "TextView.g3a"
NAME "TextViewer" ICONS assets-cg/icon-uns.png assets-cg/icon-sel.png)

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# UTF-8 Text Viewer for fx-CG
A basic text viewer programmed in an evening — expect a correspondingly polished experience!
![](screenshots/textviewer.gif)
Features:
* View UTF-8 files
* Word wrapping: no wrapping, by character, by word
* Two fonts: proportional 8x9 and fixed-width 5x7
* Basic search function to get around the document
**How search works**
It searches from the second line visible on screen to the end of the document, and if the requested search key is found, it brings the line where the key occurs to the top of the screen. Otherwise, it searches again from the start.

View File

@ -0,0 +1,16 @@
uf5x7:
type: font
name: uf5x7
charset: unicode
grid.size: 5x7
grid.padding: 1
uf8x9:
type: font
name: uf8x9
charset: unicode
grid.size: 8x11
grid.padding: 1
proportional: true
height: 9

BIN
assets-cg/icon-cg.xcf Normal file

Binary file not shown.

BIN
assets-cg/icon-sel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets-cg/icon-uns.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
assets-cg/uf5x7 Symbolic link
View File

@ -0,0 +1 @@
../../unicode-fonts/uf5x7

1
assets-cg/uf8x9 Symbolic link
View File

@ -0,0 +1 @@
../../unicode-fonts/uf8x9

BIN
screenshots/fileselect.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
screenshots/search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
screenshots/start.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
screenshots/textview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
screenshots/textviewer.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

381
src/main.c Normal file
View File

@ -0,0 +1,381 @@
#include <gint/display.h>
#include <gint/keyboard.h>
#include <gint/gint.h>
#include <justui/jscene.h>
#include <justui/jlabel.h>
#include <justui/jfkeys.h>
#include <justui/jinput.h>
#include <justui/jfileselect.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include "vtext.h"
#define ENABLE_USB 1
#ifdef ENABLE_USB
#include <gint/usb.h>
#include <gint/usb-ff-bulk.h>
#endif
extern font_t uf5x7;
extern font_t uf8x9;
// Global application data //
struct source {
/* Status (0 if everything is fine, error number otherwise) */
int status;
/* File descriptor (usually file is closed after loading and this is -1) */
int fd;
/* File path (owned by this structure) */
char *path;
/* Full data loaded to memory */
// TODO: Load partial files?
char *data;
/* General file information */
int size;
int lines;
};
struct search_results {
/* Source buffer that this indexes in. */
struct source *source;
/* Offset of occurrences, depending on the size of the file */
union {
uint16_t *occs16;
uint32_t *occs32;
};
};
struct {
/* GUI elements */
jscene *scene;
jlabel *title;
jlabel *status;
jfkeys *fkeys;
jfileselect *fileselect;
jinput *search_input;
vtext *viewer;
/* Current open file. */
struct source source;
/* Search results. */
struct search_results *search_results;
/* Current view. */
int view;
/* Current position in the menus. */
// int fkmenu;
} app;
enum {
VIEW_FILESELECT,
VIEW_TEXT,
VIEW_SEARCH,
};
// General UI update functions //
static void fkeys_sprintf(jfkeys *fk, char const *fmt, ...)
{
static char str[128];
va_list args;
va_start(args, fmt);
vsnprintf(str, sizeof str, fmt, args);
va_end(args);
jfkeys_set(fk, str);
}
static void update_fkeys(void)
{
char wrap[16];
if(!app.viewer)
strcpy(wrap, "");
else if(app.viewer->wrap == VTEXT_WRAP_NONE)
strcpy(wrap, "@NOWRAP");
else if(app.viewer->wrap == VTEXT_WRAP_CHAR)
strcpy(wrap, "@WRAP(C)");
else if(app.viewer->wrap == VTEXT_WRAP_WORD)
strcpy(wrap, "@WRAP(W)");
if(app.view == VIEW_FILESELECT) {
jfkeys_set(app.fkeys, "/OPEN;;;;;");
}
else if(app.view == VIEW_TEXT) {
fkeys_sprintf(app.fkeys, "/OPEN;/SEARCH;%s;@FONT;;", wrap);
}
else if(app.view == VIEW_SEARCH) {
fkeys_sprintf(app.fkeys, "/OPEN;#SEARCH;%s;@FONT;;", wrap);
}
}
static void update_status(void)
{
struct source *s = &app.source;
if(!s->path) {
jwidget_set_visible(app.status, false);
jlabel_set_text(app.title, "Text Viewer");
return;
}
jwidget_set_visible(app.status, true);
jlabel_asprintf(app.title, "%s - Text Viewer", s->path);
if(s->status) {
jlabel_asprintf(app.status, "Error %d!", s->status);
}
else {
jlabel_asprintf(app.status, "vlines %d--%d/%d [%d] (%d lines, %d B)",
app.viewer->scroll + 1,
app.viewer->scroll + app.viewer->visible_lines,
app.viewer->virtual_lines,
app.viewer->max_scroll,
s->lines, s->size);
}
}
static void switch_to_view(int view)
{
if(view == VIEW_FILESELECT) {
jscene_show_and_focus(app.scene, app.fileselect);
}
if(view == VIEW_TEXT) {
jscene_show_and_focus(app.scene, app.viewer);
}
if(view == VIEW_SEARCH) {
jscene_show_and_focus(app.scene, app.search_input);
}
else {
jwidget_set_visible(app.search_input, false);
}
app.view = view;
update_fkeys();
update_status();
}
static void source_analyze(struct source *source)
{
source->lines = 1;
for(int i = 0; i < source->size; i++)
source->lines += (source->data[i] == '\n');
// TODO: Check if source is valid UTF-8
}
static int source_open(struct source *source, char const *path)
{
memset(source, 0, sizeof *source);
int fd = open(path, O_RDONLY);
if(fd < 0)
return fd;
int size = lseek(fd, 0, SEEK_END);
lseek(fd, 0, SEEK_SET);
char *data = malloc(size + 1);
if(!data) {
close(fd);
return -ENOMEM;
}
int rc = read(fd, data, size);
if(rc < 0) {
close(fd);
return rc;
}
if(rc < size) {
close(fd);
return -999;
}
data[size] = 0;
close(fd);
source->fd = -1;
source->path = strdup(path);
source->data = data;
source->size = size;
source_analyze(source);
return 0;
}
static void source_close(struct source *source)
{
free(source->path);
free(source->data);
memset(source, 0, sizeof *source);
}
void txt_view(void)
{
memset(&app, 0, sizeof app);
//
jscene *scene = jscene_create_fullscreen(NULL);
jlabel *title = jlabel_create("Text Viewer", scene);
jwidget *stack = jwidget_create(scene);
jfkeys *fkeys = jfkeys_create(";;;;;",scene);
if(!scene || !title || !stack || !fkeys) {
jwidget_destroy(scene);
return;
}
app.scene = scene;
app.title = title;
app.fkeys = fkeys;
jwidget_set_background(title, C_BLACK);
jlabel_set_text_color(title, C_WHITE);
jwidget_set_stretch(title, 1, 0, false);
jwidget_set_padding(title, 3, 6, 3, 6);
jlayout_set_vbox(scene)->spacing = 3;
jlayout_set_stack(stack);
jwidget_set_padding(stack, 0, 6, 0, 6);
jwidget_set_stretch(stack, 1, 1, false);
// Main tab //
jwidget *tab_viewer = jwidget_create(NULL);
vtext *viewer = vtext_create(tab_viewer);
jinput *search_input = jinput_create("Search: ", 40, tab_viewer);
jlabel *status = jlabel_create("", tab_viewer);
app.viewer = viewer;
app.search_input = search_input;
app.status = status;
jwidget_set_margin(viewer, 2, 0, 0, 0);
jwidget_set_stretch(viewer, 1, 1, false);
vtext_set_line_spacing(viewer, 3);
vtext_set_font(viewer, &uf8x9);
jwidget_set_stretch(status, 1, 0, false);
jwidget_set_padding(status, 2, 0, 0, 0);
jwidget_set_borders(status, J_BORDER_SOLID, C_BLACK, 1, 0, 0, 0);
jwidget_set_stretch(search_input, 1, 0, false);
jwidget_set_visible(search_input, false);
jlayout_set_vbox(tab_viewer)->spacing = 3;
jwidget_add_child(stack, tab_viewer);
jwidget_set_stretch(tab_viewer, 1, 1, false);
// File selection tab //
jfileselect *fileselect = jfileselect_create(stack);
jfileselect_set_show_file_size(fileselect, true);
jwidget_set_stretch(fileselect, 1, 1, false);
app.fileselect = fileselect;
// Initial state //
switch_to_view(VIEW_TEXT);
// Event handling //
int key = 0;
while(1) {
jevent e = jscene_run(scene);
if(e.type == JSCENE_PAINT) {
dclear(C_WHITE);
jscene_render(scene);
dupdate();
}
if(e.type == JINPUT_VALIDATED) {
char const *key = jinput_value(search_input);
if(*key) {
/* Don't switch to text view so that we can keep searching */
vtext_scroll_to_substring(viewer, key);
}
else {
switch_to_view(VIEW_TEXT);
}
}
if(e.type == JINPUT_CANCELED) {
switch_to_view(VIEW_TEXT);
}
if(e.type == JFILESELECT_LOADED) {
char const *path = jfileselect_current_folder(fileselect);
jlabel_asprintf(title, "Browsing: %s", path ? path : "(nil)");
}
if(e.type == JFILESELECT_CANCELED) {
switch_to_view(VIEW_TEXT);
}
if(e.type == JFILESELECT_VALIDATED) {
char const *file = jfileselect_selected_file(fileselect);
if(file) {
source_close(&app.source);
app.source.status = source_open(&app.source, file);
if(!app.source.status) {
vtext_set_source(viewer, app.source.data, app.source.size);
}
switch_to_view(VIEW_TEXT);
}
}
if(e.type == VTEXT_CHANGED) {
update_status();
}
// Fkey menu navigation //
if(e.type != JSCENE_KEY || e.key.type == KEYEV_UP) continue;
key = e.key.key;
#if ENABLE_USB
if(key == KEY_OPTN && e.key.shift && e.key.alpha) {
if(!usb_is_open()) {
usb_interface_t const *intf[] = { &usb_ff_bulk, NULL };
usb_open(intf, GINT_CALL_NULL);
usb_open_wait();
}
usb_fxlink_screenshot(true);
}
#endif
if(jscene_focused_widget(scene) == search_input)
continue;
if(key == KEY_F1) {
jfileselect_set_saveas(fileselect, false);
jfileselect_browse(fileselect, "/");
switch_to_view(VIEW_FILESELECT);
}
else if(key == KEY_F2 && app.source.data) {
jinput_clear(search_input);
switch_to_view(VIEW_SEARCH);
}
else if(key == KEY_F3 && app.source.data) {
if(viewer->wrap == VTEXT_WRAP_NONE)
vtext_set_word_wrapping(viewer, VTEXT_WRAP_CHAR);
else if(viewer->wrap == VTEXT_WRAP_CHAR)
vtext_set_word_wrapping(viewer, VTEXT_WRAP_WORD);
else if(viewer->wrap == VTEXT_WRAP_WORD)
vtext_set_word_wrapping(viewer, VTEXT_WRAP_NONE);
update_fkeys();
}
else if(key == KEY_F4) {
if(viewer->font == &uf8x9)
vtext_set_font(viewer, &uf5x7);
else
vtext_set_font(viewer, &uf8x9);
}
}
jwidget_destroy(scene);
}
int main(void)
{
txt_view();
return 1;
}

386
src/vtext.c Normal file
View File

@ -0,0 +1,386 @@
#include "vtext.h"
#include <justui/jwidget.h>
#include <justui/jwidget-api.h>
#include <justui/jscene.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Type identifier for vtext */
static int vtext_type_id = -1;
/* Events */
uint16_t VTEXT_CHANGED;
vtext *vtext_create(jwidget *parent)
{
if(vtext_type_id < 0)
return NULL;
vtext *t = malloc(sizeof *t);
if(!t)
return NULL;
jwidget_init(&t->widget, vtext_type_id, parent);
t->data = NULL;
t->size = 0;
t->wrap = VTEXT_WRAP_WORD;
t->scroll = 0;
t->horiz_scroll = 0;
t->virtual_lines = 0;
t->vline_offsets = NULL;
t->visible_lines = 0;
t->line_spacing = 0;
t->font = dfont_default();
return t;
}
static char const *word_boundary(char const *start, char const *cursor, bool
look_ahead)
{
char const *str = cursor;
/* Look for a word boundary behind the cursor */
while(1) {
/* Current position is end-of-string: suitable */
if(*str == 0) return str;
/* Current position is start of string: bad */
if(str <= start) break;
/* Look for heteregoneous neighboring characters */
int space_l = (str[-1] == ' ' || str[-1] == '\n');
int space_r = (str[0] == ' ' || str[0] == '\n');
if(!space_l && space_r) return str;
str--;
}
/* If we can't look ahead, return the starting position to force a cut */
if(!look_ahead) return cursor;
str++;
/* Otherwise, look ahead */
while(*str) {
int space_l = (str[-1] == ' ' || str[-1] == '\n');
int space_r = (str[0] == ' ' || str[0] == '\n');
if(!space_l && space_r) return str;
str++;
}
/* If there's really nothing, return end-of-string */
return str;
}
static char const *next_line(char const *text, int width, font_t const *font,
vtext_wrap wrap, int *chars_on_this_line)
{
char const *endline = text;
while(*endline && *endline != '\n')
endline++;
char const *endscreen = NULL;
if(wrap == VTEXT_WRAP_CHAR || wrap == VTEXT_WRAP_WORD) {
endscreen = drsize(text, font, width, NULL);
if(wrap == VTEXT_WRAP_WORD) {
endscreen = word_boundary(text, endscreen, false);
while(*endscreen == ' ')
endscreen++;
}
}
if(!endscreen || endline <= endscreen) {
if(chars_on_this_line)
*chars_on_this_line = endline - text;
return endline + (*endline != 0);
}
else {
if(chars_on_this_line)
*chars_on_this_line = endscreen - text;
return endscreen + (*endscreen && endscreen == text);
}
}
static void update_after_layout(vtext *t)
{
int ch = jwidget_content_height(t);
int line_height = t->font->line_height + t->line_spacing;
int visible_lines = (ch + t->line_spacing) / line_height;
t->visible_lines = visible_lines;
}
static void update_after_content_change(vtext *t)
{
if(!t || !t->data)
return;
char const *data = t->data;
t->virtual_lines = 0;
int cw = jwidget_content_width(t) - 4;
while(*data) {
t->virtual_lines++;
data = next_line(data, cw, t->font, t->wrap, NULL);
}
t->vline_offsets = malloc(t->virtual_lines * sizeof *t->vline_offsets);
if(!t->vline_offsets) {
t->data = NULL;
t->size = 0;
return;
}
data = t->data;
int i = 0;
while(*data) {
t->vline_offsets[i] = data - t->data;
i++;
data = next_line(data, cw, t->font, t->wrap, NULL);
}
t->max_scroll = max(t->virtual_lines - t->visible_lines, 0);
}
/* Adjust positioning to make sure we stay on legal values */
static void shake(vtext *t)
{
vtext_scroll_to(t, t->scroll);
if(t->wrap != VTEXT_WRAP_NONE)
t->horiz_scroll = 0;
}
static void emit_changed(vtext *t)
{
jwidget_emit(t, (jevent){ .type = VTEXT_CHANGED });
}
bool vtext_scroll_to(vtext *t, int target_scroll)
{
target_scroll = max(0, min(target_scroll, t->max_scroll));
if(t->scroll == target_scroll)
return false;
t->scroll = target_scroll;
t->widget.update = true;
emit_changed(t);
return true;
}
static void const *memmem(void const *haystack, size_t haystacklen,
void const *needle, size_t needlelen)
{
if(haystacklen < needlelen)
return NULL;
for(size_t i = 0; i < haystacklen - needlelen; i++) {
if(!memcmp(haystack + i, needle, needlelen))
return haystack + i;
}
return NULL;
}
static bool scroll_to_substring_within(vtext *t, char const *substring,
int start, int length)
{
length = min(length, t->size - start);
char const *occ = memmem(t->data + start, length, substring,
strlen(substring));
if(!occ)
return false;
/* Find nearest line with lazy linear search */
int s = 0;
s = 0;
while(s + 1 < t->virtual_lines &&
(int)t->vline_offsets[s + 1] <= occ - t->data) {
s++;
}
vtext_scroll_to(t, s);
return true;
}
bool vtext_scroll_to_substring(vtext *t, char const *key)
{
int offset = (t->scroll + 1 < t->virtual_lines)
? (int)t->vline_offsets[t->scroll + 1]
: t->size;
if(scroll_to_substring_within(t, key, offset, t->size - offset))
return true;
if(scroll_to_substring_within(t, key, 0, offset + strlen(key)-1))
return true;
return false;
}
void vtext_set_source(vtext *t, char const *data, int size)
{
t->data = data;
t->size = size;
t->scroll = 0;
t->horiz_scroll = 0;
update_after_content_change(t);
t->widget.update = true;
}
// Trivial properties //
void vtext_set_font(vtext *t, font_t const *font)
{
t->font = font ? font : dfont_default();
update_after_layout(t);
update_after_content_change(t);
shake(t);
t->widget.update = true;
}
void vtext_set_line_spacing(vtext *t, int line_spacing)
{
t->line_spacing = line_spacing;
t->widget.update = true;
update_after_layout(t);
shake(t);
}
void vtext_set_word_wrapping(vtext *t, vtext_wrap word_wrapping)
{
t->wrap = word_wrapping;
update_after_content_change(t);
shake(t);
t->widget.update = true;
}
// Polymorphic widget operations //
static void vtext_poly_csize(void *t0)
{
vtext *t = t0;
jwidget *w = &t->widget;
w->w = 128;
w->h = 64;
}
static void vtext_poly_layout(void *t0)
{
vtext *t = t0;
update_after_layout(t);
shake(t);
}
static void vtext_poly_render(void *t0, int x, int y)
{
vtext *t = t0;
font_t const *old_font = dfont(t->font);
if(!t->data) {
dtext(x, y, C_BLACK, "(No file opened)");
dfont(old_font);
return;
}
int line_height = t->font->line_height + t->line_spacing;
struct dwindow w = {
.left = x,
.top = y,
.right = x + jwidget_content_width(t) - 4,
.bottom = y + jwidget_content_height(t),
};
struct dwindow oldw = dwindow_set(w);
for(int i = 0; i < t->visible_lines; i++) {
int line = t->scroll + i;
int line_y = y + line_height * i;
if(line >= t->virtual_lines)
continue;
char const *curr_line = t->data + t->vline_offsets[line];
char const *end_line = (line + 1 >= t->virtual_lines)
? t->data + t->size
: t->data + t->vline_offsets[line + 1];
dtext_opt(x - t->horiz_scroll, line_y,
C_BLACK, C_NONE, DTEXT_LEFT, DTEXT_TOP,
curr_line, end_line - curr_line);
}
dwindow_set(oldw);
if(t->visible_lines < t->virtual_lines) {
int h_total = jwidget_content_height(t);
int h_bar = (t->visible_lines * h_total) / t->virtual_lines;
int y_bar = (t->scroll * h_total) / t->virtual_lines;
int x_bar = jwidget_content_width(t) - 2;
drect(x+x_bar, y+y_bar, x+x_bar+1, y+y_bar+h_bar-1, C_BLACK);
}
dfont(old_font);
}
static bool vtext_poly_event(void *t0, jevent ev)
{
vtext *t = t0;
if(ev.type != JSCENE_KEY || ev.key.type == KEYEV_UP)
return false;
// Movement //
int key = ev.key.key;
int move_speed = (ev.key.alpha ? t->visible_lines : 1);
if(key == KEY_UP && ev.key.shift) {
vtext_scroll_to(t, 0);
return true;
}
else if(key == KEY_UP) {
vtext_scroll_to(t, t->scroll - move_speed);
return true;
}
if(key == KEY_DOWN && ev.key.shift) {
vtext_scroll_to(t, t->virtual_lines /* auto adjust */);
return true;
}
else if(key == KEY_DOWN) {
vtext_scroll_to(t, t->scroll + move_speed);
return true;
}
if(key == KEY_RIGHT && t->wrap == VTEXT_WRAP_NONE) {
t->horiz_scroll += 32;
t->widget.update = true;
return true;
}
else if(key == KEY_LEFT && t->wrap == VTEXT_WRAP_NONE) {
t->horiz_scroll = max(0, t->horiz_scroll - 32);
t->widget.update = true;
return true;
}
return false;
}
/* vtext type definiton */
static jwidget_poly type_vtext = {
.name = "vtext",
.csize = vtext_poly_csize,
.layout = vtext_poly_layout,
.render = vtext_poly_render,
.event = vtext_poly_event,
};
__attribute__((constructor(1004)))
static void j_register_vtext(void)
{
vtext_type_id = j_register_widget(&type_vtext, "jwidget");
VTEXT_CHANGED = j_register_event();
}

67
src/vtext.h Normal file
View File

@ -0,0 +1,67 @@
// vtext: A text visualization widget
//
// This is a trivial widget, just displays line-by-line with or without line
// wrapping.
#pragma once
#include <justui/defs.h>
#include <justui/jwidget.h>
#include <gint/display.h>
/* Wrapping modes */
typedef enum {
VTEXT_WRAP_NONE,
VTEXT_WRAP_CHAR,
VTEXT_WRAP_WORD,
} vtext_wrap;
/* vtext: Text visualization widget */
typedef struct {
jwidget widget;
/* Current data block and size */
char const *data;
int size;
/* Word wrapping mode */
vtext_wrap wrap;
/* Current scroll offset, in bytes */
int scroll;
/* Current horizontal offset, in pixels */
int horiz_scroll;
/* Number of virtual lines (depends on wrapping mode) */
int virtual_lines;
/* Maximum scroll */
int max_scroll;
/* Virtual line offsets */
uint32_t *vline_offsets;
/* Number of visible lines */
int8_t visible_lines;
/* Additional spacing between lines, in pixels */
int8_t line_spacing;
/* Rendering font */
font_t const *font;
} vtext;
/* Type IDs */
extern uint16_t VTEXT_CHANGED;
/* vtext_create: Create a new text visualizer */
vtext *vtext_create(jwidget *parent);
/* Set a new data source. The viewer does not own the data. */
void vtext_set_source(vtext *t, char const *data, int size);
/* Scroll to a given (top) virtual line position. */
bool vtext_scroll_to(vtext *t, int top_vline);
/* Search a string and go to the next occurrence. */
bool vtext_scroll_to_substring(vtext *t, char const *substring);
/* Trivial properties */
void vtext_set_font(vtext *e, font_t const *font);
void vtext_set_line_spacing(vtext *e, int line_spacing);
void vtext_set_word_wrapping(vtext *e, vtext_wrap word_wrapping);