Use native candidate actions and harden frontend resource loading
This commit is contained in:
1 parent
99fe8f589f
commit
d8e780dfa1
18 files changed
+1083
-227
No files matched your search
@@ -10,7 +10,7 @@
|
||||
刚度、阻尼、方向、距离都可以调。
|
||||
- 在候选词上**右键**打开动作菜单,内容取决于输入法提供的动作。拼音有
|
||||
"忘记候选词 / 置顶为自定义短语 / 从自定义短语中删除"。
|
||||
- **忘记候选词后不会再出现**,包括系统词库和扩展词库里的词(见下文"屏蔽列表")。
|
||||
- 候选菜单只调用引擎提供的原生动作,不在前端屏蔽词语。
|
||||
- Wayland 和 X11(Xwayland)输入上下文都支持,定位和鼠标处理沿用 `classicui`。
|
||||
- 可选的 KWin 辅助脚本:只缩短候选框自己的 KWin 淡出时间。
|
||||
|
||||
@@ -104,23 +104,23 @@ systemctl --user daemon-reload && systemctl --user restart app-org.fcitx.Fcitx5@
|
||||
`SurfaceColor` 完全透明时回退到 classicui 的图片绘制,所以原有的 classicui
|
||||
主题仍然可用(但没有动画)。
|
||||
|
||||
## 屏蔽列表
|
||||
## 候选动作与资源防护
|
||||
|
||||
拼音引擎的"忘记候选词"只会删除你自己打字学到的词,系统词库(`sc.dict`)和
|
||||
扩展词库里的词是只读的,忘记之后重新输入仍会出现,当前输入状态里也一样。
|
||||
右键菜单只显示引擎的 `candidateActions()` 并通过 `triggerAction()` 调用。
|
||||
“忘记”由引擎处理,不保证永久隐藏或降低到某个排名。前端不再读取或写入
|
||||
旧的 `blocked-words.txt`,已有文件不会被删除。
|
||||
|
||||
所以 m3ui 在你点"忘记候选词"时,会把这个词记到
|
||||
`~/.local/share/fcitx5/m3ui/blocked-words.txt`(一行一个词),并在每次刷新候选
|
||||
之前把它们从候选列表里摘掉。
|
||||
图片限制为 16 MiB 文件、单边不超过 8192 像素、总计不超过 16 Mi 像素。
|
||||
SVG 渲染一次后缓存为位图,异常资源回退到既有主题或文字图标。
|
||||
这些限制不是解码沙箱,不能保证复杂 SVG 的处理时间,也不能替代底层库安全更新。
|
||||
|
||||
- 按候选词的完整文字精确匹配,只隐藏这个词本身。
|
||||
- **重启不会丢**:文件是原子写入的(先写临时文件、`fsync` 再改名,中途被杀也不会留下写坏的文件),
|
||||
权限是 `600`;某次保存失败时这个词在本次运行里仍然隐藏,下次会重试写入。
|
||||
- 文件被别的程序或手动修改后会自动重新读取,不用重启。
|
||||
- 只有**通过右键菜单点"忘记候选词"**才会记录。用拼音自带的快捷键(`Ctrl+7`)忘记的词不会被记录,
|
||||
这类词如果来自系统词库,下次还会出现。
|
||||
- 想恢复某个词:删掉文件里对应的一行即可。
|
||||
- 这是界面层的补充。换回原版 classicui 后,这些词会重新出现。
|
||||
`patches/fcitx5-icon-cache.patch` 修复核心图标缓存的循环链、越界、
|
||||
字符串终止和映射文件截断问题,损坏缓存回退为文件查找。
|
||||
`build.sh` 会检查并应用此补丁;但 `install.sh` 只安装插件,
|
||||
**不会替换运行中的 Fcitx5 核心库**,所以仅安装插件不代表核心修复已部署。
|
||||
|
||||
构建后可运行 `bash scripts/test.sh` 验证候选菜单、缓存和真实图片解码。
|
||||
也可通过 `FCITX_SOURCE_DIR`、`FCITX_BUILD_DIR` 指定已有源码和构建目录。
|
||||
|
||||
## KWin 淡出(可选,KDE Plasma)
|
||||
|
||||
|
||||
@@ -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 =
|
||||
@@ -55,6 +55,15 @@ fetch_commit https://git.oeo.one/justme/libmonet.git "$work/libmonet" "$monet_co
|
||||
[ -z "${FETCH_ONLY:-}" ] || { echo ">> sources are in place"; exit 0; }
|
||||
|
||||
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"
|
||||
cp -r "$root/src" "$srcdir/src/ui/m3ui"
|
||||
if ! grep -q 'add_subdirectory(m3ui)' "$srcdir/src/ui/CMakeLists.txt"; then
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -36,7 +36,7 @@ if (NOT MONET_INCLUDE_DIR OR NOT EXISTS "${MONET_INCLUDE_DIR}/monet/monet.h")
|
||||
endif()
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -151,24 +151,6 @@ M3UI::M3UI(Instance *instance) : instance_(instance) {
|
||||
|
||||
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() {
|
||||
readAsIni(config_, "conf/m3ui.conf");
|
||||
reloadTheme();
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
#include "fcitx/event.h"
|
||||
#include "fcitx/instance.h"
|
||||
#include "fcitx/userinterface.h"
|
||||
#include "blockstore.h"
|
||||
#include "classicui_public.h"
|
||||
#include "config.h"
|
||||
#include "plasmathemewatchdog.h"
|
||||
@@ -274,25 +273,7 @@ public:
|
||||
bool preferTextIcon() 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:
|
||||
static std::filesystem::path blockedWordsPath();
|
||||
BlockStore blockStore_{blockedWordsPath()};
|
||||
|
||||
FCITX_ADDON_DEPENDENCY_LOADER(notificationitem, instance_->addonManager());
|
||||
FCITX_ADDON_EXPORT_FUNCTION(M3UI, labelIcon);
|
||||
FCITX_ADDON_EXPORT_FUNCTION(M3UI, preferTextIcon);
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -45,7 +45,7 @@
|
||||
#include "fcitx/text.h"
|
||||
#include "fcitx/userinterface.h"
|
||||
#include "classicui.h"
|
||||
#include "blockfilter.h"
|
||||
#include "candidatemenu.h"
|
||||
#include "common.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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
std::chrono::steady_clock::time_point now) {
|
||||
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());
|
||||
auxDown = instance->outputFilter(inputContext, inputPanel.auxDown());
|
||||
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()) {
|
||||
auxUp = inputPanel.overlayMessage();
|
||||
}
|
||||
@@ -590,19 +572,31 @@ std::pair<unsigned int, unsigned int> InputWindow::sizeHint() {
|
||||
float yogaWidth = YGNodeLayoutGetWidth(rootNode_.get());
|
||||
float yogaHeight = YGNodeLayoutGetHeight(rootNode_.get());
|
||||
|
||||
menuTop_ = yogaHeight;
|
||||
panelWidth_ = yogaWidth;
|
||||
panelHeight_ = yogaHeight;
|
||||
menuTop_ = yogaHeight + 8;
|
||||
if (menuOpen_) {
|
||||
auto [menuWidth, menuHeight] = menuSize();
|
||||
menuWidth_ = 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 height = static_cast<unsigned int>(yogaHeight);
|
||||
auto width = static_cast<unsigned int>(std::ceil(yogaWidth));
|
||||
auto height = static_cast<unsigned int>(std::ceil(yogaHeight));
|
||||
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();
|
||||
cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR);
|
||||
cairo_paint(cr);
|
||||
@@ -651,7 +645,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
|
||||
break;
|
||||
}
|
||||
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);
|
||||
cairoSetSourceColor(cr, *theme.inputPanel->surfaceColor);
|
||||
cairo_fill_preserve(cr);
|
||||
@@ -664,7 +658,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
|
||||
}
|
||||
cairo_restore(cr);
|
||||
} else {
|
||||
theme.paint(cr, *theme.inputPanel->background, 0, 0, width, height,
|
||||
theme.paint(cr, *theme.inputPanel->background, 0, 0, panelWidth_, panelHeight_,
|
||||
/*alpha=*/1.0);
|
||||
}
|
||||
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;
|
||||
double w = YGNodeLayoutGetWidth(inner.get());
|
||||
if (*theme.inputPanel->fullWidthHighlight && vertical) {
|
||||
w = width - *margin.marginLeft - *margin.marginRight -
|
||||
w = panelWidth_ - *margin.marginLeft - *margin.marginRight -
|
||||
*textMargin.marginRight - *textMargin.marginLeft;
|
||||
}
|
||||
RectF target;
|
||||
@@ -810,7 +804,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
|
||||
|
||||
if (*theme.inputPanel->fullWidthHighlight && vertical) {
|
||||
// Last candidate, fill.
|
||||
highlightWidth = width - *margin.marginLeft - *margin.marginRight -
|
||||
highlightWidth = panelWidth_ - *margin.marginLeft - *margin.marginRight -
|
||||
*textMargin.marginRight - *textMargin.marginLeft;
|
||||
}
|
||||
|
||||
@@ -942,7 +936,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
|
||||
}
|
||||
|
||||
if (menuOpen_) {
|
||||
paintMenu(cr, width, menuTop_, fontHeight);
|
||||
paintMenu(cr);
|
||||
}
|
||||
|
||||
if (m3ui_logcategory().checkLogLevel(Debug)) {
|
||||
@@ -971,31 +965,26 @@ void InputWindow::click(int x, int y) {
|
||||
if (menuOpen_) {
|
||||
// Copy what is needed first, triggering an action rebuilds the list.
|
||||
int actionId = -1;
|
||||
std::string actionText;
|
||||
bool actionSelected = false;
|
||||
const auto candidateIndex = menuCandidate_;
|
||||
const std::string candidateText = menuCandidateText_;
|
||||
for (size_t i = 0; i < menuRegions_.size(); i++) {
|
||||
if (menuRegions_[i].contains(x, y)) {
|
||||
actionSelected = true;
|
||||
actionId = menuActions_[i].id();
|
||||
actionText = menuActions_[i].text();
|
||||
break;
|
||||
}
|
||||
}
|
||||
closeMenu();
|
||||
if (actionId >= 0) {
|
||||
if (actionSelected) {
|
||||
auto *actionable = candidateList->toActionable();
|
||||
const auto *candidate =
|
||||
nthCandidateIgnorePlaceholder(*candidateList, candidateIndex);
|
||||
// The list may have been refreshed since the menu was opened. Only
|
||||
// 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 &&
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1029,6 +1018,7 @@ void InputWindow::click(int x, int y) {
|
||||
}
|
||||
|
||||
void InputWindow::rightClick(int x, int y) {
|
||||
CLASSICUI_DEBUG() << "Candidate right click at " << x << ", " << y;
|
||||
auto *inputContext = inputContext_.get();
|
||||
if (!inputContext) {
|
||||
return;
|
||||
@@ -1042,36 +1032,32 @@ void InputWindow::rightClick(int x, int y) {
|
||||
inputContext->updateUserInterface(UserInterfaceComponent::InputPanel);
|
||||
return;
|
||||
}
|
||||
auto *actionable = candidateList->toActionable();
|
||||
if (!actionable) {
|
||||
return;
|
||||
}
|
||||
for (size_t idx = 0, e = candidateRegions_.size(); idx < e; idx++) {
|
||||
if (!candidateRegions_[idx].contains(x, y)) {
|
||||
continue;
|
||||
}
|
||||
const auto *candidate =
|
||||
nthCandidateIgnorePlaceholder(*candidateList, idx);
|
||||
if (!candidate || !actionable->hasAction(*candidate)) {
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
std::vector<CandidateAction> actions;
|
||||
for (auto &action : actionable->candidateActions(*candidate)) {
|
||||
if (!action.isSeparator()) {
|
||||
actions.push_back(std::move(action));
|
||||
}
|
||||
}
|
||||
if (actions.empty()) {
|
||||
auto menu = candidateMenu(*candidateList, *candidate);
|
||||
CLASSICUI_DEBUG() << "Candidate menu: engine action interface="
|
||||
<< (candidateList->toActionable() != nullptr)
|
||||
<< ", actions=" << menu.actions.size();
|
||||
if (menu.actions.empty()) {
|
||||
return;
|
||||
}
|
||||
menuActions_ = std::move(actions);
|
||||
menuActions_ = std::move(menu.actions);
|
||||
menuCandidate_ = idx;
|
||||
menuCandidateText_ = candidate->text().toString();
|
||||
menuAnchor_ = candidateRegions_[idx].left();
|
||||
menuHover_ = -1;
|
||||
menuOpen_ = true;
|
||||
inputContext->updateUserInterface(UserInterfaceComponent::InputPanel);
|
||||
return;
|
||||
}
|
||||
CLASSICUI_DEBUG() << "Candidate right click did not hit a candidate region";
|
||||
}
|
||||
|
||||
void InputWindow::closeMenu() {
|
||||
@@ -1081,6 +1067,7 @@ void InputWindow::closeMenu() {
|
||||
menuHover_ = -1;
|
||||
menuActions_.clear();
|
||||
menuRegions_.clear();
|
||||
menuLayouts_.clear();
|
||||
}
|
||||
|
||||
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) +
|
||||
pango_font_metrics_get_descent(metrics));
|
||||
pango_font_metrics_unref(metrics);
|
||||
menuItemHeight_ = fontHeight + 16;
|
||||
menuTextWidth_ = 0;
|
||||
menuItemHeight_ = std::max(40, fontHeight + 16);
|
||||
double textWidth = 0;
|
||||
menuLayouts_.clear();
|
||||
for (const auto &action : menuActions_) {
|
||||
auto layout = newPangoLayout(context_.get());
|
||||
pango_layout_set_text(layout.get(), action.text().c_str(), -1);
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
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.
|
||||
return {menuTextWidth_ + 2 * 16 + 2 * 8,
|
||||
1.0 + 6 + menuActions_.size() * menuItemHeight_ + 6};
|
||||
// M3 menu spacing, adapted to a compact desktop input panel: 16px
|
||||
// horizontal / 8px vertical padding and at least 40px per action.
|
||||
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,
|
||||
double /*fontHeight*/) {
|
||||
void InputWindow::paintMenu(cairo_t *cr) {
|
||||
auto &theme = parent_->theme();
|
||||
cairo_save(cr);
|
||||
auto line = *theme.inputPanel->outlineColor;
|
||||
if (line.alpha() == 0) {
|
||||
const auto &text = theme.inputPanelText();
|
||||
line = Color(text.red(), text.green(), text.blue(), 40);
|
||||
}
|
||||
cairoSetSourceColor(cr, line);
|
||||
cairo_rectangle(cr, 12, yTop, width - 24, 1);
|
||||
cairo_fill(cr);
|
||||
const double height = 16.0 + menuActions_.size() * menuItemHeight_;
|
||||
auto surface = materialStyle() ? *theme.inputPanel->surfaceColor
|
||||
: theme.inputPanelBackground();
|
||||
// Menus must remain legible over application content, even when the
|
||||
// candidate panel is configured to be translucent.
|
||||
surface.setAlphaF(1);
|
||||
fillRoundedRect(cr, surface, menuLeft_, menuTop_, menuWidth_, height, 12);
|
||||
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++) {
|
||||
Rect region;
|
||||
region.setPosition(8, y).setSize(width - 16, menuItemHeight_);
|
||||
menuRegions_.push_back(region);
|
||||
const auto ®ion = menuRegions_[i];
|
||||
const bool hovered = static_cast<int>(i) == menuHover_;
|
||||
if (hovered) {
|
||||
fillRoundedRect(cr, *theme.inputPanel->highlightFillColor, 8, y,
|
||||
width - 16, menuItemHeight_,
|
||||
*theme.inputPanel->itemRadius);
|
||||
auto state = theme.inputPanelText();
|
||||
state.setAlphaF(0.08);
|
||||
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;
|
||||
pango_layout_get_pixel_size(layout.get(), &w, &h);
|
||||
cairoSetSourceColor(cr, hovered
|
||||
? theme.inputPanelHighlightCandidateText()
|
||||
: theme.inputPanelText());
|
||||
renderLayout(cr, layout.get(), 8 + 16, y + (menuItemHeight_ - h) / 2.0);
|
||||
y += menuItemHeight_;
|
||||
pango_layout_get_pixel_size(menuLayouts_[i].get(), nullptr, &h);
|
||||
cairoSetSourceColor(cr, theme.inputPanelText());
|
||||
renderLayout(cr, menuLayouts_[i].get(), menuLeft_ + 16,
|
||||
region.top() + (menuItemHeight_ - h) / 2.0);
|
||||
}
|
||||
cairo_restore(cr);
|
||||
}
|
||||
|
||||
+7
-3
@@ -194,7 +194,7 @@ protected:
|
||||
|
||||
// Candidate action menu.
|
||||
void closeMenu();
|
||||
void paintMenu(cairo_t *cr, double width, double yTop, double fontHeight);
|
||||
void paintMenu(cairo_t *cr);
|
||||
std::pair<double, double> menuSize();
|
||||
bool menuOpen_ = false;
|
||||
int menuCandidate_ = -1;
|
||||
@@ -206,9 +206,13 @@ protected:
|
||||
std::vector<CandidateAction> menuActions_;
|
||||
std::vector<Rect> menuRegions_;
|
||||
double menuTop_ = 0;
|
||||
double menuLeft_ = 0;
|
||||
double menuAnchor_ = 0;
|
||||
double menuWidth_ = 0;
|
||||
double panelWidth_ = 0;
|
||||
double panelHeight_ = 0;
|
||||
int menuItemHeight_ = 0;
|
||||
std::vector<std::string> menuLabels_;
|
||||
double menuTextWidth_ = 0;
|
||||
std::vector<GObjectUniquePtr<PangoLayout>> menuLayouts_;
|
||||
|
||||
private:
|
||||
std::pair<unsigned int, unsigned int> sizeHint();
|
||||
|
||||
@@ -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
|
||||
+63
-87
@@ -12,6 +12,7 @@
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -24,11 +25,8 @@
|
||||
#include <cairo.h>
|
||||
#include <format>
|
||||
#include <gdk-pixbuf/gdk-pixbuf.h>
|
||||
#include <gio/gio.h>
|
||||
#include <gio/gunixinputstream.h>
|
||||
#include <glib.h>
|
||||
#include <glibconfig.h>
|
||||
#include <librsvg/rsvg.h>
|
||||
#include <pango/pango-font.h>
|
||||
#include <pango/pango-fontmap.h>
|
||||
#include <pango/pango-layout.h>
|
||||
@@ -51,11 +49,27 @@
|
||||
#include "classicui.h"
|
||||
#include "colorhelper.h"
|
||||
#include "common.h"
|
||||
#include "imageloader.h"
|
||||
#include "resourceguard.h"
|
||||
|
||||
namespace fcitx::m3ui {
|
||||
|
||||
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) {
|
||||
if (g_unichar_iszerowidth(c)) {
|
||||
return 0;
|
||||
@@ -88,18 +102,19 @@ std::pair<std::string, size_t> extractTextForLabel(const std::string &label) {
|
||||
return {extracted, currentWidth};
|
||||
}
|
||||
|
||||
cairo_status_t readFromFd(void *closure, unsigned char *data,
|
||||
unsigned int length) {
|
||||
int fd = *static_cast<int *>(closure);
|
||||
struct ImageReadBuffer {
|
||||
const std::vector<uint8_t> &bytes;
|
||||
size_t offset = 0;
|
||||
};
|
||||
|
||||
while (length) {
|
||||
auto sz = fs::safeRead(fd, data, length);
|
||||
if (sz <= 0) {
|
||||
return CAIRO_STATUS_READ_ERROR;
|
||||
}
|
||||
length -= sz;
|
||||
data += sz;
|
||||
cairo_status_t readFromBuffer(void *closure, unsigned char *data,
|
||||
unsigned int length) {
|
||||
auto &buffer = *static_cast<ImageReadBuffer *>(closure);
|
||||
if (length > buffer.bytes.size() - buffer.offset) {
|
||||
return CAIRO_STATUS_READ_ERROR;
|
||||
}
|
||||
std::memcpy(data, buffer.bytes.data() + buffer.offset, length);
|
||||
buffer.offset += length;
|
||||
return CAIRO_STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
@@ -204,13 +219,18 @@ ThemeImage::Pixmap pixBufToPixmap(GdkPixbuf *image) {
|
||||
}
|
||||
|
||||
ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) {
|
||||
if (file.fd() < 0) {
|
||||
auto bytes = readImageSnapshot(file);
|
||||
if (!bytes) {
|
||||
return nullptr;
|
||||
}
|
||||
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 =
|
||||
cairo_image_surface_create_from_png_stream(readFromFd, &fd);
|
||||
cairo_image_surface_create_from_png_stream(readFromBuffer, &buffer);
|
||||
if (!surface) {
|
||||
return nullptr;
|
||||
}
|
||||
@@ -221,14 +241,7 @@ ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) {
|
||||
return ThemeImage::Pixmap{surface};
|
||||
}
|
||||
|
||||
GObjectUniquePtr<GInputStream> stream(
|
||||
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);
|
||||
auto image = loadRasterImage(*bytes);
|
||||
if (!image) {
|
||||
return nullptr;
|
||||
}
|
||||
@@ -236,67 +249,20 @@ ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) {
|
||||
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) {
|
||||
if (!file.isValid()) {
|
||||
auto bytes = readImageSnapshot(file);
|
||||
if (!bytes) {
|
||||
return std::nullopt;
|
||||
}
|
||||
GObjectUniquePtr<GInputStream> stream(
|
||||
g_unix_input_stream_new(file.fd(), false));
|
||||
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) {
|
||||
auto surface = loadSvgImage(*bytes);
|
||||
if (!surface) {
|
||||
return std::nullopt;
|
||||
}
|
||||
ThemeImage::Svg svg;
|
||||
svg.width = size->first;
|
||||
svg.height = size->second;
|
||||
svg.pattern = renderSvg(handle.get(), svg.width, svg.height);
|
||||
if (!svg.pattern) {
|
||||
svg.width = cairo_image_surface_get_width(surface.get());
|
||||
svg.height = cairo_image_surface_get_height(surface.get());
|
||||
svg.pattern.reset(cairo_pattern_create_for_surface(surface.get()));
|
||||
if (cairo_pattern_status(svg.pattern.get()) != CAIRO_STATUS_SUCCESS) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return svg;
|
||||
@@ -343,8 +309,12 @@ double pixelFloor(double f) { return std::floor(f + RoundEpsilon); }
|
||||
template <typename CreatePattern>
|
||||
std::optional<ThemeImage::NineTiles>
|
||||
createImage(int width, int height, int marginLeft, int marginTop,
|
||||
int marginRight, int marginBottom, bool retainSourceOffset,
|
||||
CreatePattern createPattern) {
|
||||
int marginRight, int marginBottom, bool retainSourceOffset,
|
||||
CreatePattern createPattern) {
|
||||
if (!safeImageMargins(width, height, marginLeft, marginTop, marginRight,
|
||||
marginBottom)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
ThemeImage::NineTiles result;
|
||||
result.width = width;
|
||||
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 sourceHeight = cairo_image_surface_get_height(pixmap.get());
|
||||
if (sourceWidth % scale || sourceHeight % scale) {
|
||||
if (scale <= 0 || sourceWidth % scale || sourceHeight % scale) {
|
||||
return std::nullopt;
|
||||
}
|
||||
int width = sourceWidth / scale;
|
||||
@@ -430,7 +400,7 @@ void loadScaledImages(std::map<int, Image> &images,
|
||||
int width, int height, Load load) {
|
||||
for (int scale = 2; scale <= supportedScale; 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 (image->width == width && image->height == height) {
|
||||
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 M3UI *classicui)
|
||||
: size_(size) {
|
||||
if (!safeImageSize(size, size)) {
|
||||
return;
|
||||
}
|
||||
bool preferTextIcon =
|
||||
!label.empty() &&
|
||||
((icon == "input-keyboard" &&
|
||||
@@ -576,7 +549,7 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon,
|
||||
if (!preferTextIcon && !icon.empty()) {
|
||||
std::filesystem::path iconPath =
|
||||
iconTheme.findIconPath(icon, size, 1, gdkPixbufSupportedFormats());
|
||||
auto fd = StandardPaths::openPath(iconPath);
|
||||
auto fd = StandardPaths::openPath(iconPath, O_RDONLY | O_NONBLOCK);
|
||||
if (isSvgPath(iconPath)) {
|
||||
if (auto svg = loadSvg(fd)) {
|
||||
if (auto image = createImage(svg->pattern.get(), svg->width,
|
||||
@@ -594,6 +567,9 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon,
|
||||
if (!valid()) {
|
||||
Pixmap textImage(
|
||||
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());
|
||||
if (auto image = createImage(std::move(textImage))) {
|
||||
image_ = std::move(*image);
|
||||
@@ -605,7 +581,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg,
|
||||
const Color &color, const Color &borderColor) {
|
||||
if (!cfg.image->empty()) {
|
||||
std::filesystem::path imagePath;
|
||||
auto imageFile = StandardPaths::global().open(
|
||||
auto imageFile = openImageFile(
|
||||
StandardPathsType::PkgData,
|
||||
std::filesystem::path("themes") / theme.name() / *cfg.image,
|
||||
theme.isSystemTheme() ? StandardPathsMode::System
|
||||
@@ -643,7 +619,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg,
|
||||
|
||||
if (!cfg.overlay->empty()) {
|
||||
std::filesystem::path imagePath;
|
||||
auto imageFile = StandardPaths::global().open(
|
||||
auto imageFile = openImageFile(
|
||||
StandardPathsType::PkgData,
|
||||
std::filesystem::path("themes") / theme.name() / *cfg.overlay,
|
||||
theme.isSystemTheme() ? StandardPathsMode::System
|
||||
@@ -741,7 +717,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg,
|
||||
ThemeImage::ThemeImage(const Theme &theme, const ActionImageConfig &cfg) {
|
||||
if (!cfg.image->empty()) {
|
||||
std::filesystem::path imagePath;
|
||||
auto imageFile = StandardPaths::global().open(
|
||||
auto imageFile = openImageFile(
|
||||
StandardPathsType::PkgData,
|
||||
std::filesystem::path("themes") / theme.name() / *cfg.image,
|
||||
theme.isSystemTheme() ? StandardPathsMode::System
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user