Add accessibility support
Alpine 3.24 Success
Alpine 3.24 Flatpak Success
Arch Linux AUR Success

It's not very pretty code, but at least it's somewhat contained,
and it works at least on Windows (Fluent Search) and macOS (Shortcat).

It's probably all wrong for disabled users, yet better than nothing.

Once Qt goes out, this goes out.  It's tightly coupled to it.
This commit is contained in:
p
2026-09-17 07:33:12 +02:00
parent 5233860a5a
commit e468a4915b
25 changed files with 5938 additions and 126 deletions
+2
View File
@@ -15,3 +15,5 @@ AlignOperands: DontAlign
SpacesBeforeTrailingComments: 2
AttributeMacros: ['DAWN_FORMAT']
WhitespaceSensitiveMacros: ['G_DEFINE_QUARK']
ForEachMacros: [
'wl_list_for_each', 'wl_list_for_each_safe', 'wl_list_for_each_reverse']
+29
View File
@@ -237,6 +237,34 @@ endif()
option(DAWN_WITH_WIP "Expose unfinished subapplications" OFF)
# Qt states its features in a generated header rather than in its CMake package.
include(CheckCXXSourceCompiles)
block()
set(CMAKE_REQUIRED_LIBRARIES Qt6::Gui)
check_cxx_source_compiles([[
#include <QtGui/qtguiglobal.h>
#if !QT_CONFIG(accessibility)
#error Qt has no accessibility support
#endif
#if defined Q_OS_UNIX && !defined Q_OS_MACOS
#if !QT_CONFIG(accessibility_atspi_bridge)
#error Qt has no AT-SPI bridge
#endif
#endif
int main() { return 0; }
]] dawn_qt_accessibility)
endblock()
option(DAWN_WITH_ACCESSIBILITY "Expose the UI to assistive technologies"
"${dawn_qt_accessibility}")
if (DAWN_WITH_ACCESSIBILITY AND NOT dawn_qt_accessibility)
message(FATAL_ERROR "DAWN_WITH_ACCESSIBILITY=ON but this Qt build lacks "
"accessibility support (on Linux it also needs the AT-SPI bridge)")
elseif (NOT DAWN_WITH_ACCESSIBILITY)
message(WARNING "Building without accessibility: screen readers and "
"external UI tests will see nothing of dn's interface")
endif()
# --- Build configuration ------------------------------------------------------
# CMAKE_INSTALL_FULL_LOCALEDIR goes into dawn-config.h.
@@ -464,6 +492,7 @@ add_executable(dnthumbd dnthumbd/main.cpp)
target_link_libraries(dnthumbd PRIVATE libdn)
qt_add_executable(dn
dn/accessible.cpp
dn/action.cpp
dn/app.cpp
dn/display-profile.cpp
+1
View File
@@ -8,6 +8,7 @@
#pragma once
#cmakedefine01 DAWN_WITH_WIP
#cmakedefine01 DAWN_WITH_ACCESSIBILITY
#define DAWN_NAME "@PROJECT_NAME@"
#define DAWN_NAMESPACE "@DAWN_NAMESPACE@"
+3016
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
//
// accessible.hpp: platform accessibility over the Kit widget tree
//
// Copyright The Dawn Authors
// SPDX-License-Identifier: MPL-2.0
//
#pragma once
#include "kit.hpp"
namespace dn
{
class Window;
// Installs the interface factory. Call before the first window exists: a
// window Qt has already asked about would keep whatever it answered then.
void accessible_init();
// Kit's semantic change channel, as Window binds it.
void accessible_changed(Window *window, Change what, Widget *w);
// The window's page tree is about to be destroyed wholesale. Retires every
// interface registered for it, while the widgets are still there.
void accessible_retire_page(Window *window);
// The window itself is going, after its pages already have.
void accessible_forget_window(Window *window);
// The window title changed, and with it the name of its semantic root.
void accessible_renamed(Window *window);
// The window gained or lost the keyboard. Focus that outlives activation
// is not focus, so whatever holds it says so again either way.
void accessible_activated(Window *window);
} // namespace dn
+4 -4
View File
@@ -499,12 +499,12 @@ App::shutdown()
Qt::QueuedConnection);
}
// A Wayland shell carries the viewer as a child window; elsewhere the
// top-level is the viewer itself. Nothing here has Q_OBJECT, so qobject_cast
// and findChild() would fall back to QWindow and match anything.
static Window *
Window *
content_window(QObject *window)
{
// Null during teardown, where a shell may outlive its content by a hair.
if (!window)
return nullptr;
if (auto *win = dynamic_cast<Window *>(window))
return win;
for (QObject *child : window->children())
+5
View File
@@ -32,6 +32,11 @@ namespace dn
class Window;
// A Wayland shell carries the viewer as a child window; elsewhere the
// top-level is the viewer itself. Nothing here has Q_OBJECT, so qobject_cast
// and findChild() would fall back to QWindow and match anything.
Window *content_window(QObject *window);
enum class SettingsChange : uint8_t { Bookmarks, Preferences };
/// Process-wide user settings and categorized change notification.
+59 -29
View File
@@ -1644,6 +1644,7 @@ static int
hit_file(const Browser &b, float x, float y)
{
for (int i = 0; i < int(b.files_.size()); i++) {
// This is not supposed to test the cell, that would make no sense!
if (b.files_[size_t(i)].tile.contains(x, y))
return i;
}
@@ -1886,6 +1887,7 @@ scan_dir(Browser &b)
b.places_dirty_ = true;
if (b.dir_url_.isEmpty()) {
clear_cursor(b);
b.set_files({});
return;
}
@@ -1975,15 +1977,13 @@ scan_dir(Browser &b)
if (!o.gpu.empty())
b.sheet_.release(o.gpu);
}
b.files_ = std::move(files);
b.set_files(std::move(files));
clear_cursor(b);
if (!keep.empty()) {
for (int i = 0; i < int(b.files_.size()); i++) {
if (b.files_[size_t(i)].path == keep) {
b.cursor_ = i;
remember_cursor_x(b);
break;
}
if (const auto it = b.file_by_path_.find(keep);
it != b.file_by_path_.end()) {
b.cursor_ = it->second;
remember_cursor_x(b);
}
}
@@ -2263,9 +2263,9 @@ fill_places(Browser &b)
if (!list)
return;
// Rebuilding the sidebar is not a focus change: erase_children drops the
// pointer into the dying rows, and this puts it back on their successor,
// leaving whatever decided the ring in the first place alone.
// Rebuilding the sidebar is not the user moving the focus: erase_children
// drops the pointer into the dying rows, and this puts it back on their
// successor, leaving whatever decided the ring in the first place alone.
string restore_path;
for (const auto &item : b.place_items_) {
if (item.button == b.kit_.focus_) {
@@ -2304,7 +2304,7 @@ fill_places(Browser &b)
for (size_t i = b.place_items_.size(); i--;) {
const auto &item = b.place_items_.at(i);
if (item.path == restore_path) {
b.kit_.focus_ = item.button;
b.kit_.reseat_focus(item.button);
break;
}
}
@@ -2431,9 +2431,8 @@ apply_action(Browser &b, Action action)
request_render(b);
return true;
case Action::Activate:
if (b.cursor_ >= 0 && b.cursor_ < int(b.files_.size()) && b.page_ &&
b.page_->host && b.page_->host->activate)
b.page_->host->activate(b.file_url(b.cursor_));
if (b.cursor_ >= 0 && b.cursor_ < int(b.files_.size()))
b.activate_file(b.file_url(b.cursor_));
return true;
case Action::Reload:
if (!b.dir_url_.isEmpty()) {
@@ -2519,24 +2518,55 @@ Browser::tip() const
void
Browser::select_file(const QUrl &url)
{
clear_cursor(*this);
const string path = url_to_path(url).toStdString();
if (!path.empty()) {
for (int i = 0; i < int(this->files_.size()); i++) {
if (this->files_[size_t(i)].path == path) {
this->cursor_ = i;
remember_cursor_x(*this);
// This won't quite work if the browser
// is not currently visible.
if (const int ri = find_cursor_row(*this); ri >= 0)
scroll_to_row(*this, this->rows_[size_t(ri)]);
break;
}
}
this->select_index(this->file_index(url_to_path(url).toStdString()), true);
}
void
Browser::set_files(vector<File> files)
{
this->files_ = std::move(files);
this->file_by_path_.clear();
this->file_by_path_.reserve(this->files_.size());
for (int i = 0; i < int(this->files_.size()); i++)
this->file_by_path_.emplace(this->files_[size_t(i)].path, i);
this->file_rev_++;
}
int
Browser::file_index(const string &path) const
{
if (path.empty())
return -1;
const auto it = this->file_by_path_.find(path);
return it == this->file_by_path_.end() ? -1 : it->second;
}
void
Browser::select_index(int index, bool reveal)
{
if (index < 0 || index >= int(this->files_.size())) {
clear_cursor(*this);
request_render(*this);
return;
}
this->cursor_ = index;
remember_cursor_x(*this);
// Scrolling needs a laid-out row; a hidden browser has none yet.
if (reveal) {
if (const int ri = find_cursor_row(*this); ri >= 0)
scroll_to_row(*this, this->rows_[size_t(ri)]);
}
request_render(*this);
}
void
Browser::activate_file(const QUrl &url)
{
this->select_file(url);
if (this->page_ && this->page_->host && this->page_->host->activate)
this->page_->host->activate(url);
}
void
Browser::file_gone(const QUrl &url)
{
@@ -2683,7 +2713,7 @@ Browser::destroy()
}
this->thumb_inflight_.clear();
clear_gpu(*this);
this->files_.clear();
this->set_files({});
this->side_dirs_.clear();
this->places_ = nullptr;
this->place_items_.clear();
+12
View File
@@ -161,7 +161,14 @@ struct Browser : Widget {
bool can_prev_dir_ = false;
bool can_next_dir_ = false;
bool can_parent_dir_ = false;
// Only ever replaced through set_files(), which owns the two below.
std::vector<File> files_;
// Path to current row, rebuilt with the listing so a query does not
// walk files_ for every accessible child.
std::unordered_map<std::string, int> file_by_path_;
// Bumped only when the listing is replaced, so accessibility can skip
// membership work on ordinary frames.
uint64_t file_rev_ = 0;
std::vector<DirRow> side_dirs_;
std::unordered_map<std::string, CachedSize> size_cache_;
std::unordered_map<std::string, ThumbInflight> thumb_inflight_;
@@ -197,8 +204,13 @@ struct Browser : Widget {
void hist_clear_forward();
[[nodiscard]] bool hist_can_back() const;
[[nodiscard]] bool hist_can_forward() const;
// The listing, its path index and its revision, which only move as one.
void set_files(std::vector<File> files);
void select_file(const QUrl &url);
void select_index(int index, bool reveal);
void activate_file(const QUrl &url);
void file_gone(const QUrl &url);
[[nodiscard]] int file_index(const std::string &path) const;
[[nodiscard]] QUrl file_url(int index) const;
[[nodiscard]] BrowseSetup browse_setup() const { return this->setup_; }
void screen_changed(
+21 -38
View File
@@ -297,12 +297,13 @@ dialog_save_as(Kit &kit, Dialog &dialog, const QString &suggested,
{
auto col = make_unique<Column>();
col->gap = 8.f;
col->add_child(dialog_label(N_("Save As"), true), size_t(-1));
auto title = dialog_label(N_("Save As"), true);
auto entry = make_unique<Entry>();
Entry *field = entry.get();
field->text = suggested;
title->buddy = field;
// TODO(p): Place the caret at the end, properly.
col->add_child(std::move(title), size_t(-1));
col->add_child(std::move(entry), size_t(-1));
auto warning = make_unique<Label>();
Label *message = warning.get();
@@ -338,10 +339,11 @@ dialog_location(
{
auto col = make_unique<Column>();
col->gap = 8.f;
col->add_child(dialog_label(N_("Enter location"), true), size_t(-1));
auto title = dialog_label(N_("Enter location"), true);
auto entry = make_unique<Entry>();
Entry *field = entry.get();
title->buddy = field;
col->add_child(std::move(title), size_t(-1));
col->add_child(std::move(entry), size_t(-1));
function<void(Kit &)> submit =
@@ -688,30 +690,6 @@ col(const Colour &c, float alpha = 1.f)
return {c.r, c.g, c.b, c.a * alpha};
}
static Rect
intersection(Rect a, Rect b)
{
const int x = max(a.x, b.x);
const int y = max(a.y, b.y);
return {x, y, max(0, min(a.right(), b.right()) - x),
max(0, min(a.bottom(), b.bottom()) - y)};
}
static Rect
visible_rect(const Widget *w, Rect host)
{
if (!w || !w->shown() || w->r.empty())
return {};
Rect visible = intersection(w->r, host);
for (const Widget *p = w->parent_; p; p = p->parent_) {
if (!p->shown())
return {};
if (p->clips_children())
visible = intersection(visible, p->r);
}
return visible;
}
// Anything the keyboard can reach is worth a hint, so this asks focusable()
// rather than testing for a type: a widget opts in by being reachable at all.
// The exception is a container that is focusable as a whole -- one chip over
@@ -946,7 +924,7 @@ Hint::collect(Widget *scope)
if (f.tile.empty())
continue;
const Rect clipped = intersection(f.tile, well);
const Rect clipped = f.tile.intersect(well);
if (clipped.empty())
continue;
@@ -985,7 +963,7 @@ Hint::refresh_rects()
t.file_i >= int(t.browser->files_.size()))
continue;
const Browser::File &f = t.browser->files_[size_t(t.file_i)];
const Rect clipped = intersection(f.tile, t.browser->r);
const Rect clipped = f.tile.intersect(t.browser->r);
if (clipped.empty())
continue;
t.at = clipped;
@@ -1030,11 +1008,7 @@ Hint::fire(Kit &kit, Target t)
if (!browser || file_i < 0 || file_i >= int(browser->files_.size()))
return;
const QUrl url = browser->file_url(file_i);
browser->select_file(url);
if (browser->page_ && browser->page_->host &&
browser->page_->host->activate)
browser->page_->host->activate(url);
browser->activate_file(browser->file_url(file_i));
}
// --- Page -------------------------------------------------------------------
@@ -1089,7 +1063,11 @@ Page::Page(unique_ptr<Toolbar> tb, unique_ptr<Sidebar> sb, Side s,
app->flat = true;
app->focus_on_press = false;
app->icon = "open-menu-symbolic";
app->tip_text = "Menu";
// The action table already names this, and says what opens it from
// the keyboard; it is also what an icon-only control is read out as.
const ActionDef &def = action_def(Action::Menu);
app->tip_text = action_tip(def, false);
app->tip_accel = action_accel(def);
app->activate_on_press = true;
app->on_click = [this](Kit &kit) { open_app_menu(kit, false); };
this->app_menu_button = app.get();
@@ -1170,11 +1148,16 @@ Page::sync_app_menu()
}
void
Page::set_banner(unique_ptr<Widget> w)
Page::set_banner(Kit &kit, unique_ptr<Widget> w)
{
invalidate_measure();
if (this->banner)
if (this->banner) {
// The assignment below destroys it, and the registry the host keeps
// is keyed by live widget pointers: every destruction path has to
// go through here first, while the subtree is still whole.
kit.forget_tree(this->banner);
this->banner->parent_ = nullptr;
}
this->banner = w.get();
if (this->banner)
this->banner->parent_ = this;
+1 -1
View File
@@ -159,7 +159,7 @@ struct Page : Composite {
Page(std::unique_ptr<Toolbar> tb, std::unique_ptr<Sidebar> sb, Side side,
std::unique_ptr<Widget> body);
void set_banner(std::unique_ptr<Widget> w);
void set_banner(Kit &kit, std::unique_ptr<Widget> w);
void open_app_menu(Kit &kit, bool kbd);
void sync_app_menu();
void bind_actions(Kit &kit);
+3 -3
View File
@@ -553,8 +553,8 @@ make_crop_jpeg_page(Kit &kit, const HostActions &host, Cropper **out)
PageSetup setup;
setup.mode = Mode::CropJpeg;
setup.content = std::move(content);
setup.toolbar =
make_toolbar(kItems, [c](const ToolbarSpec &spec) -> unique_ptr<Widget> {
setup.toolbar = make_toolbar(
kItems, [c](const ToolbarSpec &spec) -> unique_ptr<Widget> {
if (spec.action == Action::CropReset) {
auto button = make_unique<Button>();
button->flat = true;
@@ -593,7 +593,7 @@ make_crop_jpeg_page(Kit &kit, const HostActions &host, Cropper **out)
auto banner = make_banner(
&c->error_label_, [c](Kit &) { c->message_dismissed_ = true; });
c->error_ = banner.get();
page->set_banner(std::move(banner));
page->set_banner(kit, std::move(banner));
return page;
}
+1 -1
View File
@@ -1883,7 +1883,7 @@ make_viewer_page(Kit &kit, const HostActions &host, Viewer **out)
setup.content = std::move(content);
auto page = make_page(kit, host, std::move(setup));
page->set_banner(std::move(error));
page->set_banner(kit, std::move(error));
if (out)
*out = v;
return page;
+190 -45
View File
@@ -701,6 +701,15 @@ Rect::inset(int px, int py) const
return {this->x + px, this->y + py, nw > 0 ? nw : 0, nh > 0 ? nh : 0};
}
Rect
Rect::intersect(Rect other) const
{
const int nx = max(this->x, other.x);
const int ny = max(this->y, other.y);
return {nx, ny, max(0, min(right(), other.right()) - nx),
max(0, min(bottom(), other.bottom()) - ny)};
}
// --- Widget ------------------------------------------------------------------
void
@@ -871,6 +880,22 @@ Widget::double_click(Kit &, float, float, Qt::MouseButton, unsigned)
return false;
}
Rect
visible_rect(const Widget *w, Rect host)
{
if (!w || !w->shown() || w->r.empty())
return {};
Rect visible = w->r.intersect(host);
for (const Widget *p = w->parent_; p; p = p->parent_) {
if (!p->shown())
return {};
if (p->clips_children())
visible = visible.intersect(p->r);
}
return visible;
}
// --- Button ------------------------------------------------------------------
static int
@@ -1284,7 +1309,7 @@ constexpr float kEntryPadY = 3.f;
// Both walk whole grapheme clusters, so that combining marks and surrogate
// pairs never get split down the middle.
static int
int
grapheme_before(const QString &text, int at)
{
if (at <= 0)
@@ -1295,7 +1320,7 @@ grapheme_before(const QString &text, int at)
return prev < 0 ? 0 : int(prev);
}
static int
int
grapheme_after(const QString &text, int at)
{
const int end = int(text.size());
@@ -1307,6 +1332,28 @@ grapheme_after(const QString &text, int at)
return next < 0 ? end : int(next);
}
int
grapheme_at_or_before(const QString &text, int at)
{
at = clamp(at, 0, int(text.size()));
QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text);
finder.setPosition(at);
if (finder.isAtBoundary())
return at;
return grapheme_before(text, at);
}
int
grapheme_at_or_after(const QString &text, int at)
{
at = clamp(at, 0, int(text.size()));
QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text);
finder.setPosition(at);
if (finder.isAtBoundary())
return at;
return grapheme_after(text, at);
}
// Qt hands us the control codes as text too; none of them are insertable.
static QString
printable_only(const QString &text)
@@ -1374,10 +1421,100 @@ Entry::rescroll(const Kit &kit)
this->scroll_ = clamp(caret_x - view * 0.5f, 0.f, text_w - view);
}
void
Entry::reveal(const Kit &kit, int start, int end)
{
const int n = int(this->text.size());
start = grapheme_at_or_before(this->text, clamp(start, 0, n));
end = grapheme_at_or_after(this->text, clamp(end, 0, n));
if (end < start) {
const int tmp = start;
start = end;
end = tmp;
}
const float text_w =
float(this->text_cache_.text_width(kit, this->text, false));
const float view = float(inner_w(kit));
if (text_w <= view) {
this->scroll_ = 0.f;
return;
}
const float left =
float(this->text_cache_.caret_x(kit, this->text, start, false));
const float right =
float(this->text_cache_.caret_x(kit, this->text, end, false));
const float from = min(left, right);
const float to = max(left, right);
if (to - from >= view) {
this->scroll_ = clamp(from, 0.f, text_w - view);
return;
}
if (from < this->scroll_)
this->scroll_ = from;
else if (to > this->scroll_ + view)
this->scroll_ = to - view;
this->scroll_ = clamp(this->scroll_, 0.f, text_w - view);
}
void
Entry::move_caret(Kit &kit, int to)
{
this->caret = clamp(to, 0, int(this->text.size()));
this->caret =
grapheme_at_or_before(this->text, clamp(to, 0, int(this->text.size())));
touch_caret(kit);
if (kit.input_method_changed)
kit.input_method_changed();
if (kit.notify)
kit.notify(Change::Text, this);
}
void
Entry::splice(Kit &kit, int start, int end, const QString &with)
{
const int n = int(this->text.size());
start = clamp(start, 0, n);
end = clamp(end, 0, n);
if (end < start)
swap(start, end);
// An empty range is an insertion point, and goes to a cluster boundary.
// A span grows to whole clusters, so that a surrogate pair or a
// combining mark is never half-removed.
if (start == end) {
start = end = grapheme_at_or_before(this->text, start);
} else {
start = grapheme_at_or_before(this->text, start);
end = grapheme_at_or_after(this->text, end);
}
// Taking nothing out and putting nothing in is not an edit: Delete at
// the end of the text would otherwise re-run on_change, and with it the
// listing the field filters, on every keypress.
if (start == end && with.isEmpty())
return;
this->text.replace(start, end - start, with);
this->caret = start + int(with.size());
// Tell the host before on_change, which may rebuild the listing around
// this field: the old string is still on the adapter, the new one here.
if (kit.notify)
kit.notify(Change::Text, this);
if (this->on_change)
this->on_change(kit);
}
void
Entry::replace(Kit &kit, int start, int end, const QString &with)
{
// Committed text ends whatever composed it, which is why the input
// method does not come through here: it has a preedit to put back, and
// tells the platform once, after it has.
this->preedit.clear();
this->preedit_caret = 0;
splice(kit, start, end, with);
touch_caret(kit);
if (kit.input_method_changed)
kit.input_method_changed();
@@ -1386,15 +1523,7 @@ Entry::move_caret(Kit &kit, int to)
void
Entry::set_text(Kit &kit, const QString &next)
{
this->text = next;
this->caret = clamp(this->caret, 0, int(this->text.size()));
this->preedit.clear();
this->preedit_caret = 0;
touch_caret(kit);
if (this->on_change)
this->on_change(kit);
if (kit.input_method_changed)
kit.input_method_changed();
replace(kit, 0, int(this->text.size()), next);
}
Size
@@ -1560,25 +1689,14 @@ Entry::key(Kit &kit, const Key &ev)
case Qt::Key_End:
move_caret(kit, int(this->text.size()));
return true;
case Qt::Key_Backspace: {
if (this->caret <= 0)
return true;
const int from = grapheme_before(this->text, this->caret);
QString next = this->text;
next.remove(from, this->caret - from);
this->caret = from;
set_text(kit, next);
case Qt::Key_Backspace:
if (this->caret > 0)
replace(
kit, grapheme_before(this->text, this->caret), this->caret, {});
return true;
}
case Qt::Key_Delete: {
const int to = grapheme_after(this->text, this->caret);
if (to <= this->caret)
return true;
QString next = this->text;
next.remove(this->caret, to - this->caret);
set_text(kit, next);
case Qt::Key_Delete:
replace(kit, this->caret, grapheme_after(this->text, this->caret), {});
return true;
}
case Qt::Key_Return:
case Qt::Key_Enter:
if (this->on_submit) {
@@ -1600,10 +1718,7 @@ Entry::key(Kit &kit, const Key &ev)
const QString insert = printable_only(ev.text);
if (insert.isEmpty())
return false;
QString next = this->text;
next.insert(this->caret, insert);
this->caret += int(insert.size());
set_text(kit, next);
replace(kit, this->caret, this->caret, insert);
return true;
}
@@ -1611,19 +1726,12 @@ bool
Entry::input_method(
Kit &kit, const QString &commit, const QString &pre, int pre_caret)
{
if (!commit.isEmpty()) {
QString next = this->text;
const QString insert = printable_only(commit);
next.insert(this->caret, insert);
this->caret += int(insert.size());
this->text = next;
}
if (const QString insert = printable_only(commit); !insert.isEmpty())
splice(kit, this->caret, this->caret, insert);
this->preedit = pre;
this->preedit_caret = clamp(pre_caret, 0, int(pre.size()));
this->caret = clamp(this->caret, 0, int(this->text.size()));
touch_caret(kit);
if (!commit.isEmpty() && this->on_change)
this->on_change(kit);
if (kit.input_method_changed)
kit.input_method_changed();
return true;
@@ -3107,9 +3215,18 @@ Menu::clear(Kit &kit)
{
// A sub is on the popup stack for as long as it is open, so dropping it
// here would leave kit.popups_ walking freed memory every frame.
// Closing takes down whatever it opened above it, but that settles the
// stack alone: the items are destroyed below without anything being
// told, which is what forgetting is for. It has to go a level at a
// time, because a popup roots its own parent chain -- an item two menus
// down is no descendant of this one, and forgetting this tree would
// walk straight past it.
for (auto &sub : this->subs_) {
if (sub)
sub->close(kit);
if (!sub)
continue;
sub->close(kit);
sub->clear(kit);
kit.forget_tree(sub.get());
}
this->subs_.clear();
if (this->col)
@@ -4483,7 +4600,8 @@ Kit::sync_focus()
void
Kit::set_focus(Widget *w, bool ring)
{
if (this->focus_ != w) {
const bool moved = this->focus_ != w;
if (moved) {
for (Widget *p = w; p; p = p->parent_) {
if (auto *column = dynamic_cast<ScrollColumn *>(p);
column && column->follow_focus)
@@ -4492,6 +4610,25 @@ Kit::set_focus(Widget *w, bool ring)
}
this->focus_ = w;
this->focus_visible_ = ring;
// Re-seating the same focus is not a focus change, and saying it was
// would have a screen reader read the control out a second time.
if (moved && this->notify)
this->notify(Change::Focus, w);
}
void
Kit::reseat_focus(Widget *w)
{
// The ring is left alone on purpose: the focus did not move because
// anybody asked it to, but because the widget that held it was rebuilt
// and this is its successor. Whoever is listening still has to hear
// it -- from the outside a different object has the keyboard than a
// moment ago, and forget_tree() has already said the old one is gone.
if (this->focus_ == w)
return;
this->focus_ = w;
if (this->notify)
this->notify(Change::Focus, w);
}
bool
@@ -4926,6 +5063,12 @@ Kit::destroy()
void
Kit::forget_tree(Widget *tree)
{
// First of all, while the subtree is still whole and still reachable
// from its parent: whoever exposed it has to retire it in that order,
// and callers forget a subtree before dropping it, never after.
if (this->notify)
this->notify(Change::Retired, tree);
auto forget = [tree](auto &target) {
for (auto *w = target; w; w = w->parent_) {
if (w == tree) {
@@ -5476,6 +5619,8 @@ Kit::frame_ui(Widget &ui, const function<void()> &placed)
sync_focus();
ui.prepare(*this);
prepare_popups();
if (this->notify)
this->notify(Change::State, nullptr);
this->hot_ = hit(this->mouse_x_, this->mouse_y_);
tooltip(this->hot_);
prepare_tooltip(*this);
+54
View File
@@ -74,6 +74,9 @@ struct Rect {
}
[[nodiscard]] bool empty() const { return this->w <= 0 || this->h <= 0; }
[[nodiscard]] Rect inset(int px, int py) const;
// The overlap, or an empty rectangle where there is none. Never
// negative: a miss is {x, y, 0, 0}, not a rectangle inside out.
[[nodiscard]] Rect intersect(Rect other) const;
bool operator==(const Rect &) const = default;
};
@@ -261,6 +264,12 @@ struct Widget {
void paint_children(Kit &kit) const;
};
// The part of w that is really on screen: clipped by host and by every
// clipping ancestor, and empty when anything in the chain is hidden. The
// hint overlay and the accessibility adapters both ask this, and the two
// must not disagree about what a user can see.
[[nodiscard]] Rect visible_rect(const Widget *w, Rect host);
struct Composite : Widget {
std::vector<std::unique_ptr<Widget>> kids;
@@ -351,6 +360,14 @@ struct Label : Widget {
QString tip_key() const override { return this->tip_accel; }
};
// UTF-16 offsets that sit on a grapheme cluster: a surrogate pair or a
// combining mark is never split. before/after walk to the neighbouring
// cluster; at_or_* stay put when already on a boundary.
int grapheme_before(const QString &text, int at);
int grapheme_after(const QString &text, int at);
int grapheme_at_or_before(const QString &text, int at);
int grapheme_at_or_after(const QString &text, int at);
// A single-line text field. There is no selection: the caret is the whole
// of the state, and a click just places it.
struct Entry : Widget {
@@ -389,12 +406,23 @@ struct Entry : Widget {
bool text_target(const Kit &kit, TextTarget &out) const override;
[[nodiscard]] int wake_ms() const override;
// Every committed edit ends up in splice(): it clamps the span to whole
// grapheme clusters, leaves the caret after what went in, tells the host
// and then runs on_change. replace() is that plus what a finished edit
// owes the platform -- the preedit is over, and the input method has to
// hear about it. The input method itself uses splice() directly, since
// it has a new preedit to set before saying anything.
void splice(Kit &kit, int start, int end, const QString &with);
void replace(Kit &kit, int start, int end, const QString &with);
// Whole-value assignment, which leaves the caret at the end.
void set_text(Kit &kit, const QString &next);
void move_caret(Kit &kit, int to);
// Resets the blink, and re-scrolls to keep the caret in view.
void touch_caret(const Kit &kit);
// Just the scroll: arranging the field must not restart its blink.
void rescroll(const Kit &kit);
// Bring [start, end] into view without moving the caret.
void reveal(const Kit &kit, int start, int end);
[[nodiscard]] int inner_w(const Kit &kit) const;
// The text as painted: the placeholder stands in when empty.
[[nodiscard]] QString painted() const;
@@ -799,6 +827,23 @@ struct Titlebar : Panel {
unsigned mods) override;
};
// Why the host is being told about a widget. These are the distinctions an
// accessibility adapter has to make, and no more: the kit does not know what
// the platform calls any of them, nor that anyone is listening at all.
enum class Change : uint8_t {
// The keyboard focus moved to the widget, or away from it when null.
Focus,
// The widget and everything below it is about to stop existing.
Retired,
// After layout: enabled, checked, expanded, names, and which popups
// are on the stack. Null widget, because this is a sweep of what
// the last frame committed rather than one mutation.
State,
// Committed text and caret of the widget, already applied. Preedit is
// not this: it is not the Value a client reads back.
Text,
};
struct Kit {
using Packed = Sheet::Packed;
struct Glyph {
@@ -862,6 +907,10 @@ struct Kit {
// The focused Entry changed, or moved its caret: the platform has to
// re-query the input method state.
std::function<void()> input_method_changed;
// Semantic changes, for whoever exposes this tree to the outside. A
// separate channel from the one above on purpose: the input method has
// its own needs, and must not have them overwritten by a second listener.
std::function<void(Change, Widget *)> notify;
bool fullscreen_ = false;
bool maximized_ = false;
bool active_ = true;
@@ -920,6 +969,11 @@ struct Kit {
// focus, or focus with no ring. Re-seating the same focus across a tree
// rebuild is not a focus change, and leaves the ring as it found it.
void set_focus(Widget *w, bool ring);
// Focus carried over to the successor of a widget that has just been
// rebuilt away. The ring is whatever it already was, because nothing
// about how the user got here has changed -- but the object that has
// the focus has, and that much is still worth saying out loud.
void reseat_focus(Widget *w);
// What a mnemonic, a hint chip and a buddy label all mean by "use this":
// the widget's default action if it has one, and the keyboard otherwise.
bool activate(Widget *w);
+2
View File
@@ -11,6 +11,7 @@
#include <libdn/ipc-instance.hpp>
#include <libdn/libdn.hpp>
#include "accessible.hpp"
#include "app.hpp"
#include "instance.hpp"
#include "thumbnail-cache.hpp"
@@ -257,6 +258,7 @@ main(int argc, char **argv)
#endif
dn::App app(argc, argv);
dn::accessible_init();
install_qt_translations(app);
QStringList raw = parser.positionalArguments();
const bool bare = raw.isEmpty();
+17
View File
@@ -5,12 +5,18 @@
// SPDX-License-Identifier: MPL-2.0
//
#include <dawn-config.h>
#include "wayland-window.hpp"
#include "app.hpp"
#include "wayland-color-bridge.hpp"
#include "xdg-shell-client-protocol.h"
#if DAWN_WITH_ACCESSIBILITY
#include <QAccessible>
#endif
#include <QByteArray>
#include <QCloseEvent>
#include <QCoreApplication>
@@ -97,6 +103,17 @@ WaylandWindow::initialize(const QUrl &url, BrowseSetup setup, Mode mode)
return true;
}
QAccessibleInterface *
WaylandWindow::accessibleRoot() const
{
#if DAWN_WITH_ACCESSIBILITY
return QAccessible::queryAccessibleInterface(
const_cast<WaylandWindow *>(this));
#else
return nullptr;
#endif
}
void
WaylandWindow::begin_close()
{
+2
View File
@@ -13,6 +13,7 @@
#include <memory>
class QAccessibleInterface;
class QCloseEvent;
class QKeyEvent;
class QPaintEvent;
@@ -55,6 +56,7 @@ public:
~WaylandWindow() override;
bool initialize(const QUrl &url, BrowseSetup setup, Mode mode);
QAccessibleInterface *accessibleRoot() const override;
};
// What Qt's own CSD does on titlebar right-click, in shell-local coordinates.
+33 -1
View File
@@ -11,6 +11,7 @@
#include "window.hpp"
#include "accessible.hpp"
#include "action.hpp"
#include "app-menu-macos.hpp"
#include "app.hpp"
@@ -22,6 +23,9 @@
#include "wayland-window.hpp"
#endif
#if DAWN_WITH_ACCESSIBILITY
#include <QAccessible>
#endif
#include <QByteArray>
#include <QCloseEvent>
#include <QCoreApplication>
@@ -167,6 +171,9 @@ Window::Window(App *app, QWindow *parent) : QWindow(parent), app_(app)
this->kit_.post = std::move(post);
this->kit_.request_render = [this] { request_render(); };
this->kit_.input_method_changed = [this] { sync_input_method(); };
this->kit_.notify = [this](Change what, Widget *w) {
accessible_changed(this, what, w);
};
this->kit_.start_move = [this] {
this->system_grab_ = shell()->startSystemMove();
request_render();
@@ -219,6 +226,10 @@ Window::~Window()
if (QGuiApplication *app = qGuiApp)
app->removeEventFilter(this);
shutdown();
// After shutdown(), which drops the pages and with them everything that
// was exposed; the kit is still to come, and has nobody left to tell.
this->kit_.notify = nullptr;
accessible_forget_window(this);
}
Extent
@@ -333,6 +344,10 @@ Window::drop_frames()
this->cropper_ = nullptr;
this->commander_ = nullptr;
this->awaiting_view_ = false;
// Last, and not first: closing popups and settling focus above is
// ordinary toolkit work that may expose a control all over again. The
// page trees are still whole here, which is what retirement needs.
accessible_retire_page(this);
for (auto &page : this->pages_)
page.reset();
}
@@ -663,8 +678,11 @@ Window::sync_title()
? app
: url_parse_name(url) + QStringLiteral(" \u2014 ") + app;
QWindow *w = shell();
if (w->title() != title)
if (w->title() != title) {
w->setTitle(title);
// This runs every frame, and only here has the name really changed.
accessible_renamed(this);
}
if (this != w && this->title() != title)
setTitle(title);
auto set_bar = [&](Page *ui) {
@@ -790,6 +808,16 @@ Window::shell()
return this;
}
QAccessibleInterface *
Window::accessibleRoot() const
{
#if DAWN_WITH_ACCESSIBILITY
return QAccessible::queryAccessibleInterface(const_cast<Window *>(this));
#else
return nullptr;
#endif
}
void
Window::sync_csd()
{
@@ -1193,6 +1221,7 @@ void
Window::focus_gained()
{
sync_macos_app_menu(this->app_);
accessible_activated(this);
request_render();
}
@@ -1210,6 +1239,9 @@ Window::focus_lost()
this->alt_armed_ = false;
this->kit_.close_transient_popups();
// Past the guard above, so that the hand-off it describes stays what it
// is: one window, and nothing for a screen reader to read out twice.
accessible_activated(this);
request_render();
}
+8 -1
View File
@@ -27,6 +27,7 @@
#include <span>
#include <string>
class QAccessibleInterface;
class QCloseEvent;
class QExposeEvent;
class QInputMethodEvent;
@@ -88,7 +89,6 @@ class Window final : public QWindow
const TextTarget &target, Qt::InputMethodQuery q) const;
void sync_input_method();
[[nodiscard]] Extent pixel_size() const;
[[nodiscard]] QWindow *shell();
App *app_ = nullptr;
Renderer renderer_;
@@ -157,6 +157,13 @@ public:
Mode application() const { return application_mode(mode_); }
[[nodiscard]] QUrl current_url() const;
[[nodiscard]] HostActions &host() { return this->host_; }
// What the accessibility adapters need of a window, and no more: where
// the semantic root lives, and the widget tree hanging off it.
[[nodiscard]] QWindow *shell();
// Qt's application interface lists top-level windows through this, which
// QWindow itself leaves null. The factory decides shell vs client.
QAccessibleInterface *accessibleRoot() const override;
[[nodiscard]] Kit &kit() { return this->kit_; }
[[nodiscard]] Page *active_page() { return active_ui(); }
[[nodiscard]] const Actor *active_actor() const;
[[nodiscard]] std::span<const MenuNode> active_menu() const;
+3 -3
View File
@@ -243,9 +243,9 @@ organised listing of available actions.
Keyboard navigation is
https://ckardaris.com/blog/2026/08/28/keyboard-driven-guis.html[first-class].
I've also added Vimperator-like hints, which I always wanted outside a browser.
There are also applications for getting this on a system-wide level--e.g.,
macOS has Homerow/Vimium-native--so it might make more sense to simply implement
the right accessibility APIs.
Given that there are applications for getting this on a system-wide level--macOS
has Shortcat/Homerow, Windows has Fluent Search--it might make more sense to
leave it to accessibility APIs, though it's a very cheap feature to implement.
Why no animations
~~~~~~~~~~~~~~~~~
+68
View File
@@ -176,6 +176,74 @@ if (MSGFMT_EXECUTABLE)
gettext_plural gettext_library PROPERTIES SKIP_RETURN_CODE 77)
endif()
# Accessibility is only ever proven from the outside, over the same bus and
# through the same client library a screen reader uses. libatspi is a test
# dependency and nothing else: dn links none of it.
option(DAWN_WITH_ACCESSIBILITY_TESTS
"Require what the external accessibility test needs" OFF)
if (DAWN_WITH_ACCESSIBILITY AND UNIX AND NOT APPLE)
pkg_check_modules(ATSPI IMPORTED_TARGET atspi-2)
# Every object libatspi hands out is a GObject, but atspi-2.pc requires
# only glib, so referencing and unreferencing them needs this as well.
pkg_check_modules(GOBJECT IMPORTED_TARGET gobject-2.0)
# Overflow coverage resizes the window through Weston, because Qt's
# AT-SPI Component.SetSize is a stub and a production Value interface on
# the frame is not a resize API. The test asserts that resize, so this
# is not optional to it: without the plugin there is no test to run.
# weston.pc carries the release, and libweston is versioned by its major.
pkg_check_modules(WESTON IMPORTED_TARGET weston)
if (WESTON_FOUND)
string(REGEX MATCH "^[0-9]+" WESTON_ABI "${WESTON_VERSION}")
pkg_check_modules(LIBWESTON IMPORTED_TARGET "libweston-${WESTON_ABI}")
endif()
pkg_check_modules(WAYLAND_SERVER IMPORTED_TARGET wayland-server)
if (ATSPI_FOUND AND GOBJECT_FOUND AND WESTON_FOUND AND LIBWESTON_FOUND
AND WAYLAND_SERVER_FOUND)
add_executable(test-accessible test-accessible.cpp)
target_include_directories(test-accessible PRIVATE
"${PROJECT_BINARY_DIR}")
target_link_libraries(test-accessible PRIVATE
PkgConfig::ATSPI
PkgConfig::GOBJECT)
add_dependencies(test-accessible dn)
if (NOT CMAKE_CROSSCOMPILING)
add_dependencies(test-accessible test-fixtures)
endif()
add_library(weston-a11y-resize MODULE weston-a11y-resize.c)
target_link_libraries(weston-a11y-resize PRIVATE
PkgConfig::WESTON
PkgConfig::LIBWESTON
PkgConfig::WAYLAND_SERVER)
set_target_properties(weston-a11y-resize PROPERTIES
PREFIX ""
C_STANDARD 11
C_STANDARD_REQUIRED ON)
add_dependencies(test-accessible weston-a11y-resize)
add_test(NAME accessible
COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/run-accessible.sh"
"$<TARGET_FILE:test-accessible>"
"$<TARGET_FILE:dn>"
"${DAWN_TEST_FIXTURES_DIR}")
# The launcher skips when the session it needs cannot be built; once
# dn is up, anything going wrong at all is a failure.
set_tests_properties(accessible PROPERTIES SKIP_RETURN_CODE 77)
if (DAWN_WITH_ACCESSIBILITY_TESTS)
set_tests_properties(accessible PROPERTIES
ENVIRONMENT "DN_A11Y_STRICT=1")
endif()
elseif (DAWN_WITH_ACCESSIBILITY_TESTS)
message(FATAL_ERROR "DAWN_WITH_ACCESSIBILITY_TESTS=ON but atspi-2, "
"gobject-2.0, or the weston plugin headers were not found")
endif()
elseif (DAWN_WITH_ACCESSIBILITY_TESTS)
message(FATAL_ERROR "DAWN_WITH_ACCESSIBILITY_TESTS=ON without a Linux "
"accessibility build")
endif()
add_executable(test-lxdr test-lxdr.cpp)
target_link_libraries(test-lxdr PRIVATE libdn)
add_test(NAME lxdr COMMAND test-lxdr)
+155
View File
@@ -0,0 +1,155 @@
#!/bin/sh
# Run the external accessibility test in a session of its very own.
#
# Usage: run-accessible.sh TEST-BINARY DN-BINARY DIRECTORY
#
# Everything the test touches is private: its own D-Bus session, which is
# also what starts the accessibility bus through ordinary service
# activation; its own headless Weston; and its own XDG directories, which is
# what keeps dn's single-instance socket away from the developer's session.
# The locale is pinned, because nodes are found by their translated names.
#
# Env:
# DN_A11Y_STRICT fail instead of skipping when something is missing,
# which is what a job configured for accessibility wants.
# WESTON_RENDERER vulkan (default) | gl | pixman. docs/functional-
# specification.adoc: gl has been seen rendering upside-
# down while headless. An offscreen Qt platform is not an
# option here: it exercises neither client-side
# decorations nor compositor geometry.
# WESTON_TIMEOUT 180 (default), seconds before the session is killed.
set -eu
if [ ! $# -eq 3 ]
then
echo >&2 "usage: run-accessible.sh TEST DN DIRECTORY"
exit 2
fi
test_bin=$1
dn_bin=$2
dir=$3
missing() {
echo >&2 "run-accessible: $1"
# Asked for explicitly, so its absence is a failure rather than a skip.
[ -z "${DN_A11Y_STRICT:-}" ] || exit 1
exit 77
}
command -v dbus-run-session >/dev/null 2>&1 || missing "no dbus-run-session"
command -v dbus-daemon >/dev/null 2>&1 || missing "no dbus-daemon"
command -v weston >/dev/null 2>&1 || missing "no weston"
# The bus comes up through D-Bus activation, so all that has to exist is the
# service file -- wherever the spec says to look for it, and not in any one
# distribution's libexec.
found=
IFS=:
for d in ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}
do [ -f "$d/dbus-1/services/org.a11y.Bus.service" ] && found=$d
done
unset IFS
if [ -z "$found" ]
then missing "no org.a11y.Bus service (install at-spi2-core)"
fi
renderer=${WESTON_RENDERER:-vulkan}
timeout_s=${WESTON_TIMEOUT:-180}
rt=$(mktemp -d)
chmod 700 "$rt"
cleanup() {
rm -rf "$rt"
}
trap cleanup EXIT INT TERM
log=$rt/weston.log
mkdir -p "$rt/run" "$rt/config" "$rt/cache" "$rt/data" "$rt/state"
chmod 700 "$rt/run"
# Qt must find neither the session's compositor nor an X server, and the
# bridge must not be pointed at the session's own accessibility bus.
unset DISPLAY QT_QPA_PLATFORM WAYLAND_DISPLAY AT_SPI_BUS_ADDRESS
# dbus-broker on the a11y bus activates Registry via systemd, which a
# dbus-run-session does not have. dbus-daemon runs the service Exec=.
export ATSPI_DBUS_IMPLEMENTATION=dbus-daemon
export XDG_RUNTIME_DIR=$rt/run
export XDG_CONFIG_HOME=$rt/config
export XDG_CACHE_HOME=$rt/cache
export XDG_DATA_HOME=$rt/data
export XDG_STATE_HOME=$rt/state
# Otherwise there's autodetection, for performance reasons.
export QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1
# Names are translated strings, so the test only knows the untranslated ones.
export LANGUAGE=
if locale -a 2>/dev/null | grep -qi '^C\.utf-\?8$'
then export LC_ALL=C.UTF-8
else export LC_ALL=C
fi
# Weston exits with the compositor's status, never with its startup client's,
# so the client writes its own down on the way out.
result=$rt/status
session=0
# Overflow coverage asks the compositor to configure a new toplevel size.
# The helper lives next to the test binary; an absolute path is what
# weston_load_module accepts besides names in /usr/lib/weston. The test
# asserts that resize, and the build only produces it together with the
# plugin, so a missing one here is a broken build rather than a thin host.
test_dir=$(CDPATH= cd -- "$(dirname -- "$test_bin")" && pwd)
plugin=$test_dir/weston-a11y-resize.so
if [ ! -f "$plugin" ]
then
echo >&2 "run-accessible: $plugin was not built"
exit 1
fi
# dbus-broker activates SystemdService= units, which a dbus-run-session
# has no user systemd for. dbus-daemon runs the service file's Exec=,
# which is how the accessibility bus is meant to come up here.
timeout "$timeout_s" dbus-run-session --dbus-daemon="$(command -v dbus-daemon)" -- \
weston \
--backend=headless \
--renderer="$renderer" \
--shell=desktop \
--width=1920 \
--height=1080 \
--socket=weston-a11y-$$ \
--idle-time=0 \
--no-config \
--fake-seat \
--log="$log" \
--modules="$plugin" \
-- /bin/sh -c '"$1" "$2" "$3"; echo $? >"$4"' \
run-accessible "$test_bin" "$dn_bin" "$dir" "$result" \
|| session=$?
status=1
if [ -s "$result" ]
then
status=$(cat "$result")
# Whatever is in there came out of the session; a file that is not a
# number is one more way for this to have gone wrong.
case $status in
(''|*[!0-9]*) status=1 ;;
esac
else
# Killed with the session, or never started: either way, not a pass.
echo >&2 "run-accessible: the test left no exit status behind"
fi
if [ "$status" -eq 0 ] && [ "$session" -ne 0 ]
then
echo >&2 "run-accessible: the session exited $session"
status=$session
fi
if [ "$status" -ne 0 ]
then
echo >&2 "run-accessible: exit $status"
[ -f "$log" ] && sed -n '1,60p' "$log" >&2
fi
exit "$status"
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
//
// weston-a11y-resize.c: compositor window resize for the AT-SPI harness
//
// Copyright The Dawn Authors
// SPDX-License-Identifier: MPL-2.0
//
// The accessibility test must shrink the window to force toolbar overflow.
// Qt's AT-SPI Component.SetSize is a stub, and a production Value interface
// on the frame is not a resize API. This module is window-management: it
// sends xdg_toplevel configure through libweston-desktop.
//
// Commands on $XDG_RUNTIME_DIR/dn-a11y-resize (SOCK_DGRAM):
// shrink configure a width that packs the toolbar into overflow
// wide configure at least 1600 px, enough to unpack it
// restore configure the size snapshotted before the first change
//
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <libweston/desktop.h>
#include <libweston/libweston.h>
#include <wayland-server.h>
#include <weston.h>
enum {
kNarrowWidth = 400,
kWideWidth = 1600,
};
struct resize_mod {
struct weston_compositor *ec;
int fd;
struct wl_event_source *source;
struct sockaddr_un addr;
int32_t saved_w;
int32_t saved_h;
bool saved;
};
static struct weston_desktop_surface *
first_toplevel(struct weston_compositor *ec)
{
struct weston_layer *layer;
wl_list_for_each (layer, &ec->layer_list, link) {
struct weston_view *view;
wl_list_for_each (view, &layer->view_list.link, layer_link.link) {
struct weston_desktop_surface *ds;
if (!view->surface ||
!weston_surface_is_desktop_surface(view->surface))
continue;
ds = weston_surface_get_desktop_surface(view->surface);
if (ds && !weston_desktop_surface_get_parent(ds))
return ds;
}
}
return NULL;
}
static void
snapshot(struct resize_mod *mod, struct weston_desktop_surface *ds)
{
struct weston_surface *surface;
struct weston_geometry geo;
int32_t w;
int32_t h;
if (mod->saved)
return;
surface = weston_desktop_surface_get_surface(ds);
geo = weston_desktop_surface_get_geometry(ds);
w = surface->width_from_buffer;
h = surface->height_from_buffer;
if (w < geo.width) {
w = geo.width;
h = geo.height;
}
if (w < surface->width) {
w = surface->width;
h = surface->height;
}
if (w < kNarrowWidth || h < kNarrowWidth)
return;
mod->saved_w = w;
mod->saved_h = h;
mod->saved = true;
}
static void
configure(struct weston_desktop_surface *ds, int32_t width, int32_t height)
{
weston_desktop_surface_set_size(ds, width, height);
}
static void
apply(struct resize_mod *mod, const char *cmd)
{
struct weston_desktop_surface *ds = first_toplevel(mod->ec);
int32_t width;
int32_t height;
if (!ds)
return;
snapshot(mod, ds);
if (!mod->saved)
return;
if (strcmp(cmd, "restore") == 0) {
configure(ds, mod->saved_w, mod->saved_h);
return;
}
height = mod->saved_h;
if (strcmp(cmd, "shrink") == 0)
width = kNarrowWidth;
else if (strcmp(cmd, "wide") == 0)
width = mod->saved_w > kWideWidth ? mod->saved_w : kWideWidth;
else
return;
configure(ds, width, height);
}
static int
on_socket(int fd, uint32_t mask, void *data)
{
struct resize_mod *mod = data;
char buf[64];
ssize_t n;
if (!(mask & WL_EVENT_READABLE))
return 0;
n = recv(fd, buf, sizeof buf - 1, 0);
if (n <= 0)
return 0;
while (n > 0 && (buf[n - 1] == '\n' || buf[n - 1] == '\r'))
n--;
buf[n] = '\0';
apply(mod, buf);
return 0;
}
WL_EXPORT int
wet_module_init(struct weston_compositor *ec, int *argc, char *argv[])
{
struct resize_mod *mod;
struct wl_event_loop *loop;
const char *runtime;
int n;
(void) argc;
(void) argv;
runtime = getenv("XDG_RUNTIME_DIR");
if (!runtime || !ec || !ec->wl_display)
return -1;
mod = calloc(1, sizeof *mod);
if (!mod)
return -1;
mod->ec = ec;
mod->fd = -1;
mod->addr.sun_family = AF_UNIX;
n = snprintf(mod->addr.sun_path, sizeof mod->addr.sun_path,
"%s/dn-a11y-resize", runtime);
if (n < 0 || n >= (int) sizeof mod->addr.sun_path) {
free(mod);
return -1;
}
mod->fd = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (mod->fd < 0) {
free(mod);
return -1;
}
unlink(mod->addr.sun_path);
if (bind(mod->fd, (struct sockaddr *) &mod->addr, sizeof mod->addr) < 0) {
close(mod->fd);
free(mod);
return -1;
}
loop = wl_display_get_event_loop(ec->wl_display);
mod->source =
wl_event_loop_add_fd(loop, mod->fd, WL_EVENT_READABLE, on_socket, mod);
if (!mod->source) {
unlink(mod->addr.sun_path);
close(mod->fd);
free(mod);
return -1;
}
return 0;
}