Compare commits

...
4 Commits
Author SHA1 Message Date
justme d8e780dfa1 Use native candidate actions and harden frontend resource loading 2026-10-20 11:52:39 +08:00
justmeandClaude Sonnet 5.5 99fe8f589f Fetch libmonet in build.sh and document monet colours
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 00:23:12 +08:00
justmeandClaude Sonnet 5.5 5bea01e224 Colour the candidate window from the desktop accent via libmonet
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 00:23:12 +08:00
justmeandClaude Sonnet 5.5 064ca6b4d4 Map a libmonet scheme onto candidate window colours
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 00:23:12 +08:00
22 changed files with 1298 additions and 231 deletions

No files matched your search

+22 -16
View File
@@ -10,7 +10,7 @@
刚度、阻尼、方向、距离都可以调。 刚度、阻尼、方向、距离都可以调。
- 在候选词上**右键**打开动作菜单,内容取决于输入法提供的动作。拼音有 - 在候选词上**右键**打开动作菜单,内容取决于输入法提供的动作。拼音有
"忘记候选词 / 置顶为自定义短语 / 从自定义短语中删除"。 "忘记候选词 / 置顶为自定义短语 / 从自定义短语中删除"。
- **忘记候选词后不会再出现**,包括系统词库和扩展词库里的词(见下文"屏蔽列表")。 - 候选菜单只调用引擎提供的原生动作,不在前端屏蔽词语。
- Wayland 和 X11(Xwayland)输入上下文都支持,定位和鼠标处理沿用 `classicui`。 - Wayland 和 X11(Xwayland)输入上下文都支持,定位和鼠标处理沿用 `classicui`。
- 可选的 KWin 辅助脚本:只缩短候选框自己的 KWin 淡出时间。 - 可选的 KWin 辅助脚本:只缩短候选框自己的 KWin 淡出时间。
@@ -47,7 +47,7 @@ sudo dnf install fcitx5-devel extra-cmake-modules cairo-devel pango-devel \
然后: 然后:
``` ```
scripts/build.sh # 下载 Fcitx5 和 yoga 源码,叠加 src/,构建 m3ui scripts/build.sh # 下载 Fcitx5、yoga 和 libmonet 源码,叠加 src/,构建 m3ui
scripts/install.sh # 安装到 ~/.local,不改动 $HOME 以外的任何东西 scripts/install.sh # 安装到 ~/.local,不改动 $HOME 以外的任何东西
``` ```
@@ -56,6 +56,12 @@ scripts/install.sh # 安装到 ~/.local,不改动 $HOME 以外的任何东西
没有固定提交的版本会被拒绝,除非你自己用 `FCITX_COMMIT=<sha>` 指定。这个插件跑在输入法进程里, 没有固定提交的版本会被拒绝,除非你自己用 `FCITX_COMMIT=<sha>` 指定。这个插件跑在输入法进程里,
看得到你打的所有字,所以构建来源值得认真对待。 看得到你打的所有字,所以构建来源值得认真对待。
## 莫奈取色
候选窗的颜色由 [libmonet](https://git.oeo.one/justme/libmonet) 从桌面强调色生成(`UseMonet=True`,默认开),
深浅色跟随系统。输入法进程内不会启动任何子进程:直接复用 Fcitx5 自己监听的 XDG portal
(KDE Plasma 6、GNOME 47+ 都有),取不到时回退到默认的粉色。`UseMonet=False` 回到主题文件里的固定颜色。
## 使用 ## 使用
手动试用: 手动试用:
@@ -98,23 +104,23 @@ systemctl --user daemon-reload && systemctl --user restart app-org.fcitx.Fcitx5@
`SurfaceColor` 完全透明时回退到 classicui 的图片绘制,所以原有的 classicui `SurfaceColor` 完全透明时回退到 classicui 的图片绘制,所以原有的 classicui
主题仍然可用(但没有动画)。 主题仍然可用(但没有动画)。
## 屏蔽列表 ## 候选动作与资源防护
拼音引擎的"忘记候选词"只会删除你自己打字学到的词,系统词库(`sc.dict`)和 右键菜单只显示引擎的 `candidateActions()` 并通过 `triggerAction()` 调用。
扩展词库里的词是只读的,忘记之后重新输入仍会出现,当前输入状态里也一样。 “忘记”由引擎处理,不保证永久隐藏或降低到某个排名。前端不再读取或写入
旧的 `blocked-words.txt`,已有文件不会被删除。
所以 m3ui 在你点"忘记候选词"时,会把这个词记到 图片限制为 16 MiB 文件、单边不超过 8192 像素、总计不超过 16 Mi 像素。
`~/.local/share/fcitx5/m3ui/blocked-words.txt`(一行一个词),并在每次刷新候选 SVG 渲染一次后缓存为位图,异常资源回退到既有主题或文字图标。
之前把它们从候选列表里摘掉。 这些限制不是解码沙箱,不能保证复杂 SVG 的处理时间,也不能替代底层库安全更新。
- 按候选词的完整文字精确匹配,只隐藏这个词本身。 `patches/fcitx5-icon-cache.patch` 修复核心图标缓存的循环链、越界、
- **重启不会丢**:文件是原子写入的(先写临时文件、`fsync` 再改名,中途被杀也不会留下写坏的文件), 字符串终止和映射文件截断问题,损坏缓存回退为文件查找。
权限是 `600`;某次保存失败时这个词在本次运行里仍然隐藏,下次会重试写入。 `build.sh` 会检查并应用此补丁;但 `install.sh` 只安装插件,
- 文件被别的程序或手动修改后会自动重新读取,不用重启。 **不会替换运行中的 Fcitx5 核心库**,所以仅安装插件不代表核心修复已部署。
- 只有**通过右键菜单点"忘记候选词"**才会记录。用拼音自带的快捷键(`Ctrl+7`)忘记的词不会被记录,
这类词如果来自系统词库,下次还会出现。 构建后可运行 `bash scripts/test.sh` 验证候选菜单、缓存和真实图片解码。
- 想恢复某个词:删掉文件里对应的一行即可。 也可通过 `FCITX_SOURCE_DIR`、`FCITX_BUILD_DIR` 指定已有源码和构建目录。
- 这是界面层的补充。换回原版 classicui 后,这些词会重新出现。
## KWin 淡出(可选,KDE Plasma) ## KWin 淡出(可选,KDE Plasma)
+1
View File
@@ -10,6 +10,7 @@ UseAccentColor=False
PerScreenDPI=True PerScreenDPI=True
ForceWaylandDPI=0 ForceWaylandDPI=0
EnableFractionalScale=True EnableFractionalScale=True
UseMonet=True
Animations=True Animations=True
Bounce=True Bounce=True
SpatialStiffness=2000 SpatialStiffness=2000
+297
View File
@@ -0,0 +1,297 @@
--- a/src/lib/fcitx/icontheme.cpp
+++ b/src/lib/fcitx/icontheme.cpp
@@ -7,9 +7,11 @@
#include "icontheme.h"
#include <fcntl.h>
#include <sys/stat.h>
+#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
+#include <cstring>
#include <filesystem>
#include <fstream>
#include <functional>
@@ -39,10 +41,6 @@
#include "config.h" // IWYU pragma: keep
#include "misc_p.h"
-#ifdef HAVE_SYS_MMAN_H
-#include <sys/mman.h>
-#endif
-
namespace {
// helper type for the visitor
@@ -194,6 +192,9 @@
FCITX_DEFINE_READ_ONLY_PROPERTY_PRIVATE(IconThemeDirectory, int, threshold);
static uint32_t iconNameHash(const char *p) {
+ if (!*p) {
+ return 0;
+ }
uint32_t h = static_cast<signed char>(*p);
for (p += 1; *p != '\0'; p++) {
h = (h << 5) - h + *p;
@@ -207,7 +208,8 @@
#ifdef _WIN32
FCITX_UNUSED(filename);
#else
- auto fd = StandardPaths::openPath(filename);
+ // Never block opening a FIFO masquerading as a cache.
+ auto fd = StandardPaths::openPath(filename, O_RDONLY | O_NONBLOCK);
if (!fd.isValid()) {
return;
}
@@ -225,35 +227,46 @@
}
struct stat st;
- if (fstat(fd.fd(), &st) < 0) {
+ constexpr size_t maxCacheSize = 16 * 1024 * 1024;
+ if (fstat(fd.fd(), &st) < 0 || !S_ISREG(st.st_mode) ||
+ st.st_size < 12 ||
+ static_cast<uint64_t>(st.st_size) > maxCacheSize) {
return;
}
- memory_ = static_cast<uint8_t *>(
- mmap(nullptr, st.st_size, PROT_READ, MAP_SHARED, fd.fd(), 0));
- if (memory_ == MAP_FAILED) {
- // Failed to mmap is not critical here.
+ // Own a bounded snapshot: a concurrent in-place truncation must not
+ // turn a cache lookup into SIGBUS. Invalid snapshots are discarded.
+ memory_.resize(st.st_size);
+ size_t done = 0;
+ while (done < memory_.size()) {
+ auto n = fs::safeRead(fd.fd(), memory_.data() + done,
+ memory_.size() - done);
+ if (n <= 0) {
+ memory_.clear();
+ return;
+ }
+ done += n;
+ }
+
+ isValid_ = true;
+ if (readWord(0) != 1 || readWord(2) != 0) { // version major minor
+ isValid_ = false;
return;
}
- size_ = st.st_size;
-
- if (readWord(0) != 1 || readWord(2) != 0) { // version major minor
- return;
- }
-
- isValid_ = true;
// Check that all the directories are older than the cache
uint32_t dirListOffset = readDoubleWord(8);
uint32_t dirListLen = readDoubleWord(dirListOffset);
// We assume it won't be so long just in case we hit a corruptted file.
- if (dirListLen > 4096) {
+ if (!isValid_ || dirListLen > 4096 ||
+ !checkRange(static_cast<size_t>(dirListOffset) + 4,
+ static_cast<size_t>(dirListLen) * 4)) {
isValid_ = false;
return;
}
for (uint32_t i = 0; i < dirListLen; ++i) {
uint32_t offset = readDoubleWord(dirListOffset + 4 + (4 * i));
- if (!isValid_ || offset >= size_) {
+ if (!isValid_) {
isValid_ = false;
return;
}
@@ -263,7 +276,8 @@
return;
}
std::filesystem::path subdirName(dir);
- if (subdirName.is_absolute()) {
+ if (subdirName.is_absolute() ||
+ std::ranges::find(subdirName, "..") != subdirName.end()) {
isValid_ = false;
return;
}
@@ -277,65 +291,52 @@
}
IconThemeCache() = default;
- IconThemeCache(IconThemeCache &&other) noexcept
- : isValid_(other.isValid_), memory_(other.memory_), size_(other.size_) {
- other.isValid_ = false;
- other.memory_ = nullptr;
- other.size_ = 0;
- }
-
- IconThemeCache &operator=(IconThemeCache other) {
- std::swap(other.isValid_, isValid_);
- std::swap(other.size_, size_);
- std::swap(other.memory_, memory_);
- return *this;
- }
+ IconThemeCache(IconThemeCache &&other) noexcept = default;
+ IconThemeCache &operator=(IconThemeCache &&other) noexcept = default;
bool isValid() const { return isValid_; }
- ~IconThemeCache() {
-#ifndef _WIN32
- if (memory_) {
- munmap(memory_, size_);
- }
-#endif
- }
-
- uint16_t readWord(uint32_t offset) const {
- if (offset + 2 > size_ || (offset % 2)) {
+ bool checkRange(size_t offset, size_t length) const {
+ if (offset > memory_.size() || length > memory_.size() - offset) {
+ isValid_ = false;
+ return false;
+ }
+ return true;
+ }
+
+ uint16_t readWord(size_t offset) const {
+ if (!checkRange(offset, 2) || (offset % 2)) {
isValid_ = false;
return 0;
}
return memory_[offset + 1] | memory_[offset] << 8;
}
- uint32_t readDoubleWord(unsigned int offset) const {
- if (offset + 4 > size_ || (offset % 4)) {
+ uint32_t readDoubleWord(size_t offset) const {
+ if (!checkRange(offset, 4) || (offset % 4)) {
isValid_ = false;
return 0;
}
- return memory_[offset + 3] | memory_[offset + 2] << 8 |
- memory_[offset + 1] << 16 | memory_[offset] << 24;
- }
-
- char *checkString(uint32_t offset) const {
- // assume string length is less than 1k.
- for (uint32_t i = 0; i < 1024; i++) {
- if (offset + i >= size_) {
- return nullptr;
- }
- auto c = memory_[offset + i];
- if (c == '\0') {
- break;
- }
- }
- return reinterpret_cast<char *>(memory_ + offset);
+ return uint32_t(memory_[offset + 3]) | uint32_t(memory_[offset + 2]) << 8 |
+ uint32_t(memory_[offset + 1]) << 16 | uint32_t(memory_[offset]) << 24;
+ }
+
+ const char *checkString(size_t offset) const {
+ if (!checkRange(offset, 1)) {
+ return nullptr;
+ }
+ const auto length = std::min<size_t>(1024, memory_.size() - offset);
+ auto *str = reinterpret_cast<const char *>(memory_.data() + offset);
+ if (!std::memchr(str, '\0', length)) {
+ isValid_ = false;
+ return nullptr;
+ }
+ return str;
}
std::unordered_set<std::string> lookup(const std::string &name) const;
mutable bool isValid_ = false;
- uint8_t *memory_ = nullptr;
- size_t size_ = 0;
+ std::vector<uint8_t> memory_;
};
std::unordered_set<std::string>
@@ -343,27 +344,43 @@
std::unordered_set<std::string> ret;
auto hash = iconNameHash(name.c_str());
- uint32_t hashOffset = readDoubleWord(4);
+ if (!isValid_) {
+ return ret;
+ }
+ size_t hashOffset = readDoubleWord(4);
uint32_t hashBucketCount = readDoubleWord(hashOffset);
- if (!isValid_ || hashBucketCount == 0) {
+ if (!isValid_ || hashBucketCount == 0 ||
+ hashBucketCount > memory_.size() / 4 ||
+ !checkRange(hashOffset + 4, static_cast<size_t>(hashBucketCount) * 4)) {
isValid_ = false;
return ret;
}
- uint32_t bucketIndex = hash % hashBucketCount;
- uint32_t bucketOffset = readDoubleWord(hashOffset + 4 + (bucketIndex * 4));
- while (bucketOffset > 0 && bucketOffset <= size_ - 12) {
+ size_t bucketIndex = hash % hashBucketCount;
+ size_t bucketOffset = readDoubleWord(hashOffset + 4 + (bucketIndex * 4));
+ std::unordered_set<size_t> visited;
+ while (bucketOffset != 0 && bucketOffset != UINT32_MAX) {
+ // A cache is only an accelerator. Bound even acyclic hostile chains.
+ if (!isValid_ || !checkRange(bucketOffset, 12) ||
+ visited.size() >= 4096 || !visited.insert(bucketOffset).second) {
+ isValid_ = false;
+ return {};
+ }
uint32_t nameOff = readDoubleWord(bucketOffset + 4);
auto *namePtr = checkString(nameOff);
- if (nameOff < size_ && namePtr && name == namePtr) {
- uint32_t dirListOffset = readDoubleWord(8);
+ if (!isValid_ || !namePtr) {
+ return {};
+ }
+ if (name == namePtr) {
+ size_t dirListOffset = readDoubleWord(8);
uint32_t dirListLen = readDoubleWord(dirListOffset);
- uint32_t listOffset = readDoubleWord(bucketOffset + 8);
+ size_t listOffset = readDoubleWord(bucketOffset + 8);
uint32_t listLen = readDoubleWord(listOffset);
- if (!isValid_ || listOffset + 4 + 8 * listLen > size_) {
+ if (!isValid_ || listLen > 4096 ||
+ !checkRange(listOffset + 4, static_cast<size_t>(listLen) * 8)) {
isValid_ = false;
return ret;
}
@@ -371,11 +388,11 @@
ret.reserve(listLen);
for (uint32_t j = 0; j < listLen && isValid_; ++j) {
uint32_t dirIndex = readWord(listOffset + 4 + (8 * j));
- uint32_t o = readDoubleWord(dirListOffset + 4 + (dirIndex * 4));
- if (!isValid_ || dirIndex >= dirListLen || o >= size_) {
+ if (!isValid_ || dirIndex >= dirListLen) {
isValid_ = false;
return ret;
}
+ uint32_t o = readDoubleWord(dirListOffset + 4 + (dirIndex * 4));
if (auto *str = checkString(o)) {
ret.emplace(str);
} else {
@@ -565,7 +582,7 @@
std::unordered_set<std::string> dirFilter;
if (baseDir.second.isValid()) {
dirFilter = baseDir.second.lookup(iconname);
- hasCache = true;
+ hasCache = baseDir.second.isValid();
}
for (const auto &directory : directories_) {
if ((hasCache && !dirFilter.contains(directory.path())) ||
@@ -600,7 +617,7 @@
std::unordered_set<std::string> dirFilter;
if (baseDir.second.isValid()) {
dirFilter = baseDir.second.lookup(iconname);
- hasCache = true;
+ hasCache = baseDir.second.isValid();
}
auto checkDirectoryWithSize =
+14
View File
@@ -27,6 +27,8 @@ commit=${FCITX_COMMIT:-${known_commits[$version]:-}}
[ -n "$commit" ] || die "no pinned commit for Fcitx5 $version. Look up the commit of its tag and pass it as FCITX_COMMIT=<sha>" [ -n "$commit" ] || die "no pinned commit for Fcitx5 $version. Look up the commit of its tag and pass it as FCITX_COMMIT=<sha>"
# Commit of the yoga submodule pinned by Fcitx5 (third_party/yoga). # Commit of the yoga submodule pinned by Fcitx5 (third_party/yoga).
yoga_commit=${YOGA_COMMIT:-042f5013152eb81c1552dec945b88f7b95ca350f} yoga_commit=${YOGA_COMMIT:-042f5013152eb81c1552dec945b88f7b95ca350f}
# libmonet (header-only) supplies the colours derived from the desktop accent.
monet_commit=${MONET_COMMIT:-b97a55ac75ae82435628dc585cccadb45e4d2feb}
# fetch_commit <url> <dir> <commit>, leaves <dir> checked out at exactly <commit> # fetch_commit <url> <dir> <commit>, leaves <dir> checked out at exactly <commit>
fetch_commit() { fetch_commit() {
@@ -48,9 +50,20 @@ echo ">> fetching Fcitx5 $version ($commit)"
fetch_commit https://github.com/fcitx/fcitx5 "$srcdir" "$commit" fetch_commit https://github.com/fcitx/fcitx5 "$srcdir" "$commit"
echo ">> fetching yoga ($yoga_commit)" echo ">> fetching yoga ($yoga_commit)"
fetch_commit https://github.com/facebook/yoga "$srcdir/third_party/yoga" "$yoga_commit" fetch_commit https://github.com/facebook/yoga "$srcdir/third_party/yoga" "$yoga_commit"
echo ">> fetching libmonet ($monet_commit)"
fetch_commit https://git.oeo.one/justme/libmonet.git "$work/libmonet" "$monet_commit"
[ -z "${FETCH_ONLY:-}" ] || { echo ">> sources are in place"; exit 0; } [ -z "${FETCH_ONLY:-}" ] || { echo ">> sources are in place"; exit 0; }
echo ">> overlaying m3ui" echo ">> overlaying m3ui"
echo ">> applying icon cache hardening"
cache_patch="$root/patches/fcitx5-icon-cache.patch"
if git -C "$srcdir" apply --check "$cache_patch"; then
git -C "$srcdir" apply "$cache_patch"
elif git -C "$srcdir" apply --reverse --check "$cache_patch"; then
echo ">> icon cache hardening already applied"
else
die "icon cache patch does not match Fcitx5 $version; refusing a partial build"
fi
rm -rf "$srcdir/src/ui/m3ui" rm -rf "$srcdir/src/ui/m3ui"
cp -r "$root/src" "$srcdir/src/ui/m3ui" cp -r "$root/src" "$srcdir/src/ui/m3ui"
if ! grep -q 'add_subdirectory(m3ui)' "$srcdir/src/ui/CMakeLists.txt"; then if ! grep -q 'add_subdirectory(m3ui)' "$srcdir/src/ui/CMakeLists.txt"; then
@@ -61,6 +74,7 @@ fi
echo ">> configuring" echo ">> configuring"
cmake -S "$srcdir" -B "$work/cmake" -G Ninja \ cmake -S "$srcdir" -B "$work/cmake" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \ -DCMAKE_BUILD_TYPE=Release \
-DMONET_INCLUDE_DIR="$work/libmonet/include" \
-DCMAKE_INSTALL_PREFIX="${PREFIX:-$HOME/.local/m3ui}" \ -DCMAKE_INSTALL_PREFIX="${PREFIX:-$HOME/.local/m3ui}" \
-DENABLE_X11=On -DENABLE_WAYLAND=On \ -DENABLE_X11=On -DENABLE_WAYLAND=On \
-DENABLE_TEST=Off -DENABLE_TESTING_ADDONS=Off -DENABLE_ENCHANT=Off \ -DENABLE_TEST=Off -DENABLE_TESTING_ADDONS=Off -DENABLE_ENCHANT=Off \
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# Run the regression tests against an already built, patched Fcitx5 tree.
set -euo pipefail
ulimit -c 0
root=$(cd "$(dirname "$0")/.." && pwd)
work=${WORK_DIR:-$root/build}
version=${FCITX_VERSION:-$(pkg-config --modversion Fcitx5Core)}
srcdir=${FCITX_SOURCE_DIR:-$work/fcitx5-$version}
builddir=${FCITX_BUILD_DIR:-$work/cmake}
out=$(mktemp -d "${TMPDIR:-/tmp}/m3ui-tests-XXXXXX")
trap 'rm -f "$out/testm3uicandidatemenu" "$out/testiconthemecache" "$out/testm3uiresourceguard" "$out/testm3uiimageloader"; rmdir "$out"' EXIT
flags=(-std=c++20 -Wall -Wextra -I"$srcdir/src/lib" -I"$builddir/src/lib")
libs=(-L"$builddir/bin" -Wl,-rpath,"$builddir/bin" -lFcitx5Core -lFcitx5Config -lFcitx5Utils)
for name in testm3uicandidatemenu testiconthemecache testm3uiresourceguard; do
"${CXX:-c++}" "${flags[@]}" "$root/tests/$name.cpp" "${libs[@]}" -o "$out/$name"
done
read -r -a image_flags <<< "$(pkg-config --cflags --libs cairo gdk-pixbuf-2.0 librsvg-2.0)"
"${CXX:-c++}" "${flags[@]}" "$root/tests/testm3uiimageloader.cpp" \
"$root/src/imageloader.cpp" "${image_flags[@]}" "${libs[@]}" -o "$out/testm3uiimageloader"
for name in testm3uicandidatemenu testiconthemecache testm3uiresourceguard testm3uiimageloader; do
echo ">> $name"
timeout 15 "$out/$name"
done
+9 -1
View File
@@ -29,14 +29,22 @@ if (ENABLE_DBUS)
set(CLASSICUI_LIBS ${CLASSICUI_LIBS} Fcitx5::Module::DBus) set(CLASSICUI_LIBS ${CLASSICUI_LIBS} Fcitx5::Module::DBus)
endif() endif()
# libmonet, the shared colour code, header only. build.sh fetches it at a pinned
# commit and passes the directory.
if (NOT MONET_INCLUDE_DIR OR NOT EXISTS "${MONET_INCLUDE_DIR}/monet/monet.h")
message(FATAL_ERROR "MONET_INCLUDE_DIR must point at the include directory of libmonet")
endif()
add_fcitx5_addon(m3ui add_fcitx5_addon(m3ui
classicui.cpp window.cpp theme.cpp inputwindow.cpp plasmathemewatchdog.cpp colorhelper.cpp ${CLASSICUI_SRCS} classicui.cpp window.cpp theme.cpp imageloader.cpp inputwindow.cpp plasmathemewatchdog.cpp colorhelper.cpp ${CLASSICUI_SRCS}
) )
if (ENABLE_X11) if (ENABLE_X11)
target_compile_definitions(m3ui PRIVATE -DFCITX_XCB_EWMH) target_compile_definitions(m3ui PRIVATE -DFCITX_XCB_EWMH)
endif() endif()
target_include_directories(m3ui PRIVATE "${MONET_INCLUDE_DIR}")
target_link_libraries(m3ui target_link_libraries(m3ui
Fcitx5::Core Fcitx5::Core
PkgConfig::Cairo PkgConfig::Pango PkgConfig::Cairo PkgConfig::Pango
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <utility>
#include <vector>
#include "fcitx/candidateaction.h"
#include "fcitx/candidatelist.h"
namespace fcitx::m3ui {
struct CandidateMenu {
std::vector<CandidateAction> actions;
};
inline CandidateMenu candidateMenu(const CandidateList &list,
const CandidateWord &candidate) {
CandidateMenu menu;
if (auto *actionable = list.toActionable();
actionable && actionable->hasAction(candidate)) {
for (auto &action : actionable->candidateActions(candidate)) {
if (!action.isSeparator()) {
menu.actions.push_back(std::move(action));
}
}
}
// Ranking and learning belong to the engine. In particular, forgetting a
// learned candidate must not be turned into permanent UI-side blocking.
return menu;
}
} // namespace fcitx::m3ui
+53 -21
View File
@@ -7,6 +7,7 @@
#include "classicui.h" #include "classicui.h"
#include <fcntl.h> #include <fcntl.h>
#include <cmath>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
@@ -150,24 +151,6 @@ M3UI::M3UI(Instance *instance) : instance_(instance) {
M3UI::~M3UI() {} M3UI::~M3UI() {}
std::filesystem::path M3UI::blockedWordsPath() {
return StandardPaths::global().userDirectory(StandardPathsType::PkgData) /
"m3ui" / "blocked-words.txt";
}
bool M3UI::blockWord(const std::string &word) {
if (blockStore_.add(word)) {
return true;
}
if (BlockStore::valid(word)) {
CLASSICUI_ERROR() << "Could not save the blocked word to "
<< blockStore_.path()
<< ", it stays hidden until Fcitx5 restarts unless "
"a later save succeeds.";
}
return false;
}
void M3UI::reloadConfig() { void M3UI::reloadConfig() {
readAsIni(config_, "conf/m3ui.conf"); readAsIni(config_, "conf/m3ui.conf");
reloadTheme(); reloadTheme();
@@ -180,6 +163,7 @@ void M3UI::reloadTheme() {
auto color = variant.dataAs<uint32_t>(); auto color = variant.dataAs<uint32_t>();
auto oldIsDark = isDark_; auto oldIsDark = isDark_;
isDark_ = (color == 1); isDark_ = (color == 1);
portalSchemeKnown_ = true;
if (oldIsDark != isDark_) { if (oldIsDark != isDark_) {
CLASSICUI_DEBUG() << "XDG Portal AppearanceChanged " CLASSICUI_DEBUG() << "XDG Portal AppearanceChanged "
"isDark" "isDark"
@@ -189,8 +173,10 @@ void M3UI::reloadTheme() {
} }
}; };
// The portal is also how the colours for UseMonet are learned, over the
// connection Fcitx5 already has, so nothing has to be started for it.
if (dbus()) { if (dbus()) {
if (*config_.useDarkTheme) { if (*config_.useDarkTheme || *config_.useMonet) {
if (!darkModeEntry_ && settingMonitor_) { if (!darkModeEntry_ && settingMonitor_) {
darkModeEntry_ = settingMonitor_->watch( darkModeEntry_ = settingMonitor_->watch(
"org.freedesktop.appearance", "color-scheme", parseMessage); "org.freedesktop.appearance", "color-scheme", parseMessage);
@@ -253,7 +239,7 @@ void M3UI::reloadTheme() {
}; };
if (dbus()) { if (dbus()) {
if (*config_.useAccentColor) { if (*config_.useAccentColor || *config_.useMonet) {
if (!accentColorEntry_ && settingMonitor_) { if (!accentColorEntry_ && settingMonitor_) {
accentColorEntry_ = accentColorEntry_ =
settingMonitor_->watch("org.freedesktop.appearance", settingMonitor_->watch("org.freedesktop.appearance",
@@ -266,7 +252,53 @@ void M3UI::reloadTheme() {
} }
#endif #endif
theme_.populateColor(accentColor_); if (*config_.useMonet) {
theme_.applyMonet(mapScheme(monetScheme()));
}
// The accent colour is only watched for UseMonet, it must not recolour the
// fields of a classic theme unless the user asked for that.
theme_.populateColor(*config_.useAccentColor ? accentColor_
: std::nullopt);
}
monet::Scheme M3UI::monetScheme() const {
// Nothing is ever started from here: a slow program would stall the input
// method. The portal is read through the connection Fcitx5 has, the other
// sources only read files.
monet::Context ctx = monet::Context::system();
ctx.run = [](const std::vector<std::string> &, int) {
return std::optional<std::string>();
};
monet::Source portal{
"fcitx-portal", [this](const monet::Context &) {
monet::Appearance a;
if (accentColor_) {
auto q = [](float c) {
return static_cast<std::uint8_t>(std::lround(c * 255.0f));
};
a.accent = monet::Rgb{q(accentColor_->redF()),
q(accentColor_->greenF()),
q(accentColor_->blueF())};
}
if (portalSchemeKnown_) {
a.mode = isDark_ ? monet::Mode::Dark : monet::Mode::Light;
}
return a;
}};
// MONET_SEED and MONET_MODE first, then what the portal said, then the rest.
std::vector<monet::Source> sources;
for (auto &source : monet::defaultSources(ctx)) {
sources.push_back(std::move(source));
if (sources.back().name == "override") {
sources.push_back(portal);
}
}
const monet::Appearance a = monet::detect(ctx, sources);
CLASSICUI_DEBUG() << "Monet seed " << monet::hex(*a.accent) << " from "
<< a.accentSource << ", "
<< (*a.mode == monet::Mode::Dark ? "dark" : "light")
<< " from " << a.modeSource;
return monet::generate(*a.accent, *a.mode);
} }
void M3UI::suspend() { void M3UI::suspend() {
+9 -19
View File
@@ -30,7 +30,6 @@
#include "fcitx/event.h" #include "fcitx/event.h"
#include "fcitx/instance.h" #include "fcitx/instance.h"
#include "fcitx/userinterface.h" #include "fcitx/userinterface.h"
#include "blockstore.h"
#include "classicui_public.h" #include "classicui_public.h"
#include "config.h" #include "config.h"
#include "plasmathemewatchdog.h" #include "plasmathemewatchdog.h"
@@ -201,6 +200,11 @@ FCITX_CONFIGURATION(
"font DPI. If the value is 0, it means this option is " "font DPI. If the value is 0, it means this option is "
"disabled.")}}; "disabled.")}};
Option<bool> useMonet{
this, "UseMonet",
_("Take the colours from the desktop: its accent colour and its light "
"or dark setting"),
true};
Option<bool> animations{this, "Animations", _("Enable animations"), true}; Option<bool> animations{this, "Animations", _("Enable animations"), true};
Option<bool> bounce{this, "Bounce", Option<bool> bounce{this, "Bounce",
_("Bouncy overshoot (off: critically damped)"), true}; _("Bouncy overshoot (off: critically damped)"), true};
@@ -244,6 +248,8 @@ public:
FCITX_ADDON_DEPENDENCY_LOADER(dbus, instance_->addonManager()); FCITX_ADDON_DEPENDENCY_LOADER(dbus, instance_->addonManager());
Instance *instance() const { return instance_; } Instance *instance() const { return instance_; }
const Configuration *getConfig() const override; const Configuration *getConfig() const override;
// The scheme for the current desktop colours, see classicui.cpp.
monet::Scheme monetScheme() const;
void setConfig(const RawConfig &config) override { void setConfig(const RawConfig &config) override {
config_.load(config, true); config_.load(config, true);
safeSaveAsIni(config_, "conf/m3ui.conf"); safeSaveAsIni(config_, "conf/m3ui.conf");
@@ -267,25 +273,7 @@ public:
bool preferTextIcon() const; bool preferTextIcon() const;
bool showLayoutNameInIcon() const; bool showLayoutNameInIcon() const;
// Words the user chose to forget. Forgetting in the Pinyin engine only
// removes learned words, anything from a read only dictionary would come
// back, so the window also hides these. The list lives in a file, see
// BlockStore for how it is kept safe.
bool hasBlockedWords() const { return !blockStore_.empty(); }
bool isBlocked(const std::string &word) const {
return blockStore_.contains(word);
}
// Picks up changes made to the file behind our back and retries a save that
// failed earlier. One stat call.
void reloadBlockedWords() { blockStore_.reloadIfChanged(); }
// Returns false if the word could not be saved, it is hidden for this
// session anyway.
bool blockWord(const std::string &word);
private: private:
static std::filesystem::path blockedWordsPath();
BlockStore blockStore_{blockedWordsPath()};
FCITX_ADDON_DEPENDENCY_LOADER(notificationitem, instance_->addonManager()); FCITX_ADDON_DEPENDENCY_LOADER(notificationitem, instance_->addonManager());
FCITX_ADDON_EXPORT_FUNCTION(M3UI, labelIcon); FCITX_ADDON_EXPORT_FUNCTION(M3UI, labelIcon);
FCITX_ADDON_EXPORT_FUNCTION(M3UI, preferTextIcon); FCITX_ADDON_EXPORT_FUNCTION(M3UI, preferTextIcon);
@@ -330,6 +318,8 @@ private:
mutable Theme subconfigTheme_; mutable Theme subconfigTheme_;
bool suspended_ = true; bool suspended_ = true;
bool isDark_ = false; bool isDark_ = false;
// The portal has answered, so isDark_ is a value and not just the default.
bool portalSchemeKnown_ = false;
std::optional<Color> accentColor_ = std::nullopt; std::optional<Color> accentColor_ = std::nullopt;
std::unique_ptr<EventSource> deferedEnableTray_; std::unique_ptr<EventSource> deferedEnableTray_;
+100
View File
@@ -0,0 +1,100 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include "imageloader.h"
#include <algorithm>
#include <cstring>
#include <gio/gio.h>
#include <librsvg/rsvg.h>
#include "resourceguard.h"
namespace fcitx::m3ui {
LoadedSurface loadSvgImage(const std::vector<uint8_t> &bytes) {
if (bytes.empty() || bytes.size() > maxImageFileSize) {
return nullptr;
}
UniqueCPtr<GInputStream, g_object_unref> stream(
g_memory_input_stream_new_from_data(bytes.data(), bytes.size(), nullptr));
if (!stream) {
return nullptr;
}
// No base URI: do not grant SVGs access to sibling files. Keep librsvg's
// default safety limits (in particular, never use FLAG_UNLIMITED).
UniqueCPtr<RsvgHandle, g_object_unref> handle(rsvg_handle_new_from_stream_sync(
stream.get(), nullptr, RSVG_HANDLE_FLAGS_NONE, nullptr, nullptr));
g_input_stream_close(stream.get(), nullptr, nullptr);
double width = 0;
double height = 0;
if (!handle ||
!rsvg_handle_get_intrinsic_size_in_pixels(handle.get(), &width, &height) ||
!safeImageSize(width, height)) {
return nullptr;
}
const auto w = static_cast<int>(std::ceil(width));
const auto h = static_cast<int>(std::ceil(height));
LoadedSurface surface(cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h));
if (cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
UniqueCPtr<cairo_t, cairo_destroy> context(cairo_create(surface.get()));
const RsvgRectangle viewport{0, 0, static_cast<double>(w),
static_cast<double>(h)};
// Render once, rather than recording and replaying SVG commands on each
// repaint. This bounds pixel storage, not SVG parse/render execution time.
if (cairo_status(context.get()) != CAIRO_STATUS_SUCCESS ||
!rsvg_handle_render_document(handle.get(), context.get(), &viewport,
nullptr) ||
cairo_status(context.get()) != CAIRO_STATUS_SUCCESS ||
cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
return surface;
}
LoadedPixbuf loadRasterImage(const std::vector<uint8_t> &bytes) {
if (bytes.empty() || bytes.size() > maxImageFileSize) {
return nullptr;
}
// Check PNG dimensions even when the filename has another extension.
constexpr unsigned char signature[] = {137, 80, 78, 71, 13, 10, 26, 10};
if (bytes.size() >= sizeof(signature) &&
!std::memcmp(bytes.data(), signature, sizeof(signature)) &&
!safePngSize(bytes)) {
return nullptr;
}
// The callback data must outlive the loader, including its destruction.
bool rejected = false;
UniqueCPtr<GdkPixbufLoader, g_object_unref> loader(gdk_pixbuf_loader_new());
if (!loader) {
return nullptr;
}
g_signal_connect(
loader.get(), "size-prepared",
G_CALLBACK(+[](GdkPixbufLoader *loader, int width, int height,
gpointer data) {
if (!safeImageSize(width, height)) {
*static_cast<bool *>(data) = true;
gdk_pixbuf_loader_set_size(loader, 1, 1);
}
}),
&rejected);
bool loaded = true;
for (size_t offset = 0; offset < bytes.size() && !rejected;) {
const auto length = std::min<size_t>(4096, bytes.size() - offset);
if (!gdk_pixbuf_loader_write(loader.get(), bytes.data() + offset,
length, nullptr)) {
loaded = false;
break;
}
offset += length;
}
const bool closed = gdk_pixbuf_loader_close(loader.get(), nullptr);
auto *image = gdk_pixbuf_loader_get_pixbuf(loader.get());
if (!loaded || !closed || rejected || !image ||
!safeImageSize(gdk_pixbuf_get_width(image), gdk_pixbuf_get_height(image))) {
return nullptr;
}
return LoadedPixbuf(static_cast<GdkPixbuf *>(g_object_ref(image)));
}
} // namespace fcitx::m3ui
+18
View File
@@ -0,0 +1,18 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#pragma once
#include <cstdint>
#include <vector>
#include <cairo.h>
#include <gdk-pixbuf/gdk-pixbuf.h>
#include "fcitx-utils/misc.h"
namespace fcitx::m3ui {
// Own the returned reference independently of the incremental loader.
using LoadedPixbuf = UniqueCPtr<GdkPixbuf, g_object_unref>;
LoadedPixbuf loadRasterImage(const std::vector<uint8_t> &bytes);
using LoadedSurface = UniqueCPtr<cairo_surface_t, cairo_surface_destroy>;
LoadedSurface loadSvgImage(const std::vector<uint8_t> &bytes);
} // namespace fcitx::m3ui
+80 -84
View File
@@ -45,7 +45,7 @@
#include "fcitx/text.h" #include "fcitx/text.h"
#include "fcitx/userinterface.h" #include "fcitx/userinterface.h"
#include "classicui.h" #include "classicui.h"
#include "blockfilter.h" #include "candidatemenu.h"
#include "common.h" #include "common.h"
#include "theme.h" #include "theme.h"
@@ -131,16 +131,6 @@ double springSettleSeconds(double stiffness, double damping) {
return damping >= 1.0 ? 7.5 / w : 5.3 / (damping * w); return damping >= 1.0 ? 7.5 / w : 5.3 / (damping * w);
} }
// The engine's "forget" action, as shown in English or Chinese.
bool isForgetAction(const std::string &text) {
std::string lower = text;
std::ranges::transform(lower, lower.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return lower.find("forget") != std::string::npos ||
text.find("\xe5\xbf\x98\xe8\xae\xb0") != std::string::npos; // 忘记
}
double millisecondsSince(std::chrono::steady_clock::time_point start, double millisecondsSince(std::chrono::steady_clock::time_point start,
std::chrono::steady_clock::time_point now) { std::chrono::steady_clock::time_point now) {
return std::chrono::duration<double, std::milli>(now - start).count(); return std::chrono::duration<double, std::milli>(now - start).count();
@@ -430,14 +420,6 @@ std::pair<int, int> InputWindow::update(InputContext *inputContext) {
auxUp = instance->outputFilter(inputContext, inputPanel.auxUp()); auxUp = instance->outputFilter(inputContext, inputPanel.auxUp());
auxDown = instance->outputFilter(inputContext, inputPanel.auxDown()); auxDown = instance->outputFilter(inputContext, inputPanel.auxDown());
candidateList = inputPanel.candidateList(); candidateList = inputPanel.candidateList();
// Drop forgotten words, removing from the engine's list keeps labels
// and selection keys consistent with what is shown.
parent_->reloadBlockedWords();
if (candidateList && parent_->hasBlockedWords()) {
removeBlockedCandidates(*candidateList, [this](const std::string &w) {
return parent_->isBlocked(w);
});
}
} else if (!inputPanel.overlayMessage().empty()) { } else if (!inputPanel.overlayMessage().empty()) {
auxUp = inputPanel.overlayMessage(); auxUp = inputPanel.overlayMessage();
} }
@@ -590,19 +572,31 @@ std::pair<unsigned int, unsigned int> InputWindow::sizeHint() {
float yogaWidth = YGNodeLayoutGetWidth(rootNode_.get()); float yogaWidth = YGNodeLayoutGetWidth(rootNode_.get());
float yogaHeight = YGNodeLayoutGetHeight(rootNode_.get()); float yogaHeight = YGNodeLayoutGetHeight(rootNode_.get());
menuTop_ = yogaHeight; panelWidth_ = yogaWidth;
panelHeight_ = yogaHeight;
menuTop_ = yogaHeight + 8;
if (menuOpen_) { if (menuOpen_) {
auto [menuWidth, menuHeight] = menuSize(); auto [menuWidth, menuHeight] = menuSize();
menuWidth_ = menuWidth;
yogaWidth = std::max<double>(yogaWidth, menuWidth); yogaWidth = std::max<double>(yogaWidth, menuWidth);
yogaHeight += menuHeight; menuLeft_ = std::clamp(menuAnchor_, 0.0,
static_cast<double>(yogaWidth) - menuWidth);
yogaHeight = menuTop_ + menuHeight;
menuRegions_.clear();
for (size_t i = 0; i < menuActions_.size(); i++) {
Rect region;
region.setPosition(menuLeft_ + 4, menuTop_ + 8 + i * menuItemHeight_)
.setSize(menuWidth_ - 8, menuItemHeight_);
menuRegions_.push_back(region);
}
} }
auto width = static_cast<unsigned int>(yogaWidth); auto width = static_cast<unsigned int>(std::ceil(yogaWidth));
auto height = static_cast<unsigned int>(yogaHeight); auto height = static_cast<unsigned int>(std::ceil(yogaHeight));
return {width, height}; return {width, height};
} }
void InputWindow::paint(cairo_t *cr, double width, double height) { void InputWindow::paint(cairo_t *cr, double /*width*/, double /*height*/) {
auto &theme = parent_->theme(); auto &theme = parent_->theme();
cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR); cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR);
cairo_paint(cr); cairo_paint(cr);
@@ -651,7 +645,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
break; break;
} }
cairo_save(cr); cairo_save(cr);
roundedRectPath(cr, 0.5, 0.5, width - 1, height - 1, roundedRectPath(cr, 0.5, 0.5, panelWidth_ - 1, panelHeight_ - 1,
*theme.inputPanel->panelRadius); *theme.inputPanel->panelRadius);
cairoSetSourceColor(cr, *theme.inputPanel->surfaceColor); cairoSetSourceColor(cr, *theme.inputPanel->surfaceColor);
cairo_fill_preserve(cr); cairo_fill_preserve(cr);
@@ -664,7 +658,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
} }
cairo_restore(cr); cairo_restore(cr);
} else { } else {
theme.paint(cr, *theme.inputPanel->background, 0, 0, width, height, theme.paint(cr, *theme.inputPanel->background, 0, 0, panelWidth_, panelHeight_,
/*alpha=*/1.0); /*alpha=*/1.0);
} }
const auto &margin = *theme.inputPanel->contentMargin; const auto &margin = *theme.inputPanel->contentMargin;
@@ -725,7 +719,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
const auto &inner = candidateNodes_[hi].inner; const auto &inner = candidateNodes_[hi].inner;
double w = YGNodeLayoutGetWidth(inner.get()); double w = YGNodeLayoutGetWidth(inner.get());
if (*theme.inputPanel->fullWidthHighlight && vertical) { if (*theme.inputPanel->fullWidthHighlight && vertical) {
w = width - *margin.marginLeft - *margin.marginRight - w = panelWidth_ - *margin.marginLeft - *margin.marginRight -
*textMargin.marginRight - *textMargin.marginLeft; *textMargin.marginRight - *textMargin.marginLeft;
} }
RectF target; RectF target;
@@ -810,7 +804,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
if (*theme.inputPanel->fullWidthHighlight && vertical) { if (*theme.inputPanel->fullWidthHighlight && vertical) {
// Last candidate, fill. // Last candidate, fill.
highlightWidth = width - *margin.marginLeft - *margin.marginRight - highlightWidth = panelWidth_ - *margin.marginLeft - *margin.marginRight -
*textMargin.marginRight - *textMargin.marginLeft; *textMargin.marginRight - *textMargin.marginLeft;
} }
@@ -942,7 +936,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
} }
if (menuOpen_) { if (menuOpen_) {
paintMenu(cr, width, menuTop_, fontHeight); paintMenu(cr);
} }
if (m3ui_logcategory().checkLogLevel(Debug)) { if (m3ui_logcategory().checkLogLevel(Debug)) {
@@ -971,31 +965,26 @@ void InputWindow::click(int x, int y) {
if (menuOpen_) { if (menuOpen_) {
// Copy what is needed first, triggering an action rebuilds the list. // Copy what is needed first, triggering an action rebuilds the list.
int actionId = -1; int actionId = -1;
std::string actionText; bool actionSelected = false;
const auto candidateIndex = menuCandidate_; const auto candidateIndex = menuCandidate_;
const std::string candidateText = menuCandidateText_; const std::string candidateText = menuCandidateText_;
for (size_t i = 0; i < menuRegions_.size(); i++) { for (size_t i = 0; i < menuRegions_.size(); i++) {
if (menuRegions_[i].contains(x, y)) { if (menuRegions_[i].contains(x, y)) {
actionSelected = true;
actionId = menuActions_[i].id(); actionId = menuActions_[i].id();
actionText = menuActions_[i].text();
break; break;
} }
} }
closeMenu(); closeMenu();
if (actionId >= 0) { if (actionSelected) {
auto *actionable = candidateList->toActionable(); auto *actionable = candidateList->toActionable();
const auto *candidate = const auto *candidate =
nthCandidateIgnorePlaceholder(*candidateList, candidateIndex); nthCandidateIgnorePlaceholder(*candidateList, candidateIndex);
// The list may have been refreshed since the menu was opened. Only // The list may have been refreshed since the menu was opened. Only
// act when the candidate is still the one the user picked, a // act when the candidate is still the one the user picked, a
// different word must never be forgotten or blocked. // different word must never be forgotten.
if (actionable && candidate && if (actionable && candidate &&
candidate->text().toString() == candidateText) { candidate->text().toString() == candidateText) {
// Block before the engine refreshes its list so the refreshed
// list is already filtered.
if (isForgetAction(actionText)) {
parent_->blockWord(candidateText);
}
actionable->triggerAction(*candidate, actionId); actionable->triggerAction(*candidate, actionId);
} }
} }
@@ -1029,6 +1018,7 @@ void InputWindow::click(int x, int y) {
} }
void InputWindow::rightClick(int x, int y) { void InputWindow::rightClick(int x, int y) {
CLASSICUI_DEBUG() << "Candidate right click at " << x << ", " << y;
auto *inputContext = inputContext_.get(); auto *inputContext = inputContext_.get();
if (!inputContext) { if (!inputContext) {
return; return;
@@ -1042,36 +1032,32 @@ void InputWindow::rightClick(int x, int y) {
inputContext->updateUserInterface(UserInterfaceComponent::InputPanel); inputContext->updateUserInterface(UserInterfaceComponent::InputPanel);
return; return;
} }
auto *actionable = candidateList->toActionable();
if (!actionable) {
return;
}
for (size_t idx = 0, e = candidateRegions_.size(); idx < e; idx++) { for (size_t idx = 0, e = candidateRegions_.size(); idx < e; idx++) {
if (!candidateRegions_[idx].contains(x, y)) { if (!candidateRegions_[idx].contains(x, y)) {
continue; continue;
} }
const auto *candidate = const auto *candidate =
nthCandidateIgnorePlaceholder(*candidateList, idx); nthCandidateIgnorePlaceholder(*candidateList, idx);
if (!candidate || !actionable->hasAction(*candidate)) { if (!candidate) {
return; return;
} }
std::vector<CandidateAction> actions; auto menu = candidateMenu(*candidateList, *candidate);
for (auto &action : actionable->candidateActions(*candidate)) { CLASSICUI_DEBUG() << "Candidate menu: engine action interface="
if (!action.isSeparator()) { << (candidateList->toActionable() != nullptr)
actions.push_back(std::move(action)); << ", actions=" << menu.actions.size();
} if (menu.actions.empty()) {
}
if (actions.empty()) {
return; return;
} }
menuActions_ = std::move(actions); menuActions_ = std::move(menu.actions);
menuCandidate_ = idx; menuCandidate_ = idx;
menuCandidateText_ = candidate->text().toString(); menuCandidateText_ = candidate->text().toString();
menuAnchor_ = candidateRegions_[idx].left();
menuHover_ = -1; menuHover_ = -1;
menuOpen_ = true; menuOpen_ = true;
inputContext->updateUserInterface(UserInterfaceComponent::InputPanel); inputContext->updateUserInterface(UserInterfaceComponent::InputPanel);
return; return;
} }
CLASSICUI_DEBUG() << "Candidate right click did not hit a candidate region";
} }
void InputWindow::closeMenu() { void InputWindow::closeMenu() {
@@ -1081,6 +1067,7 @@ void InputWindow::closeMenu() {
menuHover_ = -1; menuHover_ = -1;
menuActions_.clear(); menuActions_.clear();
menuRegions_.clear(); menuRegions_.clear();
menuLayouts_.clear();
} }
std::pair<double, double> InputWindow::menuSize() { std::pair<double, double> InputWindow::menuSize() {
@@ -1090,56 +1077,65 @@ std::pair<double, double> InputWindow::menuSize() {
const int fontHeight = PANGO_PIXELS(pango_font_metrics_get_ascent(metrics) + const int fontHeight = PANGO_PIXELS(pango_font_metrics_get_ascent(metrics) +
pango_font_metrics_get_descent(metrics)); pango_font_metrics_get_descent(metrics));
pango_font_metrics_unref(metrics); pango_font_metrics_unref(metrics);
menuItemHeight_ = fontHeight + 16; menuItemHeight_ = std::max(40, fontHeight + 16);
menuTextWidth_ = 0; double textWidth = 0;
menuLayouts_.clear();
for (const auto &action : menuActions_) { for (const auto &action : menuActions_) {
auto layout = newPangoLayout(context_.get()); auto layout = newPangoLayout(context_.get());
pango_layout_set_text(layout.get(), action.text().c_str(), -1); pango_layout_set_text(layout.get(), action.text().c_str(), -1);
int w = 0; int w = 0;
int h = 0; int h = 0;
pango_layout_get_pixel_size(layout.get(), &w, &h); pango_layout_get_pixel_size(layout.get(), &w, &h);
menuTextWidth_ = std::max<double>(menuTextWidth_, w); textWidth = std::max<double>(textWidth, w);
menuLayouts_.push_back(std::move(layout));
} }
// 1px divider + padding, item padding of 16 and a margin of 8 each side. // M3 menu spacing, adapted to a compact desktop input panel: 16px
return {menuTextWidth_ + 2 * 16 + 2 * 8, // horizontal / 8px vertical padding and at least 40px per action.
1.0 + 6 + menuActions_.size() * menuItemHeight_ + 6}; const double width = std::clamp(textWidth + 32, 112.0, 280.0);
for (auto &layout : menuLayouts_) {
pango_layout_set_width(layout.get(), (width - 32) * PANGO_SCALE);
pango_layout_set_wrap(layout.get(), PANGO_WRAP_WORD_CHAR);
int h = 0;
pango_layout_get_pixel_size(layout.get(), nullptr, &h);
menuItemHeight_ = std::max(menuItemHeight_, h + 16);
}
return {width, 16.0 + menuActions_.size() * menuItemHeight_};
} }
void InputWindow::paintMenu(cairo_t *cr, double width, double yTop, void InputWindow::paintMenu(cairo_t *cr) {
double /*fontHeight*/) {
auto &theme = parent_->theme(); auto &theme = parent_->theme();
cairo_save(cr); cairo_save(cr);
auto line = *theme.inputPanel->outlineColor; const double height = 16.0 + menuActions_.size() * menuItemHeight_;
if (line.alpha() == 0) { auto surface = materialStyle() ? *theme.inputPanel->surfaceColor
const auto &text = theme.inputPanelText(); : theme.inputPanelBackground();
line = Color(text.red(), text.green(), text.blue(), 40); // Menus must remain legible over application content, even when the
} // candidate panel is configured to be translucent.
cairoSetSourceColor(cr, line); surface.setAlphaF(1);
cairo_rectangle(cr, 12, yTop, width - 24, 1); fillRoundedRect(cr, surface, menuLeft_, menuTop_, menuWidth_, height, 12);
cairo_fill(cr); auto outline = theme.inputPanelText();
outline.setAlphaF(0.12);
roundedRectPath(cr, menuLeft_ + 0.5, menuTop_ + 0.5,
menuWidth_ - 1, height - 1, 12);
cairoSetSourceColor(cr, outline);
cairo_set_line_width(cr, 1);
cairo_stroke(cr);
roundedRectPath(cr, menuLeft_, menuTop_, menuWidth_, height, 12);
cairo_clip(cr);
menuRegions_.clear();
double y = yTop + 1 + 6;
for (size_t i = 0; i < menuActions_.size(); i++) { for (size_t i = 0; i < menuActions_.size(); i++) {
Rect region; const auto &region = menuRegions_[i];
region.setPosition(8, y).setSize(width - 16, menuItemHeight_);
menuRegions_.push_back(region);
const bool hovered = static_cast<int>(i) == menuHover_; const bool hovered = static_cast<int>(i) == menuHover_;
if (hovered) { if (hovered) {
fillRoundedRect(cr, *theme.inputPanel->highlightFillColor, 8, y, auto state = theme.inputPanelText();
width - 16, menuItemHeight_, state.setAlphaF(0.08);
*theme.inputPanel->itemRadius); fillRoundedRect(cr, state, region.left(), region.top(),
region.width(), region.height(), 8);
} }
auto layout = newPangoLayout(context_.get());
pango_layout_set_text(layout.get(), menuActions_[i].text().c_str(), -1);
int w = 0;
int h = 0; int h = 0;
pango_layout_get_pixel_size(layout.get(), &w, &h); pango_layout_get_pixel_size(menuLayouts_[i].get(), nullptr, &h);
cairoSetSourceColor(cr, hovered cairoSetSourceColor(cr, theme.inputPanelText());
? theme.inputPanelHighlightCandidateText() renderLayout(cr, menuLayouts_[i].get(), menuLeft_ + 16,
: theme.inputPanelText()); region.top() + (menuItemHeight_ - h) / 2.0);
renderLayout(cr, layout.get(), 8 + 16, y + (menuItemHeight_ - h) / 2.0);
y += menuItemHeight_;
} }
cairo_restore(cr); cairo_restore(cr);
} }
+7 -3
View File
@@ -194,7 +194,7 @@ protected:
// Candidate action menu. // Candidate action menu.
void closeMenu(); void closeMenu();
void paintMenu(cairo_t *cr, double width, double yTop, double fontHeight); void paintMenu(cairo_t *cr);
std::pair<double, double> menuSize(); std::pair<double, double> menuSize();
bool menuOpen_ = false; bool menuOpen_ = false;
int menuCandidate_ = -1; int menuCandidate_ = -1;
@@ -206,9 +206,13 @@ protected:
std::vector<CandidateAction> menuActions_; std::vector<CandidateAction> menuActions_;
std::vector<Rect> menuRegions_; std::vector<Rect> menuRegions_;
double menuTop_ = 0; double menuTop_ = 0;
double menuLeft_ = 0;
double menuAnchor_ = 0;
double menuWidth_ = 0;
double panelWidth_ = 0;
double panelHeight_ = 0;
int menuItemHeight_ = 0; int menuItemHeight_ = 0;
std::vector<std::string> menuLabels_; std::vector<GObjectUniquePtr<PangoLayout>> menuLayouts_;
double menuTextWidth_ = 0;
private: private:
std::pair<unsigned int, unsigned int> sizeHint(); std::pair<unsigned int, unsigned int> sizeHint();
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <monet/monet.h>
#include "fcitx-utils/color.h"
namespace fcitx::m3ui {
// The colours of the candidate window that follow the desktop, taken from a
// libmonet scheme. Only colours are decided here, the radii and the spacing stay
// with the theme file.
struct MonetColors {
Color surface; // the panel
Color outline; // its border
Color highlightFill; // the pill behind the current candidate
Color normal; // candidate text and the typed pinyin
Color highlight; // the part of the typed pinyin being converted
Color highlightBackground;
Color highlightCandidate; // text on the pill
Color candidateLabel; // the number in front of a candidate
Color highlightCandidateLabel;
Color candidateComment;
Color highlightCandidateComment;
};
inline Color toColor(monet::Rgb c, int alpha = 255) {
return Color(c.r, c.g, c.b, static_cast<unsigned short>(alpha));
}
inline MonetColors mapScheme(const monet::Scheme &s) {
MonetColors c;
c.surface = toColor(s.surfaceContainer, 245);
c.outline = toColor(s.outlineVariant);
c.highlightFill = toColor(s.primaryContainer);
c.normal = toColor(s.onSurface);
c.highlight = toColor(s.primary);
c.highlightBackground = toColor(s.primaryContainer);
c.highlightCandidate = toColor(s.onPrimaryContainer);
c.candidateLabel = toColor(s.onSurfaceVariant);
c.highlightCandidateLabel = toColor(s.onPrimaryContainer);
c.candidateComment = toColor(s.onSurfaceVariant);
c.highlightCandidateComment = toColor(s.onPrimaryContainer);
return c;
}
} // namespace fcitx::m3ui
+68
View File
@@ -0,0 +1,68 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#pragma once
#include <sys/stat.h>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <optional>
#include <vector>
#include "fcitx-utils/fs.h"
#include "fcitx-utils/unixfd.h"
namespace fcitx::m3ui {
// UI assets are not arbitrary documents. Reject excessive inputs and let the
// existing theme/icon fallbacks handle them instead of risking the IM process.
inline constexpr size_t maxImageFileSize = 16 * 1024 * 1024;
inline bool safeImageSize(double width, double height) {
return std::isfinite(width) && std::isfinite(height) && width > 0 &&
height > 0 && width <= 8192 && height <= 8192 &&
std::ceil(width) * std::ceil(height) <= 16 * 1024 * 1024;
}
inline bool safeImageMargins(int width, int height, int left, int top,
int right, int bottom) {
// Subtraction avoids overflow from adding untrusted margin values.
return width > 0 && height > 0 && left >= 0 && top >= 0 && right >= 0 &&
bottom >= 0 && left <= width && right <= width - left &&
top <= height && bottom <= height - top;
}
inline bool safePngSize(const std::vector<uint8_t> &bytes) {
constexpr unsigned char signature[] = {137, 80, 78, 71, 13, 10, 26, 10};
if (bytes.size() < 24 ||
std::memcmp(bytes.data(), signature, sizeof(signature)) ||
std::memcmp(bytes.data() + 8, "\0\0\0\rIHDR", 8)) {
return false;
}
auto dimension = [&](size_t offset) {
return uint32_t(bytes[offset]) << 24 |
uint32_t(bytes[offset + 1]) << 16 |
uint32_t(bytes[offset + 2]) << 8 | uint32_t(bytes[offset + 3]);
};
return safeImageSize(dimension(16), dimension(20));
}
inline std::optional<std::vector<uint8_t>> readImageSnapshot(const UnixFD &file) {
struct stat st;
if (!file.isValid() || fstat(file.fd(), &st) < 0 || !S_ISREG(st.st_mode) ||
st.st_size <= 0 || static_cast<uint64_t>(st.st_size) > maxImageFileSize) {
return std::nullopt;
}
std::vector<uint8_t> bytes(st.st_size);
size_t done = 0;
while (done < bytes.size()) {
const auto n = fs::safeRead(file.fd(), bytes.data() + done,
bytes.size() - done);
if (n <= 0) {
return std::nullopt;
}
done += n;
}
return bytes;
}
} // namespace fcitx::m3ui
+82 -87
View File
@@ -12,6 +12,7 @@
#include <cmath> #include <cmath>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <cstring>
#include <filesystem> #include <filesystem>
#include <optional> #include <optional>
#include <string> #include <string>
@@ -24,11 +25,8 @@
#include <cairo.h> #include <cairo.h>
#include <format> #include <format>
#include <gdk-pixbuf/gdk-pixbuf.h> #include <gdk-pixbuf/gdk-pixbuf.h>
#include <gio/gio.h>
#include <gio/gunixinputstream.h>
#include <glib.h> #include <glib.h>
#include <glibconfig.h> #include <glibconfig.h>
#include <librsvg/rsvg.h>
#include <pango/pango-font.h> #include <pango/pango-font.h>
#include <pango/pango-fontmap.h> #include <pango/pango-fontmap.h>
#include <pango/pango-layout.h> #include <pango/pango-layout.h>
@@ -51,11 +49,27 @@
#include "classicui.h" #include "classicui.h"
#include "colorhelper.h" #include "colorhelper.h"
#include "common.h" #include "common.h"
#include "imageloader.h"
#include "resourceguard.h"
namespace fcitx::m3ui { namespace fcitx::m3ui {
namespace { namespace {
UnixFD openImageFile(StandardPathsType type, const std::filesystem::path &name,
StandardPathsModes modes, std::filesystem::path *outPath) {
for (const auto &directory : StandardPaths::global().directories(type, modes)) {
auto path = directory / name;
auto file = StandardPaths::openPath(path, O_RDONLY | O_NONBLOCK);
struct stat st;
if (file.isValid() && fstat(file.fd(), &st) == 0 && S_ISREG(st.st_mode)) {
*outPath = std::move(path);
return file;
}
}
return {};
}
inline uint32_t charWidth(uint32_t c) { inline uint32_t charWidth(uint32_t c) {
if (g_unichar_iszerowidth(c)) { if (g_unichar_iszerowidth(c)) {
return 0; return 0;
@@ -88,18 +102,19 @@ std::pair<std::string, size_t> extractTextForLabel(const std::string &label) {
return {extracted, currentWidth}; return {extracted, currentWidth};
} }
cairo_status_t readFromFd(void *closure, unsigned char *data, struct ImageReadBuffer {
unsigned int length) { const std::vector<uint8_t> &bytes;
int fd = *static_cast<int *>(closure); size_t offset = 0;
};
while (length) { cairo_status_t readFromBuffer(void *closure, unsigned char *data,
auto sz = fs::safeRead(fd, data, length); unsigned int length) {
if (sz <= 0) { auto &buffer = *static_cast<ImageReadBuffer *>(closure);
return CAIRO_STATUS_READ_ERROR; if (length > buffer.bytes.size() - buffer.offset) {
} return CAIRO_STATUS_READ_ERROR;
length -= sz;
data += sz;
} }
std::memcpy(data, buffer.bytes.data() + buffer.offset, length);
buffer.offset += length;
return CAIRO_STATUS_SUCCESS; return CAIRO_STATUS_SUCCESS;
} }
@@ -204,13 +219,18 @@ ThemeImage::Pixmap pixBufToPixmap(GdkPixbuf *image) {
} }
ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) { ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) {
if (file.fd() < 0) { auto bytes = readImageSnapshot(file);
if (!bytes) {
return nullptr; return nullptr;
} }
if (path.extension() == ".png") { if (path.extension() == ".png") {
int fd = file.fd(); // Check IHDR before Cairo/libpng allocates the full raster.
if (!safePngSize(*bytes)) {
return nullptr;
}
ImageReadBuffer buffer{*bytes};
auto *surface = auto *surface =
cairo_image_surface_create_from_png_stream(readFromFd, &fd); cairo_image_surface_create_from_png_stream(readFromBuffer, &buffer);
if (!surface) { if (!surface) {
return nullptr; return nullptr;
} }
@@ -221,14 +241,7 @@ ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) {
return ThemeImage::Pixmap{surface}; return ThemeImage::Pixmap{surface};
} }
GObjectUniquePtr<GInputStream> stream( auto image = loadRasterImage(*bytes);
g_unix_input_stream_new(file.fd(), false));
if (!stream) {
return nullptr;
}
GObjectUniquePtr<GdkPixbuf> image(
gdk_pixbuf_new_from_stream(stream.get(), nullptr, nullptr));
g_input_stream_close(stream.get(), nullptr, nullptr);
if (!image) { if (!image) {
return nullptr; return nullptr;
} }
@@ -236,67 +249,20 @@ ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) {
return pixBufToPixmap(image.get()); return pixBufToPixmap(image.get());
} }
std::optional<std::pair<int, int>> svgSize(RsvgHandle *svg) {
double w = 0;
double h = 0;
if (rsvg_handle_get_intrinsic_size_in_pixels(svg, &w, &h)) {
int width = std::max(1, static_cast<int>(std::ceil(w)));
int height = std::max(1, static_cast<int>(std::ceil(h)));
return std::make_pair(width, height);
}
return std::nullopt;
}
ThemeImage::CairoPattern renderSvg(RsvgHandle *svg, int width, int height) {
const cairo_rectangle_t extents{0, 0, static_cast<double>(width),
static_cast<double>(height)};
ThemeImage::Pixmap surface(
cairo_recording_surface_create(CAIRO_CONTENT_COLOR_ALPHA, &extents));
if (cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
UniqueCPtr<cairo_t, cairo_destroy> context(cairo_create(surface.get()));
if (cairo_status(context.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
const RsvgRectangle viewport{0, 0, static_cast<double>(width),
static_cast<double>(height)};
if (!rsvg_handle_render_document(svg, context.get(), &viewport, nullptr) ||
cairo_status(context.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
ThemeImage::CairoPattern pattern(
cairo_pattern_create_for_surface(surface.get()));
if (cairo_pattern_status(pattern.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
return pattern;
}
std::optional<ThemeImage::Svg> loadSvg(UnixFD &file) { std::optional<ThemeImage::Svg> loadSvg(UnixFD &file) {
if (!file.isValid()) { auto bytes = readImageSnapshot(file);
if (!bytes) {
return std::nullopt; return std::nullopt;
} }
GObjectUniquePtr<GInputStream> stream( auto surface = loadSvgImage(*bytes);
g_unix_input_stream_new(file.fd(), false)); if (!surface) {
if (!stream) {
return std::nullopt;
}
GObjectUniquePtr<RsvgHandle> handle(rsvg_handle_new_from_stream_sync(
stream.get(), nullptr, RSVG_HANDLE_FLAGS_NONE, nullptr, nullptr));
g_input_stream_close(stream.get(), nullptr, nullptr);
if (!handle) {
return std::nullopt;
}
auto size = svgSize(handle.get());
if (!size) {
return std::nullopt; return std::nullopt;
} }
ThemeImage::Svg svg; ThemeImage::Svg svg;
svg.width = size->first; svg.width = cairo_image_surface_get_width(surface.get());
svg.height = size->second; svg.height = cairo_image_surface_get_height(surface.get());
svg.pattern = renderSvg(handle.get(), svg.width, svg.height); svg.pattern.reset(cairo_pattern_create_for_surface(surface.get()));
if (!svg.pattern) { if (cairo_pattern_status(svg.pattern.get()) != CAIRO_STATUS_SUCCESS) {
return std::nullopt; return std::nullopt;
} }
return svg; return svg;
@@ -343,8 +309,12 @@ double pixelFloor(double f) { return std::floor(f + RoundEpsilon); }
template <typename CreatePattern> template <typename CreatePattern>
std::optional<ThemeImage::NineTiles> std::optional<ThemeImage::NineTiles>
createImage(int width, int height, int marginLeft, int marginTop, createImage(int width, int height, int marginLeft, int marginTop,
int marginRight, int marginBottom, bool retainSourceOffset, int marginRight, int marginBottom, bool retainSourceOffset,
CreatePattern createPattern) { CreatePattern createPattern) {
if (!safeImageMargins(width, height, marginLeft, marginTop, marginRight,
marginBottom)) {
return std::nullopt;
}
ThemeImage::NineTiles result; ThemeImage::NineTiles result;
result.width = width; result.width = width;
result.height = height; result.height = height;
@@ -396,7 +366,7 @@ createImage(ThemeImage::Pixmap pixmap, int marginLeft = 0, int marginTop = 0,
} }
int sourceWidth = cairo_image_surface_get_width(pixmap.get()); int sourceWidth = cairo_image_surface_get_width(pixmap.get());
int sourceHeight = cairo_image_surface_get_height(pixmap.get()); int sourceHeight = cairo_image_surface_get_height(pixmap.get());
if (sourceWidth % scale || sourceHeight % scale) { if (scale <= 0 || sourceWidth % scale || sourceHeight % scale) {
return std::nullopt; return std::nullopt;
} }
int width = sourceWidth / scale; int width = sourceWidth / scale;
@@ -430,7 +400,7 @@ void loadScaledImages(std::map<int, Image> &images,
int width, int height, Load load) { int width, int height, Load load) {
for (int scale = 2; scale <= supportedScale; scale++) { for (int scale = 2; scale <= supportedScale; scale++) {
auto scaledPath = scaledImagePath(path, scale); auto scaledPath = scaledImagePath(path, scale);
auto file = StandardPaths::openPath(scaledPath); auto file = StandardPaths::openPath(scaledPath, O_RDONLY | O_NONBLOCK);
if (auto image = load(scale, file, scaledPath)) { if (auto image = load(scale, file, scaledPath)) {
if (image->width == width && image->height == height) { if (image->width == width && image->height == height) {
images.emplace(scale, std::move(*image)); images.emplace(scale, std::move(*image));
@@ -568,6 +538,9 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon,
const std::string &label, uint32_t size, const std::string &label, uint32_t size,
const M3UI *classicui) const M3UI *classicui)
: size_(size) { : size_(size) {
if (!safeImageSize(size, size)) {
return;
}
bool preferTextIcon = bool preferTextIcon =
!label.empty() && !label.empty() &&
((icon == "input-keyboard" && ((icon == "input-keyboard" &&
@@ -576,7 +549,7 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon,
if (!preferTextIcon && !icon.empty()) { if (!preferTextIcon && !icon.empty()) {
std::filesystem::path iconPath = std::filesystem::path iconPath =
iconTheme.findIconPath(icon, size, 1, gdkPixbufSupportedFormats()); iconTheme.findIconPath(icon, size, 1, gdkPixbufSupportedFormats());
auto fd = StandardPaths::openPath(iconPath); auto fd = StandardPaths::openPath(iconPath, O_RDONLY | O_NONBLOCK);
if (isSvgPath(iconPath)) { if (isSvgPath(iconPath)) {
if (auto svg = loadSvg(fd)) { if (auto svg = loadSvg(fd)) {
if (auto image = createImage(svg->pattern.get(), svg->width, if (auto image = createImage(svg->pattern.get(), svg->width,
@@ -594,6 +567,9 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon,
if (!valid()) { if (!valid()) {
Pixmap textImage( Pixmap textImage(
cairo_image_surface_create(CAIRO_FORMAT_ARGB32, size, size)); cairo_image_surface_create(CAIRO_FORMAT_ARGB32, size, size));
if (cairo_surface_status(textImage.get()) != CAIRO_STATUS_SUCCESS) {
return;
}
drawTextIcon(textImage.get(), label, size, classicui->config()); drawTextIcon(textImage.get(), label, size, classicui->config());
if (auto image = createImage(std::move(textImage))) { if (auto image = createImage(std::move(textImage))) {
image_ = std::move(*image); image_ = std::move(*image);
@@ -605,7 +581,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg,
const Color &color, const Color &borderColor) { const Color &color, const Color &borderColor) {
if (!cfg.image->empty()) { if (!cfg.image->empty()) {
std::filesystem::path imagePath; std::filesystem::path imagePath;
auto imageFile = StandardPaths::global().open( auto imageFile = openImageFile(
StandardPathsType::PkgData, StandardPathsType::PkgData,
std::filesystem::path("themes") / theme.name() / *cfg.image, std::filesystem::path("themes") / theme.name() / *cfg.image,
theme.isSystemTheme() ? StandardPathsMode::System theme.isSystemTheme() ? StandardPathsMode::System
@@ -643,7 +619,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg,
if (!cfg.overlay->empty()) { if (!cfg.overlay->empty()) {
std::filesystem::path imagePath; std::filesystem::path imagePath;
auto imageFile = StandardPaths::global().open( auto imageFile = openImageFile(
StandardPathsType::PkgData, StandardPathsType::PkgData,
std::filesystem::path("themes") / theme.name() / *cfg.overlay, std::filesystem::path("themes") / theme.name() / *cfg.overlay,
theme.isSystemTheme() ? StandardPathsMode::System theme.isSystemTheme() ? StandardPathsMode::System
@@ -741,7 +717,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg,
ThemeImage::ThemeImage(const Theme &theme, const ActionImageConfig &cfg) { ThemeImage::ThemeImage(const Theme &theme, const ActionImageConfig &cfg) {
if (!cfg.image->empty()) { if (!cfg.image->empty()) {
std::filesystem::path imagePath; std::filesystem::path imagePath;
auto imageFile = StandardPaths::global().open( auto imageFile = openImageFile(
StandardPathsType::PkgData, StandardPathsType::PkgData,
std::filesystem::path("themes") / theme.name() / *cfg.image, std::filesystem::path("themes") / theme.name() / *cfg.image,
theme.isSystemTheme() ? StandardPathsMode::System theme.isSystemTheme() ? StandardPathsMode::System
@@ -1180,6 +1156,25 @@ std::vector<Rect> Theme::mask(const BackgroundImageConfig &cfg, int width,
return result; return result;
} }
void Theme::applyMonet(const MonetColors &c) {
// A classic theme draws its panel from images, there is nothing to recolour.
if (inputPanel->surfaceColor->alpha() == 0) {
return;
}
auto *panel = inputPanel.mutableValue();
panel->surfaceColor.setValue(c.surface);
panel->outlineColor.setValue(c.outline);
panel->highlightFillColor.setValue(c.highlightFill);
panel->normalColor.setValue(c.normal);
panel->highlightColor.setValue(c.highlight);
panel->highlightBackgroundColor.setValue(c.highlightBackground);
panel->highlightCandidateColor.setValue(c.highlightCandidate);
panel->candidateLabelColor.setValue(c.candidateLabel);
panel->highlightCandidateLabelColor.setValue(c.highlightCandidateLabel);
panel->candidateCommentColor.setValue(c.candidateComment);
panel->highlightCandidateCommentColor.setValue(c.highlightCandidateComment);
}
void Theme::populateColor(std::optional<Color> accent) { void Theme::populateColor(std::optional<Color> accent) {
inputPanelBackground_ = *inputPanel->background->color; inputPanelBackground_ = *inputPanel->background->color;
inputPanelBorder_ = *inputPanel->background->borderColor; inputPanelBorder_ = *inputPanel->background->borderColor;
+5
View File
@@ -30,6 +30,7 @@
#include "fcitx-utils/rect.h" #include "fcitx-utils/rect.h"
#include "fcitx/icontheme.h" #include "fcitx/icontheme.h"
#include "common.h" #include "common.h"
#include "monettheme.h"
namespace fcitx::m3ui { namespace fcitx::m3ui {
enum class Gravity { enum class Gravity {
@@ -364,6 +365,10 @@ public:
const auto &accentColorFields() const { return accentColorFields_; } const auto &accentColorFields() const { return accentColorFields_; }
void populateColor(std::optional<Color> accent); void populateColor(std::optional<Color> accent);
// Replaces the colours of a Material style theme (one with a SurfaceColor)
// with colours that follow the desktop. Other themes are left as they are.
// Call it after load() and before populateColor().
void applyMonet(const MonetColors &colors);
const auto &inputPanelBackground() const { return inputPanelBackground_; } const auto &inputPanelBackground() const { return inputPanelBackground_; }
const auto &inputPanelBorder() const { return inputPanelBorder_; } const auto &inputPanelBorder() const { return inputPanelBorder_; }
+62
View File
@@ -0,0 +1,62 @@
// The candidate window colours taken from a libmonet scheme must stay readable
// for any seed, in both modes.
// g++ -std=c++20 -Isrc -I<libmonet>/include tests/monettheme_test.cpp
// (add the flags from: pkg-config --cflags --libs Fcitx5Utils)
#include <cmath>
#include <cstdio>
#include "monettheme.h"
using namespace fcitx::m3ui;
static int failures = 0;
#define CHECK_MSG(cond, ...) \
do { \
if (!(cond)) { \
std::printf("FAIL line %d: %s ", __LINE__, #cond); \
std::printf(__VA_ARGS__); \
std::printf("\n"); \
failures++; \
} \
} while (0)
static monet::Rgb rgb(const fcitx::Color &c) {
return {static_cast<std::uint8_t>(std::lround(c.redF() * 255)), static_cast<std::uint8_t>(std::lround(c.greenF() * 255)),
static_cast<std::uint8_t>(std::lround(c.blueF() * 255))};
}
int main() {
unsigned state = 4242;
auto next = [&] { state = state * 1664525u + 1013904223u; return static_cast<std::uint8_t>(state >> 24); };
std::vector<monet::Rgb> seeds = {{61, 174, 233}, {255, 176, 201}, {0, 0, 0}, {255, 255, 255}, {128, 128, 128},
{255, 0, 0}, {0, 255, 0}, {0, 0, 255}, {255, 255, 0}, {0x67, 0x50, 0xA4}};
for (int i = 0; i < 300; i++) seeds.push_back({next(), next(), next()});
for (auto seed : seeds) {
for (auto mode : {monet::Mode::Light, monet::Mode::Dark}) {
const MonetColors c = mapScheme(monet::generate(seed, mode));
const char *m = mode == monet::Mode::Dark ? "dark" : "light";
const auto surface = rgb(c.surface), fill = rgb(c.highlightFill);
auto pair = [&](const char *name, monet::Rgb bg, const fcitx::Color &fg, double min) {
CHECK_MSG(monet::contrast(bg, rgb(fg)) >= min, "%s %s, seed %s: %.2f < %.1f", m, name, monet::hex(seed).c_str(),
monet::contrast(bg, rgb(fg)), min);
};
pair("candidate text", surface, c.normal, 7.0);
pair("candidate number", surface, c.candidateLabel, 4.5);
pair("candidate comment", surface, c.candidateComment, 4.5);
pair("typed pinyin being converted", surface, c.highlight, 4.5);
pair("text on the current candidate", fill, c.highlightCandidate, 7.0);
pair("number on the current candidate", fill, c.highlightCandidateLabel, 7.0);
pair("comment on the current candidate", fill, c.highlightCandidateComment, 7.0);
// the panel border can be seen against the panel
CHECK_MSG(monet::contrast(surface, rgb(c.outline)) >= 1.1, "%s border of seed %s", m, monet::hex(seed).c_str());
// the pill can be told apart from the panel, by tone since a container is only a few tones off the surface
CHECK_MSG(std::fabs(monet::toneOf(fill) - monet::toneOf(surface)) >= 3.0, "%s pill of seed %s: tones %.1f and %.1f", m,
monet::hex(seed).c_str(), monet::toneOf(fill), monet::toneOf(surface));
CHECK_MSG(c.surface.alpha() == 245 && c.normal.alpha() == 255 && c.highlightFill.alpha() == 255, "%s alpha of seed %s", m, monet::hex(seed).c_str());
// dark panel in dark mode, light panel in light mode
CHECK_MSG((monet::toneOf(surface) < 50) == (mode == monet::Mode::Dark), "%s panel tone of seed %s", m, monet::hex(seed).c_str());
}
}
std::printf(failures ? "%d check(s) failed\n" : "monettheme: all checks passed\n", failures);
return failures ? 1 : 0;
}
+167
View File
@@ -0,0 +1,167 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <random>
#include <string>
#include <vector>
#include "fcitx-utils/environ.h"
#include "fcitx-utils/log.h"
#include "fcitx-utils/standardpaths.h"
#include "fcitx/icontheme.h"
namespace fs = std::filesystem;
using fcitx::IconTheme;
using fcitx::setEnvironment;
using fcitx::StandardPaths;
using fcitx::StandardPathsOption;
void word(std::vector<uint8_t> &bytes, size_t offset, uint32_t value) {
for (int i = 3; i >= 0; --i) {
bytes.at(offset + 3 - i) = (value >> (8 * i)) & 255;
}
}
std::vector<uint8_t> validCache() {
std::vector<uint8_t> bytes(128);
bytes[1] = 1; // version 1.0
word(bytes, 4, 16); // hash table
word(bytes, 8, 48); // directory table
word(bytes, 16, 1); // buckets
word(bytes, 20, 24); // first node
word(bytes, 24, UINT32_MAX); // end of chain
word(bytes, 28, 64); // icon name
word(bytes, 32, 80); // image list
word(bytes, 48, 1);
word(bytes, 52, 96); // directory string
const std::string name = "audit-missing";
std::copy(name.begin(), name.end(), bytes.begin() + 64);
word(bytes, 80, 1); // one image in directory index zero
const std::string dir = "16x16/apps";
std::copy(dir.begin(), dir.end(), bytes.begin() + 96);
return bytes;
}
void save(const fs::path &path, const std::vector<uint8_t> &bytes) {
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(reinterpret_cast<const char *>(bytes.data()), bytes.size());
out.close();
FCITX_ASSERT(out.good());
fs::last_write_time(path, fs::file_time_type::clock::now() +
std::chrono::seconds(10));
}
int main() {
auto pattern = (fs::temp_directory_path() / "fcitx-icon-cache-test-XXXXXX").string();
auto *temporary = mkdtemp(pattern.data());
FCITX_ASSERT(temporary);
const fs::path root(temporary);
// The fixture is private and all recursive cleanup is limited to this root.
struct Cleanup {
fs::path root;
~Cleanup() { fs::remove_all(root); }
} cleanup{root};
setEnvironment("HOME", (root / "home").c_str());
setEnvironment("XDG_DATA_HOME", (root / "data").c_str());
setEnvironment("XDG_DATA_DIRS", (root / "empty").c_str());
StandardPaths paths("fcitx5", {}, StandardPathsOption::SkipBuiltInPath);
const auto themeDir = root / "data/icons/audit";
fs::create_directories(themeDir / "16x16/apps");
std::ofstream(themeDir / "index.theme")
<< "[Icon Theme]\nName=Audit\nDirectories=16x16/apps\n"
"[16x16/apps]\nSize=16\nType=Fixed\n";
const auto icon = themeDir / "16x16/apps/audit-missing.png";
std::ofstream(icon) << "lookup only";
const auto cache = themeDir / "icon-theme.cache";
auto check = [&](const std::vector<uint8_t> &bytes) {
save(cache, bytes);
IconTheme theme(std::string("audit"), paths);
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon);
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon);
};
check(validCache());
auto bytes = validCache();
// Distinguish cache-assisted lookup from a parser that always ignores it.
word(bytes, 80, 0);
save(cache, bytes);
{
IconTheme theme(std::string("audit"), paths);
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}).empty());
}
for (uint32_t offset : {UINT32_MAX, UINT32_MAX - 3, uint32_t(126)}) {
bytes = validCache();
word(bytes, 4, offset);
check(bytes);
}
bytes = validCache();
word(bytes, 16, UINT32_MAX); // overflowing hash table length
check(bytes);
bytes = validCache();
word(bytes, 8, UINT32_MAX - 3); // overflowing directory table offset
check(bytes);
bytes = validCache();
word(bytes, 24, 24); // self cycle on a nonmatching name
bytes[64] = 'x';
check(bytes);
bytes = validCache();
word(bytes, 24, 36); // two-node cycle
word(bytes, 36, 24);
word(bytes, 40, 64);
word(bytes, 44, 80);
bytes[64] = 'x';
check(bytes);
bytes = validCache();
word(bytes, 80, UINT32_MAX); // overflowing image count
check(bytes);
bytes = validCache();
bytes[85] = 2; // invalid directory index
check(bytes);
bytes = validCache();
bytes.resize(1200, 'x');
word(bytes, 28, 128); // no terminating NUL in 1024 bytes
check(bytes);
for (size_t length = 0; length < 128; ++length) {
bytes = validCache();
bytes.resize(length);
check(bytes);
}
// Deterministic mutation smoke test: no assumptions about whether each
// mutated cache is valid, but neither lookup may hang or access bad memory.
std::mt19937 random(0x1c0);
for (int i = 0; i < 512; ++i) {
bytes = validCache();
for (int j = 0; j < 4; ++j) {
bytes[random() % bytes.size()] = random() & 255;
}
save(cache, bytes);
IconTheme theme(std::string("audit"), paths);
theme.findIconPath("audit-missing", 16, 1, {".png"});
theme.findIconPath("audit-missing", 32, 2, {".png"});
}
save(cache, validCache());
{
IconTheme theme(std::string("audit"), paths);
std::ofstream(cache, std::ios::trunc).close();
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon);
}
fs::remove(cache);
FCITX_ASSERT(mkfifo(cache.c_str(), 0600) == 0);
{
IconTheme theme(std::string("audit"), paths);
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon);
}
fs::remove(cache);
// Oversized caches are ignored without loading their contents.
save(cache, validCache());
fs::resize_file(cache, 16 * 1024 * 1024 + 1);
{
IconTheme theme(std::string("audit"), paths);
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon);
}
return 0;
}
+63
View File
@@ -0,0 +1,63 @@
#include <memory>
#include <vector>
#include "fcitx-utils/log.h"
#include "fcitx/candidatelist.h"
#include "../src/candidatemenu.h"
using namespace fcitx;
using namespace fcitx::m3ui;
namespace {
class EngineActions : public ActionableCandidateList {
public:
explicit EngineActions(bool available) : available_(available) {}
bool hasAction(const CandidateWord &) const override { return available_; }
std::vector<CandidateAction>
candidateActions(const CandidateWord &) const override {
CandidateAction action;
action.setId(-42); // IDs must not be interpreted by the UI.
action.setText("Engine action");
CandidateAction separator;
separator.setSeparator(true);
return {separator, action};
}
void triggerAction(const CandidateWord &, int) override {}
private:
bool available_;
};
} // namespace
int main() {
CommonCandidateList list;
list.setPageSize(5);
list.append<DisplayOnlyCandidateWord>(Text("候选词"));
list.append<DisplayOnlyCandidateWord>(Text("保留的词"));
// Never invent a blocking action when ranking operations are unavailable.
auto menu = candidateMenu(list, list.candidate(0));
FCITX_ASSERT(menu.actions.empty());
// An engine can have an action interface but no actions for this word.
list.setActionableImpl(std::make_unique<EngineActions>(false));
menu = candidateMenu(list, list.candidate(0));
FCITX_ASSERT(menu.actions.empty());
// Keep engine actions and IDs intact; omit separators, never append blocking.
list.setActionableImpl(std::make_unique<EngineActions>(true));
menu = candidateMenu(list, list.candidate(0));
FCITX_ASSERT(menu.actions.size() == 1);
FCITX_ASSERT(menu.actions[0].id() == -42);
FCITX_ASSERT(menu.actions[0].text() == "Engine action");
FCITX_ASSERT(list.size() == 2);
FCITX_ASSERT(list.candidate(0).text().toString() == "候选词");
// Do not offer an ineffective block action for a read-only list.
DisplayOnlyCandidateList readOnly;
readOnly.setContent(std::vector<std::string>{"只读"});
menu = candidateMenu(readOnly, readOnly.candidate(0));
FCITX_ASSERT(menu.actions.empty());
}
+77
View File
@@ -0,0 +1,77 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <cstdint>
#include <string>
#include <vector>
#include "fcitx-utils/log.h"
#include "../src/imageloader.h"
using namespace fcitx::m3ui;
int main() {
FCITX_ASSERT(!loadRasterImage({}));
FCITX_ASSERT(!loadSvgImage({}));
FCITX_ASSERT(!loadRasterImage({'b', 'a', 'd'}));
FCITX_ASSERT(!loadSvgImage({'b', 'a', 'd'}));
// Valid PNG bytes exercise the real incremental decoder, regardless of
// extension. The reference must remain valid after loader destruction.
LoadedSurface surface(cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 16, 16));
std::vector<uint8_t> png;
FCITX_ASSERT(cairo_surface_write_to_png_stream(
surface.get(),
+[](void *data, const unsigned char *bytes, unsigned int length) {
auto &out = *static_cast<std::vector<uint8_t> *>(data);
out.insert(out.end(), bytes, bytes + length);
return CAIRO_STATUS_SUCCESS;
},
&png) == CAIRO_STATUS_SUCCESS);
auto image = loadRasterImage(png);
FCITX_ASSERT(image);
FCITX_ASSERT(gdk_pixbuf_get_width(image.get()) == 16);
FCITX_ASSERT(gdk_pixbuf_get_height(image.get()) == 16);
png[16] = 255; // Huge IHDR, rejected before entering libpng.
FCITX_ASSERT(!loadRasterImage(png));
png.resize(23);
FCITX_ASSERT(!loadRasterImage(png));
// A real non-PNG image exercises size-prepared rather than PNG preflight.
gchar *bmpData = nullptr;
gsize bmpSize = 0;
FCITX_ASSERT(gdk_pixbuf_save_to_buffer(image.get(), &bmpData, &bmpSize,
"bmp", nullptr, nullptr));
std::vector<uint8_t> bmp(bmpData, bmpData + bmpSize);
g_free(bmpData);
FCITX_ASSERT(loadRasterImage(bmp));
// BMP BITMAPINFOHEADER width is little-endian at byte 18.
FCITX_ASSERT(bmp.size() > 26 && bmp[14] >= 40);
bmp[18] = 1;
bmp[19] = 32; // 8193 pixels wide
bmp[20] = bmp[21] = 0;
FCITX_ASSERT(!loadRasterImage(bmp));
auto svg = [](const std::string &text) {
return loadSvgImage(std::vector<uint8_t>(text.begin(), text.end()));
};
auto rendered = svg("<svg xmlns='http://www.w3.org/2000/svg' width='16' "
"height='16'><rect width='16' height='16' "
"fill='red'/></svg>");
FCITX_ASSERT(rendered);
FCITX_ASSERT(cairo_surface_get_type(rendered.get()) == CAIRO_SURFACE_TYPE_IMAGE);
FCITX_ASSERT(cairo_image_surface_get_width(rendered.get()) == 16);
FCITX_ASSERT(cairo_image_surface_get_height(rendered.get()) == 16);
cairo_surface_flush(rendered.get());
const auto *pixel = reinterpret_cast<const uint32_t *>(
cairo_image_surface_get_data(rendered.get()));
FCITX_ASSERT(pixel[0] == 0xffff0000); // Native-endian Cairo ARGB32.
FCITX_ASSERT(!svg("<svg xmlns='http://www.w3.org/2000/svg' "
"width='1e30' height='16'/>"));
FCITX_ASSERT(!svg("<svg xmlns='http://www.w3.org/2000/svg' "
"width='8192' height='8192'/>"));
FCITX_ASSERT(!svg("<svg xmlns='http://www.w3.org/2000/svg' "
"width='0' height='16'/>"));
FCITX_ASSERT(!svg("<svg xmlns='http://www.w3.org/2000/svg'"));
std::vector<uint8_t> oversized(16 * 1024 * 1024 + 1);
FCITX_ASSERT(!loadRasterImage(oversized));
FCITX_ASSERT(!loadSvgImage(oversized));
}
+63
View File
@@ -0,0 +1,63 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <fcntl.h>
#include <unistd.h>
#include <cstdlib>
#include <filesystem>
#include <limits>
#include "fcitx-utils/log.h"
#include "../src/resourceguard.h"
using namespace fcitx::m3ui;
int main() {
FCITX_ASSERT(safeImageSize(1, 1));
FCITX_ASSERT(safeImageSize(4096, 4096));
FCITX_ASSERT(safeImageSize(8192, 128));
FCITX_ASSERT(!safeImageSize(0, 16));
FCITX_ASSERT(!safeImageSize(-1, 16));
FCITX_ASSERT(!safeImageSize(8193, 16));
FCITX_ASSERT(!safeImageSize(8192, 8192));
FCITX_ASSERT(!safeImageSize(4096.1, 4096));
FCITX_ASSERT(!safeImageSize(std::numeric_limits<double>::infinity(), 16));
FCITX_ASSERT(!safeImageSize(std::numeric_limits<double>::quiet_NaN(), 16));
FCITX_ASSERT(safeImageMargins(16, 16, 0, 0, 0, 0));
FCITX_ASSERT(safeImageMargins(16, 16, 8, 8, 8, 8));
FCITX_ASSERT(!safeImageMargins(16, 16, 9, 8, 8, 8));
FCITX_ASSERT(!safeImageMargins(16, 16, -1, 0, 0, 0));
FCITX_ASSERT(!safeImageMargins(0, 16, 0, 0, 0, 0));
FCITX_ASSERT(!safeImageMargins(16, 16,
std::numeric_limits<int>::max(), 0, 1, 0));
std::vector<uint8_t> png = {137, 80, 78, 71, 13, 10, 26, 10,
0, 0, 0, 13, 'I', 'H', 'D', 'R',
0, 0, 0, 16, 0, 0, 0, 16};
FCITX_ASSERT(safePngSize(png));
png[16] = 255;
FCITX_ASSERT(!safePngSize(png));
png[16] = 0;
png[19] = 0;
FCITX_ASSERT(!safePngSize(png));
png.resize(23);
FCITX_ASSERT(!safePngSize(png));
FCITX_ASSERT(!readImageSnapshot(fcitx::UnixFD{}));
auto name = (std::filesystem::temp_directory_path() /
"fcitx-image-test-XXXXXX").string();
fcitx::UnixFD file = fcitx::UnixFD::own(mkstemp(name.data()));
FCITX_ASSERT(file.isValid());
// Unlink immediately; the descriptor owns this isolated fixture.
FCITX_ASSERT(unlink(name.c_str()) == 0);
FCITX_ASSERT(!readImageSnapshot(file));
FCITX_ASSERT(write(file.fd(), "image", 5) == 5);
FCITX_ASSERT(lseek(file.fd(), 0, SEEK_SET) == 0);
auto bytes = readImageSnapshot(file);
FCITX_ASSERT(bytes && bytes->size() == 5);
// A short read (e.g. concurrent truncation) is rejected.
FCITX_ASSERT(lseek(file.fd(), 3, SEEK_SET) == 3);
FCITX_ASSERT(!readImageSnapshot(file));
FCITX_ASSERT(ftruncate(file.fd(), maxImageFileSize + 1) == 0);
FCITX_ASSERT(!readImageSnapshot(file));
int fds[2];
FCITX_ASSERT(pipe(fds) == 0);
fcitx::UnixFD readEnd = fcitx::UnixFD::own(fds[0]);
fcitx::UnixFD writeEnd = fcitx::UnixFD::own(fds[1]);
FCITX_ASSERT(!readImageSnapshot(readEnd));
}