mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
Paste pictures into the prompt box with Ctrl+V and show attachments as picture tiles (#631)
Pictures can be pasted into the prompt box with Ctrl+V (Cmd+V, Shift+Insert): a copied image is saved as a PNG in the person's own cache folder and attached, copied picture files are attached as they are, and attachments show as picture tiles.
This commit is contained in:
Generated
+1
@@ -1378,6 +1378,7 @@ dependencies = [
|
||||
name = "craft-anything-ui-egui"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-anything-ai",
|
||||
"craft-anything-engine",
|
||||
"craft-anything-engines",
|
||||
|
||||
@@ -41,6 +41,8 @@ egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
# Pictures pasted from the clipboard (egui only receives pasted text); egui-winit already uses it.
|
||||
arboard = { version = "3.6", default-features = false, features = ["image-data"] }
|
||||
rayon = "1"
|
||||
proptest = "1"
|
||||
# JavaScript (pure Rust, wasm-safe)
|
||||
|
||||
@@ -863,6 +863,47 @@ pub fn encode_png(img: &Image) -> Result<Vec<u8>, ToolError> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `img` as an 8-bit RGBA PNG of at most `max` bytes, or `None` when it would be bigger. It's
|
||||
/// compressed a chunk at a time, so a picture that won't fit stops as soon as it passes `max`
|
||||
/// instead of being held whole first.
|
||||
pub fn encode_png_within(img: &Image, max: usize) -> Result<Option<Vec<u8>>, ToolError> {
|
||||
/// Takes bytes until `max`, then refuses them and says so.
|
||||
struct Capped {
|
||||
out: Vec<u8>,
|
||||
max: usize,
|
||||
over: bool,
|
||||
}
|
||||
impl std::io::Write for Capped {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
if self.out.len().saturating_add(buf.len()) > self.max {
|
||||
self.over = true;
|
||||
return Err(std::io::Error::other("the PNG passed its size limit"));
|
||||
}
|
||||
self.out.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut capped = Capped { out: Vec::new(), max, over: false };
|
||||
let written = (|| {
|
||||
let mut enc = png::Encoder::new(&mut capped, img.width, img.height);
|
||||
enc.set_color(png::ColorType::Rgba);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut w = enc.write_header().map_err(|e| format!("PNG header: {e}"))?;
|
||||
let mut stream = w.stream_writer().map_err(|e| format!("PNG data: {e}"))?;
|
||||
std::io::Write::write_all(&mut stream, &img.rgba).map_err(|e| format!("PNG data: {e}"))?;
|
||||
stream.finish().map_err(|e| format!("PNG data: {e}"))?;
|
||||
w.finish().map_err(|e| format!("PNG end: {e}"))
|
||||
})();
|
||||
match written {
|
||||
_ if capped.over => Ok(None),
|
||||
Ok(()) => Ok(Some(capped.out)),
|
||||
Err(e) => Err(ToolError::internal(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` scaled to `w`×`h` (box sampling: each output pixel is the mean of the input pixels it
|
||||
/// covers). Aspect ratio isn't kept: comparisons bring both pictures to one size.
|
||||
pub fn resize(img: &Image, w: u32, h: u32) -> Image {
|
||||
@@ -997,6 +1038,31 @@ pub fn png_artifact(bytes: &[u8]) -> Result<Value, ToolError> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The capped encoder gives a PNG of the same pixels when it fits, and stops without one when
|
||||
/// it doesn't: noise hardly compresses, so 64×64 of it can't fit in 4 KB.
|
||||
#[test]
|
||||
fn a_png_over_its_limit_is_refused_while_encoding() {
|
||||
let small = Image { width: 3, height: 2, rgba: (0..24).collect() };
|
||||
let png = encode_png_within(&small, 1 << 20).unwrap().unwrap();
|
||||
let back = decode_png(&png).unwrap();
|
||||
assert_eq!((back.width, back.height, back.rgba), (3, 2, small.rgba.clone()));
|
||||
assert_eq!(encode_png_within(&small, png.len()).unwrap().as_deref(), Some(png.as_slice()), "exactly the limit fits");
|
||||
assert_eq!(encode_png_within(&small, png.len() - 1).unwrap(), None);
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let noise: Vec<u8> = (0..64 * 64 * 4)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed as u8
|
||||
})
|
||||
.collect();
|
||||
let noisy = Image { width: 64, height: 64, rgba: noise };
|
||||
assert_eq!(encode_png_within(&noisy, 4096).unwrap(), None);
|
||||
let fits = encode_png_within(&noisy, 1 << 20).unwrap().unwrap();
|
||||
assert_eq!(png_dimensions(&fits).unwrap(), (64, 64));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1063,12 +1063,11 @@ fn pictures_in(dir: &Path) -> Vec<PathBuf> {
|
||||
/// Copy pictures into `dir` as picture-N.ext, numbering on from what's there. Files that aren't
|
||||
/// PNG, JPEG, WebP or GIF, or are over 20 MB, are skipped.
|
||||
fn copy_pictures(dir: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>, ToolError> {
|
||||
const MAX: u64 = 20 * 1024 * 1024;
|
||||
std::fs::create_dir_all(dir).map_err(|e| ToolError::io(dir.display(), e))?;
|
||||
let mut next = pictures_in(dir).iter().map(|p| picture_number(p)).max().unwrap_or(0);
|
||||
let mut added = Vec::new();
|
||||
for f in files {
|
||||
if !is_picture(f) || f.metadata().map_or(true, |m| m.len() > MAX) {
|
||||
if !attachable_picture(f) {
|
||||
continue;
|
||||
}
|
||||
next += 1;
|
||||
@@ -1085,6 +1084,93 @@ fn picture_number(p: &Path) -> u32 {
|
||||
p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.strip_prefix("picture-")).and_then(|n| n.parse().ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The most pixels a pasted picture may have (a 16K screen): more is refused before encoding.
|
||||
const MAX_PASTED_PIXELS: u64 = 15360 * 8640;
|
||||
|
||||
/// The largest picture a message sends ([`copy_pictures`] skips bigger files).
|
||||
const MAX_PICTURE_BYTES: u64 = 20 * 1024 * 1024;
|
||||
|
||||
/// Whether the file starts the way a PNG, JPEG, GIF or WebP picture does, whatever its name says.
|
||||
/// Reads its first bytes, so not while drawing.
|
||||
pub fn picture_content(p: &Path) -> bool {
|
||||
use std::io::Read as _;
|
||||
let mut head = Vec::with_capacity(12);
|
||||
if std::fs::File::open(p).and_then(|f| f.take(12).read_to_end(&mut head)).is_err() {
|
||||
return false;
|
||||
}
|
||||
head.starts_with(b"\x89PNG\r\n\x1a\n")
|
||||
|| head.starts_with(&[0xFF, 0xD8, 0xFF])
|
||||
|| head.starts_with(b"GIF87a")
|
||||
|| head.starts_with(b"GIF89a")
|
||||
|| (head.starts_with(b"RIFF") && head.get(8..12) == Some(b"WEBP".as_slice()))
|
||||
}
|
||||
|
||||
/// A picture file a message can take: a picture ([`is_picture`]) of at most 20 MB. Reads its
|
||||
/// size, so not while drawing.
|
||||
pub fn attachable_picture(p: &Path) -> bool {
|
||||
is_picture(p) && p.metadata().is_ok_and(|m| m.is_file() && m.len() <= MAX_PICTURE_BYTES)
|
||||
}
|
||||
|
||||
/// Where pictures pasted from the clipboard wait until their message is sent: this person's own
|
||||
/// cache folder (never a temp folder other users can read).
|
||||
pub fn pasted_dir() -> PathBuf {
|
||||
dirs::cache_dir().or_else(|| dirs::home_dir().map(|h| h.join(".cache"))).unwrap_or_else(std::env::temp_dir).join("Craft Anything").join("pasted")
|
||||
}
|
||||
|
||||
/// Save a picture pasted from the clipboard (8-bit RGBA, `width`×`height`) as `pasted-<time>.png`
|
||||
/// in `dir`, readable only by this person, where it waits until its message is sent (which
|
||||
/// copies it into the project). Pastes from earlier days are cleared out first, except those in
|
||||
/// `keep` (still attached to a draft or a message).
|
||||
pub fn save_pasted_picture(dir: &Path, width: u32, height: u32, rgba: Vec<u8>, keep: &[PathBuf]) -> Result<PathBuf, ToolError> {
|
||||
let pixels = u64::from(width).saturating_mul(u64::from(height));
|
||||
if pixels == 0 || pixels > MAX_PASTED_PIXELS {
|
||||
return Err(ToolError::new(ErrorCode::OverLimit, format!("the pasted picture is {width}×{height}; at most a 16K screen fits")));
|
||||
}
|
||||
if pixels.checked_mul(4) != Some(rgba.len() as u64) {
|
||||
return Err(ToolError::invalid("the pasted picture's pixels don't match its size"));
|
||||
}
|
||||
// Encoded against the limit, so a picture that won't fit never takes a second copy's memory.
|
||||
let image = crate::tools::web_common::Image { width, height, rgba };
|
||||
let max = usize::try_from(MAX_PICTURE_BYTES).unwrap_or(usize::MAX);
|
||||
let Some(png) = crate::tools::web_common::encode_png_within(&image, max)? else {
|
||||
return Err(ToolError::new(ErrorCode::OverLimit, "the pasted picture is over 20 MB as a PNG; a message takes pictures up to 20 MB")
|
||||
.with_remediation("Paste a smaller part of the screen, or save it as a JPEG and attach the file."));
|
||||
};
|
||||
std::fs::create_dir_all(dir).map_err(|e| ToolError::io(dir.display(), e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
forget_old_pastes(dir, keep);
|
||||
// The name is taken with create_new, so two pastes (or two copies of the app) never share one.
|
||||
let ms = now_ms();
|
||||
let mut n = 1u32;
|
||||
let path = loop {
|
||||
let path = if n == 1 { dir.join(format!("pasted-{ms}.png")) } else { dir.join(format!("pasted-{ms}-{n}.png")) };
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(_) => break path,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && n < 1000 => n = n.saturating_add(1),
|
||||
Err(e) => return Err(ToolError::io(path.display(), e)),
|
||||
}
|
||||
};
|
||||
craft_anything_platform::fs::write_atomic_private(&path, &png)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Remove pastes over a day old from `dir` (only ours: `pasted-….png`), except those in `keep`.
|
||||
fn forget_old_pastes(dir: &Path, keep: &[PathBuf]) {
|
||||
let day = std::time::Duration::from_secs(24 * 60 * 60);
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let p = e.path();
|
||||
let ours = p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("pasted-") && n.ends_with(".png"));
|
||||
let old = e.metadata().ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| age > day);
|
||||
if ours && old && !keep.contains(&p) {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The default projects folder: `Documents/Craft Anything`, else `~/Craft Anything`.
|
||||
pub fn default_projects_dir() -> PathBuf {
|
||||
dirs::document_dir().or_else(dirs::home_dir).unwrap_or_else(|| PathBuf::from(".")).join("Craft Anything")
|
||||
@@ -1094,6 +1180,72 @@ pub fn default_projects_dir() -> PathBuf {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_message_takes_picture_files_up_to_20_mb() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let small = dir.path().join("small.png");
|
||||
std::fs::write(&small, b"png").unwrap();
|
||||
let big = dir.path().join("big.PNG");
|
||||
std::fs::File::create(&big).unwrap().set_len(MAX_PICTURE_BYTES + 1).unwrap();
|
||||
let text = dir.path().join("notes.txt");
|
||||
std::fs::write(&text, b"hi").unwrap();
|
||||
let folder = dir.path().join("folder.png");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
assert!(attachable_picture(&small));
|
||||
for no in [&big, &text, &folder, &dir.path().join("gone.png")] {
|
||||
assert!(!attachable_picture(no), "{}", no.display());
|
||||
}
|
||||
let copied = copy_pictures(&dir.path().join("attachments"), &[small, big]).unwrap();
|
||||
assert_eq!(copied.len(), 1, "the big one is left out: {copied:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_is_told_by_its_first_bytes_not_its_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = |name: &str, bytes: &[u8]| {
|
||||
let p = dir.path().join(name);
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
p
|
||||
};
|
||||
for yes in [
|
||||
file("a.png", b"\x89PNG\r\n\x1a\n...."),
|
||||
file("b.jpg", &[0xFF, 0xD8, 0xFF, 0xE0]),
|
||||
file("c.gif", b"GIF89a.."),
|
||||
file("d.webp", b"RIFF\x10\0\0\0WEBPVP8 "),
|
||||
file("e.png", &[0xFF, 0xD8, 0xFF, 0xDB]),
|
||||
] {
|
||||
assert!(picture_content(&yes), "{}", yes.display());
|
||||
}
|
||||
for no in [file("f.png", b"png"), file("g.webp", b"RIFF\0\0\0\0WAVE"), file("h.png", b""), dir.path().join("gone.png")] {
|
||||
assert!(!picture_content(&no), "{}", no.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_picture_is_saved_as_a_png_and_bad_sizes_are_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = save_pasted_picture(dir.path(), 2, 1, vec![255, 0, 0, 255, 0, 255, 0, 255], &[]).unwrap();
|
||||
assert!(is_picture(&p));
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
assert_eq!(crate::tools::web_common::png_dimensions(&bytes).unwrap(), (2, 1));
|
||||
let second = save_pasted_picture(dir.path(), 2, 1, vec![0; 8], &[]).unwrap();
|
||||
assert_ne!(p, second, "two pastes in one millisecond don't overwrite each other");
|
||||
assert!(save_pasted_picture(dir.path(), 2, 2, vec![0; 8], &[]).is_err(), "pixels that don't match the size");
|
||||
assert!(save_pasted_picture(dir.path(), 0, 0, Vec::new(), &[]).is_err());
|
||||
assert!(save_pasted_picture(dir.path(), u32::MAX, u32::MAX, Vec::new(), &[]).is_err());
|
||||
assert_eq!(pictures_in(dir.path()).len(), 2, "fresh pastes are kept");
|
||||
// A day later, a paste clears out old ones, but not one still attached somewhere.
|
||||
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 24 * 60 * 60);
|
||||
for f in [&p, &second] {
|
||||
std::fs::File::options().write(true).open(f).unwrap().set_modified(old).unwrap();
|
||||
}
|
||||
let third = save_pasted_picture(dir.path(), 2, 1, vec![0; 8], std::slice::from_ref(&second)).unwrap();
|
||||
assert!(!p.exists() && second.exists() && third.exists());
|
||||
let mut names: Vec<_> = std::fs::read_dir(dir.path()).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names.len(), 2, "no temp files left behind: {names:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_projects_usage_adds_up_across_runs_and_a_newer_file_is_left_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -28,6 +28,8 @@ log = { workspace = true }
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Native open/save dialogs, always shown from a worker thread (never blocks a frame).
|
||||
rfd = { workspace = true }
|
||||
# Pictures pasted from the clipboard (see `paste`).
|
||||
arboard = { workspace = true }
|
||||
pollster = "0.4"
|
||||
# Safe, small OS-language lookup; MIT OR Apache-2.0, no native runtime dependency.
|
||||
sys-locale = "0.3.2"
|
||||
|
||||
@@ -336,6 +336,7 @@ fn composer(app: &mut CraftApp, ui: &mut Ui, width: f32, dense: bool) {
|
||||
hint,
|
||||
rows: (1, if dense { 4 } else { 8 }),
|
||||
attach: None,
|
||||
previews: &[],
|
||||
stop_button: answering.then_some("Stop"),
|
||||
stopping: false,
|
||||
send: !answering,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# French UI catalog: English key<TAB>French text. See i18n.rs.
|
||||
Attach pictures (or drop or paste them here) Joindre des images (ou les déposer ou coller ici)
|
||||
Coding agent app Application d'agent de code
|
||||
Load a picture Charger une image
|
||||
Paste pictures Coller des images
|
||||
The clipboard can't be read: Impossible de lire le presse-papiers :
|
||||
The pasted picture wasn't attached: another screen was opened meanwhile. L'image collée n'a pas été jointe : un autre écran a été ouvert entre-temps.
|
||||
The picture on the clipboard is too large. L'image du presse-papiers est trop grande.
|
||||
There's no picture on the clipboard to paste. Le presse-papiers ne contient aucune image à coller.
|
||||
Turn on an AI Activer une IA
|
||||
Turn off an AI Désactiver une IA
|
||||
Remove an AI Retirer une IA
|
||||
@@ -93,6 +100,8 @@ Getting Godot Récupération de Godot
|
||||
Getting Unity Hub Récupération de Unity Hub
|
||||
Getting the Epic Games Launcher Récupération de l'Epic Games Launcher
|
||||
The installer is open. Follow its steps, then come back here. L'installateur est ouvert. Suivez ses étapes, puis revenez ici.
|
||||
{name} can't be attached: a message takes pictures up to 20 MB. Impossible de joindre {name} : un message accepte des images de 20 Mo au plus.
|
||||
{name} can't be attached: it isn't a PNG, JPEG, GIF or WebP picture. Impossible de joindre {name} : ce n'est pas une image PNG, JPEG, GIF ou WebP.
|
||||
{name} is ready. {name} est prêt.
|
||||
Starting… Démarrage…
|
||||
Getting Ghidra Récupération de Ghidra
|
||||
@@ -589,7 +598,6 @@ Ask about the build, e.g. "where are the latest files?" Nothing changes and no b
|
||||
Add more info. The builder reads it at its next step. Ajoutez des précisions. Le constructeur les lira à sa prochaine étape.
|
||||
Ask for a change, e.g. "make the header blue" or "add a contact form" Demandez une modification, par ex. « mets l'en-tête en bleu » ou « ajoute un formulaire de contact »
|
||||
Ask for a change Demander une modification
|
||||
Attach pictures (or drop them here) Joindre des images (ou les déposer ici)
|
||||
Pause Pause
|
||||
Pausing… Mise en pause…
|
||||
Change it Modifier
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 82 and column 20.
|
@@ -161,7 +161,7 @@ Asking for its models… Запрашиваем его модели…
|
||||
Asset catalog Каталог ресурсов
|
||||
At most this many rounds of fixes after the first build. Не более такого числа раундов исправлений после первой сборки.
|
||||
Attach pictures Прикрепить изображения
|
||||
Attach pictures (or drop them here) Прикрепить изображения (или перетащить их сюда)
|
||||
Attach pictures (or drop or paste them here) Прикрепить изображения (или перетащить или вставить их сюда)
|
||||
Auto mode Авторежим
|
||||
Automatic Автоматически
|
||||
Automatic ({name}) Автоматически ({name})
|
||||
@@ -619,6 +619,7 @@ List segments Список сегментов
|
||||
List shared Cutter sessions Список общих сессий Cutter
|
||||
List strings Список строк
|
||||
Live: the project's latest build В реальном времени: последняя сборка проекта
|
||||
Load a picture Загрузить изображение
|
||||
Load a screenshot Загрузить скриншот
|
||||
Loading… Загрузка…
|
||||
Log Журнал
|
||||
@@ -794,6 +795,7 @@ Page through evidence Листать доказательства
|
||||
Part of the app got stuck, so it was stopped and the rest carried on. Your work is kept. Часть приложения зависла, поэтому она была остановлена, а остальное продолжило работу. Ваша работа сохранена.
|
||||
Part of the app is stuck. Restarting clears it, and your work is kept. Часть приложения зависла. Перезапуск очистит её, а ваша работа сохранена.
|
||||
Part of this screen hit a bug and was skipped. Your work is safe. Часть этого экрана столкнулась с ошибкой и была пропущена. Ваша работа в безопасности.
|
||||
Paste pictures Вставить изображения
|
||||
Paste your API key (kept in your system keychain) Вставьте ключ API (хранится в системной связке ключей)
|
||||
Paste your {name} key Вставьте ключ {name}
|
||||
Path Путь
|
||||
@@ -1074,6 +1076,7 @@ The builder's questions Вопросы конструктора
|
||||
The builder's questions come to you as cards, and it waits for your answer. Вопросы конструктора приходят к вам в виде карточек, и он ждёт вашего ответа.
|
||||
The builder's questions will come to you. Вопросы конструктора придут к вам.
|
||||
The built-in engines still show you everything they find. Встроенные движки по-прежнему показывают вам всё, что находят.
|
||||
The clipboard can't be read: Не удалось прочитать буфер обмена:
|
||||
The code Код
|
||||
The code format Android apps use. Формат кода, который используют приложения Android.
|
||||
The coding agent's estimate at API prices, not your bill. A subscription isn't charged this. Оценка агента кодирования по ценам API, а не ваш счёт. По подписке это не списывается.
|
||||
@@ -1084,6 +1087,8 @@ The installer is open. Follow its steps, then come back here. Установщи
|
||||
The lowest-level human-readable form of machine code: one small instruction per line. Самая низкоуровневая форма машинного кода, читаемая человеком: одна небольшая инструкция на строку.
|
||||
The names were removed from the program before it was shipped, so we have to work them out. Имена были удалены из программы до её выпуска, поэтому нам приходится их вычислять.
|
||||
The packed contents of an Electron app (a website inside a desktop app) Упакованное содержимое приложения Electron (веб-сайт внутри настольного приложения)
|
||||
The pasted picture wasn't attached: another screen was opened meanwhile. Вставленное изображение не прикреплено: тем временем был открыт другой экран.
|
||||
The picture on the clipboard is too large. Изображение в буфере обмена слишком большое.
|
||||
The program's machine code written out as one tiny instruction per line. Машинный код программы, записанный в виде одной крошечной инструкции на строку.
|
||||
The project's name and round Название проекта и раунд
|
||||
The raw result Сырой результат
|
||||
@@ -1096,6 +1101,7 @@ The {} view isn't available yet in this build. Вид «{}» пока недос
|
||||
Theme Тема
|
||||
There's no /{name} command. You can use {commands}. Команды /{name} нет. Можно использовать {commands}.
|
||||
There's no AI brain set up yet, so here's what the built-in engines found: «Мозг» ИИ ещё не настроен, поэтому вот что нашли встроенные движки:
|
||||
There's no picture on the clipboard to paste. В буфере обмена нет изображения для вставки.
|
||||
They're my app: edit them in place Это моё приложение: редактировать на месте
|
||||
Things a program borrows from the operating system or libraries, like "open a file" or "draw a window". То, что программа берёт у операционной системы или библиотек, например «открыть файл» или «нарисовать окно».
|
||||
Things a program or library offers for other programs to use. То, что программа или библиотека предлагает другим программам для использования.
|
||||
@@ -1330,6 +1336,8 @@ your files there are the app, edited in place ваши файлы там — э
|
||||
{mb} MB {mb} МБ
|
||||
{name} builds what you make. {name} собирает то, что вы создаёте.
|
||||
{name} builds what you make; answers come from {brain}. {name} собирает то, что вы создаёте; ответы поступают от {brain}.
|
||||
{name} can't be attached: a message takes pictures up to 20 MB. Не удалось прикрепить {name}: сообщение принимает изображения не больше 20 МБ.
|
||||
{name} can't be attached: it isn't a PNG, JPEG, GIF or WebP picture. Не удалось прикрепить {name}: это не изображение PNG, JPEG, GIF или WebP.
|
||||
{name} is ready {name} готов
|
||||
{name} is ready. {name} готов.
|
||||
{name} isn't available yet in this build. {name} пока недоступен в этой сборке.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 48 and column 52.
|
@@ -172,7 +172,7 @@ Asking for its models… 正在获取可用模型…
|
||||
Asset catalog 资源目录
|
||||
At most this many rounds of fixes after the first build. 首次构建后,最多进行这么多轮修正。
|
||||
Attach pictures 附上图片
|
||||
Attach pictures (or drop them here) 附加图片 (或拖到这里)
|
||||
Attach pictures (or drop or paste them here) 附加图片 (或拖到这里、粘贴到这里)
|
||||
Auto mode 自动模式
|
||||
Automatic 自动
|
||||
Automatic ({name}) 自动 ({name})
|
||||
@@ -628,6 +628,7 @@ List segments 列出区段
|
||||
List shared Cutter sessions 列出共享的 Cutter 会话
|
||||
List strings 列出字符串
|
||||
Live: the project's latest build 当前:项目的最新构建
|
||||
Load a picture 加载图片
|
||||
Load a screenshot 加载截图
|
||||
Loading… 正在加载…
|
||||
Log 日志
|
||||
@@ -799,6 +800,7 @@ Page through evidence 分页浏览证据
|
||||
Part of the app got stuck, so it was stopped and the rest carried on. Your work is kept. 应用的某个部分卡住,已将其停止,其他部分继续运行。你的工作已保留。
|
||||
Part of the app is stuck. Restarting clears it, and your work is kept. 应用的某个部分卡住了。重启即可恢复,你的工作会保留。
|
||||
Part of this screen hit a bug and was skipped. Your work is safe. 此界面的一部分出现错误,已跳过。你的工作内容是安全的。
|
||||
Paste pictures 粘贴图片
|
||||
Paste your API key (kept in your system keychain) 粘贴 API 密钥 (保存在系统钥匙串中)
|
||||
Paste your {name} key 粘贴你的 {name} 密钥
|
||||
Path 路径
|
||||
@@ -1077,6 +1079,7 @@ The builder's questions 构建助手的问题
|
||||
The builder's questions come to you as cards, and it waits for your answer. 构建助手的问题会以卡片形式交给你,并等待你的回答。
|
||||
The builder's questions will come to you. 构建助手的问题会交给你回答。
|
||||
The built-in engines still show you everything they find. 内置引擎仍会显示它们发现的所有内容。
|
||||
The clipboard can't be read: 无法读取剪贴板:
|
||||
The code 代码
|
||||
The code format Android apps use. Android 应用使用的代码格式。
|
||||
The coding agent's estimate at API prices, not your bill. A subscription isn't charged this. 这是编程智能体按 API 价格估算的费用,并非你的实际账单;订阅不会按此金额收费。
|
||||
@@ -1087,6 +1090,8 @@ The installer is open. Follow its steps, then come back here. 安装程序已打
|
||||
The lowest-level human-readable form of machine code: one small instruction per line. 机器码最底层的可读形式,每行一条简短指令。
|
||||
The names were removed from the program before it was shipped, so we have to work them out. 程序发布前移除了这些名称,因此需要推导它们。
|
||||
The packed contents of an Electron app (a website inside a desktop app) Electron 应用的打包内容 (桌面应用中的网站)
|
||||
The pasted picture wasn't attached: another screen was opened meanwhile. 粘贴的图片没有附加:期间打开了另一个页面。
|
||||
The picture on the clipboard is too large. 剪贴板中的图片太大。
|
||||
The program's machine code written out as one tiny instruction per line. 将程序的机器码写成每行一条简单指令的形式。
|
||||
The project's name and round 项目名称与轮次
|
||||
The raw result 原始结果
|
||||
@@ -1097,6 +1102,7 @@ The {} view isn't available yet in this build. 此版本暂不提供 {} 视图
|
||||
Theme 主题
|
||||
There's no /{name} command. You can use {commands}. 没有 /{name} 命令。你可以使用 {commands}。
|
||||
There's no AI brain set up yet, so here's what the built-in engines found: 尚未设置 AI 助手,以下是内置引擎的发现:
|
||||
There's no picture on the clipboard to paste. 剪贴板中没有可粘贴的图片。
|
||||
They're my app: edit them in place 这些就是我的应用:原位编辑
|
||||
Things a program borrows from the operating system or libraries, like "open a file" or "draw a window". 程序借用操作系统或库提供的功能,例如“打开文件”或“绘制窗口”。
|
||||
Things a program or library offers for other programs to use. 程序或库提供给其他程序使用的功能。
|
||||
@@ -1330,6 +1336,8 @@ your files there are the app, edited in place 该位置的文件就是应用,
|
||||
{mb} MB {mb} MB
|
||||
{name} builds what you make. 由 {name} 构建你的作品。
|
||||
{name} builds what you make; answers come from {brain}. 由 {name} 负责构建,{brain} 负责回答。
|
||||
{name} can't be attached: a message takes pictures up to 20 MB. 无法附加 {name}:消息中的图片最大为 20 MB。
|
||||
{name} can't be attached: it isn't a PNG, JPEG, GIF or WebP picture. 无法附加 {name}:它不是 PNG、JPEG、GIF 或 WebP 图片。
|
||||
{name} is ready {name} 已就绪
|
||||
{name} is ready. {name} 已就绪。
|
||||
{name} isn't available yet in this build. 此版本尚未提供{name}。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 60 and column 52.
|
@@ -172,7 +172,7 @@ Asking for its models… 正在取得可用模型…
|
||||
Asset catalog 資源目錄
|
||||
At most this many rounds of fixes after the first build. 首次建置後,最多進行這麼多輪修正。
|
||||
Attach pictures 附上圖片
|
||||
Attach pictures (or drop them here) 附加圖片 (或拖到這裡)
|
||||
Attach pictures (or drop or paste them here) 附加圖片 (或拖到這裡、貼到這裡)
|
||||
Auto mode 自動模式
|
||||
Automatic 自動
|
||||
Automatic ({name}) 自動 ({name})
|
||||
@@ -628,6 +628,7 @@ List segments 列出區段
|
||||
List shared Cutter sessions 列出共用的 Cutter 工作階段
|
||||
List strings 列出字串
|
||||
Live: the project's latest build 目前:專案的最新建置
|
||||
Load a picture 載入圖片
|
||||
Load a screenshot 載入螢幕擷取畫面
|
||||
Loading… 正在載入…
|
||||
Log 記錄
|
||||
@@ -799,6 +800,7 @@ Page through evidence 分頁瀏覽證據
|
||||
Part of the app got stuck, so it was stopped and the rest carried on. Your work is kept. 應用程式的某個部分卡住,已將其停止,其他部分繼續執行。你的工作已保留。
|
||||
Part of the app is stuck. Restarting clears it, and your work is kept. 應用程式的某個部分卡住了。重新啟動即可恢復,你的工作會保留。
|
||||
Part of this screen hit a bug and was skipped. Your work is safe. 此畫面的一部分發生錯誤,已略過。你的工作內容是安全的。
|
||||
Paste pictures 貼上圖片
|
||||
Paste your API key (kept in your system keychain) 貼上 API 金鑰 (儲存在系統鑰匙圈中)
|
||||
Paste your {name} key 貼上你的 {name} 金鑰
|
||||
Path 路徑
|
||||
@@ -1077,6 +1079,7 @@ The builder's questions 建置助手的問題
|
||||
The builder's questions come to you as cards, and it waits for your answer. 建置助手的問題會以卡片形式交給你,並等待你的回答。
|
||||
The builder's questions will come to you. 建置助手的問題會交給你回答。
|
||||
The built-in engines still show you everything they find. 內建引擎仍會顯示它們發現的所有內容。
|
||||
The clipboard can't be read: 無法讀取剪貼簿:
|
||||
The code 程式碼
|
||||
The code format Android apps use. Android 應用程式使用的程式碼格式。
|
||||
The coding agent's estimate at API prices, not your bill. A subscription isn't charged this. 這是程式設計代理程式依 API 價格估算的費用,並非你的實際帳單;訂閱不會依此金額收費。
|
||||
@@ -1087,6 +1090,8 @@ The installer is open. Follow its steps, then come back here. 安裝程式已開
|
||||
The lowest-level human-readable form of machine code: one small instruction per line. 機器碼最底層的可讀形式,每行一條簡短指令。
|
||||
The names were removed from the program before it was shipped, so we have to work them out. 程式發佈前移除了這些名稱,因此需要推導它們。
|
||||
The packed contents of an Electron app (a website inside a desktop app) Electron 應用程式的打包內容 (桌面應用程式中的網站)
|
||||
The pasted picture wasn't attached: another screen was opened meanwhile. 貼上的圖片沒有附加:期間開啟了另一個頁面。
|
||||
The picture on the clipboard is too large. 剪貼簿中的圖片太大。
|
||||
The program's machine code written out as one tiny instruction per line. 將程式的機器碼寫成每行一條簡單指令的形式。
|
||||
The project's name and round 專案名稱與輪次
|
||||
The raw result 原始結果
|
||||
@@ -1097,6 +1102,7 @@ The {} view isn't available yet in this build. 此版本暫不提供 {} 檢視
|
||||
Theme 佈景主題
|
||||
There's no /{name} command. You can use {commands}. 沒有 /{name} 命令。你可以使用 {commands}。
|
||||
There's no AI brain set up yet, so here's what the built-in engines found: 尚未設定 AI 助手,以下是內建引擎的發現:
|
||||
There's no picture on the clipboard to paste. 剪貼簿中沒有可貼上的圖片。
|
||||
They're my app: edit them in place 這些就是我的應用程式:就地編輯
|
||||
Things a program borrows from the operating system or libraries, like "open a file" or "draw a window". 程式借用作業系統或函式庫提供的功能,例如「開啟檔案」或「繪製視窗」。
|
||||
Things a program or library offers for other programs to use. 程式或程式庫提供給其他程式使用的功能。
|
||||
@@ -1330,6 +1336,8 @@ your files there are the app, edited in place 該位置的檔案就是應用程
|
||||
{mb} MB {mb} MB
|
||||
{name} builds what you make. 由 {name} 建置你的作品。
|
||||
{name} builds what you make; answers come from {brain}. 由 {name} 負責建置,{brain} 負責回答。
|
||||
{name} can't be attached: a message takes pictures up to 20 MB. 無法附加 {name}:訊息中的圖片最大為 20 MB。
|
||||
{name} can't be attached: it isn't a PNG, JPEG, GIF or WebP picture. 無法附加 {name}:它不是 PNG、JPEG、GIF 或 WebP 圖片。
|
||||
{name} is ready {name} 已就緒
|
||||
{name} is ready. {name} 已就緒。
|
||||
{name} isn't available yet in this build. 此版本尚未提供{name}。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 60 and column 52.
|
@@ -82,7 +82,7 @@ Ask: "Is it safe to run?" 「実行しても安全?」と質問します。
|
||||
Ask: "What does it send over the internet?" 「インターネットに何を送っている?」と質問します。
|
||||
At most this many rounds of fixes after the first build. 最初のビルドの後に修正する回数の上限です。
|
||||
Attach pictures 画像を添付
|
||||
Attach pictures (or drop them here) 画像を添付(ここにドロップも可)
|
||||
Attach pictures (or drop or paste them here) 画像を添付(ここにドロップ・貼り付けも可)
|
||||
Auto mode 自動モード
|
||||
Automatic 自動
|
||||
Automatic ({name}) 自動({name})
|
||||
@@ -341,6 +341,7 @@ Lets the builder make realistic 3D models and textures for games, {model} a mode
|
||||
Light copy: shares big files with the project 軽量コピー: 大きなファイルをプロジェクトと共有
|
||||
Likeness 類似度
|
||||
Live: the project's latest build ライブ: プロジェクトの最新ビルド
|
||||
Load a picture 画像を読み込み
|
||||
Load a screenshot スクリーンショットを読み込む
|
||||
Loading… 読み込んでいます…
|
||||
Log ログ
|
||||
@@ -475,6 +476,7 @@ Other pages of a website studied and compared besides the one you gave. 指定
|
||||
Part of the app got stuck, so it was stopped and the rest carried on. Your work is kept. アプリの一部が応答しなくなったため、その部分だけを停止し、残りはそのまま続けました。作業内容は残っています。
|
||||
Part of the app is stuck. Restarting clears it, and your work is kept. アプリの一部が止まっています。再起動で解消します。作業内容は残ります。
|
||||
Part of this screen hit a bug and was skipped. Your work is safe. この画面の一部で不具合が発生したため、その部分を飛ばしました。作業内容は失われていません。
|
||||
Paste pictures 画像を貼り付け
|
||||
Paste your API key (kept in your system keychain) API キーを貼り付け(システムのキーチェーンに保存)
|
||||
Paste your {name} key {name} のキーを貼り付け
|
||||
Path パス
|
||||
@@ -655,16 +657,20 @@ The builder's questions ビルダーの質問
|
||||
The builder's questions come to you as cards, and it waits for your answer. ビルダーの質問はカードで届き、あなたの回答を待ちます。
|
||||
The builder's questions will come to you. ビルダーからの質問はあなたに表示されます。
|
||||
The built-in engines still show you everything they find. 内蔵エンジンは見つけた内容をすべて表示します。
|
||||
The clipboard can't be read: クリップボードを読み取れません:
|
||||
The code コード
|
||||
The coding agent's estimate at API prices, not your bill. A subscription isn't charged this. API 価格でのコーディングエージェントの見積もりで、請求額ではありません。サブスクリプション利用時はこの費用は請求されません。
|
||||
The download keeps going in the background. ダウンロードはバックグラウンドで続きます。
|
||||
The full toolset, evidence and logs. すべてのツール、エビデンス、ログ。
|
||||
The installer is open. Follow its steps, then come back here. インストーラを開きました。手順に従ってから、ここに戻ってください。
|
||||
The pasted picture wasn't attached: another screen was opened meanwhile. 貼り付けた画像は添付されませんでした:その間に別の画面が開かれました。
|
||||
The picture on the clipboard is too large. クリップボードの画像が大きすぎます。
|
||||
The project's name and round プロジェクト名とラウンド
|
||||
The raw result 未加工の結果
|
||||
Theme テーマ
|
||||
There's no /{name} command. You can use {commands}. /{name} というコマンドはありません。{commands} が使えます。
|
||||
There's no AI brain set up yet, so here's what the built-in engines found: まだ AI が設定されていないため、内蔵エンジンが見つけた内容を示します:
|
||||
There's no picture on the clipboard to paste. 貼り付けられる画像がクリップボードにありません。
|
||||
They're my app: edit them in place 自分のアプリなので、その場で編集する
|
||||
Things we couldn't establish yet are listed here, with what would settle them. まだ確定できていないことを、確定に必要な情報とともにここに表示します。
|
||||
This answer won't be there after a restart: この回答は再起動後には残りません:
|
||||
@@ -818,6 +824,8 @@ your files there are the app, edited in place そこにあるファイルがア
|
||||
{line}, and {n} more {line}、ほか {n} 件
|
||||
{name} builds what you make. {name} が作るものをビルドします。
|
||||
{name} builds what you make; answers come from {brain}. {name} がビルドし、回答は {brain} から得ます。
|
||||
{name} can't be attached: a message takes pictures up to 20 MB. {name} は添付できません: メッセージに添付できる画像は 20 MB までです。
|
||||
{name} can't be attached: it isn't a PNG, JPEG, GIF or WebP picture. {name} は添付できません: PNG、JPEG、GIF、WebP の画像ではありません。
|
||||
{name} is ready {name} の準備ができました
|
||||
{name} is ready. {name} の準備ができました。
|
||||
{name} isn't on this computer yet. {name} はまだこのコンピュータにありません。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 67 and column 27.
|
@@ -25,6 +25,7 @@ pub mod just_ask;
|
||||
pub mod make;
|
||||
pub mod manage;
|
||||
pub mod palette;
|
||||
pub mod paste;
|
||||
pub mod pins;
|
||||
pub mod project;
|
||||
pub mod prompt_box;
|
||||
@@ -172,6 +173,11 @@ pub enum Done {
|
||||
Pictures(Vec<PathBuf>),
|
||||
/// Pictures for the next message were chosen.
|
||||
ToAttach(Vec<PathBuf>),
|
||||
/// Pictures were pasted: for the project open then (`Some`), or for Make (`None`).
|
||||
Pasted {
|
||||
project: Option<PathBuf>,
|
||||
files: Vec<PathBuf>,
|
||||
},
|
||||
/// The game engines on this computer were listed.
|
||||
GameEngines(craft_anything_engines::game::Inventory),
|
||||
/// What engine made the game at `path`.
|
||||
@@ -227,6 +233,11 @@ pub enum Done {
|
||||
path: PathBuf,
|
||||
image: Option<egui::TextureHandle>,
|
||||
},
|
||||
/// An attachment's picture for its chip in a prompt box.
|
||||
Preview {
|
||||
path: PathBuf,
|
||||
image: Option<egui::TextureHandle>,
|
||||
},
|
||||
Nothing,
|
||||
}
|
||||
|
||||
@@ -375,6 +386,8 @@ pub struct CraftApp {
|
||||
/// What was typed for each note still waiting for a builder, by note id, to put back in the
|
||||
/// prompt box when the note is taken back.
|
||||
pub drafts: std::collections::HashMap<u64, project::Draft>,
|
||||
/// A picture still being pasted into the open project's prompt box (`paste_pictures`).
|
||||
pub pending_paste: Option<project::PendingPaste>,
|
||||
/// Each project's building time and AI use, every build together (read from its usage.json).
|
||||
pub usage: std::collections::HashMap<PathBuf, usage::ProjectClock>,
|
||||
/// All AI use since the app opened: builds and chat.
|
||||
@@ -394,6 +407,8 @@ pub struct CraftApp {
|
||||
open_url: Option<String>,
|
||||
/// The open project's screenshot thumbnails (dropped when another project opens).
|
||||
pub thumbs: std::collections::HashMap<PathBuf, project::Thumb>,
|
||||
/// Pictures of the attachments in the prompt boxes (`CraftApp::previews`).
|
||||
pub previews: std::collections::HashMap<PathBuf, project::Thumb>,
|
||||
/// The kinds of thing this app can make; a fork registers its own here.
|
||||
pub kinds: craft_anything_ai::rebuild::KindRegistry,
|
||||
/// Game engines on this computer (`None` until the first look finishes).
|
||||
@@ -469,6 +484,7 @@ impl CraftApp {
|
||||
server_form: ServerForm::default(),
|
||||
runs: craft_anything_ai::rebuild::RunSet::new(craft_anything_engine::settings::BuildSettings::default().parallel),
|
||||
drafts: Default::default(),
|
||||
pending_paste: None,
|
||||
usage: Default::default(),
|
||||
session_usage: Default::default(),
|
||||
workspace: None,
|
||||
@@ -479,6 +495,7 @@ impl CraftApp {
|
||||
pins_read_at: None,
|
||||
open_url: None,
|
||||
thumbs: Default::default(),
|
||||
previews: Default::default(),
|
||||
kinds: craft_anything_ai::rebuild::KindRegistry::built_in(),
|
||||
game_engines: None,
|
||||
asset_keys: None,
|
||||
@@ -1269,6 +1286,7 @@ impl CraftApp {
|
||||
Done::PicturesAdded(added) => self.pictures_added(added),
|
||||
Done::Created { ws, request, backend, pictures } => self.created(*ws, request, backend, pictures),
|
||||
Done::Thumb { path, image } => self.thumb_ready(path, image),
|
||||
Done::Preview { path, image } => self.preview_ready(path, image),
|
||||
Done::Tool { tool, params, route, result } => self.tool_done(tool, params, route, result),
|
||||
Done::Acted { tool, result } => self.acted(tool, result),
|
||||
Done::Opened { path, result } => match result {
|
||||
@@ -1384,6 +1402,7 @@ impl CraftApp {
|
||||
}
|
||||
Done::Pictures(files) => self.add_pictures(files),
|
||||
Done::ToAttach(files) => self.attach(files),
|
||||
Done::Pasted { project, files } => self.pasted(project, files),
|
||||
Done::Attached { root, text, attachments, files } => self.deliver(root, text, attachments, files),
|
||||
Done::GameEngines(inv) => self.engines_found(inv),
|
||||
Done::GameFound { path, found } => {
|
||||
@@ -1486,9 +1505,7 @@ impl CraftApp {
|
||||
theme::apply(ctx, want.0, want.1);
|
||||
self.applied_theme = Some(want);
|
||||
}
|
||||
for f in self.jobs.poll() {
|
||||
guard("job result", || self.handle_done(f));
|
||||
}
|
||||
self.take_finished_jobs();
|
||||
if let Some(url) = self.open_url.take() {
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
@@ -1692,6 +1709,17 @@ pub(crate) fn backends(hub: &AiHub) -> Done {
|
||||
Done::Backends(hub.backends(), hub.chosen(craft_anything_ai::Role::Builder), hub.chosen(craft_anything_ai::Role::Extractor), hub.options())
|
||||
}
|
||||
|
||||
impl CraftApp {
|
||||
/// Act on every background job that has finished since the last frame.
|
||||
pub(crate) fn take_finished_jobs(&mut self) {
|
||||
for f in self.jobs.poll() {
|
||||
let (job, ok) = (f.job, f.result.is_ok());
|
||||
guard("job result", || self.handle_done(f));
|
||||
guard("pasted message", || self.paste_done(job, ok));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn guard<R>(what: &str, f: impl FnOnce() -> R) -> Option<R> {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||
Ok(r) => Some(r),
|
||||
|
||||
@@ -103,6 +103,7 @@ pub fn hero(app: &mut CraftApp, ui: &mut Ui, w: f32) {
|
||||
};
|
||||
// Websites are photographed for the builder; anything else looks right from pictures.
|
||||
let website = matches!(resolve_target(d), TargetRef::Website { .. }) || d.editing_url;
|
||||
let previews = app.previews(ui.ctx(), &app.state.make.pictures.clone());
|
||||
let out = crate::prompt_box::show(
|
||||
ui,
|
||||
&mut app.state.make.request,
|
||||
@@ -112,6 +113,7 @@ pub fn hero(app: &mut CraftApp, ui: &mut Ui, w: f32) {
|
||||
hint: tr(hint),
|
||||
rows: (3, 10),
|
||||
attach: (!website).then_some("Add pictures of the original (optional: screenshots help it look right)"),
|
||||
previews: &previews,
|
||||
stop_button: None,
|
||||
stopping: false,
|
||||
send: false,
|
||||
@@ -126,6 +128,9 @@ pub fn hero(app: &mut CraftApp, ui: &mut Ui, w: f32) {
|
||||
if out.attach {
|
||||
app.pick(Pick::Pictures);
|
||||
}
|
||||
if out.paste {
|
||||
app.paste_pictures(false);
|
||||
}
|
||||
let submit = out.send;
|
||||
if app.state.make.request.trim().is_empty() {
|
||||
ui.add_space(10.0);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Pictures pasted with Ctrl+V (Cmd+V on a Mac) into a prompt box that takes attachments.
|
||||
//!
|
||||
//! egui-winit turns the paste shortcut (Ctrl+V, Shift+Insert on Windows, a Paste key) into a text
|
||||
//! paste and drops the press when the clipboard holds no text, so a copied screenshot never reaches
|
||||
//! egui. The key's release still does: one released with no press egui saw, and no text pasted, is
|
||||
//! a paste of something that isn't text.
|
||||
//! The clipboard is then read on a worker ([`read_pictures`]): copied picture files are attached
|
||||
//! as they are, and a copied image is saved as a PNG first.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// What the events said about the paste shortcut, kept between passes.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
struct Watch {
|
||||
/// The pass this was worked out for, so every caller in a pass gets the same answer (and a
|
||||
/// second pass in the same frame, which has no events of its own, doesn't paste again).
|
||||
pass: u64,
|
||||
/// A paste key went down where egui saw it (typing a "v"), or egui-winit made it a copy or a
|
||||
/// cut (Ctrl+Insert): its release is no paste.
|
||||
down: bool,
|
||||
/// Text was pasted since: the clipboard's text was the paste.
|
||||
text_pasted: bool,
|
||||
/// Ctrl (Cmd on a Mac) was held since the last paste key came up: a V released after another
|
||||
/// program's shortcut (Win+V, the clipboard history) isn't a paste.
|
||||
command: bool,
|
||||
/// Shift was held since then, for Shift+Insert.
|
||||
shift: bool,
|
||||
/// This pass's answer.
|
||||
wanted: bool,
|
||||
}
|
||||
|
||||
/// The keys egui-winit may read as the paste shortcut.
|
||||
const PASTE_KEYS: [egui::Key; 3] = [egui::Key::V, egui::Key::Insert, egui::Key::Paste];
|
||||
|
||||
/// What `events` mean, after `before`, with `held` the modifiers this pass: a paste of something
|
||||
/// other than text happened when a paste key came up after its shortcut's modifier was held,
|
||||
/// without egui having seen the key go down or seen any text pasted.
|
||||
fn step(before: Watch, events: &[egui::Event], held: egui::Modifiers) -> Watch {
|
||||
let mut w = Watch { wanted: false, ..before };
|
||||
w.command |= held.command;
|
||||
w.shift |= held.shift;
|
||||
let fresh = |w: &mut Watch| (w.down, w.text_pasted, w.command, w.shift) = (false, false, false, false);
|
||||
for e in events {
|
||||
match e {
|
||||
egui::Event::Key { key, pressed: true, .. } if PASTE_KEYS.contains(key) => w.down = true,
|
||||
egui::Event::Copy | egui::Event::Cut => w.down = true,
|
||||
egui::Event::Paste(_) => w.text_pasted = true,
|
||||
egui::Event::Key { key, pressed: false, modifiers, .. } if PASTE_KEYS.contains(key) => {
|
||||
let shortcut = match key {
|
||||
egui::Key::V => w.command || modifiers.command,
|
||||
egui::Key::Insert => (w.shift || modifiers.shift) && !modifiers.command,
|
||||
_ => true,
|
||||
};
|
||||
w.wanted |= shortcut && !w.down && !w.text_pasted;
|
||||
fresh(&mut w);
|
||||
}
|
||||
egui::Event::Key { modifiers, .. } => {
|
||||
w.command |= modifiers.command;
|
||||
w.shift |= modifiers.shift;
|
||||
}
|
||||
// Focus came or went mid-press: start again rather than guess.
|
||||
egui::Event::WindowFocused(_) => fresh(&mut w),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// The paste shortcut was used this pass with something other than text on the clipboard
|
||||
/// (maybe a picture). The same answer for every call in a pass; call it on every pass a box that
|
||||
/// takes pictures shows, so no key's press or release is missed.
|
||||
pub fn picture_wanted(ctx: &egui::Context) -> bool {
|
||||
let id = egui::Id::new("paste-watch");
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
let before: Watch = ctx.data(|d| d.get_temp(id)).unwrap_or_default();
|
||||
if before.pass == pass && pass != 0 {
|
||||
return before.wanted;
|
||||
}
|
||||
let now = Watch { pass, ..ctx.input(|i| step(before, &i.raw.events, i.modifiers)) };
|
||||
ctx.data_mut(|d| d.insert_temp(id, now));
|
||||
now.wanted
|
||||
}
|
||||
|
||||
/// The pictures on the clipboard, as files: copied picture files as they are, or a copied image
|
||||
/// saved as a PNG in `dir` (clearing out old pastes not in `keep`). Empty when it holds neither.
|
||||
/// A copied picture a message can't take (over 20 MB, or not a picture inside whatever its name
|
||||
/// says) refuses the paste, rather than showing as attached and being left out when the message
|
||||
/// goes, or sent as a file the builder can't read. Blocking: run it on a worker.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_pictures(dir: &Path, keep: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|e| format!("{} {e}", crate::i18n::tr("The clipboard can't be read:")))?;
|
||||
let files: Vec<PathBuf> = clipboard.get().file_list().unwrap_or_default().into_iter().filter(|p| craft_anything_engine::workspace::is_picture(p)).collect();
|
||||
if !files.is_empty() {
|
||||
return attachable(files);
|
||||
}
|
||||
let Ok(image) = clipboard.get().image() else { return Ok(Vec::new()) };
|
||||
let (Ok(width), Ok(height)) = (u32::try_from(image.width), u32::try_from(image.height)) else {
|
||||
return Err(crate::i18n::tr("The picture on the clipboard is too large."));
|
||||
};
|
||||
craft_anything_engine::workspace::save_pasted_picture(dir, width, height, image.bytes.into_owned(), keep).map(|p| vec![p]).map_err(|e| e.message)
|
||||
}
|
||||
|
||||
/// Copied picture files, if a message can take every one; else which one it can't.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn attachable(files: Vec<PathBuf>) -> Result<Vec<PathBuf>, String> {
|
||||
use craft_anything_engine::workspace::{attachable_picture, picture_content};
|
||||
let name = |f: &Path| f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
for f in &files {
|
||||
if !attachable_picture(f) {
|
||||
return Err(crate::i18n::trf("{name} can't be attached: a message takes pictures up to 20 MB.", &[("name", name(f).as_str())]));
|
||||
}
|
||||
if !picture_content(f) {
|
||||
return Err(crate::i18n::trf("{name} can't be attached: it isn't a PNG, JPEG, GIF or WebP picture.", &[("name", name(f).as_str())]));
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
|
||||
const NONE: egui::Modifiers = egui::Modifiers::NONE;
|
||||
|
||||
fn key_with(key: egui::Key, pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_picture_files_a_message_cant_take_refuse_the_paste() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let small = dir.path().join("small.png");
|
||||
std::fs::write(&small, b"\x89PNG\r\n\x1a\n").unwrap();
|
||||
let big = dir.path().join("huge screenshot.png");
|
||||
std::fs::File::create(&big).unwrap().set_len(20 * 1024 * 1024 + 1).unwrap();
|
||||
assert_eq!(attachable(vec![small.clone()]).unwrap(), vec![small.clone()]);
|
||||
let refused = attachable(vec![small.clone(), big]).unwrap_err();
|
||||
assert!(refused.contains("huge screenshot.png") && refused.contains("20 MB"), "{refused}");
|
||||
// A picture's name on something else: the builder couldn't read it.
|
||||
let fake = dir.path().join("notes.png");
|
||||
std::fs::write(&fake, b"just some text").unwrap();
|
||||
let refused = attachable(vec![small, fake]).unwrap_err();
|
||||
assert!(refused.contains("notes.png") && refused.contains("isn't a PNG"), "{refused}");
|
||||
}
|
||||
|
||||
/// V with Ctrl (Cmd) held.
|
||||
fn key(pressed: bool) -> egui::Event {
|
||||
key_with(egui::Key::V, pressed, egui::Modifiers::COMMAND)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paste_without_text_shows_as_a_v_released_that_never_went_down() {
|
||||
// A picture on the clipboard: egui-winit swallows the press, the release comes through.
|
||||
assert!(step(Watch::default(), &[key(false)], NONE).wanted);
|
||||
// Typing a "v": egui sees both.
|
||||
let down = step(Watch::default(), &[key(true)], NONE);
|
||||
assert!(!down.wanted && down.down);
|
||||
assert!(!step(down, &[key(false)], NONE).wanted);
|
||||
// Text on the clipboard: it's pasted as text, and no picture is looked for.
|
||||
let text = step(Watch::default(), &[egui::Event::Paste("hi".into())], NONE);
|
||||
assert!(!text.wanted);
|
||||
assert!(!step(text, &[key(false)], NONE).wanted);
|
||||
// The next paste is looked at afresh.
|
||||
let after = step(step(text, &[key(false)], NONE), &[key(false)], NONE);
|
||||
assert!(after.wanted);
|
||||
// A text paste whose release went to another window doesn't hide the next picture.
|
||||
let away = step(text, &[egui::Event::WindowFocused(false)], NONE);
|
||||
assert!(step(away, &[key(false)], NONE).wanted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_paste_shortcut_counts() {
|
||||
// Win+V (the clipboard history) swallows the press too, but no Ctrl was held.
|
||||
assert!(!step(Watch::default(), &[key_with(egui::Key::V, false, NONE)], NONE).wanted);
|
||||
// Ctrl released before V: Ctrl was held on an earlier pass.
|
||||
let held = step(Watch::default(), &[], egui::Modifiers::COMMAND);
|
||||
assert!(step(held, &[key_with(egui::Key::V, false, NONE)], NONE).wanted);
|
||||
// Shift+Insert pastes; Ctrl+Insert is a copy, which egui-winit sends as Copy.
|
||||
assert!(step(Watch::default(), &[key_with(egui::Key::Insert, false, egui::Modifiers::SHIFT)], NONE).wanted);
|
||||
let copy = [egui::Event::Copy, key_with(egui::Key::Insert, false, egui::Modifiers::COMMAND)];
|
||||
assert!(!step(Watch::default(), ©, egui::Modifiers::COMMAND).wanted);
|
||||
// A Paste key needs nothing held.
|
||||
assert!(step(Watch::default(), &[key_with(egui::Key::Paste, false, NONE)], NONE).wanted);
|
||||
}
|
||||
|
||||
/// A prompt box shown each frame; `.1` counts the frames it asked for a pasted picture.
|
||||
fn prompt_box(attach: bool) -> egui_kittest::Harness<'static, (String, usize)> {
|
||||
egui_kittest::Harness::new_ui_state(
|
||||
move |ui, (text, pastes): &mut (String, usize)| {
|
||||
let b = crate::prompt_box::PromptBox {
|
||||
name: "Ask for a change",
|
||||
hint: String::new(),
|
||||
rows: (1, 4),
|
||||
attach: attach.then_some("Attach pictures"),
|
||||
previews: &[],
|
||||
stop_button: None,
|
||||
stopping: false,
|
||||
send: true,
|
||||
recall: false,
|
||||
history: None,
|
||||
font_size: 14.0,
|
||||
};
|
||||
if crate::prompt_box::show(ui, text, &[], b).paste {
|
||||
*pastes += 1;
|
||||
}
|
||||
},
|
||||
(String::new(), 0),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_v_with_a_picture_asks_the_prompt_box_for_a_paste_where_it_takes_pictures() {
|
||||
for attach in [true, false] {
|
||||
let mut h = prompt_box(attach);
|
||||
h.get_by_label("Ask for a change").focus();
|
||||
h.run();
|
||||
// What egui-winit sends for Ctrl+V with a screenshot on the clipboard: the release only.
|
||||
h.event_modifiers(key(false), egui::Modifiers::COMMAND);
|
||||
h.run();
|
||||
assert_eq!(h.state().1, usize::from(attach), "attach: {attach}");
|
||||
assert!(h.state().0.is_empty(), "nothing is typed into the box");
|
||||
// Text pastes are left to the text field.
|
||||
h.event(egui::Event::Paste("hello".into()));
|
||||
h.event(key(false));
|
||||
h.run();
|
||||
assert_eq!(h.state().1, usize::from(attach));
|
||||
assert_eq!(h.state().0, "hello");
|
||||
}
|
||||
}
|
||||
|
||||
/// Live: puts a picture on this computer's clipboard (replacing what's there) and reads it back.
|
||||
#[test]
|
||||
#[ignore = "replaces what's on the system clipboard"]
|
||||
fn live_a_picture_on_the_clipboard_is_read_back_as_a_png() {
|
||||
let mut c = arboard::Clipboard::new().unwrap();
|
||||
c.set_image(arboard::ImageData { width: 3, height: 2, bytes: vec![200u8; 24].into() }).unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let files = read_pictures(dir.path(), &[]).unwrap();
|
||||
assert_eq!(files.len(), 1, "{files:?}");
|
||||
let bytes = std::fs::read(&files[0]).unwrap();
|
||||
assert_eq!(craft_anything_engine::tools::web_common::png_dimensions(&bytes).unwrap(), (3, 2));
|
||||
c.set_text("just text").unwrap();
|
||||
assert!(read_pictures(dir.path(), &[]).unwrap().is_empty(), "text isn't a picture");
|
||||
}
|
||||
}
|
||||
@@ -406,6 +406,52 @@ impl CraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
impl CraftApp {
|
||||
/// A small picture of each file in `files` for its chip in a prompt box, decoded on a worker
|
||||
/// the first time it's asked for (`None` until then, or if it can't be read). Pictures no
|
||||
/// prompt box holds any more are forgotten.
|
||||
pub fn previews(&mut self, ctx: &egui::Context, files: &[PathBuf]) -> Vec<Option<egui::TextureHandle>> {
|
||||
let (attached, make) = (&self.state.project_attachments, &self.state.make.pictures);
|
||||
self.previews.retain(|p, _| attached.contains(p) || make.contains(p) || files.contains(p));
|
||||
files.iter().map(|p| self.preview(ctx, p)).collect()
|
||||
}
|
||||
|
||||
fn preview(&mut self, ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
|
||||
if let Some(t) = self.previews.get(path) {
|
||||
return if let Thumb::Ready(t) = t { Some(t.clone()) } else { None };
|
||||
}
|
||||
self.previews.insert(path.to_path_buf(), Thumb::Loading);
|
||||
let (p, ctx) = (path.to_path_buf(), ctx.clone());
|
||||
self.jobs.spawn(tr("Load a picture"), false, move |_| {
|
||||
use craft_anything_engine::tools::web_common::{decode_picture, resize};
|
||||
// The same size limit as attaching: a larger file wouldn't be sent anyway.
|
||||
let small = std::fs::metadata(&p).is_ok_and(|m| m.len() <= 20 * 1024 * 1024);
|
||||
let image =
|
||||
small.then(|| std::fs::read(&p).ok()).flatten().and_then(|b| decode_picture(&b).ok()).filter(|i| i.width > 0 && i.height > 0).map(|img| {
|
||||
let longest = img.width.max(img.height);
|
||||
// In u64: a picture's own sizes can be anything up to the decode budget.
|
||||
let scaled = |side: u32| u32::try_from(u64::from(side) * u64::from(PREVIEW_PIXELS) / u64::from(longest)).unwrap_or(PREVIEW_PIXELS).max(1);
|
||||
let img = if longest > PREVIEW_PIXELS { resize(&img, scaled(img.width), scaled(img.height)) } else { img };
|
||||
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.rgba);
|
||||
ctx.load_texture(format!("preview:{}", p.display()), ci, egui::TextureOptions::LINEAR)
|
||||
});
|
||||
Ok(Done::Preview { path: p, image })
|
||||
});
|
||||
None
|
||||
}
|
||||
|
||||
pub fn preview_ready(&mut self, path: PathBuf, image: Option<egui::TextureHandle>) {
|
||||
// Removed from the box while it was decoded: nothing shows it.
|
||||
if self.previews.contains_key(&path) {
|
||||
self.previews.insert(path, image.map_or(Thumb::Missing, Thumb::Ready));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest side of an attachment's picture as decoded (twice its hover size in points, so
|
||||
/// it's sharp on a high-density screen).
|
||||
const PREVIEW_PIXELS: u32 = 720;
|
||||
|
||||
/// How much of a page's screenshot a thumbnail shows (from the top), and its widest, in pixels.
|
||||
const THUMB_TOP: u32 = 1800;
|
||||
const THUMB_WIDTH: u32 = 720;
|
||||
@@ -1530,6 +1576,7 @@ fn composer(app: &mut CraftApp, ui: &mut Ui, width: f32, t: &Tokens) {
|
||||
.unwrap_or("Ask for a change, e.g. \"make the header blue\" or \"add a contact form\""))
|
||||
};
|
||||
ui.allocate_ui(Vec2::new(width, 0.0), |ui| {
|
||||
let previews = app.previews(ui.ctx(), &app.state.project_attachments.clone());
|
||||
let history = || {
|
||||
let asked = app.workspace.as_ref().and_then(|w| app.asked.get(&w.root)).map_or(&[][..], Vec::as_slice);
|
||||
crate::just_ask::prompts(&app.state.feed, asked)
|
||||
@@ -1542,7 +1589,8 @@ fn composer(app: &mut CraftApp, ui: &mut Ui, width: f32, t: &Tokens) {
|
||||
name: "Ask for a change",
|
||||
hint,
|
||||
rows: (1, 8),
|
||||
attach: Some("Attach pictures (or drop them here)"),
|
||||
attach: Some("Attach pictures (or drop or paste them here)"),
|
||||
previews: &previews,
|
||||
// The one way to pause (Continue picks up where it stopped).
|
||||
stop_button: running.then_some("Pause"),
|
||||
stopping: pausing,
|
||||
@@ -1568,6 +1616,9 @@ fn composer(app: &mut CraftApp, ui: &mut Ui, width: f32, t: &Tokens) {
|
||||
if out.attach {
|
||||
app.pick(crate::Pick::Attach);
|
||||
}
|
||||
if out.paste {
|
||||
app.paste_pictures(true);
|
||||
}
|
||||
if out.stop {
|
||||
app.pause();
|
||||
}
|
||||
@@ -1623,6 +1674,14 @@ fn questions_controls(app: &mut CraftApp, ui: &mut Ui, now: craft_anything_engin
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture being pasted into a project's prompt box: its job, and whether Send was pressed
|
||||
/// meanwhile (the message then goes once the picture is in, with it).
|
||||
pub struct PendingPaste {
|
||||
pub job: u64,
|
||||
pub root: PathBuf,
|
||||
pub send: bool,
|
||||
}
|
||||
|
||||
/// What was typed for a note while its project's builder worked.
|
||||
pub struct Draft {
|
||||
pub root: PathBuf,
|
||||
@@ -1641,6 +1700,74 @@ impl CraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pictures pasted with Ctrl+V: attached to the project's next message (`attach`), else added
|
||||
/// as pictures of the original on Make. The clipboard is read on a worker; one with no
|
||||
/// picture says so.
|
||||
pub fn paste_pictures(&mut self, attach: bool) {
|
||||
// Tests never read the person's clipboard.
|
||||
if self.headless {
|
||||
return;
|
||||
}
|
||||
// One at a time: a second paste would take the first one's place as the paste a message
|
||||
// waits for, and the first picture would turn up in the next message.
|
||||
if self.pasting().is_some() {
|
||||
return;
|
||||
}
|
||||
// Where it goes is decided now: another project may be open by the time it's read.
|
||||
let project = if attach {
|
||||
match &self.workspace {
|
||||
Some(ws) => Some(ws.root.clone()),
|
||||
None => return,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Pastes still attached somewhere are kept when old ones are cleared out.
|
||||
let mut keep: Vec<PathBuf> = self.state.project_attachments.iter().chain(&self.state.make.pictures).cloned().collect();
|
||||
keep.extend(self.state.unsent.values().flat_map(|(_, files)| files.iter().cloned()));
|
||||
keep.extend(self.drafts.values().flat_map(|d| d.attachments.iter().cloned()));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let root = project.clone();
|
||||
let job = self.jobs.spawn(tr("Paste pictures"), false, move |_| {
|
||||
let files = crate::paste::read_pictures(&craft_anything_engine::workspace::pasted_dir(), &keep)?;
|
||||
if files.is_empty() {
|
||||
return Err(tr("There's no picture on the clipboard to paste."));
|
||||
}
|
||||
Ok(Done::Pasted { project, files })
|
||||
});
|
||||
// A message sent before the picture is in waits for it (`send_message`).
|
||||
if let Some(root) = root {
|
||||
self.pending_paste = Some(PendingPaste { job, root, send: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paste into a project's prompt box that is still being read, if one is.
|
||||
pub(crate) fn pasting(&self) -> Option<&PendingPaste> {
|
||||
self.pending_paste.as_ref().filter(|p| self.jobs.running().any(|j| j.id == p.job))
|
||||
}
|
||||
|
||||
/// A job finished: when it was the paste a message is waiting for, the message goes now, with
|
||||
/// its picture. If the paste failed, what was typed stays in the box to send again.
|
||||
pub(crate) fn paste_done(&mut self, job: u64, ok: bool) {
|
||||
let Some(p) = self.pending_paste.take_if(|p| p.job == job) else { return };
|
||||
if p.send && ok && self.state.screen == Screen::Project && self.workspace.as_ref().is_some_and(|w| w.root == p.root) {
|
||||
self.send_message();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pasted pictures were read: attached to the project they were pasted in while its screen is
|
||||
/// still showing, or added to Make's pictures while Make is still showing.
|
||||
pub(crate) fn pasted(&mut self, project: Option<PathBuf>, files: Vec<PathBuf>) {
|
||||
let showing = |root: &PathBuf| self.state.screen == Screen::Project && self.workspace.as_ref().is_some_and(|w| &w.root == root);
|
||||
match project {
|
||||
Some(root) if showing(&root) => self.attach(files),
|
||||
None if self.state.screen == Screen::Home => self.add_pictures(files),
|
||||
_ => self.toast(tr("The pasted picture wasn't attached: another screen was opened meanwhile."), true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send what's in the project's prompt box: a follow-up, or a note while it's working.
|
||||
pub fn send_message(&mut self) {
|
||||
let Some(ws) = self.workspace.clone() else { return };
|
||||
@@ -1652,6 +1779,13 @@ impl CraftApp {
|
||||
if self.queue_position().is_some() {
|
||||
return self.toast(tr("This project is waiting its turn. Send your message once it starts."), true);
|
||||
}
|
||||
// A picture still being pasted goes with the message: it's sent once the picture is in.
|
||||
if self.pasting().is_some_and(|p| p.root == ws.root) {
|
||||
if let Some(p) = self.pending_paste.as_mut() {
|
||||
p.send = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.state.project_input.clear();
|
||||
let attachments = std::mem::take(&mut self.state.project_attachments);
|
||||
if attachments.is_empty() {
|
||||
@@ -1738,6 +1872,60 @@ mod tests {
|
||||
drop(release.0);
|
||||
}
|
||||
|
||||
/// Send pressed while a picture is still being pasted: the message waits for it and goes with it,
|
||||
/// instead of going without it and leaving the picture for the next message.
|
||||
#[test]
|
||||
fn a_message_sent_while_a_picture_is_pasted_waits_for_it() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let ws = Workspace::create(d.path(), "Notes", "A notes app", TargetRef::None).unwrap();
|
||||
let root = ws.root.clone();
|
||||
let mut app = crate::demo::app(crate::demo::engine(craft_anything_engine::Mode::Beginner, true));
|
||||
app.open_workspace(ws);
|
||||
app.state.screen = Screen::Project;
|
||||
let shot = d.path().join("shot.png");
|
||||
std::fs::write(&shot, b"\x89PNG\r\n\x1a\n").unwrap();
|
||||
let (go, wait) = std::sync::mpsc::channel::<()>();
|
||||
let (project, files) = (Some(root.clone()), vec![shot.clone()]);
|
||||
let job = app.jobs.spawn("Paste pictures", false, move |_| {
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_secs(10));
|
||||
Ok(Done::Pasted { project, files })
|
||||
});
|
||||
app.pending_paste = Some(PendingPaste { job, root, send: false });
|
||||
// While it's read, it's the one paste: another Ctrl+V waits (`paste_pictures` checks this).
|
||||
assert!(app.pasting().is_some_and(|p| p.job == job));
|
||||
app.state.project_input = "Like this".into();
|
||||
app.send_message();
|
||||
assert_eq!(app.state.project_input, "Like this", "it waits for the picture");
|
||||
go.send(()).unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while app.pending_paste.is_some() && std::time::Instant::now() < deadline {
|
||||
app.take_finished_jobs();
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(app.state.project_input.is_empty() && app.state.project_attachments.is_empty(), "the message went");
|
||||
assert!(app.jobs.running().any(|j| j.title == "Attach pictures"), "with its picture");
|
||||
assert!(app.pasting().is_none(), "a next paste can start");
|
||||
}
|
||||
|
||||
/// A paste read while the person went Home goes nowhere, though the project is still the
|
||||
/// open one: it would land in a prompt box nobody can see.
|
||||
#[test]
|
||||
fn a_paste_reaches_a_project_only_while_its_screen_shows() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let ws = Workspace::create(d.path(), "Notes", "A notes app", TargetRef::None).unwrap();
|
||||
let root = ws.root.clone();
|
||||
let mut app = crate::demo::app(crate::demo::engine(craft_anything_engine::Mode::Beginner, true));
|
||||
app.open_workspace(ws);
|
||||
let shot = vec![PathBuf::from("pasted-1.png")];
|
||||
app.state.screen = Screen::Home;
|
||||
app.pasted(Some(root.clone()), shot.clone());
|
||||
assert!(app.state.project_attachments.is_empty());
|
||||
assert!(app.state.toasts.iter().any(|(t, error)| *error && t.contains("another screen")), "{:?}", app.state.toasts);
|
||||
app.state.screen = Screen::Project;
|
||||
app.pasted(Some(root), shot.clone());
|
||||
assert_eq!(app.state.project_attachments, shot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fix_that_names_a_command_offers_it() {
|
||||
let hint = craft_anything_ai::cli_agents::sign_in_hint("Not logged in").unwrap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The prompt box used everywhere the person types to the builder or the assistant: it grows
|
||||
//! with the text (Enter sends, Shift+Enter adds a line), shows attached pictures as chips, and
|
||||
//! has the attach, stop and send buttons in its bottom row. Up at the start of the box takes back
|
||||
//! has the attach, stop and send buttons in its bottom row; where it takes pictures, Ctrl+V pastes
|
||||
//! one ([`crate::paste`]). Up at the start of the box takes back
|
||||
//! the last message still waiting, to edit it; with none waiting, Up at the very start and Down at
|
||||
//! the very end go through earlier prompts, and Down past the newest brings back what was being
|
||||
//! typed. Anywhere else they move the cursor as usual.
|
||||
@@ -27,6 +28,8 @@ pub struct PromptBox<'a> {
|
||||
pub rows: (usize, usize),
|
||||
/// The paperclip's tooltip; `None` hides it (no attachments here).
|
||||
pub attach: Option<&'a str>,
|
||||
/// A small picture of each attachment, in order, once it's decoded (`CraftApp::previews`).
|
||||
pub previews: &'a [Option<egui::TextureHandle>],
|
||||
/// A run or answer is in progress: a stop button beside Send, named this ("Stop", "Pause").
|
||||
pub stop_button: Option<&'a str>,
|
||||
/// The stop was asked for and hasn't finished: the button says so and ignores clicks (#551).
|
||||
@@ -116,6 +119,8 @@ pub struct PromptOut {
|
||||
/// Send was pressed (button or Enter) with something to send.
|
||||
pub send: bool,
|
||||
pub attach: bool,
|
||||
/// Ctrl+V with something other than text on the clipboard, where the box takes pictures.
|
||||
pub paste: bool,
|
||||
pub stop: bool,
|
||||
/// Up was pressed at the start of the box while a message could be taken back.
|
||||
pub recall: bool,
|
||||
@@ -146,20 +151,44 @@ pub fn show(ui: &mut Ui, text: &mut String, attachments: &[PathBuf], b: PromptBo
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, p) in attachments.iter().enumerate() {
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
egui::Frame::new()
|
||||
.fill(t.raised)
|
||||
.stroke(egui::Stroke::new(1.0, t.card_border))
|
||||
.corner_radius(t.r_sm())
|
||||
.inner_margin(Margin { left: 8, right: 2, top: 2, bottom: 2 })
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
icons::show(ui, "paperclip", 13.0, t.icon);
|
||||
ui.add(egui::Label::new(RichText::new(&name).size(theme::SMALL)).truncate()).on_hover_text(p.display().to_string());
|
||||
if icons::button(ui, "x", 24.0, false, &trf("Remove {name}", &[("name", &name)])).clicked() {
|
||||
out.remove = Some(i);
|
||||
}
|
||||
let remove = trf("Remove {name}", &[("name", &name)]);
|
||||
match b.previews.get(i).cloned().flatten() {
|
||||
// A picture: a square tile filled with its middle, the whole of it on
|
||||
// hover, and the x in its corner.
|
||||
Some(tex) => {
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::Vec2::splat(TILE), egui::Sense::hover());
|
||||
egui::Image::new((tex.id(), rect.size())).uv(middle(tex.size_vec2())).corner_radius(t.r_lg()).paint_at(ui, rect);
|
||||
ui.painter().rect_stroke(rect, t.r_lg(), egui::Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, &name));
|
||||
resp.on_hover_ui(|ui| {
|
||||
ui.add(egui::Image::new((tex.id(), fit(tex.size_vec2(), PREVIEW_HOVER))));
|
||||
ui.label(RichText::new(p.display().to_string()).size(theme::CAPTION));
|
||||
});
|
||||
});
|
||||
let x = egui::Rect::from_min_size(rect.right_top() + egui::vec2(-26.0, 4.0), egui::Vec2::splat(22.0));
|
||||
ui.painter().circle_filled(x.center(), 10.0, t.card);
|
||||
// A child of its own, so the row's next tile isn't placed after it.
|
||||
if icons::button(&mut ui.new_child(egui::UiBuilder::new().max_rect(x)), "x", 22.0, false, &remove).clicked() {
|
||||
out.remove = Some(i);
|
||||
}
|
||||
}
|
||||
// Not decoded yet, or not readable: its name.
|
||||
None => {
|
||||
egui::Frame::new()
|
||||
.fill(t.raised)
|
||||
.stroke(egui::Stroke::new(1.0, t.card_border))
|
||||
.corner_radius(t.r_sm())
|
||||
.inner_margin(Margin { left: 8, right: 2, top: 2, bottom: 2 })
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
icons::show(ui, "paperclip", 13.0, t.icon);
|
||||
ui.add(egui::Label::new(RichText::new(&name).size(theme::SMALL)).truncate()).on_hover_text(p.display().to_string());
|
||||
if icons::button(ui, "x", 24.0, false, &remove).clicked() {
|
||||
out.remove = Some(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
@@ -215,6 +244,12 @@ pub fn show(ui: &mut Ui, text: &mut String, attachments: &[PathBuf], b: PromptBo
|
||||
out.send = true;
|
||||
}
|
||||
});
|
||||
// A paste of a picture never reaches the text field: it's attached instead, when the box
|
||||
// has the focus or nothing else does.
|
||||
let pasted = b.attach.is_some() && crate::paste::picture_wanted(ui.ctx());
|
||||
if pasted && (focused || ui.memory(|m| m.focused().is_none())) {
|
||||
out.paste = true;
|
||||
}
|
||||
// Hint: available slash commands when typing one.
|
||||
if text.trim_start().starts_with('/') && !text.trim().contains(' ') {
|
||||
ui.label(RichText::new(format!("{} {}", tr("Commands:"), crate::slash::names())).size(theme::CAPTION).color(t.text_faint));
|
||||
@@ -248,6 +283,28 @@ pub fn show(ui: &mut Ui, text: &mut String, attachments: &[PathBuf], b: PromptBo
|
||||
out
|
||||
}
|
||||
|
||||
/// An attachment's tile (points, square), and the box its whole picture fits in on hover.
|
||||
const TILE: f32 = 96.0;
|
||||
const PREVIEW_HOVER: egui::Vec2 = egui::vec2(360.0, 360.0);
|
||||
|
||||
/// The middle square of a picture of `size`, as texture coordinates: a tile is filled, not stretched.
|
||||
fn middle(size: egui::Vec2) -> egui::Rect {
|
||||
let full = egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0));
|
||||
if size.x <= 0.0 || size.y <= 0.0 {
|
||||
return full;
|
||||
}
|
||||
let (w, h) = ((size.y / size.x).min(1.0), (size.x / size.y).min(1.0));
|
||||
egui::Rect::from_center_size(full.center(), egui::vec2(w, h))
|
||||
}
|
||||
|
||||
/// `size` scaled to fit in `max`, keeping its shape.
|
||||
fn fit(size: egui::Vec2, max: egui::Vec2) -> egui::Vec2 {
|
||||
if size.x <= 0.0 || size.y <= 0.0 {
|
||||
return max;
|
||||
}
|
||||
size * (max.x / size.x).min(max.y / size.y)
|
||||
}
|
||||
|
||||
/// What Tab would complete the box to, when it starts with a partial command name. Only the
|
||||
/// name is replaced, so a question after it is kept; `None` when there is nothing to complete.
|
||||
fn completed_command(text: &str) -> Option<String> {
|
||||
@@ -363,6 +420,49 @@ mod tests {
|
||||
assert_eq!(draft.shown_pictures, draft_pictures, "the draft's own pictures come back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_tiles_sit_side_by_side_in_one_row() {
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
let files: Vec<PathBuf> = ["a.png", "b.png", "c.png"].map(PathBuf::from).to_vec();
|
||||
let mut h = egui_kittest::Harness::new_ui_state(
|
||||
move |ui, text: &mut String| {
|
||||
let tex = ui.ctx().load_texture("tile", egui::ColorImage::filled([4, 2], egui::Color32::RED), egui::TextureOptions::LINEAR);
|
||||
let previews = vec![Some(tex); 3];
|
||||
let b = PromptBox {
|
||||
name: "Box",
|
||||
hint: String::new(),
|
||||
rows: (1, 4),
|
||||
attach: Some("Attach"),
|
||||
previews: &previews,
|
||||
stop_button: None,
|
||||
stopping: false,
|
||||
send: true,
|
||||
recall: false,
|
||||
history: None,
|
||||
font_size: 14.0,
|
||||
};
|
||||
show(ui, text, &files, b);
|
||||
},
|
||||
String::new(),
|
||||
);
|
||||
h.run();
|
||||
let tiles: Vec<egui::Rect> = ["a.png", "b.png", "c.png"].iter().map(|n| h.get_by_label(n).rect()).collect();
|
||||
assert!(tiles.iter().all(|r| (r.top() - tiles[0].top()).abs() < 0.5 && (r.width() - TILE).abs() < 0.5), "{tiles:?}");
|
||||
assert!(tiles.windows(2).all(|w| w[1].left() > w[0].right()), "side by side, not overlapping: {tiles:?}");
|
||||
let x = h.get_by_label("Remove b.png").rect();
|
||||
assert!(tiles[1].contains_rect(x), "the x sits inside its tile: {x:?} in {:?}", tiles[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_shows_the_middle_square_of_its_picture() {
|
||||
let r = middle(egui::vec2(400.0, 200.0));
|
||||
assert_eq!((r.min.x, r.max.x, r.min.y, r.max.y), (0.25, 0.75, 0.0, 1.0));
|
||||
let r = middle(egui::vec2(100.0, 400.0));
|
||||
assert_eq!((r.min.x, r.max.x, r.min.y, r.max.y), (0.0, 1.0, 0.375, 0.625));
|
||||
assert_eq!(middle(egui::Vec2::ZERO).max, egui::pos2(1.0, 1.0));
|
||||
assert_eq!(fit(egui::vec2(720.0, 360.0), PREVIEW_HOVER), egui::vec2(360.0, 180.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_comes_back_without_its_pictures_line() {
|
||||
assert_eq!(as_typed("Add tags\n(2 pictures attached)"), "Add tags");
|
||||
@@ -390,6 +490,7 @@ mod tests {
|
||||
hint: String::new(),
|
||||
rows: (1, 8),
|
||||
attach: None,
|
||||
previews: &[],
|
||||
stop_button: Some("Pause"),
|
||||
stopping: self.stopping,
|
||||
send: true,
|
||||
|
||||
@@ -72,6 +72,7 @@ const ACTIONS: &[Action] = &[
|
||||
a("stop_pin", "close what a pin opened", Tool(&["project_pin_stop"])),
|
||||
a("add_pictures", "pictures of the original for the builder", Tool(&["rebuild_start"])),
|
||||
a("attach", "attach pictures to the next message", Pending { tool: None, why: "rebuild_note and rebuild_start take text; attachments with a message come next" }),
|
||||
a("paste_pictures", "attach pictures from the clipboard (Ctrl+V)", UiOnly("the clipboard belongs to the app; headless callers name picture files")),
|
||||
a("raise_asset_budget", "give a project another default budget for generated assets", Tool(&["project_assets_budget"])),
|
||||
a("set_generate_assets", "let a game's builder generate pictures and 3D models, or not", Tool(&["project_assets_allow"])),
|
||||
a("Project › Open folder", "show the project folder in the file manager", Tool(&["project_open_folder"])),
|
||||
@@ -151,6 +152,7 @@ const PLUMBING: &[(&str, &str)] = &[
|
||||
("builder_backend", "part of start_make"), ("need_builder", "part of start_make"), ("start_run", "part of start_make"), ("start_run_with", "part of start_run and continue_build"),
|
||||
("resume", "part of follow_up"), ("poll_run", "polling"), ("launch", "part of start_run: RunSet::start, the queue rebuild_start uses too"),
|
||||
("thumb_ready", "a job's result"), ("thumb", "drawing"), ("refresh_side_panel", "a view tally"),
|
||||
("previews", "drawing"), ("preview", "drawing"), ("preview_ready", "a job's result"),
|
||||
];
|
||||
|
||||
fn crate_dir() -> PathBuf {
|
||||
|
||||
@@ -466,6 +466,12 @@ Gaps found while using the app for real projects.
|
||||
- [x] A prompt brought back with Up keeps its pictures (their paths are absolute, so they don't
|
||||
come back after the project is moved)
|
||||
- [ ] Change a project's builder from its page (a tool, so the CLI and MCP can too) — in PR #245
|
||||
- [x] Ctrl+V (Shift+Insert, Cmd+V) in a prompt box that takes pictures pastes a copied image (saved
|
||||
as a PNG) or copied picture files; attachments show as square picture tiles, the whole
|
||||
picture on hover, decoded on a worker
|
||||
- [ ] A picture over 20 MB dropped on a prompt box or picked in its dialog still shows as
|
||||
attached, and the message then goes without it (`copy_pictures` skips it); refuse it when
|
||||
it's added, as a paste does (`workspace::attachable_picture`)
|
||||
- [ ] When the builder decides a question, show the question and its choice as an answered card
|
||||
in the feed (the run sees only the tool call today; the builder's words say what it chose)
|
||||
- [ ] A question's own cost: a forked Claude Code session reports the whole session's running
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ workspace clippy, layers, fmt and the secret scan of all history were clean. Chi
|
||||
| WebAssembly tag interfaces | `formats_wasm_compare` resolves retained imported and defined tag exports to their declared parameter types. Identical unresolved or invalid diagnostics may still count as unchanged; those counts do not establish interface compatibility, and invalid/partial limitations remain present | Synthetic core modules with tag changes and re-exports, mixed index spaces, import/tag limits, and missing or non-function type references; formats and engine tests. No real module corpus or WebAssembly execution was used for this correction |
|
||||
| Questions and approvals | A file inbox per project; `core_person_ask`, `core_action_approve` and MCP consent requests become cards in the app | Cross-handle round-trip tests, a full approval round trip through the engine |
|
||||
| Runtime assets in pinned builds | Frozen and light pins, size estimates and project copies keep runtime files in `obj` folders. An `obj` directory beside a regular C#, F# or Visual Basic project file is still recognised as compiler output; ambiguous folders are retained. New pins record the direct-launch program relative to the copy so a newer helper cannot replace it; older pins retain discovery behavior | Synthetic filesystem tests cover both pin modes, model bytes, compiler-output controls and project duplication. A Linux tool-level regression starts temporary scripts through `project_pin_open`, with a newer `Build/obj/helper`, and verifies the originally selected program and its output; a missing recorded program is refused. No real pinned application was launched for this correction |
|
||||
| Desktop app | Simple mode: sidebar with New project and the project history (kept across restarts); New project either makes something or just looks inside an app or file, a website or a game's files; a Game files screen (a game's archives; pictures from Unreal paks, Unity .assets files and Unity bundles saved as PNG after the person confirms they may unpack the game; uncompressed textures and the block formats DXT1, DXT5 and BC4–BC7), make prompt with picture attachments and, for anything recreated, a Clean room / Deep choice (also in the title bar; in Clean room, Just look inside, Game files, opening a file and the workbench ask for Deep mode instead), project screen (steps with the time spent and the AI cost and tokens of every build, saved in the project's usage.json by the run (a coding agent's figure is shown as an estimate at API prices, not the bill), what the build is on now and its activity as it arrives (kept in `build-activity.log`), pinned builder checklist, feed, question and approval cards, a Continue card when a build was paused or stopped part-way (the app or computer closed; the project shows Interrupted) that carries on the builder's own session instead of starting over, and "It's done" beside Continue or Try again to keep the build as it is (`project_build_finish`), checks with screenshots and the requirements not met yet, the builder's open questions, preview, play, folder, follow-ups while it builds, which Claude Code takes at its next step; Up in the prompt box takes back the last note still waiting). Projects can start in a folder the person picks, whose files are either the app, edited in place, or material to start from (asked, or the answer remembered in Settings), and are listed from any number of folders (`project_folders`, `project_list`, `rebuild --project-folder`). Make says which AI builds ("Built by": automatic, or any AI that can build, by name); a pick stays with the project and builds its later changes. With nothing to build with, Start offers the coding agents found on the computer. Settings → AI turns an AI off or removes it, and sets how hard the Claude API or Claude Code thinks (every Claude level, low to max; also `ai effort` in the CLI). In the prompt box, Up at the very start and Down at the very end go through earlier prompts when no note is waiting. Questions about a build ("Just ask" under the prompt box, or a message starting with `/btw`, the first slash command; `rebuild_question_ask`) are answered in the conversation, where each folds away and is kept in the project, without a build or a round and changing nothing: Claude Code answers from a read-only copy of the builder's own session. Under each project's prompt box, "Builder's questions: Ask me / Decide yourself" chooses whether the builder's questions come to the person as cards or are decided by the builder, which says what it chose (`project_questions_set`; the default is in Settings and follows Auto mode until picked). Projects can be renamed, duplicated and deleted (right-click in the sidebar, or the project's page; a delete keeps the person's own files). A build can be pinned (Pin this build on its page, or `project_pin_*` from the CLI and MCP): a frozen copy in the project's pinned/ folder (the default) or a light one that shares dependencies and media with the project through hard links (each one's size shown first), opened from the sidebar's collapsible Pinned section (icons with a running dot when folded); a pin or Play runs once per folder unless another copy is asked for, and a start that quits at once says so with its last output. Typed drafts survive a restart. In a narrow window the sidebar folds to icons and the project's details move above its feed; messages float over the screen and errors stay until dismissed. Advanced mode: workbench with every panel wired to real tools, as a screen beside the projects (builds and their questions stay a click away) | UI unit and smoke tests (egui_kittest), offscreen snapshots of every screen |
|
||||
| Desktop app | Simple mode: sidebar with New project and the project history (kept across restarts); New project either makes something or just looks inside an app or file, a website or a game's files; a Game files screen (a game's archives; pictures from Unreal paks, Unity .assets files and Unity bundles saved as PNG after the person confirms they may unpack the game; uncompressed textures and the block formats DXT1, DXT5 and BC4–BC7), make prompt with picture attachments (picked, dropped, or pasted with Ctrl+V, shown as picture tiles) and, for anything recreated, a Clean room / Deep choice (also in the title bar; in Clean room, Just look inside, Game files, opening a file and the workbench ask for Deep mode instead), project screen (steps with the time spent and the AI cost and tokens of every build, saved in the project's usage.json by the run (a coding agent's figure is shown as an estimate at API prices, not the bill), what the build is on now and its activity as it arrives (kept in `build-activity.log`), pinned builder checklist, feed, question and approval cards, a Continue card when a build was paused or stopped part-way (the app or computer closed; the project shows Interrupted) that carries on the builder's own session instead of starting over, and "It's done" beside Continue or Try again to keep the build as it is (`project_build_finish`), checks with screenshots and the requirements not met yet, the builder's open questions, preview, play, folder, follow-ups while it builds, which Claude Code takes at its next step; Up in the prompt box takes back the last note still waiting). Projects can start in a folder the person picks, whose files are either the app, edited in place, or material to start from (asked, or the answer remembered in Settings), and are listed from any number of folders (`project_folders`, `project_list`, `rebuild --project-folder`). Make says which AI builds ("Built by": automatic, or any AI that can build, by name); a pick stays with the project and builds its later changes. With nothing to build with, Start offers the coding agents found on the computer. Settings → AI turns an AI off or removes it, and sets how hard the Claude API or Claude Code thinks (every Claude level, low to max; also `ai effort` in the CLI). In the prompt box, Up at the very start and Down at the very end go through earlier prompts when no note is waiting. Questions about a build ("Just ask" under the prompt box, or a message starting with `/btw`, the first slash command; `rebuild_question_ask`) are answered in the conversation, where each folds away and is kept in the project, without a build or a round and changing nothing: Claude Code answers from a read-only copy of the builder's own session. Under each project's prompt box, "Builder's questions: Ask me / Decide yourself" chooses whether the builder's questions come to the person as cards or are decided by the builder, which says what it chose (`project_questions_set`; the default is in Settings and follows Auto mode until picked). Projects can be renamed, duplicated and deleted (right-click in the sidebar, or the project's page; a delete keeps the person's own files). A build can be pinned (Pin this build on its page, or `project_pin_*` from the CLI and MCP): a frozen copy in the project's pinned/ folder (the default) or a light one that shares dependencies and media with the project through hard links (each one's size shown first), opened from the sidebar's collapsible Pinned section (icons with a running dot when folded); a pin or Play runs once per folder unless another copy is asked for, and a start that quits at once says so with its last output. Typed drafts survive a restart. In a narrow window the sidebar folds to icons and the project's details move above its feed; messages float over the screen and errors stay until dismissed. Advanced mode: workbench with every panel wired to real tools, as a screen beside the projects (builds and their questions stay a click away) | UI unit and smoke tests (egui_kittest), offscreen snapshots of every screen |
|
||||
| CLI | Every tool, `--deep` or `--clean-room` for one run (never saved), `rebuild` (including `--continue-in`, and `--resume` to carry on a stopped build), `mcp --inbox`, `ai`, `setup`, `doctor`, `report` | CLI integration tests |
|
||||
| SQLite database files (`db_sqlite_show`) | A rowid alias column (`INTEGER PRIMARY KEY`, including the table-constraint form) shows the rowid; `DESC`, non-`INTEGER` and composite keys keep their stored values | Hand-built fixtures; generated files compared with the official SQLite library |
|
||||
| SQLite WITHOUT ROWID tables | Read from the index b-tree, interior records and overflow included; rows in primary-key order, values in declaration order, `rowid` null | Hand-built fixtures; generated files compared with the official SQLite library |
|
||||
|
||||
Reference in New Issue
Block a user