diff --git a/assets/icons/ui/design/requested_icons.txt b/assets/icons/ui/design/requested_icons.txt index 1789b23700..04f5dd9ec9 100644 --- a/assets/icons/ui/design/requested_icons.txt +++ b/assets/icons/ui/design/requested_icons.txt @@ -237,6 +237,7 @@ settings_monitor false settings_mouse false settings_palette false settings_plug false +settings_profile false sheet false sheet_rules false shell false diff --git a/assets/icons/ui/design/svg/flat/settings_profile.svg b/assets/icons/ui/design/svg/flat/settings_profile.svg new file mode 100644 index 0000000000..0546b069ea --- /dev/null +++ b/assets/icons/ui/design/svg/flat/settings_profile.svg @@ -0,0 +1,5 @@ + + Settings profile + + + diff --git a/assets/locales/ar.json b/assets/locales/ar.json index f3ae511939..1f79972a26 100644 --- a/assets/locales/ar.json +++ b/assets/locales/ar.json @@ -495,7 +495,7 @@ "Rename Folder": "إعادة تسمية المجلد", "Render Stage": "مرحلة التصيير", "Reopen designs": "إعادة فتح التصاميم", - "Reopen the designs that were open when SolidCraft was last closed": "إعادة فتح التصاميم التي كانت مفتوحة عند آخر إغلاق لـ SolidCraft", + "Reopen the designs that were open when SolidCraft was last closed": "إعادة فتح التصاميم التي كانت مفتوحة آخر مرة", "Replace existing library file": "استبدال ملف المكتبة الموجود", "Report a bug — review a screenshot and open a GitHub draft": "الإبلاغ عن خطأ — راجع لقطة الشاشة وافتح مسودة GitHub", "Report copied. Keep this text to submit on GitHub when you are online.": "تم نسخ التقرير. احتفظ بهذا النص لإرساله على GitHub عند الاتصال بالإنترنت.", @@ -2206,7 +2206,7 @@ "prefs.font_sample": "نص تجريبي للخط · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "تعذّرت قراءة الخط المحدد.", "prefs.general": "عام", - "prefs.general_note": "اللغة والحفظ والتحديثات.", + "prefs.general_note": "ملفات التعريف واللغة والملفات والتحديثات.", "prefs.gesture_orbit": "تدوير العرض", "prefs.gesture_pan": "إزاحة العرض", "prefs.gesture_zoom": "تكبير", @@ -2263,10 +2263,10 @@ "prefs.logo_accent": "لون التمييز", "prefs.logo_theme": "ألوان السمة", "prefs.max_file_size": "أكبر ملف يمكن فتحه", - "prefs.max_file_size_design": "ملفات التصميم", + "prefs.max_file_size_design": "أكبر ملف للفتح: التصميم", "prefs.max_file_size_flag": "بدأ هذا التشغيل بالخيار --max-file-size {size}، ويسري حتى إغلاق SolidCraft. تسري هذه الإعدادات عند تشغيله دون هذا الخيار.", "prefs.max_file_size_note": "القيم الافتراضية: 512 MB و256 MB؛ الحد الأقصى 16 GB. يتطلب فتح ملف ذاكرة تعادل حجمه عدة مرات. قد يحتاج التصميم الذي يحتفظ باستيراد كبير إلى حد أعلى لملف التصميم لحفظه وإعادة فتحه.", - "prefs.max_file_size_step": "ملفات STEP والاستيراد", + "prefs.max_file_size_step": "أكبر ملف للفتح: STEP والاستيراد", "prefs.max_file_size_web": "يفتح تطبيق الويب ملفات STEP والاستيراد حتى 512 MB والتصاميم حتى 256 MB؛ لا يتسع تبويب المتصفح لملفات أكبر. يمكن رفع هذه الحدود في تطبيق سطح المكتب.", "prefs.megabytes": "MB", "prefs.menu_grid": "أيقونات كبيرة", @@ -2283,7 +2283,7 @@ "prefs.navigator_fit_note": "النقر على وجه أو ركن أو سهم في المكعب، أو على محور في أداة المحاور، يوجّه العرض أيضًا نحو منتصف النموذج من المسافة الواحدة التي تُظهره كاملًا من كل جهة (كرته المحيطة). يستخدم العرض الرئيسي التكبير والمركز ذاتهما ما دام هذا مفعّلًا، فلا تغيّر العروض المختارة بعده سوى الاتجاه. عند الإيقاف: يدور العرض في مكانه محتفظًا بالهدف والتكبير.", "prefs.network_host_remove": "إزالة", "prefs.network_locations": "السماح بمواقع الشبكة في التصاميم", - "prefs.network_locations_note": "عند الإيقاف: الملفات المرتبطة بالتصميم على حاسوب آخر (\\\\server\\share) لا تُفتح إلا من الخوادم المدرجة هنا، أو بعد أن تسمح لأحدها عند السؤال.", + "prefs.network_locations_note": "للمؤسسات التي تحفظ التصاميم على خوادم الملفات. عند الإيقاف: الخوادم التي تسمح بها فقط.", "prefs.network_locations_section": "مواقع الشبكة", "prefs.not_on_gpu": "غير متاح على هذا المعالج الرسومي", "prefs.on": "تشغيل", @@ -2309,9 +2309,9 @@ "prefs.profile_applied": "تم تطبيق ملف التعريف.", "prefs.profile_changed": "تغيّرت الإعدادات منذ حفظ ملف التعريف. «حفظ» يحفظها فيه.", "prefs.profile_deleted": "تم حذف ملف التعريف.", - "prefs.profile_export": "تصدير…", + "prefs.profile_export": "تصدير", "prefs.profile_exported": "تم تصدير ملف التعريف.", - "prefs.profile_imported": "تم استيراد ملف التعريف. اختره في «ملف التعريف النشط» للتبديل إليه.", + "prefs.profile_imported": "تم استيراد ملف التعريف.", "prefs.profile_name_hint": "اسم ملف التعريف، مثل: حاسوب محمول", "prefs.profile_rename": "إعادة تسمية", "prefs.profile_renamed": "تمت إعادة تسمية ملف التعريف.", @@ -2336,7 +2336,7 @@ "prefs.ribbon_reveal": "إظهار مكان نتائج البحث في الشريط", "prefs.ribbon_reveal_note": "عند تمرير المؤشر فوق نتيجة في «البحث في الأوامر» يظهر مكان الأمر في الشريط: تبويبه أو زره أو صفه في القائمة المنسدلة للوحة. ويومض هناك الأمر الذي بدأ من البحث، ويُعلِّم الأمر الجاري من قائمة منسدلة لوحتَه.", "prefs.right_click": "نقر أيمن", - "prefs.saving": "الحفظ التلقائي والاستعادة", + "prefs.saving": "البدء والحفظ", "prefs.scale_note": "أو Ctrl + / Ctrl − في النافذة. الأزرار 28 px عند 100%.", "prefs.scale_text": "الحجم والنص", "prefs.search": "البحث في الإعدادات", @@ -5085,7 +5085,7 @@ "ui.slicer_no_response": "لم يستجب {slicer} في الوقت المحدد. الملف محفوظ مسبقًا في {path}: أرسله مرة أخرى بعد أن يبدأ برنامج التقطيع.", "ui.slicer_resend_hint": "الإرسال مرة أخرى يستبدل الملف المفتوح في برنامج التقطيع.", "ui.slicer_single_instance": "فتح الملفات في برنامج تقطيع يعمل مسبقًا", - "ui.slicer_single_instance_tip": "يحمّل PrusaSlicer وSuperSlicer وCura الملف في النافذة المفتوحة بدلًا من تشغيل نسخة أخرى. أما برامج التقطيع الأخرى فتُشغَّل كما هي. يفعل PrusaSlicer 3.0 هذا دائمًا: لا يقبل مشغّله الملف بأي طريقة أخرى.", + "ui.slicer_single_instance_tip": "لبرامج التقطيع التي تفتح الملف في نافذة قيد التشغيل.", "ui.slicer_starting": "جارٍ تشغيل {slicer}…", "ui.slicer_still_starting": "لا يزال الانتظار جاريًا لبدء {slicer}. لم يُرسل أي شيء آخر.", "ui.slicer_stl_fallback": "تعذّرت كتابة ملف 3MF، لذا هذا ملف STL.", @@ -5230,7 +5230,7 @@ "update.available": "SolidCraft {version} متاح", "update.button": "تحديث", "update.button_version": "التحديث إلى {version}", - "update.check_automatically": "التحقق من التحديثات تلقائيًا", + "update.check_automatically": "التحقق عند البدء ومرة يوميًا", "update.check_for_updates": "التحقق من وجود تحديثات", "update.check_now": "تحقق الآن", "update.check_unavailable": "التحقق من التحديثات غير متاح لهذا التثبيت.", diff --git a/assets/locales/de.json b/assets/locales/de.json index 7b36ae06fe..9bf1a31684 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -495,7 +495,7 @@ "Rename Folder": "Ordner umbenennen", "Render Stage": "Renderphase", "Reopen designs": "Designs erneut öffnen", - "Reopen the designs that were open when SolidCraft was last closed": "Die Designs erneut öffnen, die beim letzten Beenden von SolidCraft geöffnet waren", + "Reopen the designs that were open when SolidCraft was last closed": "Die zuletzt geöffneten Entwürfe erneut öffnen", "Replace existing library file": "Vorhandene Bibliotheksdatei ersetzen", "Report a bug — review a screenshot and open a GitHub draft": "Fehler melden — Screenshot prüfen und einen GitHub-Entwurf öffnen", "Report copied. Keep this text to submit on GitHub when you are online.": "Bericht kopiert. Bewahren Sie diesen Text auf, um ihn auf GitHub einzureichen, sobald Sie online sind.", @@ -2203,7 +2203,7 @@ "prefs.font_sample": "Zwölf Boxkämpfer jagen Viktor quer über den großen Sylter Deich · Ø12,50 mm · 45,0° · 0123456789", "prefs.font_unreadable": "Die ausgewählte Schriftdatei konnte nicht gelesen werden.", "prefs.general": "Allgemein", - "prefs.general_note": "Sprache, Speichern und Updates.", + "prefs.general_note": "Profile, Sprache, Dateien und Updates.", "prefs.gesture_orbit": "Ansicht drehen", "prefs.gesture_pan": "Ansicht verschieben", "prefs.gesture_zoom": "Ansicht zoomen", @@ -2258,10 +2258,10 @@ "prefs.logo_accent": "Akzentfarbe", "prefs.logo_theme": "Designfarben", "prefs.max_file_size": "Maximale Dateigröße zum Öffnen", - "prefs.max_file_size_design": "Designdateien", + "prefs.max_file_size_design": "Größte zu öffnende Datei: Entwurf", "prefs.max_file_size_flag": "Dieser Lauf wurde mit --max-file-size {size} gestartet. Der Wert gilt, bis SolidCraft geschlossen wird. Diese Einstellungen gelten beim Start ohne diese Option.", "prefs.max_file_size_note": "Standard: 512 MB und 256 MB; höchstens 16 GB. Das Öffnen einer Datei benötigt ein Mehrfaches ihrer Größe im Arbeitsspeicher. Ein Design mit großem Import benötigt zum Speichern und erneuten Öffnen eventuell eine höhere Grenze für Designdateien.", - "prefs.max_file_size_step": "STEP- und Importdateien", + "prefs.max_file_size_step": "Größte zu öffnende Datei: STEP und Import", "prefs.max_file_size_web": "Die Web-App öffnet STEP- und Importdateien bis 512 MB und Designs bis 256 MB: Ein Browser-Tab kann keine größeren Dateien aufnehmen. In der Desktop-App lassen sich diese Grenzen erhöhen.", "prefs.megabytes": "MB", "prefs.menu_grid": "Große Symbole", @@ -2278,7 +2278,7 @@ "prefs.navigator_fit_note": "Eine Fläche, Ecke oder ein Pfeil des Würfels oder eine Achse des Gizmos blickt auch auf die Mitte des Modells, aus dem einen Abstand, der es von jeder Seite einpasst (seine Hüllkugel). Home nutzt denselben Zoom und dieselbe Mitte, solange dies an ist, sodass danach gewählte Ansichten sich nur drehen. Aus: Die Ansicht dreht sich an Ort und Stelle und behält Ziel und Zoom.", "prefs.network_host_remove": "Entfernen", "prefs.network_locations": "Netzwerkspeicherorte in Designs zulassen", - "prefs.network_locations_note": "Deaktiviert: Verknüpfte Dateien eines Designs auf einem anderen Computer (\\\\server\\share) werden nur von den hier aufgeführten Servern geöffnet oder nachdem Sie einen Server auf Nachfrage ausdrücklich zugelassen haben.", + "prefs.network_locations_note": "Für Organisationen, die Entwürfe auf Dateiservern speichern. Aus: nur erlaubte Server.", "prefs.network_locations_section": "Netzwerkspeicherorte", "prefs.not_on_gpu": "Auf dieser GPU nicht verfügbar", "prefs.on": "An", @@ -2304,9 +2304,9 @@ "prefs.profile_applied": "Profil angewendet.", "prefs.profile_changed": "Die Einstellungen wurden seit dem Speichern des Profils geändert. „Speichern“ übernimmt sie ins Profil.", "prefs.profile_deleted": "Profil gelöscht.", - "prefs.profile_export": "Exportieren…", + "prefs.profile_export": "Exportieren", "prefs.profile_exported": "Profil exportiert.", - "prefs.profile_imported": "Profil importiert. Unter „Aktives Profil“ auswählen, um dazu zu wechseln.", + "prefs.profile_imported": "Profil importiert.", "prefs.profile_name_hint": "Profilname, z. B. Laptop", "prefs.profile_rename": "Umbenennen", "prefs.profile_renamed": "Profil umbenannt.", @@ -2331,7 +2331,7 @@ "prefs.ribbon_reveal": "Suchergebnisse im Menüband zeigen", "prefs.ribbon_reveal_note": "Steht der Zeiger über einem Ergebnis in Befehle suchen, zeigt das Menüband, wo der Befehl liegt: seine Registerkarte, seine Schaltfläche oder seine Zeile im Aufklappmenü eines Panels. Ein aus der Suche gestarteter Befehl blinkt dort, und ein laufender Befehl aus einem Aufklappmenü markiert sein Panel.", "prefs.right_click": "Rechtsklick", - "prefs.saving": "Automatisches Speichern und Wiederherstellung", + "prefs.saving": "Starten und Speichern", "prefs.scale_note": "Die Skalierung lässt sich auch mit Strg + / Strg − im Fenster ändern. Bei 100 % sind die Schaltflächen 28 px groß.", "prefs.scale_text": "Skalierung und Text", "prefs.search": "Einstellungen suchen", @@ -5076,7 +5076,7 @@ "ui.slicer_no_response": "{slicer} hat nicht rechtzeitig geantwortet. Die Datei ist bereits unter {path} gespeichert: Senden Sie sie erneut, sobald der Slicer gestartet ist.", "ui.slicer_resend_hint": "Erneutes Senden ersetzt die Datei, die der Slicer geöffnet hat.", "ui.slicer_single_instance": "Dateien in einem bereits laufenden Slicer öffnen", - "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer und Cura laden die Datei in das geöffnete Fenster, statt ein weiteres zu starten. Andere Slicer werden wie gewohnt gestartet. PrusaSlicer 3.0 tut das immer: Sein Starter kann eine Datei nicht anders übernehmen.", + "ui.slicer_single_instance_tip": "Für Slicer, die Dateien in ein laufendes Fenster laden.", "ui.slicer_starting": "{slicer} wird gestartet…", "ui.slicer_still_starting": "Es wird weiter darauf gewartet, dass {slicer} startet. Sonst wurde nichts gesendet.", "ui.slicer_stl_fallback": "Das 3MF konnte nicht geschrieben werden, daher ist dies ein STL.", @@ -5221,7 +5221,7 @@ "update.available": "SolidCraft {version} ist verfügbar", "update.button": "Update", "update.button_version": "Auf {version} aktualisieren", - "update.check_automatically": "Automatisch nach Updates suchen", + "update.check_automatically": "Beim Start und einmal täglich prüfen", "update.check_for_updates": "Nach Updates suchen", "update.check_now": "Jetzt suchen", "update.check_unavailable": "Für diese Installation ist keine Update-Prüfung verfügbar.", diff --git a/assets/locales/en-GB.json b/assets/locales/en-GB.json index 9342711665..40db6c74da 100644 --- a/assets/locales/en-GB.json +++ b/assets/locales/en-GB.json @@ -76,10 +76,8 @@ "prefs.bottom_centre": "Bottom centre", "prefs.logo_theme": "Theme's colours", "prefs.max_file_size": "Largest file to open", - "prefs.max_file_size_design": "Design files", "prefs.max_file_size_flag": "This run started with --max-file-size {size}, which applies until SolidCraft closes. These settings apply when it starts without it.", "prefs.max_file_size_note": "Defaults: 512 MB and 256 MB; at most 16 GB. Opening a file takes several times its size in memory. A design that keeps a large import may need a higher design file limit to save and reopen.", - "prefs.max_file_size_step": "STEP and import files", "prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.", "prefs.megabytes": "MB", "prefs.sketch_conflict_colour": "Conflicting geometry in the conflict colour", diff --git a/assets/locales/en.json b/assets/locales/en.json index 9465ff4f6c..49c7501ade 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -495,7 +495,7 @@ "Rename Folder": "Rename Folder", "Render Stage": "Render Stage", "Reopen designs": "Reopen designs", - "Reopen the designs that were open when SolidCraft was last closed": "Reopen the designs that were open when SolidCraft was last closed", + "Reopen the designs that were open when SolidCraft was last closed": "Reopen the designs that were open last time", "Replace existing library file": "Replace existing library file", "Report a bug — review a screenshot and open a GitHub draft": "Report a bug — review a screenshot and open a GitHub draft", "Report copied. Keep this text to submit on GitHub when you are online.": "Report copied. Keep this text to submit on GitHub when you are online.", @@ -2206,7 +2206,7 @@ "prefs.font_sample": "The quick brown fox · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "The selected font could not be read.", "prefs.general": "General", - "prefs.general_note": "Language, saving and updates.", + "prefs.general_note": "Profiles, language, files and updates.", "prefs.gesture_orbit": "Orbit", "prefs.gesture_pan": "Pan", "prefs.gesture_zoom": "Zoom", @@ -2263,10 +2263,10 @@ "prefs.logo_accent": "Accent", "prefs.logo_theme": "Theme's colors", "prefs.max_file_size": "Largest file to open", - "prefs.max_file_size_design": "Design files", + "prefs.max_file_size_design": "Largest file to open: Design", "prefs.max_file_size_flag": "This run started with --max-file-size {size}, which applies until SolidCraft closes. These settings apply when it starts without it.", "prefs.max_file_size_note": "Defaults: 512 MB and 256 MB; at most 16 GB. Opening a file takes several times its size in memory. A design that keeps a large import may need a higher design file limit to save and reopen.", - "prefs.max_file_size_step": "STEP and import files", + "prefs.max_file_size_step": "Largest file to open: STEP and import", "prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.", "prefs.megabytes": "MB", "prefs.menu_grid": "Large icons", @@ -2283,7 +2283,7 @@ "prefs.navigator_fit_note": "A face, corner or arrow of the view cube, or an axis of the gizmo, also looks at the middle of the model from the one distance that fits it from every side (its bounding sphere). Home uses the same zoom and centre while this is on, so views chosen after it only turn. Off: the view turns in place, keeping its target and zoom.", "prefs.network_host_remove": "Remove", "prefs.network_locations": "Allow network locations in designs", - "prefs.network_locations_note": "Off: a design's linked files on another computer (\\\\server\\share) are opened only from the servers listed here, or after you allow one when asked.", + "prefs.network_locations_note": "For organizations that keep designs on file servers. Off: only servers you allow.", "prefs.network_locations_section": "Network locations", "prefs.not_on_gpu": "not on this GPU", "prefs.on": "On", @@ -2309,9 +2309,9 @@ "prefs.profile_applied": "Profile applied.", "prefs.profile_changed": "Settings changed since the profile was saved. Save keeps them in it.", "prefs.profile_deleted": "Profile deleted.", - "prefs.profile_export": "Export…", + "prefs.profile_export": "Export", "prefs.profile_exported": "Profile exported.", - "prefs.profile_imported": "Profile imported. Choose it under Active profile to switch to it.", + "prefs.profile_imported": "Profile imported.", "prefs.profile_name_hint": "Profile name, e.g. Laptop", "prefs.profile_rename": "Rename", "prefs.profile_renamed": "Profile renamed.", @@ -2336,7 +2336,7 @@ "prefs.ribbon_reveal": "Show search results in the ribbon", "prefs.ribbon_reveal_note": "Hovering a result in Search commands shows where the command sits in the ribbon: its tab, its button or its row in a panel's drop-down. A command started from the search blinks there, and a running command from a drop-down marks its panel.", "prefs.right_click": "Right click", - "prefs.saving": "Autosave and recovery", + "prefs.saving": "Start and save", "prefs.scale_note": "Also Ctrl + / Ctrl − in the window. Buttons are 28 px at 100%.", "prefs.scale_text": "Scale and text", "prefs.search": "Search preferences", @@ -5085,7 +5085,7 @@ "ui.slicer_no_response": "{slicer} didn't respond in time. The file is already saved at {path}: send it again once the slicer has started.", "ui.slicer_resend_hint": "Sending again replaces the file the slicer has open.", "ui.slicer_single_instance": "Open files in a slicer that is already running", - "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer and Cura load the file in the window that is open instead of starting another. Other slicers are started as they are. PrusaSlicer 3.0 always does this: its launcher can't take a file any other way.", + "ui.slicer_single_instance_tip": "For slicers that load a file into a running window.", "ui.slicer_starting": "Starting {slicer}…", "ui.slicer_still_starting": "Still waiting for {slicer} to start. Nothing else was sent.", "ui.slicer_stl_fallback": "The 3MF could not be written, so this is an STL.", @@ -5230,7 +5230,7 @@ "update.available": "SolidCraft {version} is available", "update.button": "Update", "update.button_version": "Update to {version}", - "update.check_automatically": "Check for updates automatically", + "update.check_automatically": "Check at start and once a day", "update.check_for_updates": "Check for updates", "update.check_now": "Check now", "update.check_unavailable": "Update checks are not available for this installation.", diff --git a/assets/locales/he.json b/assets/locales/he.json index f5fd49a4a4..6a46dd8177 100644 --- a/assets/locales/he.json +++ b/assets/locales/he.json @@ -495,7 +495,7 @@ "Rename Folder": "שינוי שם תיקייה", "Render Stage": "שלב הרינדור", "Reopen designs": "פתיחה מחדש של תכנים", - "Reopen the designs that were open when SolidCraft was last closed": "פתיחה מחדש של התכנים שהיו פתוחים כש־SolidCraft נסגרה בפעם האחרונה", + "Reopen the designs that were open when SolidCraft was last closed": "פתיחה מחדש של התכנונים שהיו פתוחים בפעם הקודמת", "Replace existing library file": "החלפת קובץ הספרייה הקיים", "Report a bug — review a screenshot and open a GitHub draft": "דיווח על באג — בדיקת צילום מסך ופתיחת טיוטה ב־GitHub", "Report copied. Keep this text to submit on GitHub when you are online.": "הדיווח הועתק. יש לשמור את הטקסט כדי לשלוח אותו ב-GitHub כשהחיבור לאינטרנט יהיה זמין.", @@ -2206,7 +2206,7 @@ "prefs.font_sample": "טקסט לדוגמה בגופן · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "לא ניתן היה לקרוא את הגופן שנבחר.", "prefs.general": "כללי", - "prefs.general_note": "שפה, שמירה ועדכונים.", + "prefs.general_note": "פרופילים, שפה, קבצים ועדכונים.", "prefs.gesture_orbit": "סיבוב מבט", "prefs.gesture_pan": "הזזת מבט", "prefs.gesture_zoom": "זום", @@ -2263,10 +2263,10 @@ "prefs.logo_accent": "צבע הדגשה", "prefs.logo_theme": "צבעי ערכת הנושא", "prefs.max_file_size": "גודל קובץ מרבי לפתיחה", - "prefs.max_file_size_design": "קובצי תכן", + "prefs.max_file_size_design": "הקובץ הגדול ביותר לפתיחה: תכנון", "prefs.max_file_size_flag": "ההפעלה הנוכחית החלה עם --max-file-size {size}, שחל עד סגירת SolidCraft. הגדרות אלה חלות בהפעלה ללא אפשרות זו.", "prefs.max_file_size_note": "ברירות מחדל: 512 MB ו־256 MB; לכל היותר 16 GB. פתיחת קובץ דורשת זיכרון בנפח גדול פי כמה מגודלו. תכן ששומר ייבוא גדול עשוי לדרוש מגבלה גבוהה יותר לקובצי תכן כדי לשמור ולפתוח אותו מחדש.", - "prefs.max_file_size_step": "קובצי STEP וייבוא", + "prefs.max_file_size_step": "הקובץ הגדול ביותר לפתיחה: STEP וייבוא", "prefs.max_file_size_web": "יישום הדפדפן פותח קובצי STEP וייבוא עד 512 MB וקובצי תכן עד 256 MB: לשונית דפדפן אינה יכולה להכיל קבצים גדולים יותר. ביישום למחשב ניתן להעלות מגבלות אלה.", "prefs.megabytes": "MB", "prefs.menu_grid": "סמלים גדולים", @@ -2283,7 +2283,7 @@ "prefs.navigator_fit_note": "לחיצה על פאה, פינה או חץ בקובייה, או על ציר בגיזמו, גם מביטה אל מרכז המודל מהמרחק האחד שמכיל אותו מכל צד (הכדור החוסם שלו). תצוגת בית משתמשת באותו זום ובאותו מרכז כל עוד זה פעיל, כך שתצוגות שנבחרות אחריה רק מסתובבות. כבוי: התצוגה מסתובבת במקומה ושומרת על היעד ועל הזום.", "prefs.network_host_remove": "הסרה", "prefs.network_locations": "התרת מיקומי רשת בתכנים", - "prefs.network_locations_note": "כבוי: קבצים מקושרים של תכן במחשב אחר (\\\\server\\share) נפתחים רק מהשרתים הרשומים כאן, או אחרי שמתירים שרת כשנשאלים.", + "prefs.network_locations_note": "לארגונים השומרים תכנונים בשרתי קבצים. כשהאפשרות כבויה: רק שרתים שאישרתם.", "prefs.network_locations_section": "מיקומי רשת", "prefs.not_on_gpu": "לא זמין במעבד הגרפי הזה", "prefs.on": "פועל", @@ -2309,9 +2309,9 @@ "prefs.profile_applied": "הפרופיל הוחל.", "prefs.profile_changed": "ההגדרות השתנו מאז שהפרופיל נשמר. „שמירה“ מכניסה אותן לפרופיל.", "prefs.profile_deleted": "הפרופיל נמחק.", - "prefs.profile_export": "ייצוא…", + "prefs.profile_export": "ייצוא", "prefs.profile_exported": "הפרופיל יוצא.", - "prefs.profile_imported": "הפרופיל יובא. בחרו בו תחת „פרופיל פעיל“ כדי לעבור אליו.", + "prefs.profile_imported": "הפרופיל יובא.", "prefs.profile_name_hint": "שם הפרופיל, למשל מחשב נייד", "prefs.profile_rename": "שינוי שם", "prefs.profile_renamed": "שם הפרופיל שונה.", @@ -2336,7 +2336,7 @@ "prefs.ribbon_reveal": "הצגת תוצאות חיפוש בסרט", "prefs.ribbon_reveal_note": "מעבר עם הסמן מעל תוצאה בחיפוש פקודות מראה היכן הפקודה נמצאת בסרט: הלשונית, הכפתור או השורה שלה בתפריט הנפתח של לוח. פקודה שהופעלה מהחיפוש מהבהבת שם, ופקודה פעילה מתפריט נפתח מסמנת את הלוח שלה.", "prefs.right_click": "לחיצה ימנית", - "prefs.saving": "שמירה אוטומטית ושחזור", + "prefs.saving": "הפעלה ושמירה", "prefs.scale_note": "גם Ctrl + / Ctrl − בחלון. הכפתורים בגודל 28 px ב־100%.", "prefs.scale_text": "קנה מידה וטקסט", "prefs.search": "חיפוש הגדרות", @@ -5085,7 +5085,7 @@ "ui.slicer_no_response": "{slicer} לא הגיב בזמן. הקובץ כבר שמור ב־{path}: יש לשלוח אותו שוב אחרי שהסלייסר יופעל.", "ui.slicer_resend_hint": "שליחה חוזרת מחליפה את הקובץ שפתוח בסלייסר.", "ui.slicer_single_instance": "פתיחת קבצים בסלייסר שכבר פועל", - "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer ו־Cura טוענים את הקובץ בחלון הפתוח במקום להפעיל חלון נוסף. סלייסרים אחרים מופעלים כרגיל. PrusaSlicer 3.0 עושה זאת תמיד: המפעיל שלו לא יכול לקבל קובץ בדרך אחרת.", + "ui.slicer_single_instance_tip": "לסלייסרים שטוענים קובץ לחלון שכבר פועל.", "ui.slicer_starting": "מפעיל את {slicer}…", "ui.slicer_still_starting": "עדיין ממתין להפעלת {slicer}. שום דבר נוסף לא נשלח.", "ui.slicer_stl_fallback": "לא ניתן היה לכתוב את קובץ ה־3MF, ולכן זהו קובץ STL.", @@ -5230,7 +5230,7 @@ "update.available": "SolidCraft {version} זמין", "update.button": "עדכון", "update.button_version": "עדכון ל־{version}", - "update.check_automatically": "בדיקת עדכונים אוטומטית", + "update.check_automatically": "בדיקה בהפעלה ופעם ביום", "update.check_for_updates": "בדיקת עדכונים", "update.check_now": "בדיקה עכשיו", "update.check_unavailable": "בדיקת עדכונים אינה זמינה עבור התקנה זו.", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 6b863c4aeb..6ec718ea8b 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -495,7 +495,7 @@ "Rename Folder": "フォルダーの名前を変更", "Render Stage": "レンダリング段階", "Reopen designs": "デザインを再度開く", - "Reopen the designs that were open when SolidCraft was last closed": "前回 SolidCraft を閉じたときに開いていたデザインを再度開く", + "Reopen the designs that were open when SolidCraft was last closed": "前回開いていたデザインを再び開く", "Replace existing library file": "既存のライブラリファイルを置き換える", "Report a bug — review a screenshot and open a GitHub draft": "バグを報告 — スクリーンショットを確認してGitHub下書きを作成", "Report copied. Keep this text to submit on GitHub when you are online.": "レポートをコピーしました。このテキストを保存し、オンラインになったらGitHubで送信してください。", @@ -2206,7 +2206,7 @@ "prefs.font_sample": "いろはにほへと · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "選択したフォントを読み込めませんでした。", "prefs.general": "一般", - "prefs.general_note": "言語、保存、更新。", + "prefs.general_note": "プロファイル、言語、ファイル、更新。", "prefs.gesture_orbit": "回転", "prefs.gesture_pan": "移動", "prefs.gesture_zoom": "ズーム", @@ -2263,10 +2263,10 @@ "prefs.logo_accent": "アクセント", "prefs.logo_theme": "テーマの色", "prefs.max_file_size": "開けるファイルの最大サイズ", - "prefs.max_file_size_design": "デザインファイル", + "prefs.max_file_size_design": "開くファイルの上限: デザイン", "prefs.max_file_size_flag": "今回は --max-file-size {size} を指定して起動したため、SolidCraft を閉じるまでその値が適用されます。この設定は、そのオプションなしで起動したときに適用されます。", "prefs.max_file_size_note": "既定値は 512 MB と 256 MB、上限は 16 GB です。ファイルを開くには、サイズの数倍のメモリが必要です。大きなインポートを保持するデザインの保存と再読み込みには、デザインファイルの上限を上げる必要がある場合があります。", - "prefs.max_file_size_step": "STEP・インポートファイル", + "prefs.max_file_size_step": "開くファイルの上限: STEP・インポート", "prefs.max_file_size_web": "ウェブ版で開けるのは STEP・インポートファイルが 512 MB、デザインが 256 MB までです。ブラウザーのタブはそれ以上のファイルを保持できません。デスクトップ版では上限を上げられます。", "prefs.megabytes": "MB", "prefs.menu_grid": "大きなアイコン", @@ -2283,7 +2283,7 @@ "prefs.navigator_fit_note": "キューブの面・角・矢印やギズモの軸をクリックすると、どの方向からでも全体が収まる一定の距離(外接球)からモデルの中心を見ます。オンの間はホームビューも同じズームと中心を使うため、その後に選んだ視点は向きだけが変わります。オフ: 視点はその場で回転し、注視点とズームを保ちます。", "prefs.network_host_remove": "削除", "prefs.network_locations": "デザイン内のネットワーク上の場所を許可", - "prefs.network_locations_note": "オフ: 別のコンピューター (\\\\server\\share) にあるデザインのリンク先ファイルは、ここに表示されたサーバー、または確認時に許可したサーバーからのみ開きます。", + "prefs.network_locations_note": "ファイルサーバーでデザインを管理する組織向け。オフの場合は許可したサーバーのみ。", "prefs.network_locations_section": "ネットワーク上の場所", "prefs.not_on_gpu": "この GPU は非対応", "prefs.on": "オン", @@ -2309,9 +2309,9 @@ "prefs.profile_applied": "プロファイルを適用しました。", "prefs.profile_changed": "プロファイルの保存後に設定が変更されています。「保存」でプロファイルに反映されます。", "prefs.profile_deleted": "プロファイルを削除しました。", - "prefs.profile_export": "エクスポート…", + "prefs.profile_export": "エクスポート", "prefs.profile_exported": "プロファイルをエクスポートしました。", - "prefs.profile_imported": "プロファイルをインポートしました。切り替えるには「使用中のプロファイル」で選択してください。", + "prefs.profile_imported": "プロファイルをインポートしました。", "prefs.profile_name_hint": "プロファイル名(例: ノートPC)", "prefs.profile_rename": "名前を変更", "prefs.profile_renamed": "プロファイルの名前を変更しました。", @@ -2336,7 +2336,7 @@ "prefs.ribbon_reveal": "検索結果をリボンに表示", "prefs.ribbon_reveal_note": "コマンド検索で結果にポインターを合わせると、そのコマンドがリボンのどこにあるか(タブ、ボタン、またはパネルのドロップダウン内の行)を表示します。検索から開始したコマンドはそこで点滅し、ドロップダウンから実行中のコマンドはそのパネルに印が付きます。", "prefs.right_click": "右クリック", - "prefs.saving": "自動保存と復元", + "prefs.saving": "起動と保存", "prefs.scale_note": "ウィンドウ内では Ctrl + / Ctrl − でも変更できます。100% のボタンは 28 px です。", "prefs.scale_text": "拡大率と文字", "prefs.search": "設定を検索", @@ -5085,7 +5085,7 @@ "ui.slicer_no_response": "{slicer} が時間内に応答しませんでした。ファイルは {path} に保存済みです。スライサーが起動したら、もう一度送ってください。", "ui.slicer_resend_hint": "もう一度送ると、スライサーで開いているファイルが置き換わります。", "ui.slicer_single_instance": "すでに起動しているスライサーでファイルを開く", - "ui.slicer_single_instance_tip": "PrusaSlicer、SuperSlicer、Cura は、別のウィンドウを起動せず、開いているウィンドウでファイルを読み込みます。ほかのスライサーはそのまま起動します。PrusaSlicer 3.0 は常にこの動作です。ランチャーはほかの方法ではファイルを受け取れません。", + "ui.slicer_single_instance_tip": "実行中のウィンドウにファイルを読み込むスライサー用。", "ui.slicer_starting": "{slicer} を起動しています…", "ui.slicer_still_starting": "まだ {slicer} の起動を待っています。ほかには何も送信していません。", "ui.slicer_stl_fallback": "3MF を書き込めなかったため、STL で送信しました。", @@ -5230,7 +5230,7 @@ "update.available": "SolidCraft {version} が利用できます", "update.button": "アップデート", "update.button_version": "{version} にアップデート", - "update.check_automatically": "アップデートを自動的に確認", + "update.check_automatically": "起動時と1日1回確認", "update.check_for_updates": "アップデートを確認", "update.check_now": "今すぐ確認", "update.check_unavailable": "このインストールではアップデートを確認できません。", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 70b4d09202..78a1dd4e1c 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -1592,7 +1592,7 @@ "prefs.font_sample": "The quick brown fox · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "선택한 글꼴을 읽을 수 없습니다.", "prefs.general": "일반", - "prefs.general_note": "언어, 저장 및 업데이트.", + "prefs.general_note": "프로필, 언어, 파일 및 업데이트.", "prefs.gesture_orbit": "회전", "prefs.gesture_pan": "이동", "prefs.gesture_zoom": "확대/축소", @@ -1629,10 +1629,10 @@ "prefs.live_input": "실시간 입력", "prefs.live_preview": "실시간 미리보기", "prefs.max_file_size": "열 수 있는 최대 파일 크기", - "prefs.max_file_size_design": "디자인 파일", + "prefs.max_file_size_design": "열 파일의 최대 크기: 디자인", "prefs.max_file_size_flag": "이번 실행은 --max-file-size {size}로 시작했으며 SolidCraft를 닫을 때까지 적용됩니다. 이 설정은 해당 옵션 없이 시작할 때 적용됩니다.", "prefs.max_file_size_note": "기본값: 512 MB 및 256 MB, 최대 16 GB. 파일을 열려면 파일 크기의 몇 배에 달하는 메모리가 필요합니다. 큰 가져오기 데이터를 보관하는 디자인은 저장하고 다시 열기 위해 디자인 파일 한도를 높여야 할 수 있습니다.", - "prefs.max_file_size_step": "STEP 및 가져오기 파일", + "prefs.max_file_size_step": "열 파일의 최대 크기: STEP 및 가져오기", "prefs.max_file_size_web": "웹 앱은 STEP 및 가져오기 파일을 512 MB까지, 디자인을 256 MB까지 엽니다. 브라우저 탭은 더 큰 파일을 담을 수 없습니다. 데스크톱 앱에서는 이 한도를 높일 수 있습니다.", "prefs.megabytes": "MB", "prefs.minutes": "분", @@ -1670,7 +1670,7 @@ "prefs.ribbon_click_ends_tool": "실행 중인 도구의 버튼을 클릭하면 도구 종료", "prefs.ribbon_click_ends_tool_note": "사용 중인 스케치 도구의 강조된 리본 버튼을 클릭하면 도구가 종료되어 뷰에서 클릭하면 다시 선택됩니다. 끄기: 클릭하면 도구가 다시 시작됩니다.", "prefs.right_click": "오른쪽 클릭", - "prefs.saving": "자동 저장 및 복구", + "prefs.saving": "시작 및 저장", "prefs.scale_note": "창에서 Ctrl + / Ctrl −로도 조절합니다. 버튼은 100%에서 28 px입니다.", "prefs.scale_text": "배율 및 텍스트", "prefs.search": "설정 검색", @@ -3925,7 +3925,7 @@ "update.available": "SolidCraft {version} 사용 가능", "update.button": "업데이트", "update.button_version": "{version}(으)로 업데이트", - "update.check_automatically": "업데이트 자동 확인", + "update.check_automatically": "시작 시 및 하루에 한 번 확인", "update.check_for_updates": "업데이트 확인", "update.check_now": "지금 확인", "update.check_unavailable": "이 설치에서는 업데이트를 확인할 수 없습니다.", diff --git a/assets/locales/pending/preferences-general.json b/assets/locales/pending/preferences-general.json new file mode 100644 index 0000000000..4dab22af2f --- /dev/null +++ b/assets/locales/pending/preferences-general.json @@ -0,0 +1,130 @@ +{ + "ar": { + "prefs.general.active": "نشط", + "prefs.general.crash_reports": "تقارير الأعطال", + "prefs.general.edited": "معدّل", + "prefs.general.held": "المحفوظ", + "prefs.general.import": "استيراد...", + "prefs.general.last_check": "آخر تحقق: {time}", + "prefs.general.minutes": "دقائق", + "prefs.general.name_setup": "سمِّ هذا الإعداد", + "prefs.general.profile_name": "اسم ملف التعريف", + "prefs.general.save_new": "حفظ كجديد", + "prefs.general.step_import": "STEP والاستيراد", + "prefs.general.this_computer": "هذا الكمبيوتر", + "prefs.restore_page_defaults": "استعادة الإعدادات الافتراضية لهذه الصفحة" + }, + "de": { + "prefs.general.active": "Aktiv", + "prefs.general.crash_reports": "Absturzberichte", + "prefs.general.edited": "Geändert", + "prefs.general.held": "Enthalten", + "prefs.general.import": "Importieren...", + "prefs.general.last_check": "Zuletzt geprüft: {time}", + "prefs.general.minutes": "Minuten", + "prefs.general.name_setup": "Dieses Setup benennen", + "prefs.general.profile_name": "Profilname", + "prefs.general.save_new": "Als neu speichern", + "prefs.general.step_import": "STEP und Import", + "prefs.general.this_computer": "Dieser Computer", + "prefs.restore_page_defaults": "Seitenstandardwerte wiederherstellen" + }, + "en": { + "prefs.general.active": "Active", + "prefs.general.crash_reports": "Crash reports", + "prefs.general.edited": "Edited", + "prefs.general.held": "Held", + "prefs.general.import": "Import...", + "prefs.general.last_check": "Last checked: {time}", + "prefs.general.minutes": "Minutes", + "prefs.general.name_setup": "Name this setup", + "prefs.general.profile_name": "Profile name", + "prefs.general.save_new": "Save as new", + "prefs.general.step_import": "STEP and import", + "prefs.general.this_computer": "This computer", + "prefs.restore_page_defaults": "Restore page defaults" + }, + "en-GB": { + "prefs.network_locations_note": "For organisations that keep designs on file servers. Off: only servers you allow." + }, + "he": { + "prefs.general.active": "פעיל", + "prefs.general.crash_reports": "דוחות קריסה", + "prefs.general.edited": "נערך", + "prefs.general.held": "נכלל", + "prefs.general.import": "ייבוא...", + "prefs.general.last_check": "בדיקה אחרונה: {time}", + "prefs.general.minutes": "דקות", + "prefs.general.name_setup": "תנו שם להגדרה זו", + "prefs.general.profile_name": "שם הפרופיל", + "prefs.general.save_new": "שמירה כחדש", + "prefs.general.step_import": "STEP וייבוא", + "prefs.general.this_computer": "מחשב זה", + "prefs.restore_page_defaults": "שחזור ברירות המחדל של עמוד זה" + }, + "ja": { + "prefs.general.active": "使用中", + "prefs.general.crash_reports": "クラッシュレポート", + "prefs.general.edited": "変更あり", + "prefs.general.held": "保存対象", + "prefs.general.import": "インポート...", + "prefs.general.last_check": "最終確認: {time}", + "prefs.general.minutes": "分", + "prefs.general.name_setup": "この設定に名前を付ける", + "prefs.general.profile_name": "プロファイル名", + "prefs.general.save_new": "新規保存", + "prefs.general.step_import": "STEP とインポート", + "prefs.general.this_computer": "このコンピューター", + "prefs.restore_page_defaults": "このページを既定値に戻す" + }, + "ko": { + "Reopen the designs that were open when SolidCraft was last closed": "지난번에 열려 있던 디자인 다시 열기", + "prefs.general.active": "사용 중", + "prefs.general.crash_reports": "충돌 보고서", + "prefs.general.edited": "수정됨", + "prefs.general.held": "포함 항목", + "prefs.general.import": "가져오기...", + "prefs.general.last_check": "마지막 확인: {time}", + "prefs.general.minutes": "분", + "prefs.general.name_setup": "이 설정의 이름", + "prefs.general.profile_name": "프로필 이름", + "prefs.general.save_new": "새로 저장", + "prefs.general.step_import": "STEP 및 가져오기", + "prefs.general.this_computer": "이 컴퓨터", + "prefs.network_locations_note": "파일 서버에 디자인을 보관하는 조직용입니다. 끄면 허용한 서버만 사용합니다.", + "prefs.profile_export": "내보내기", + "prefs.profile_imported": "프로필을 가져왔습니다.", + "prefs.restore_page_defaults": "페이지 기본값 복원", + "ui.slicer_single_instance_tip": "실행 중인 창에 파일을 불러오는 슬라이서용입니다." + }, + "vi": { + "prefs.general.active": "Đang dùng", + "prefs.general.crash_reports": "Báo cáo sự cố", + "prefs.general.edited": "Đã sửa", + "prefs.general.held": "Được lưu", + "prefs.general.import": "Nhập...", + "prefs.general.last_check": "Kiểm tra lần cuối: {time}", + "prefs.general.minutes": "Phút", + "prefs.general.name_setup": "Đặt tên cấu hình này", + "prefs.general.profile_name": "Tên hồ sơ", + "prefs.general.save_new": "Lưu thành mới", + "prefs.general.step_import": "STEP và nhập", + "prefs.general.this_computer": "Máy tính này", + "prefs.restore_page_defaults": "Khôi phục mặc định của trang" + }, + "zh": { + "prefs.general.active": "当前", + "prefs.general.crash_reports": "崩溃报告", + "prefs.general.edited": "已修改", + "prefs.general.held": "包含", + "prefs.general.import": "导入...", + "prefs.general.last_check": "上次检查:{time}", + "prefs.general.minutes": "分钟", + "prefs.general.name_setup": "为此配置命名", + "prefs.general.profile_name": "配置文件名称", + "prefs.general.save_new": "另存为新配置", + "prefs.general.step_import": "STEP 和导入", + "prefs.general.this_computer": "此计算机", + "prefs.restore_page_defaults": "恢复本页默认值" + } +} diff --git a/assets/locales/vi.json b/assets/locales/vi.json index 19e9589832..e6a586836d 100644 --- a/assets/locales/vi.json +++ b/assets/locales/vi.json @@ -495,7 +495,7 @@ "Rename Folder": "Đổi tên thư mục", "Render Stage": "Giai đoạn kết xuất", "Reopen designs": "Mở lại thiết kế", - "Reopen the designs that were open when SolidCraft was last closed": "Mở lại các thiết kế đã mở khi đóng SolidCraft lần trước", + "Reopen the designs that were open when SolidCraft was last closed": "Mở lại các thiết kế đã mở lần trước", "Replace existing library file": "Thay thế tệp thư viện hiện có", "Report a bug — review a screenshot and open a GitHub draft": "Báo cáo sự cố — xem lại ảnh chụp màn hình và mở bản nháp trên GitHub", "Report copied. Keep this text to submit on GitHub when you are online.": "Đã sao chép báo cáo. Hãy giữ lại nội dung này để gửi lên GitHub khi có kết nối mạng.", @@ -2206,7 +2206,7 @@ "prefs.font_sample": "Kiểm tra phông chữ · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "Không thể đọc tệp phông chữ đã chọn.", "prefs.general": "Cài đặt chung", - "prefs.general_note": "Ngôn ngữ, lưu tệp và cập nhật.", + "prefs.general_note": "Hồ sơ, ngôn ngữ, tệp và cập nhật.", "prefs.gesture_orbit": "Xoay góc nhìn", "prefs.gesture_pan": "Trượt góc nhìn", "prefs.gesture_zoom": "Thu phóng", @@ -2263,10 +2263,10 @@ "prefs.logo_accent": "Màu nhấn", "prefs.logo_theme": "Màu của chủ đề", "prefs.max_file_size": "Kích thước tệp tối đa để mở", - "prefs.max_file_size_design": "Tệp thiết kế", + "prefs.max_file_size_design": "Tệp lớn nhất có thể mở: Thiết kế", "prefs.max_file_size_flag": "Lần chạy này bắt đầu với --max-file-size {size}, có hiệu lực đến khi đóng SolidCraft. Các cài đặt này áp dụng khi khởi động không có tùy chọn đó.", "prefs.max_file_size_note": "Mặc định: 512 MB và 256 MB; tối đa 16 GB. Mở tệp cần lượng bộ nhớ gấp nhiều lần kích thước tệp. Thiết kế giữ dữ liệu nhập lớn có thể cần giới hạn tệp thiết kế cao hơn để lưu và mở lại.", - "prefs.max_file_size_step": "Tệp STEP và tệp nhập", + "prefs.max_file_size_step": "Tệp lớn nhất có thể mở: STEP và nhập", "prefs.max_file_size_web": "Ứng dụng web mở tệp STEP và tệp nhập tối đa 512 MB, thiết kế tối đa 256 MB: thẻ trình duyệt không chứa được tệp lớn hơn. Ứng dụng máy tính cho phép tăng các giới hạn này.", "prefs.megabytes": "MB", "prefs.menu_grid": "Biểu tượng lớn", @@ -2283,7 +2283,7 @@ "prefs.navigator_fit_note": "Nhấp vào một mặt, góc hoặc mũi tên của khối lập phương, hoặc một trục của gizmo, cũng nhìn vào tâm mô hình từ một khoảng cách duy nhất chứa trọn nó từ mọi phía (khối cầu bao). Khi bật, góc nhìn chuẩn (Home) dùng cùng mức thu phóng và tâm đó, nên các góc nhìn chọn sau chỉ xoay. Tắt: góc nhìn xoay tại chỗ, giữ nguyên điểm nhìn và mức thu phóng.", "prefs.network_host_remove": "Xóa", "prefs.network_locations": "Cho phép các vị trí mạng trong thiết kế", - "prefs.network_locations_note": "Khi tắt: các tệp liên kết của thiết kế trên máy tính khác (\\\\server\\share) chỉ được mở từ các máy chủ được liệt kê tại đây, hoặc sau khi bạn đồng ý khi được hỏi.", + "prefs.network_locations_note": "Dành cho tổ chức lưu thiết kế trên máy chủ tệp. Khi tắt: chỉ các máy chủ bạn cho phép.", "prefs.network_locations_section": "Vị trí mạng", "prefs.not_on_gpu": "GPU này không hỗ trợ", "prefs.on": "Bật", @@ -2309,9 +2309,9 @@ "prefs.profile_applied": "Đã áp dụng hồ sơ.", "prefs.profile_changed": "Cài đặt đã thay đổi kể từ khi lưu hồ sơ. “Lưu” sẽ ghi chúng vào hồ sơ.", "prefs.profile_deleted": "Đã xóa hồ sơ.", - "prefs.profile_export": "Xuất tệp…", + "prefs.profile_export": "Xuất", "prefs.profile_exported": "Đã xuất hồ sơ.", - "prefs.profile_imported": "Đã nhập hồ sơ. Chọn hồ sơ trong “Hồ sơ đang dùng” để chuyển sang.", + "prefs.profile_imported": "Đã nhập hồ sơ.", "prefs.profile_name_hint": "Tên hồ sơ, ví dụ Laptop", "prefs.profile_rename": "Đổi tên", "prefs.profile_renamed": "Đã đổi tên hồ sơ.", @@ -2336,7 +2336,7 @@ "prefs.ribbon_reveal": "Hiện kết quả tìm kiếm trên ribbon", "prefs.ribbon_reveal_note": "Khi rê chuột lên một kết quả trong Tìm lệnh, ribbon cho biết lệnh nằm ở đâu: tab, nút hoặc dòng của nó trong menu thả xuống của một bảng. Lệnh bắt đầu từ tìm kiếm sẽ nhấp nháy ở đó, và lệnh đang chạy từ menu thả xuống sẽ đánh dấu bảng của nó.", "prefs.right_click": "Nhấp chuột phải", - "prefs.saving": "Tự động lưu và khôi phục", + "prefs.saving": "Khởi động và lưu", "prefs.scale_note": "Cũng có thể dùng Ctrl + / Ctrl − trong cửa sổ. Các nút có kích thước 28 px ở mức 100%.", "prefs.scale_text": "Tỉ lệ và văn bản", "prefs.search": "Tìm kiếm cài đặt", @@ -5085,7 +5085,7 @@ "ui.slicer_no_response": "{slicer} không phản hồi kịp thời. Tệp đã được lưu tại {path}: hãy gửi lại sau khi phần mềm cắt lớp khởi động xong.", "ui.slicer_resend_hint": "Gửi lại sẽ thay thế tệp đang mở trong phần mềm cắt lớp.", "ui.slicer_single_instance": "Mở tệp trong phần mềm cắt lớp đang chạy sẵn", - "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer và Cura sẽ tải tệp vào cửa sổ đang mở thay vì khởi động cửa sổ mới. Các phần mềm cắt lớp khác được khởi động như bình thường. PrusaSlicer 3.0 luôn hoạt động theo cách này: trình khởi chạy của nó không thể nhận tệp theo cách nào khác.", + "ui.slicer_single_instance_tip": "Dành cho phần mềm cắt lớp tải tệp vào cửa sổ đang chạy.", "ui.slicer_starting": "Đang khởi động {slicer}…", "ui.slicer_still_starting": "Vẫn đang chờ {slicer} khởi động. Chưa có dữ liệu nào khác được gửi.", "ui.slicer_stl_fallback": "Không thể ghi tệp 3MF nên định dạng xuất là STL.", @@ -5230,7 +5230,7 @@ "update.available": "Đã có phiên bản SolidCraft {version}", "update.button": "Cập nhật", "update.button_version": "Cập nhật lên {version}", - "update.check_automatically": "Tự động kiểm tra bản cập nhật", + "update.check_automatically": "Kiểm tra khi khởi động và mỗi ngày một lần", "update.check_for_updates": "Kiểm tra bản cập nhật", "update.check_now": "Kiểm tra ngay", "update.check_unavailable": "Không thể kiểm tra bản cập nhật cho bản cài đặt này.", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 581d750af0..a59361c46f 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -495,7 +495,7 @@ "Rename Folder": "重命名文件夹", "Render Stage": "渲染阶段", "Reopen designs": "重新打开设计", - "Reopen the designs that were open when SolidCraft was last closed": "重新打开上次关闭 SolidCraft 时打开的设计", + "Reopen the designs that were open when SolidCraft was last closed": "重新打开上次打开的设计", "Replace existing library file": "替换现有库文件", "Report a bug — review a screenshot and open a GitHub draft": "报告缺陷 — 复核截图并开启 GitHub 草稿", "Report copied. Keep this text to submit on GitHub when you are online.": "报告已复制。请保留此文本,联网后在 GitHub 上提交。", @@ -2206,7 +2206,7 @@ "prefs.font_sample": "字体示例 · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "无法读取所选字体。", "prefs.general": "常规", - "prefs.general_note": "语言、保存和更新。", + "prefs.general_note": "配置文件、语言、文件和更新。", "prefs.gesture_orbit": "环绕", "prefs.gesture_pan": "平移", "prefs.gesture_zoom": "缩放", @@ -2263,10 +2263,10 @@ "prefs.logo_accent": "强调色", "prefs.logo_theme": "主题颜色", "prefs.max_file_size": "可打开的最大文件", - "prefs.max_file_size_design": "设计文件", + "prefs.max_file_size_design": "可打开的最大文件:设计", "prefs.max_file_size_flag": "本次启动指定了 --max-file-size {size},该值在关闭 SolidCraft 前有效。不带此选项启动时才会应用这些设置。", "prefs.max_file_size_note": "默认值:512 MB 和 256 MB;上限为 16 GB。打开文件需要数倍于文件大小的内存。保留大型导入数据的设计可能需要提高设计文件上限,才能保存并重新打开。", - "prefs.max_file_size_step": "STEP 和导入文件", + "prefs.max_file_size_step": "可打开的最大文件:STEP 和导入", "prefs.max_file_size_web": "网页版可打开最大 512 MB 的 STEP 和导入文件,以及最大 256 MB 的设计:浏览器标签页无法容纳更大的文件。桌面版可提高这些上限。", "prefs.megabytes": "MB", "prefs.menu_grid": "大图标", @@ -2283,7 +2283,7 @@ "prefs.navigator_fit_note": "点击立方体的面、角或箭头,或坐标轴指示器的轴时,也会从各个方向都能容纳模型的同一距离(其包围球)注视模型中心。开启期间主视图使用相同的缩放和中心,因此之后选择的视图只改变方向。关闭:视图原地旋转,保持目标点和缩放。", "prefs.network_host_remove": "删除", "prefs.network_locations": "允许设计中的网络位置", - "prefs.network_locations_note": "关闭:设计中位于其他计算机 (\\\\server\\share) 上的链接文件,只会从此处列出的服务器打开,或在询问时你允许的服务器打开。", + "prefs.network_locations_note": "适用于将设计存放在文件服务器上的组织。关闭时:仅允许您授权的服务器。", "prefs.network_locations_section": "网络位置", "prefs.not_on_gpu": "此 GPU 不支持", "prefs.on": "开启", @@ -2309,9 +2309,9 @@ "prefs.profile_applied": "已应用配置文件。", "prefs.profile_changed": "保存配置文件后设置已更改。“保存”会将其写入配置文件。", "prefs.profile_deleted": "已删除配置文件。", - "prefs.profile_export": "导出…", + "prefs.profile_export": "导出", "prefs.profile_exported": "已导出配置文件。", - "prefs.profile_imported": "已导入配置文件。在“当前配置文件”中选择它即可切换。", + "prefs.profile_imported": "已导入配置文件。", "prefs.profile_name_hint": "配置文件名称,例如 笔记本", "prefs.profile_rename": "重命名", "prefs.profile_renamed": "已重命名配置文件。", @@ -2336,7 +2336,7 @@ "prefs.ribbon_reveal": "在功能区中显示搜索结果", "prefs.ribbon_reveal_note": "将指针悬停在“搜索命令”的结果上时,会显示该命令在功能区中的位置:其选项卡、按钮或面板下拉菜单中的行。从搜索启动的命令会在那里闪烁,从下拉菜单运行的命令会标记其面板。", "prefs.right_click": "右键单击", - "prefs.saving": "自动保存和恢复", + "prefs.saving": "启动和保存", "prefs.scale_note": "也可在窗口中使用 Ctrl + / Ctrl −。100% 时按钮高度为 28 像素。", "prefs.scale_text": "缩放与文字", "prefs.search": "搜索设置", @@ -5085,7 +5085,7 @@ "ui.slicer_no_response": "{slicer} 未在规定时间内响应。文件已保存到 {path}:切片软件启动后请再次发送。", "ui.slicer_resend_hint": "再次发送会替换切片软件中已打开的文件。", "ui.slicer_single_instance": "在已运行的切片软件中打开文件", - "ui.slicer_single_instance_tip": "PrusaSlicer、SuperSlicer 和 Cura 会在已打开的窗口中加载文件,而不是再启动一个。其他切片软件按原样启动。PrusaSlicer 3.0 始终如此:其启动器无法以其他方式接收文件。", + "ui.slicer_single_instance_tip": "适用于将文件加载到正在运行的窗口中的切片软件。", "ui.slicer_starting": "正在启动 {slicer}…", "ui.slicer_still_starting": "仍在等待 {slicer} 启动。没有发送其他内容。", "ui.slicer_stl_fallback": "无法写入 3MF,因此发送的是 STL。", @@ -5230,7 +5230,7 @@ "update.available": "SolidCraft {version} 已发布", "update.button": "更新", "update.button_version": "更新到 {version}", - "update.check_automatically": "自动检查更新", + "update.check_automatically": "启动时及每天检查一次", "update.check_for_updates": "检查更新", "update.check_now": "立即检查", "update.check_unavailable": "此安装版本无法检查更新。", diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index 442dde5435..7415c0636e 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -75,6 +75,7 @@ impl Tokens { font_compass: 13.5, font_subtitle: 15.0, font_intro: 16.0, + font_page_heading: 20.0, font_article_heading: 17.0, font_title: 22.0, font_display: 26.0, @@ -228,6 +229,8 @@ impl Tokens { selected_bg: Color32::from_rgb(232, 241, 250), figure_ground: Color32::from_rgb(247, 248, 250), figure_line: Color32::from_rgb(111, 124, 143), + figure_highlight: Color32::WHITE, + figure_shadow: Color32::BLACK, figure_face_top: Color32::from_rgb(255, 255, 255), figure_face_left: Color32::from_rgb(228, 233, 240), figure_face_right: Color32::from_rgb(201, 210, 222), @@ -241,8 +244,9 @@ impl Tokens { figure_axis_z: Color32::from_rgb(47, 111, 208), figure_ok: Color32::from_rgb(47, 125, 91), figure_warn: Color32::from_rgb(201, 138, 0), + figure_conflict: Color32::from_rgb(184, 74, 0), pref_well: Color32::from_rgb(238, 241, 245), - pref_note: Color32::from_rgb(184, 74, 0), + pref_note: Color32::from_rgb(183, 74, 0), pref_note_fill: Color32::from_rgb(251, 235, 221), pref_on_accent: Color32::from_rgb(255, 255, 255), pref_ok: Color32::from_rgb(39, 107, 44), @@ -380,6 +384,7 @@ impl Tokens { t.figure_axis_z = Color32::from_rgb(108, 168, 247); t.figure_ok = Color32::from_rgb(127, 209, 174); t.figure_warn = Color32::from_rgb(232, 194, 74); + t.figure_conflict = Color32::from_rgb(240, 162, 122); t.pref_well = Color32::from_rgb(47, 52, 62); t.pref_note = Color32::from_rgb(240, 162, 122); t.pref_note_fill = Color32::from_rgb(74, 51, 38); diff --git a/attribution/pending/preferences-profile-icon.md b/attribution/pending/preferences-profile-icon.md new file mode 100644 index 0000000000..905bf21254 --- /dev/null +++ b/attribution/pending/preferences-profile-icon.md @@ -0,0 +1 @@ +| assets/icons/ui/design/svg/flat/settings_profile.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original analytic head and shoulder geometry for settings profiles | MIT OR Apache-2.0 | diff --git a/crates/ui-egui/src/browser_canvas.rs b/crates/ui-egui/src/browser_canvas.rs index dc1c281146..e8ea1a92f8 100644 --- a/crates/ui-egui/src/browser_canvas.rs +++ b/crates/ui-egui/src/browser_canvas.rs @@ -183,6 +183,7 @@ mod tests { #[test] fn edit_canvas_is_a_card_whose_close_keeps_the_live_edits() { let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; let id = canvas(&mut h); h.app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(&h.app, id, false)); diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index 4c046de448..49af46750f 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -287,6 +287,43 @@ fn groups(t: &Tokens) -> Vec { group("card hints", &[("form_dim", t.form_dim)], &[("dialog_body", t.dialog_body)]), group("card primary", &[("form_primary", t.form_primary)], &[("dialog_body", t.dialog_body)]), group("navigation status", &[("form_primary", t.form_primary), ("warning", t.warning)], &[("dialog_body", t.dialog_body)]), + // NAVIGATOR_AXES draws all three axes on the figure ground; its filled tips have letters. + group( + "Preferences figure axes", + &[("figure_axis_x", t.figure_axis_x), ("figure_axis_y", t.figure_axis_y), ("figure_axis_z", t.figure_axis_z)], + &[("figure_ground", t.figure_ground)], + ), + group( + "Preferences axis letters", + &[("pref_on_accent", t.pref_on_accent)], + &[("figure_axis_x", t.figure_axis_x), ("figure_axis_y", t.figure_axis_y), ("figure_axis_z", t.figure_axis_z)], + ), + // Selection outlines and active geometry are drawn over the model and paper surfaces. + group( + "Preferences figure actions", + &[("figure_accent", t.figure_accent)], + &[ + ("figure_ground", t.figure_ground), + ("figure_card", t.figure_card), + ("figure_face_top", t.figure_face_top), + ("figure_face_left", t.figure_face_left), + ("figure_face_right", t.figure_face_right), + ], + ), + // ASSEMBLY_MOVE and the orbit figures distinguish the active ring from the reference dot. + group("Preferences figure markers", &[("figure_accent", t.figure_accent), ("figure_reference", t.figure_reference)], &[]), + group("Preferences reference dots", &[("figure_reference", t.figure_reference)], &[("figure_ground", t.figure_ground)]), + // SKETCH_STATE_ICONS shows these three states together on top-face pads. + group( + "Preferences sketch states", + &[("figure_ok", t.figure_ok), ("figure_warn", t.figure_warn), ("figure_conflict", t.figure_conflict)], + &[("figure_ground", t.figure_ground), ("figure_face_top", t.figure_face_top)], + ), + // Badge text and selected-card marks use these exact pairs; WCAG ratios are also + // guarded by theme::tests::preferences_foreground_pairs_hold_small_text_contrast. + group("Preferences warning badge", &[("pref_note", t.pref_note)], &[("pref_note_fill", t.pref_note_fill)]), + group("Preferences success badge", &[("pref_ok", t.pref_ok)], &[("pref_ok_fill", t.pref_ok_fill)]), + group("Preferences selected marks", &[("pref_on_accent", t.pref_on_accent)], &[("figure_accent", t.figure_accent)]), // Validation is identified by its words as well as colour. Measure both the // ink and badge surfaces against the corresponding failure states. group( @@ -438,12 +475,12 @@ const SHADED: [&str; 2] = ["analysis.draft", "analysis.access"]; /// Tokens no colour vision mode needs to keep apart, and why. A new token fails /// `every_token_is_measured_or_carries_no_meaning` until it is in a group above or here. const NO_MEANING: &[(&str, &str)] = &[ - ("figure_face_left", "neutral cube shading; face geometry identifies the orientation"), - ("figure_face_right", "neutral cube shading; face geometry identifies the orientation"), ("figure_white", "neutral axis label and highlight ink; letters identify the axes"), ("figure_black", "neutral hatching and shadows; pattern and geometry carry the meaning"), ("pref_well", "neutral well behind a labelled control or illustration"), ("pref_disabled", "dimmed control ink; label and disabled interaction identify availability"), + ("figure_highlight", "neutral model lighting; face geometry and outlines identify the model, not the highlight's hue"), + ("figure_shadow", "neutral model shading, never an axis, selection or status category"), ("nav_hover", "neutral hover surface; placement and pointer feedback identify the control"), ("nav_press", "neutral pressed/open surface; interaction and menu presence identify the state"), ("nav_dim", "neutral secondary ink; the Later label identifies the unavailable option"), @@ -518,6 +555,7 @@ const NO_MEANING: &[(&str, &str)] = &[ ("font_compass", "layout dimension"), ("font_subtitle", "layout dimension"), ("font_intro", "layout dimension"), + ("font_page_heading", "layout dimension"), ("font_article_heading", "layout dimension"), ("font_title", "layout dimension"), ("font_display", "layout dimension"), @@ -807,6 +845,12 @@ fn each_mode_changes_only_its_table() { let red_green_both = [ "axis_x", "axis_y", + "figure_axis_x", + "figure_axis_y", + "figure_axis_z", + "figure_ok", + "figure_warn", + "figure_conflict", "analysis.draft", "analysis.access", "analysis.hatch", @@ -832,6 +876,12 @@ fn each_mode_changes_only_its_table() { false, ColorVision::Tritan, set(&[ + "figure_axis_x", + "figure_axis_y", + "figure_axis_z", + "figure_ok", + "figure_warn", + "figure_conflict", "surface_accent", "mesh_accent", "form_accent", @@ -849,6 +899,12 @@ fn each_mode_changes_only_its_table() { true, ColorVision::Tritan, set(&[ + "figure_axis_x", + "figure_axis_y", + "figure_axis_z", + "figure_ok", + "figure_warn", + "figure_conflict", "surface_accent", "mesh_accent", "form_accent", diff --git a/crates/ui-egui/src/crash_ui.rs b/crates/ui-egui/src/crash_ui.rs index 130f058f4b..f09b3d7858 100644 --- a/crates/ui-egui/src/crash_ui.rs +++ b/crates/ui-egui/src/crash_ui.rs @@ -309,7 +309,41 @@ mod tests { fn inventory_actions_details_empty_state_and_keyboard_remain_reachable() { let d = folder("inventory"); let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); + h.app.home.open = false; h.app.crashes.folder = Some(d.clone()); + let assert_general_count = |h: &mut crate::scenario::Harness, count: usize| { + // The fixture folder was assigned after startup; programmatic Preferences uses the cached inventory. + h.app.crashes.refresh(); + for theme in ["light", "dark"] { + h.call("ui.prefs", json!({"open":"General", "theme":theme})); + crate::prefs::reveal_preference(h, "prefs:crash_count"); + assert_eq!(h.app.crashes.saved_count(), count); + assert!(h.widget(&format!("{count} saved")).is_some()); + let badge = crate::scenario::rect_of("prefs:crash_count").unwrap(); + assert!((badge.height() - 18.0).abs() < 0.5); + assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains_rect(badge)); + let t = crate::theme::Tokens::get(); + let fill = if count == 0 { t.dialog_footer } else { t.pref_note_fill }; + assert!( + h.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.rect == badge && rect.fill == fill)), + "{theme}: count tone reflects reports waiting" + ); + let buttons: Vec<_> = h + .output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.role() == egui::accesskit::Role::Button && node.label() == Some(h.app.tr("prefs.crash_reports"))) + .collect(); + assert_eq!(buttons.len(), 1); + assert!(!buttons[0].1.is_disabled(), "opening reports stays available even at zero"); + h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap(); + } + }; + assert_general_count(&mut h, 1); super::open(&mut h.app); h.frames(5); for handle in [ @@ -349,6 +383,7 @@ mod tests { assert!(h.app.crashes.reports.is_empty()); h.step(&json!({"key":"Escape"})).unwrap(); assert!(!h.app.crashes.open); + assert_general_count(&mut h, 0); let _ = std::fs::remove_dir_all(d); } } diff --git a/crates/ui-egui/src/design_properties.rs b/crates/ui-egui/src/design_properties.rs index c20fe7108e..1cdd5d3ca2 100644 --- a/crates/ui-egui/src/design_properties.rs +++ b/crates/ui-egui/src/design_properties.rs @@ -546,6 +546,7 @@ mod tests { assert_ne!(english.text(k.label), k.label, "`{}`'s label is in the English catalog", k.name); } let mut h = Harness::new(); + h.app.preferences.language = "en".into(); // A key a newer SolidCraft might read: kept as another tool's data, never labelled as one of ours. h.app.session.doc_mut().metadata.0.insert("future_key".into(), json!("kept")); h.step(&json!({"start": "document.metadata"})).unwrap(); diff --git a/crates/ui-egui/src/manage_dialogs_tests.rs b/crates/ui-egui/src/manage_dialogs_tests.rs index 448c8f9dfe..0aede818e1 100644 --- a/crates/ui-egui/src/manage_dialogs_tests.rs +++ b/crates/ui-egui/src/manage_dialogs_tests.rs @@ -388,6 +388,7 @@ fn appearance_card_keeps_the_approved_width_with_no_preselection() { #[test] fn parameter_inventory_remains_reachable_with_regular_draft_rows() { let mut h = fixture(); + h.app.preferences.language = "en".into(); h.app.start("parameters.change"); h.frames(4); for label in ["Filter", "Import…", "Export CSV…", "Export JSON…", "User Parameters", "Model Parameters"] { diff --git a/crates/ui-egui/src/measurement_ui_tests.rs b/crates/ui-egui/src/measurement_ui_tests.rs index 43e0e406f9..ff6c86f879 100644 --- a/crates/ui-egui/src/measurement_ui_tests.rs +++ b/crates/ui-egui/src/measurement_ui_tests.rs @@ -158,6 +158,7 @@ fn a_datum_plane_and_a_point_measure_their_distance() { #[test] fn measurement_axis_distances_follow_the_upstream_centre_min_and_max_choices() { let mut h = Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; h.app.set_origin_shown(false); h.app.ui.show_browser = false; diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 0864b2d1c6..33963ba8d1 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -456,8 +456,7 @@ fn page_path(index: usize) -> String { fn search_header(ui: &mut egui::Ui, app: &mut SolveApp) { let typed = drive_search(ui, app); - let (field, _) = - ui_kit::search_actions_row(ui, egui::Id::new("prefs_search"), &mut app.prefs_window.filter, crate::i18n::tr("prefs.search"), &[]); + let field = ui_kit::preferences_search_field(ui, egui::Id::new("prefs_search"), &mut app.prefs_window.filter, crate::i18n::tr("prefs.search")); if typed { place_search_cursor(ui.ctx(), egui::Id::new("prefs_search"), app.prefs_window.filter.chars().count()); } @@ -468,7 +467,6 @@ fn search_header(ui: &mut egui::Ui, app: &mut SolveApp) { settle_search(app); crate::prefs_search::set_reveal(ui.ctx(), app.prefs_window.reveal.clone()); crate::prefs_search::set_force_scroll(ui.ctx(), !app.prefs_window.filter.trim().is_empty()); - ui.add_space(Tokens::get().space_8); } /// Keys for the search field. Shortcut capture keeps the key for itself. @@ -2543,6 +2541,7 @@ fn unfold(ctx: &egui::Context) { } pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + crate::settings_profiles::rename_popup(app, ctx); // File › Keyboard Shortcuts and Help › Keyboard Shortcuts open this dialog at that page. if app.keymap.open { app.keymap.open = false; @@ -2581,11 +2580,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let window = ui_kit::Window::new(egui::Id::new("sc_prefs"), "settings", app.tr("ui.preferences")) .cards(10.0 / 3.0) .page_rows(24.0) + .page_header_in_titlebar() .page_padding(egui::Margin { - left: (Tokens::get().space_16 + Tokens::get().space_4) as i8, - right: (Tokens::get().space_16 + Tokens::get().space_4) as i8, - top: Tokens::get().space_6 as i8, - bottom: (Tokens::get().space_16 + Tokens::get().space_4) as i8, + left: (Tokens::get().space_24 - Tokens::get().space_2) as i8, + right: (Tokens::get().space_24 - Tokens::get().space_2) as i8, + top: (Tokens::get().space_16 + Tokens::get().space_2) as i8, + bottom: Tokens::get().space_10 as i8, }) .fields() .actions("OK", "Cancel", Some("Apply")) @@ -2599,12 +2599,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app, search_header, clear_search, - Some((Tokens::get().control_height * 3.0, crate::settings_profiles::sidebar)), + Some((Tokens::get().space_24 * 2.0, crate::settings_profiles::sidebar)), |ui| { - if ui_kit::button(ui, crate::i18n::tr("ui.restore_defaults"), ui_kit::Button::Normal) - .on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page")) - .clicked() - { + let defaults = ui_kit::button(ui, crate::i18n::tr("prefs.restore_page_defaults"), ui_kit::Button::Ghost) + .on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page")); + crate::scenario::publish_target("prefs:defaults", defaults.rect); + if defaults.clicked() { button = Some("defaults"); } }, @@ -4229,7 +4229,18 @@ pub(crate) fn reveal_preference(h: &mut crate::scenario::Harness, handle: &str) h.call("ui.scroll", json!({"x":page.center().x,"y":page.center().y,"delta":-100})); h.frames(20); } - panic!("preference {handle} was not revealed"); + panic!( + "preference {handle} was not revealed: theme={} language={} section={} filter={:?} screen={:?} window={:?} body={:?} target_rect={:?} target_point={:?}", + h.app.preferences.theme, + h.app.preferences.language, + h.app.prefs_window.section, + h.app.prefs_window.filter, + h.ctx.content_rect(), + crate::scenario::rect_of("prefs:window"), + crate::scenario::rect_of("dialog:page_body"), + crate::scenario::rect_of(handle), + crate::scenario::handle_at(handle) + ); } #[cfg(test)] @@ -4428,10 +4439,15 @@ mod tests { fn navigation_bar_appearance_segments_stay_inside_the_page_at_narrow_widths() { for width in [1600, 880, 640, 600, 480] { let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; h.call("ui.resize", json!({"width":width,"height":1100})); h.call("ui.prefs", json!({"open":"Interface/Navigation bar", "navigation_bar":{"slots":["fit","visual_style","display","spin"]}})); h.frames(20); + let header = crate::scenario::rect_of("dialog:header").unwrap(); + let search = crate::scenario::rect_of("prefs:search:frame").unwrap(); + assert!(header.contains_rect(search), "titlebar search stays inside the window at {width}"); + assert!(search.width() <= 230.0 && search.width() >= 100.0, "usable bounded search at {width}"); for (key, label, labels) in [ ("position", "Position", &["Left", "Center", "Right"][..]), ("size", "Size", &["Compact", "Regular"][..]), @@ -4593,7 +4609,7 @@ mod tests { let body = crate::scenario::rect_of("prefs:page_body").unwrap(); let window = crate::scenario::rect_of("prefs:window").unwrap(); let t = crate::theme::Tokens::get(); - let inset = t.space_16 + t.space_4 + crate::ui_kit::navigation_popup_frame().stroke.width; + let inset = t.space_24 - t.space_2 + crate::ui_kit::navigation_popup_frame().stroke.width; assert!((body.right() - (window.right() - inset)).abs() < 0.5, "{width}: {page}"); for key in sections { let section = crate::scenario::rect_of(&format!("prefs:section:{key}")).unwrap(); @@ -5924,6 +5940,7 @@ mod tests { ]; let mut found = vec![Vec::new(); cases.len()]; let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; for page in 0..PAGES.len() { open_at(&mut h.app, page); @@ -7555,6 +7572,216 @@ mod tests { h.call("ui.prefs", json!({"button": "cancel"})); } + #[test] + fn general_files_controls_edit_real_limits_and_hosts_in_both_themes() { + for theme in ["light", "dark"] { + for width in [1468, 480] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":width, "height":1100})); + let configured = h.call( + "ui.prefs", + json!({"language":"en", "theme":theme, "network_locations":true, "network_hosts":["files.example", "nas.example"]}), + ); + assert!(configured["error"].is_null(), "{theme} {width}: setting up Preferences failed: {configured}"); + let opened = h.call("ui.prefs", json!({"open":"General"})); + assert!(opened["error"].is_null(), "{theme} {width}: opening General failed: {opened}"); + let initial_body = crate::scenario::rect_of("prefs:page_body").unwrap(); + for target in [ + "dialog:widget:Profile name", + "prefs:profile:save", + "prefs:profile:import", + "prefs:profiles:held:row", + "prefs:profiles:computer:row", + ] { + let rect = crate::scenario::rect_of(target).unwrap(); + assert!(initial_body.contains_rect(rect), "{theme} {width}: {target} {rect:?} leaves {initial_body:?}"); + } + if width == 480 { + let name = crate::scenario::rect_of("dialog:widget:Profile name").unwrap(); + let save = crate::scenario::rect_of("prefs:profile:save").unwrap(); + let import = crate::scenario::rect_of("prefs:profile:import").unwrap(); + assert!(save.top() >= name.bottom(), "narrow Save wraps after the independent name entry"); + assert!((save.top() - import.top()).abs() < 0.5); + } + let painted = |h: &crate::scenario::Harness, label: &str| { + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + h.shapes + .iter() + .find_map(|shape| { + if let egui::Shape::Text(text) = &shape.shape + && text.galley.job.text == label + { + let rect = egui::Rect::from_min_size(text.pos, text.galley.size()); + if body.contains_rect(rect) && shape.clip_rect.contains_rect(rect) { + return Some(rect); + } + } + None + }) + .unwrap_or_else(|| panic!("{theme} {width}: {label} is not fully painted in the page")) + }; + // Reveal the live dropdown, not the full-width layout row's allocation rectangle. + super::reveal_preference(&mut h, "dialog:widget:Interface language"); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + let language = painted(&h, "Interface language"); + assert!((language.left() - body.left()).abs() < 0.75); + assert!((crate::scenario::rect_of("dialog:control:Interface language").unwrap().left() - body.left() - 164.0).abs() < 0.5); + super::reveal_preference(&mut h, "prefs:autosave_minutes"); + let autosave = painted(&h, "Autosave every"); + let autosave_help = crate::scenario::rect_of("dialog:row_help:Autosave every").unwrap(); + assert!((autosave.left() - body.left()).abs() < 0.75, "the painted autosave label shares the language label's left edge"); + assert!((autosave_help.left() - autosave.right() - 4.0).abs() < 1.0, "help follows the actual text, not the whole label column"); + assert!((crate::scenario::rect_of("prefs:autosave_minutes").unwrap().left() - body.left() - 164.0).abs() < 0.5); + super::reveal_preference(&mut h, "prefs:autosave_timeline"); + let timeline = crate::scenario::rect_of("prefs:autosave_timeline").unwrap(); + let autosave_control = crate::scenario::rect_of("prefs:autosave_minutes").unwrap(); + assert!((timeline.left() - autosave_control.left()).abs() < 0.5); + assert!((timeline.width() - 260.0_f32.min(body.right() - autosave_control.left())).abs() < 0.5); + assert!(body.contains_rect(timeline)); + super::reveal_preference(&mut h, "prefs:max_step_file_mb"); + super::reveal_preference(&mut h, "dialog:row:Largest file to open"); + let field = crate::scenario::rect_of("prefs:max_step_file_mb").unwrap(); + let design = crate::scenario::rect_of("prefs:max_design_file_mb").unwrap(); + assert!( + (field.width() - 72.0).abs() < 0.5 && (design.width() - 72.0).abs() < 0.5, + "{theme}: both compound file editors retain their width" + ); + let step_group = crate::scenario::rect_of("dialog:row:Largest file to open: STEP and import").unwrap(); + let design_group = crate::scenario::rect_of("dialog:row:Largest file to open: Design").unwrap(); + let step_caption = painted(&h, "STEP and import"); + let design_caption = painted(&h, "Design"); + let largest = painted(&h, "Largest file to open"); + assert!((step_caption.left() - field.left()).abs() < 0.75 && (design_caption.left() - design.left()).abs() < 0.75); + assert!((largest.left() - body.left()).abs() < 0.75); + assert!((largest.top() - step_caption.top() - 6.0).abs() <= 1.0, "the left label stays in its top-aligned 28 px cell"); + assert!(step_group.width() >= 90.0 && step_group.width() < 130.0, "{theme}: the subgroup follows its field, unit and caption"); + if width == 1468 { + assert!((step_caption.top() - design_caption.top()).abs() < 0.5, "{theme}: the actual painted captions share a top baseline"); + assert!( + (field.top() - design.top()).abs() < 0.5 && field.right() < design.left(), + "{theme}: independent editors share one visual row" + ); + assert!((design_group.left() - step_group.right() - 20.0).abs() < 0.5, "{theme}: compact pair gap"); + } else { + assert!(design_group.top() > step_group.bottom(), "{theme}: the pair wraps at narrow widths"); + assert!((design_group.left() - step_group.left()).abs() < 0.5); + } + let group = crate::scenario::rect_of("dialog:row:Largest file to open").unwrap(); + assert!(group.contains_rect(field) && group.contains_rect(design)); + assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains_rect(group)); + h.step(&json!({"click":[field.center().x, field.center().y], "double":true})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"4096"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.preferences.max_step_file_mb, 4096); + super::reveal_preference(&mut h, "prefs:max_design_file_mb"); + let field = crate::scenario::rect_of("prefs:max_design_file_mb").unwrap(); + h.step(&json!({"click":[field.center().x, field.center().y], "double":true})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"2048"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.preferences.max_design_file_mb, 2048); + assert_eq!(super::file_limits(&h.app.preferences).step, 4096 * solidcraft_engine::io::limits::MB); + let before = h.app.preferences.clone(); + for bad in [json!({"max_step_file_mb":16385}), json!({"max_design_file_mb":255})] { + assert!(h.call("ui.prefs", bad)["error"].is_string()); + assert_eq!(h.app.preferences, before, "invalid file limits fail atomically"); + } + super::reveal_preference(&mut h, "prefs:network_host:files.example:remove"); + h.step(&json!({"click":{"handle":"prefs:network_host:files.example:remove"}})).unwrap(); + assert_eq!(h.app.preferences.network_hosts, ["nas.example"]); + h.step(&json!({"widget":"Allow network locations in designs"})).unwrap(); + h.frames(3); + assert!(!h.app.preferences.network_locations); + assert_eq!(h.app.preferences.network_hosts, ["nas.example"], "hiding the chips retains the hosts"); + assert!(crate::scenario::rect_of("prefs:network_host:nas.example:remove").is_none()); + super::reveal_preference(&mut h, "prefs:print_face_highlight"); + let highlight = painted(&h, "Print face highlight"); + let highlight_help = crate::scenario::rect_of("dialog:row_help:Print face highlight").unwrap(); + assert!((highlight.left() - body.left()).abs() < 0.75, "the painted highlight label shares the language/autosave left edge"); + assert!((highlight_help.left() - highlight.right() - 4.0).abs() < 1.0); + assert!((crate::scenario::rect_of("prefs:print_face_highlight").unwrap().left() - body.left() - 164.0).abs() < 0.5); + let track = crate::scenario::rect_of("dialog:slider_track:Print face highlight").unwrap(); + // The published rectangle includes the circular handle's endpoint insets. + let input_range = track.x_range().shrink(track.height() / 2.5); + h.step(&json!({"click":[egui::lerp(input_range, 0.75), track.center().y]})).unwrap(); + assert!( + (h.app.preferences.print_face_highlight - 0.75).abs() < 0.06, + "{theme} {width}: highlight={} track={track:?} input_range={input_range:?}", + h.app.preferences.print_face_highlight + ); + super::reveal_preference(&mut h, "prefs:check_now"); + assert!(body.contains_rect(crate::scenario::rect_of("prefs:check_now").unwrap())); + assert!(h.app.prefs_window.open); + h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap(); + assert_eq!((h.app.preferences.max_step_file_mb, h.app.preferences.max_design_file_mb), (DEFAULT_STEP_MB, DEFAULT_DESIGN_MB)); + assert_eq!(h.app.preferences.network_hosts, ["files.example", "nas.example"]); + assert!(h.app.preferences.network_locations); + assert_eq!(h.app.preferences.print_face_highlight, 0.2); + } + } + } + + #[test] + fn general_autosave_rows_apply_cancel_and_defaults_keep_the_effective_interval() { + use std::time::Duration; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.step(&json!({"autosave":true})).unwrap(); + h.app.autosave_minutes = 7.0; + super::set_autosave(&mut h.app); + h.app.preferences.language = "en".into(); + h.app.preferences.nav = "tinkercad".into(); + h.app.preferences.reopen_designs = false; + h.app.run("settings.profile.save", json!({"name":"Working"})).unwrap(); + let profiles = h.app.settings_profiles.list.clone(); + h.call("ui.prefs", json!({"open":"General"})); + super::reveal_preference(&mut h, "prefs:autosave_minutes"); + super::reveal_preference(&mut h, "prefs:autosave_timeline"); + let number = crate::scenario::rect_of("prefs:autosave_minutes").unwrap(); + let timeline = crate::scenario::rect_of("prefs:autosave_timeline").unwrap(); + assert!((timeline.width() - 260.0).abs() < 0.5, "the timeline stays bounded in the control column"); + assert!((timeline.left() - number.left()).abs() < 0.5); + assert!(timeline.top() >= number.bottom()); + assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains_rect(timeline)); + h.step(&json!({"click":{"handle":"prefs:autosave_minutes"}, "double":true})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"2.5"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.autosave_minutes, 2.5); + assert_eq!(h.app.autosave.as_ref().unwrap().interval, Duration::from_secs(150)); + assert_eq!(saved_prefs(&h.app)["autosave_minutes"], 7.0, "an unapplied value is not persisted"); + h.step(&json!({"click":{"handle":"prefs:apply"}})).unwrap(); + assert_eq!(saved_prefs(&h.app)["autosave_minutes"], 2.5); + super::reveal_preference(&mut h, "prefs:autosave_minutes"); + h.step(&json!({"click":{"handle":"prefs:autosave_minutes"}, "double":true})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"0"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.autosave_minutes, 0.0); + assert!(h.app.autosave.as_ref().unwrap().interval > Duration::from_secs(365 * 24 * 60 * 60)); + h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap(); + assert_eq!(h.app.autosave_minutes, 2.5); + assert_eq!(h.app.autosave.as_ref().unwrap().interval, Duration::from_secs(150)); + h.call("ui.prefs", json!({"open":"General"})); + assert!(h.widget(h.app.tr("prefs.restore_page_defaults")).is_some(), "the translated Preferences caption is visible"); + assert!(h.widget(h.app.tr("ui.restore_defaults")).is_none(), "the generic caption is absent from Preferences"); + h.step(&json!({"widget":"Restore page defaults"})).unwrap(); + assert_eq!(h.app.autosave_minutes, 5.0); + assert_eq!(h.app.autosave.as_ref().unwrap().interval, Duration::from_secs(300)); + assert_eq!(h.app.preferences.language, crate::i18n::SYSTEM); + assert!(h.app.preferences.reopen_designs); + assert_eq!(h.app.preferences.nav, "tinkercad", "General's reset leaves other pages alone"); + assert_eq!(h.app.settings_profiles.list, profiles); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Working")); + h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap(); + assert_eq!(h.app.preferences.language, "en"); + assert!(!h.app.preferences.reopen_designs); + assert_eq!(h.app.autosave_minutes, 2.5); + assert_eq!(h.app.autosave.as_ref().unwrap().interval, Duration::from_secs(150)); + } + #[test] fn graphics_card_picker_selects_named_cards_and_restores_automatic() { let dir = std::env::temp_dir().join(format!("solidcraft-graphics-picker-{}", std::process::id())); diff --git a/crates/ui-egui/src/prefs/general.rs b/crates/ui-egui/src/prefs/general.rs index 934a664bc5..89eda1fb1c 100644 --- a/crates/ui-egui/src/prefs/general.rs +++ b/crates/ui-egui/src/prefs/general.rs @@ -2,111 +2,147 @@ use super::*; pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { - let t = Tokens::get(); - let labels = t.space_24 * 6.0 + t.space_6; - ui_kit::settings_heading_with_tag(ui, app.tr("prefs.general"), app.tr("prefs.general_note"), Some(crate::settings_profiles::tag)); - crate::settings_profiles::section(app, ui, labels); - ui_kit::settings_section(ui, "language", app.tr("ui.language"), |ui| { - ui_kit::settings_form(ui, labels, |ui| { - ui_kit::row(ui, app.tr("prefs.interface_language"), |ui| { - ui_kit::settings_control_width(ui, t.form_card_width - t.space_4, |ui| { - ui_kit::dropdown_menu( - ui, - "pref_language", - &if app.preferences.language == crate::i18n::SYSTEM { - app.tr("prefs.language_system").to_owned() - } else { - crate::i18n::Locale::for_language(&app.preferences.language).display_name().into_owned() - }, - |ui| { - if ui_kit::list_picker( - ui, - crate::i18n::SYSTEM, - app.tr("prefs.language_system"), - app.preferences.language == crate::i18n::SYSTEM, - ) - .clicked() - { - app.preferences.language = crate::i18n::SYSTEM.into(); - } - for locale in crate::i18n::LOCALES { - if ui_kit::list_picker(ui, locale.id, &locale.display_name(), app.preferences.language == locale.id).clicked() { - app.preferences.language = locale.id.into(); + ui_kit::settings_page(ui, |ui| { + let t = Tokens::get(); + let labels = t.space_24 * 6.0 + t.space_6; + ui_kit::settings_heading_with_tag(ui, app.tr("prefs.general"), app.tr("prefs.general_note"), Some(crate::settings_profiles::tag)); + crate::settings_profiles::section(app, ui); + ui_kit::settings_section(ui, "language", app.tr("ui.language"), |ui| { + ui_kit::settings_form(ui, labels, |ui| { + ui_kit::row(ui, app.tr("prefs.interface_language"), |ui| { + ui_kit::settings_control_width(ui, t.space_24 * 11.0 + t.space_16, |ui| { + ui_kit::dropdown_menu( + ui, + "pref_language", + &if app.preferences.language == crate::i18n::SYSTEM { + app.tr("prefs.language_system").to_owned() + } else { + crate::i18n::Locale::for_language(&app.preferences.language).display_name().into_owned() + }, + |ui| { + if ui_kit::list_picker( + ui, + crate::i18n::SYSTEM, + app.tr("prefs.language_system"), + app.preferences.language == crate::i18n::SYSTEM, + ) + .clicked() + { + app.preferences.language = crate::i18n::SYSTEM.into(); } - } - }, - ); + for locale in crate::i18n::LOCALES { + if ui_kit::list_picker(ui, locale.id, &locale.display_name(), app.preferences.language == locale.id).clicked() { + app.preferences.language = locale.id.into(); + } + } + }, + ); + }); }); }); }); - }); - ui_kit::settings_section(ui, "saving", app.tr("prefs.saving"), |ui| { - ui_kit::settings_block_hint(ui, app.tr("ui.autosave_every"), crate::i18n::tr_label("0 turns it off"), labels, |ui| { - ui.horizontal(|ui| { - if ui_kit::settings_number(ui, &mut app.autosave_minutes, 0.0..=120.0, 0.5).changed() { - set_autosave(app); + ui_kit::settings_section(ui, "saving", app.tr("prefs.saving"), |ui| { + ui_kit::checkbox(ui, app.tr("Reopen the designs that were open when SolidCraft was last closed"), &mut app.preferences.reopen_designs); + ui_kit::settings_form(ui, labels, |ui| { + let shown = ui_kit::settings_row_help(ui, app.tr("ui.autosave_every"), crate::i18n::tr_label("0 turns it off"), |ui| { + let number = ui_kit::settings_number(ui, &mut app.autosave_minutes, 0.0..=120.0, 0.5); + number.widget_info(|| { + let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), app.autosave_minutes); + info.label = Some(app.tr("prefs.general.minutes").to_owned()); + info + }); + crate::scenario::publish_target("prefs:autosave_minutes", number.rect); + if number.changed() { + set_autosave(app); + } + ui_kit::settings_inline_note(ui, app.tr("prefs.minutes")); + ui.is_visible() + }); + if shown { + ui_kit::below_row_control(ui, app.tr("ui.autosave_every"), t.space_24 * 10.0 + t.space_16 + t.space_4, |ui| { + ui_kit::settings_autosave_timeline(ui, app.autosave_minutes) + }); } - ui_kit::settings_inline_note(ui, app.tr("prefs.minutes")); }); - ui_kit::settings_autosave_timeline(ui, app.autosave_minutes); }); - ui_kit::settings_block(ui, app.tr_label("Reopen designs"), labels, |ui| { - ui_kit::checkbox( - ui, - crate::i18n::tr_label("Reopen the designs that were open when SolidCraft was last closed"), - &mut app.preferences.reopen_designs, - ); + ui_kit::settings_section(ui, "files", app.tr("FILES"), |ui| { + file_size_rows(app, ui, labels); + ui.horizontal(|ui| { + let width = (ui.available_width() - t.space_16 - ui.spacing().item_spacing.x).max(0.0); + ui.allocate_ui_with_layout(egui::vec2(width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| { + setting::network_locations(app, ui); + }); + ui_kit::help_icon(ui, app.tr("prefs.network_locations_note")); + }); + if app.preferences.network_locations { + let mut remove = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(t.space_6, t.space_6); + for (i, host) in app.preferences.network_hosts.iter().enumerate() { + let label = format!("\\\\{host}"); + let button = ui_kit::settings_chip( + ui, + &label, + ui_kit::SettingsChip { + key: Some(host), + monospace: true, + remove: Some(app.tr("prefs.network_host_remove")), + ..Default::default() + }, + ); + crate::scenario::publish_target(&format!("prefs:network_host:{host}:remove"), button.rect); + if button.clicked() { + remove = Some(i); + } + } + }); + if let Some(i) = remove.filter(|i| *i < app.preferences.network_hosts.len()) { + app.preferences.network_hosts.remove(i); + } + } + ui.horizontal(|ui| { + let (single, tip) = (app.tr("ui.slicer_single_instance").to_string(), app.tr("ui.slicer_single_instance_tip").to_string()); + let width = (ui.available_width() - t.space_16 - ui.spacing().item_spacing.x).max(0.0); + ui.allocate_ui_with_layout(egui::vec2(width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui_kit::checkbox(ui, &single, &mut app.preferences.slicer_single_instance).on_hover_text(&tip); + }); + ui_kit::help_icon(ui, &tip); + }); + ui_kit::settings_form(ui, labels, |ui| { + ui_kit::settings_row_help(ui, app.tr("prefs.print_face_highlight"), app.tr("prefs.print_face_highlight_tip"), |ui| { + ui_kit::settings_control_width(ui, t.space_24 * 10.0, |ui| { + ui.spacing_mut().interact_size.x = t.space_24 + t.space_16 + t.space_4; + ui.spacing_mut().button_padding.x = t.space_4; + let response = ui_kit::slider( + ui, + &mut app.preferences.print_face_highlight, + 0.0..=1.0, + ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() }, + ); + crate::scenario::publish_target("prefs:print_face_highlight", response.rect); + }); + }); + }); }); - ui_kit::settings_block(ui, app.tr_label("Crash reports"), labels, |ui| { + ui_kit::settings_section(ui, "updates", app.tr("prefs.updates"), |ui| { + crate::update::prefs_rows(app, ui); + }); + ui_kit::settings_section(ui, "crash_reports", app.tr("prefs.general.crash_reports"), |ui| { ui.horizontal(|ui| { let count = app.crashes.saved_count(); let label = crate::i18n::format("prefs.reports_saved", &[("n", &count.to_string())]); - ui_kit::settings_badge(ui, &label, count > 0); - if ui_kit::button(ui, app.tr("prefs.crash_reports"), ui_kit::Button::Normal).clicked() { + let tone = if count == 0 { ui_kit::SettingsBadgeTone::Neutral } else { ui_kit::SettingsBadgeTone::Warning }; + let badge = ui_kit::settings_state_badge(ui, &label, tone); + crate::scenario::publish_rect("prefs:crash_count", badge.rect); + if ui_kit::button(ui, app.tr("prefs.crash_reports"), ui_kit::Button::Normal) + .on_hover_text(app.tr("prefs.crash_reports_note")) + .clicked() + { crate::crash_ui::open(app); } }); - ui_kit::text(ui, app.tr("prefs.crash_reports_note"), ui_kit::Text::Hint("crash_reports")); }); }); - ui_kit::settings_section(ui, "file_size", app.tr("prefs.max_file_size"), |ui| file_size_rows(app, ui, labels)); - // Where the files a design names may be: never a network location the user hasn't allowed. - ui_kit::settings_section(ui, "network_locations", app.tr("prefs.network_locations_section"), |ui| { - setting::network_locations(app, ui); - ui_kit::text(ui, app.tr("prefs.network_locations_note"), ui_kit::Text::Hint("network_locations")); - let mut remove = None; - for (i, host) in app.preferences.network_hosts.iter().enumerate() { - ui.horizontal(|ui| { - ui_kit::text(ui, &format!("\\\\{host}"), ui_kit::Text::Hint("network_host")); - if ui_kit::button(ui, app.tr("prefs.network_host_remove"), ui_kit::Button::Small).clicked() { - remove = Some(i); - } - }); - } - if let Some(i) = remove.filter(|i| *i < app.preferences.network_hosts.len()) { - app.preferences.network_hosts.remove(i); - } - }); - ui_kit::settings_section(ui, "slicer", app.tr("ui.slicer"), |ui| { - ui_kit::settings_block(ui, app.tr("ui.slicer"), labels, |ui| { - let (single, tip) = (app.tr("ui.slicer_single_instance").to_string(), app.tr("ui.slicer_single_instance_tip").to_string()); - ui_kit::checkbox(ui, &single, &mut app.preferences.slicer_single_instance).on_hover_text(tip); - }); - ui_kit::settings_block(ui, app.tr("prefs.print_face_highlight"), labels, |ui| { - let tip = app.tr("prefs.print_face_highlight_tip").to_string(); - let response = ui_kit::slider( - ui, - &mut app.preferences.print_face_highlight, - 0.0..=1.0, - ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() }, - ); - crate::scenario::publish_target("prefs:print_face_highlight", response.rect); - response.on_hover_text(tip); - }); - }); - ui_kit::settings_section(ui, "updates", app.tr("prefs.updates"), |ui| { - ui_kit::settings_block(ui, app.tr("update.prefs_label"), labels, |ui| crate::update::prefs_rows(app, ui)); - }); } /// Preferences › General › Largest file to open (#4086): the STEP/import and design file limits, @@ -114,33 +150,57 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { fn file_size_rows(app: &mut SolveApp, ui: &mut egui::Ui, labels: f32) { use solidcraft_engine::io::limits; if !limits::ADJUSTABLE { - ui_kit::text(ui, app.tr("prefs.max_file_size_web"), ui_kit::Text::Hint("max_file_size")); + ui.horizontal(|ui| { + ui_kit::text(ui, app.tr("prefs.max_file_size"), ui_kit::Text::Inline); + ui_kit::help_icon(ui, app.tr("prefs.max_file_size_web")); + }); return; } let top = limits::ceiling() / limits::MB; let (step, design) = (app.tr("prefs.max_file_size_step"), app.tr("prefs.max_file_size_design")); - let rows = [ - (step, "prefs:max_step_file_mb", DEFAULT_STEP_MB, &mut app.preferences.max_step_file_mb), - (design, "prefs:max_design_file_mb", DEFAULT_DESIGN_MB, &mut app.preferences.max_design_file_mb), - ]; - for (label, handle, default, value) in rows { - ui_kit::settings_block(ui, label, labels, |ui| { - ui.horizontal(|ui| { - let field = ui_kit::settings_number(ui, value, default..=top, 1.0); - crate::scenario::publish_rect(handle, field.rect); - let size = limits::describe(value.saturating_mul(limits::MB)); - let note = if *value >= 1024 { - format!("{} ({size})", crate::i18n::tr("prefs.megabytes")) - } else { - crate::i18n::tr("prefs.megabytes").to_string() - }; - ui_kit::settings_inline_note(ui, ¬e); - }); - }); - } + let (step_caption, design_caption, group) = (app.tr("prefs.general.step_import"), app.tr("ui.design"), app.tr("prefs.max_file_size")); + let mut help = app.tr("prefs.max_file_size_note").to_string(); if let Some(bytes) = limits::override_bytes() { - let note = crate::i18n::format("prefs.max_file_size_flag", &[("size", &limits::describe(bytes))]); - ui_kit::text(ui, ¬e, ui_kit::Text::Hint("max_file_size_flag")); + help.push('\n'); + help.push_str(&crate::i18n::format("prefs.max_file_size_flag", &[("size", &limits::describe(bytes))])); } - ui_kit::text(ui, app.tr("prefs.max_file_size_note"), ui_kit::Text::Hint("max_file_size")); + let t = Tokens::get(); + let rows = [ + (step, step_caption, "prefs:max_step_file_mb", DEFAULT_STEP_MB, &mut app.preferences.max_step_file_mb), + (design, design_caption, "prefs:max_design_file_mb", DEFAULT_DESIGN_MB, &mut app.preferences.max_design_file_mb), + ]; + ui_kit::settings_block(ui, group, labels, |ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), |ui| { + ui.spacing_mut().item_spacing.x = t.space_16 + t.space_4; + for (label, caption, handle, default, value) in rows { + let size = limits::describe(value.saturating_mul(limits::MB)); + let unit = crate::i18n::tr("prefs.megabytes"); + let note = if *value >= 1024 { format!("{unit} ({size})") } else { unit.to_owned() }; + let number_width = t.space_24 * 3.0; + let width = (number_width + t.space_4 + ui_kit::ContentWidth::label(ui, ¬e)) + .max(ui_kit::ContentWidth::label(ui, caption)) + .min(ui.max_rect().width()); + ui.allocate_ui_with_layout( + egui::vec2(width, t.control_height + t.space_16 + t.space_4), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.spacing_mut().item_spacing.x = t.space_4; + ui_kit::settings_compound_field(ui, label, caption, |ui| { + let field = ui + .scope(|ui| { + ui.spacing_mut().interact_size.x = number_width; + ui_kit::number_field(ui, value, default..=top, 1.0, "") + }) + .inner + .on_hover_text(&help); + crate::scenario::publish_rect(handle, field.rect); + let size = limits::describe(value.saturating_mul(limits::MB)); + let note = if *value >= 1024 { format!("{unit} ({size})") } else { unit.to_owned() }; + ui_kit::settings_inline_note(ui, ¬e); + }); + }, + ); + } + }); + }); } diff --git a/crates/ui-egui/src/prefs_search.rs b/crates/ui-egui/src/prefs_search.rs index 94266666bc..5e7c0b63ed 100644 --- a/crates/ui-egui/src/prefs_search.rs +++ b/crates/ui-egui/src/prefs_search.rs @@ -782,6 +782,108 @@ mod tests { } } + #[test] + fn general_redesigned_controls_remain_searchable_without_duplicate_settings() { + use egui::accesskit::Role; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.language = "en".into(); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_GENERAL); + for (query, label) in [ + ("reopen the designs", "Reopen the designs that were open last time"), + ("autosave every", "Autosave every"), + ("largest file to open: step", "Largest file to open: STEP and import"), + ("largest file to open: design", "Largest file to open: Design"), + ("allow network locations", "Allow network locations in designs"), + ("slicer that is already running", "Open files in a slicer that is already running"), + ("print face highlight", "Print face highlight"), + ("interface language", "Interface language"), + ] { + h.app.prefs_window.filter = query.into(); + h.frames(4); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + let at = h.widget(label).unwrap_or_else(|| panic!("{label} must match {query}")); + assert!(body.contains(egui::pos2(at[0] as f32, at[1] as f32)), "{label} is drawn in the results"); + assert!(h.widget(h.app.tr("prefs.search_empty")).is_none(), "{query} has a real result instead of the empty-results message"); + let (_, _, hits) = nav(&h.ctx); + let matching: Vec<_> = hits.iter().filter(|hit| hit.label == label).collect(); + assert_eq!(matching.len(), 1, "{label} must have one real search result"); + assert_eq!(matching[0].page, crate::prefs::PAGE_GENERAL); + let control = match label { + "Autosave every" => Some(("prefs:autosave_minutes", Role::SpinButton, "Minutes", h.app.autosave_minutes)), + "Largest file to open: STEP and import" => { + Some(("prefs:max_step_file_mb", Role::SpinButton, label, h.app.preferences.max_step_file_mb as f64)) + } + "Largest file to open: Design" => { + Some(("prefs:max_design_file_mb", Role::SpinButton, label, h.app.preferences.max_design_file_mb as f64)) + } + "Print face highlight" => { + Some(("prefs:print_face_highlight", Role::Slider, label, f64::from(h.app.preferences.print_face_highlight))) + } + _ => None, + }; + if let Some((identity, role, accessible_label, value)) = control { + let field = crate::scenario::rect_of(identity).expect("the matching field retains its stable identity"); + assert!(body.contains_rect(field), "{identity} is drawn in the results"); + let tree = h.output.accesskit_update.as_ref().unwrap(); + // Search also measures invisible, disabled probe UIs. Count the live control. + let controls: Vec<_> = tree + .nodes + .iter() + .filter(|(_, node)| !node.is_disabled() && node.role() == role && node.label() == Some(accessible_label)) + .collect(); + assert_eq!(controls.len(), 1, "{identity} must have one real searchable control"); + let node = &controls[0].1; + assert_eq!(node.numeric_value(), Some(value), "{identity} exposes its actual setting value"); + let bounds = node.bounds().unwrap(); + assert!(field.contains(egui::pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32))); + } + } + // An unavailable control is still a real result. Invisible search probes are also + // disabled, so require the live navigation hit and painted label as well as metadata. + h.app.updater.kind = Some(crate::update::InstallKind::Dev); + assert!(!crate::update::available(&mut h.app)); + let label = "Check at start and once a day"; + h.app.prefs_window.filter = label.into(); + for checked in [false, true] { + h.app.preferences.check_updates = checked; + h.frames(4); + let (_, _, hits) = nav(&h.ctx); + let matching: Vec<_> = hits.iter().filter(|hit| hit.label == label).collect(); + assert_eq!(matching.len(), 1, "the disabled setting has one live result"); + assert_eq!(matching[0].page, crate::prefs::PAGE_GENERAL); + assert!(h.widget(h.app.tr("prefs.search_empty")).is_none()); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + let field = crate::scenario::rect_of("prefs:check_updates").expect("the real scheduling control"); + assert!(body.contains_rect(field)); + let tree = h.output.accesskit_update.as_ref().unwrap(); + let (_, node) = tree + .nodes + .iter() + .rev() + .find(|(_, node)| { + node.role() == Role::CheckBox + && node.label() == Some(label) + && node.bounds().is_some_and(|bounds| { + field.contains(egui::pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32)) + }) + }) + .expect("the disabled checkbox exposes its real state at the rendered control"); + assert!(node.is_disabled()); + assert_eq!(node.toggled(), Some(if checked { egui::accesskit::Toggled::True } else { egui::accesskit::Toggled::False })); + let painted = h + .shapes + .iter() + .filter(|shape| { + matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == label && body.contains(text.pos) && shape.clip_rect.contains(text.pos)) + }) + .count(); + assert_eq!(painted, 1, "one visible disabled control is painted, beyond probe metadata"); + h.step(&serde_json::json!({"click":{"handle":"prefs:check_updates"}})).unwrap(); + assert_eq!(h.app.preferences.check_updates, checked, "the search result cannot edit an unavailable setting"); + } + } + /// Typing focuses the field, Enter opens that setting, and Escape returns to the page /// that was up when the search started. Recording a shortcut keeps the key. #[test] @@ -836,9 +938,13 @@ mod tests { h.app.home.open = false; crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_GENERAL); h.frames(2); + crate::prefs::reveal_preference(&mut h, "prefs:max_step_file_mb"); let field = crate::scenario::rect_of("prefs:max_step_file_mb").expect("STEP limit field"); + assert!(crate::scenario::rect_of("prefs:page_body").expect("Preferences body").contains_rect(field)); h.step(&serde_json::json!({"click": [field.center().x, field.center().y]})).expect("click the field"); h.frames(2); + let focused = h.ctx.memory(|memory| memory.focused()).expect("the number editor has focus"); + assert!(field.contains_rect(h.ctx.read_response(focused).expect("focused editor response").rect)); h.step(&serde_json::json!({"text": "7"})).expect("type"); h.frames(2); assert!(h.app.prefs_window.filter.is_empty(), "typed `{}` into the search", h.app.prefs_window.filter); diff --git a/crates/ui-egui/src/settings_profiles.rs b/crates/ui-egui/src/settings_profiles.rs index ce3b309931..b68f735295 100644 --- a/crates/ui-egui/src/settings_profiles.rs +++ b/crates/ui-egui/src/settings_profiles.rs @@ -34,7 +34,7 @@ const MAX_THEMES: usize = 4; /// Most characters in a command id or a key name. const MAX_ID: usize = 200; -/// The 93 stored keys from the pre-split Interface, Navigation and Sketch pages, plus +/// The 98 stored keys selected dynamically before the split: Interface, Navigation and Sketch, plus /// language, excluding space_mouse_* and orbit_cursor. Page moves and title tags must never /// change profile contents (including the bridge, origin names and direct interaction). const PROFILE_KEYS: &[&str] = &[ @@ -57,6 +57,8 @@ const PROFILE_KEYS: &[&str] = &[ "history_in_browser", "history_play_button", "history_sketches", + "home_fit_size", + "invert_navigator_arrows", "keep_work_on_tab_switch", "language", "layout", @@ -72,6 +74,7 @@ const PROFILE_KEYS: &[&str] = &[ "nav_bridge_enabled", "nav_bridge_port", "navigation_bar", + "navigator_fit", "navigator_style", "orbit_around", "orbit_reverse", @@ -83,6 +86,7 @@ const PROFILE_KEYS: &[&str] = &[ "project_options_dialog", "quick_measure", "radial_context_menu", + "ribbon_click_ends_tool", "ribbon_icon_grid", "ribbon_labels", "ribbon_menus_skip_pinned", @@ -97,6 +101,7 @@ const PROFILE_KEYS: &[&str] = &[ "sketch_conflict_colour", "sketch_construction_lines", "sketch_exit_view", + "sketch_face_activates_part", "sketch_open_look_at", "sketch_recent_snap", "sketch_state_icons", @@ -145,10 +150,19 @@ pub struct Profiles { pub list: Vec, /// The profile applied or saved last, while it exists. pub active: Option, - /// The name typed in Preferences. + /// The independent name typed in Preferences, empty until the user enters one. draft: String, /// The last action's outcome in Preferences. message: Option, + /// General's row-local rename editor; never changes the active profile just to rename it. + rename: Option, +} + +struct Rename { + name: String, + text: String, + focused: bool, + at: egui::Pos2, } /// What a profile action in Preferences reports. Kept untranslated: applying a profile can @@ -508,84 +522,157 @@ fn act(app: &mut SolveApp, id: &str, params: Value, done: &'static str) { }); } -/// The Settings profiles section of the General page: the active profile (choosing one applies -/// it), a name to save the current settings under, and Rename, Delete, Export… and Import…. -pub(crate) fn section(app: &mut SolveApp, ui: &mut egui::Ui, labels: f32) { +/// General's saved profiles and row actions. All mutations still use settings.profile.*. +pub(crate) fn section(app: &mut SolveApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(); - if app.settings_profiles.draft.is_empty() - && let Some(active) = &app.settings_profiles.active - { - app.settings_profiles.draft = active.clone(); - } - ui_kit::settings_section(ui, "profiles", app.tr("prefs.profiles"), |ui| { - ui_kit::text(ui, app.tr("prefs.profiles_note"), ui_kit::Text::Hint("profiles")); - ui_kit::settings_form(ui, labels, |ui| { - let names = app.settings_profiles.names(); - let current = app.settings_profiles.active.clone().unwrap_or_else(|| crate::i18n::tr_label("None").to_string()); - let mut chosen = None; - ui_kit::row(ui, app.tr("prefs.profile_active"), |ui| { - ui_kit::settings_control_width(ui, t.form_card_width - t.space_4, |ui| { - ui_kit::dropdown_menu(ui, "pref_profile", ¤t, |ui| { - for name in &names { - if ui_kit::list_picker(ui, name, name, app.settings_profiles.active.as_ref() == Some(name)).clicked() { - chosen = Some(name.clone()); + let count = crate::i18n::format("prefs.reports_saved", &[("n", &app.settings_profiles.list.len().to_string())]); + ui_kit::settings_section_count(ui, "profiles", app.tr("ui.profiles"), Some(&count), |ui| { + let names = app.settings_profiles.names(); + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = t.space_8; + for name in &names { + let active = app.settings_profiles.active.as_ref() == Some(name); + let edited = active && app.settings_profiles.get(name).is_ok_and(|doc| *doc != capture(app, name)); + ui.push_id(name, |ui| { + let card = ui_kit::settings_row_card(ui, active, t.control_height + t.space_14, |ui| { + ui.spacing_mut().item_spacing.x = t.space_6; + let state_width = if active { + ui_kit::ContentWidth::label(ui, app.tr("prefs.general.active")) + + t.space_16 + + if edited { ui_kit::ContentWidth::label(ui, app.tr("prefs.general.edited")) + t.space_24 } else { 0.0 } + } else { + ui_kit::ContentWidth::label(ui, app.tr("ui.apply")) + t.space_24 + }; + let name_width = (ui.available_width() - t.space_24 * 3.0 - t.space_6 * 4.0 - state_width).max(0.0); + ui.allocate_ui_with_layout( + egui::vec2(name_width, t.control_height), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let side = t.space_16 + t.space_2 / 2.0; + let (icon, _) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::hover()); + crate::icons::paint_ink(ui.painter(), icon, "settings_profile", if active { t.selected_ink } else { t.form_dim }); + ui_kit::text(ui, name, if active { ui_kit::Text::SelectedNameCell } else { ui_kit::Text::NameCell(false) }); + }, + ); + if edited { + let badge = ui_kit::settings_state_badge(ui, app.tr("prefs.general.edited"), ui_kit::SettingsBadgeTone::Warning); + crate::scenario::publish_rect(&format!("prefs:profile:{name}:edited"), badge.rect); + } + if active { + let badge = ui_kit::settings_state_badge(ui, app.tr("prefs.general.active"), ui_kit::SettingsBadgeTone::Accent); + crate::scenario::publish_rect(&format!("prefs:profile:{name}:active"), badge.rect); + } else { + let button = ui_kit::button(ui, app.tr("ui.apply"), ui_kit::Button::Small); + crate::scenario::publish_target(&format!("prefs:profile:{name}:apply"), button.rect); + if button.clicked() { + act(app, "settings.profile.apply", json!({ "name": name }), "prefs.profile_applied"); + } + } + for (action, icon, label) in [ + ("export", "export_tray", app.tr("prefs.profile_export")), + ("rename", "edit", app.tr("prefs.profile_rename")), + ("delete", "delete", app.tr_label("Delete")), + ] { + let (target, _) = ui.allocate_exact_size(egui::vec2(t.space_24, t.space_24), egui::Sense::hover()); + let button = ui_kit::icon_button_at(ui, target, icon, label); + crate::scenario::publish_target(&format!("prefs:profile:{name}:{action}"), button.rect); + if button.clicked() { + match action { + "export" => { + let name = name.clone(); + app.pick_save(&format!("{name}.solidcraft-profile.json"), &["json"], move |app, path| { + if let Some(path) = path { + act(app, "settings.profile.export", json!({ "name": name, "path": path }), "prefs.profile_exported"); + } + }); + } + "rename" => { + app.settings_profiles.rename = + Some(Rename { name: name.clone(), text: name.clone(), focused: false, at: button.rect.left_bottom() }); + } + "delete" => { + act(app, "settings.profile.delete", json!({ "name": name }), "prefs.profile_deleted"); + } + _ => {} + } + } + } + }); + crate::scenario::publish_rect(&format!("prefs:profile:{name}:card"), card.response.rect); + }); + } + }); + // Searching for "profiles" must keep this entry on the results page; without a hit here the + // whole section was dropped and the name field could not be reached with a query active. + let entry = [app.tr("prefs.general.profile_name"), app.tr("prefs.general.save_new"), app.tr("prefs.general.import")]; + if crate::prefs_search::visible(ui, &entry) { + ui.horizontal_wrapped(|ui| { + let mut draft = std::mem::take(&mut app.settings_profiles.draft); + let enter = ui_kit::named_control(ui, app.tr("prefs.general.profile_name"), |ui| { + ui_kit::settings_control_width(ui, t.space_24 * 9.0 + t.space_4, |ui| { + ui_kit::text_field(ui, &mut draft, app.tr("prefs.general.name_setup")).0 + }) + }); + app.settings_profiles.draft = draft; + let name = app.settings_profiles.draft.trim().to_string(); + let valid = profile_name(&name).is_some(); + let save = + ui_kit::icon_label_action(ui, app.tr("prefs.general.save_new"), None, valid, true).on_hover_text(app.tr("prefs.profiles_note")); + crate::scenario::publish_target("prefs:profile:save", save.rect); + if valid && (enter || save.clicked()) { + act(app, "settings.profile.save", json!({ "name": name }), "prefs.profile_saved"); + } + let import = ui_kit::icon_label_button(ui, app.tr("prefs.general.import"), "import_tray", true, false); + crate::scenario::publish_target("prefs:profile:import", import.rect); + if import.clicked() { + app.pick_open_for(&["json"], |app, path| { + if let Some(path) = path { + act(app, "settings.profile.import", json!({ "path": path }), "prefs.profile_imported"); + } + }); + } + }); + } + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = t.space_8; + for (label, chips, accent) in [ + ( + app.tr("prefs.general.held"), + &[ + ("prefs.structure.appearance", "settings_palette"), + ("prefs.structure.layout", "settings_monitor"), + ("ui.navigation", "settings_mouse"), + ("prefs.structure.sketch", "select_sketch"), + ("prefs.shortcuts", "settings_keyboard"), + ("ui.language", "settings"), + ][..], + true, + ), + ( + app.tr("prefs.general.this_computer"), + &[("prefs.structure.mouse3d", "lock"), ("ui.graphics", "lock"), ("prefs.assistant", "lock")][..], + false, + ), + ] { + let key = if accent { "held" } else { "computer" }; + let row = ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = t.space_6; + let (rect, _) = ui.allocate_exact_size(egui::vec2(t.space_24 * 3.0 + t.space_12, t.space_24), egui::Sense::hover()); + let mut label_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + let caption = ui_kit::text(&mut label_ui, label, ui_kit::Text::Inline); + crate::scenario::publish_rect(&format!("prefs:profiles:{key}:label"), caption.rect); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(t.space_6, t.space_6); + for (index, (chip, icon)) in chips.iter().enumerate() { + let response = + ui_kit::settings_chip(ui, app.tr(chip), ui_kit::SettingsChip { icon: Some(icon), accent, ..Default::default() }); + if index == 0 { + crate::scenario::publish_rect(&format!("prefs:profiles:{key}:first_chip"), response.rect); } } }); }); - }); - if let Some(name) = chosen { - app.settings_profiles.draft = name.clone(); - act(app, "settings.profile.apply", json!({ "name": name }), "prefs.profile_applied"); - } - if let Some(active) = app.settings_profiles.active.clone() - && app.settings_profiles.get(&active).is_ok_and(|doc| *doc != capture(app, &active)) - { - ui_kit::text(ui, app.tr("prefs.profile_changed"), ui_kit::Text::Note("profile_changed")); - } - ui_kit::row(ui, crate::i18n::tr_label("Name"), |ui| { - let mut draft = std::mem::take(&mut app.settings_profiles.draft); - let (enter, _) = ui_kit::text_field(ui, &mut draft, app.tr("prefs.profile_name_hint")); - app.settings_profiles.draft = draft; - if enter { - let name = app.settings_profiles.draft.clone(); - act(app, "settings.profile.save", json!({ "name": name }), "prefs.profile_saved"); - } - }); - }); - let draft = app.settings_profiles.draft.trim().to_string(); - let active = app.settings_profiles.active.clone(); - ui.horizontal_wrapped(|ui| { - if ui_kit::button(ui, app.tr("prefs.profile_save"), ui_kit::Button::Enabled(profile_name(&draft).is_some())).clicked() { - act(app, "settings.profile.save", json!({ "name": draft }), "prefs.profile_saved"); - } - let renamable = active.as_ref().is_some_and(|a| *a != draft) && profile_name(&draft).is_some(); - if ui_kit::button(ui, app.tr("prefs.profile_rename"), ui_kit::Button::Enabled(renamable)).clicked() - && let Some(name) = &active - { - act(app, "settings.profile.rename", json!({ "name": name, "to": draft }), "prefs.profile_renamed"); - } - if ui_kit::button(ui, crate::i18n::tr_label("Delete"), ui_kit::Button::Enabled(active.is_some())).clicked() - && let Some(name) = &active - { - act(app, "settings.profile.delete", json!({ "name": name }), "prefs.profile_deleted"); - app.settings_profiles.draft.clear(); - } - if ui_kit::button(ui, app.tr("prefs.profile_export"), ui_kit::Button::Enabled(active.is_some())).clicked() - && let Some(name) = active.clone() - { - app.pick_save(&format!("{name}.solidcraft-profile.json"), &["json"], move |app, path| { - if let Some(path) = path { - act(app, "settings.profile.export", json!({ "name": name, "path": path }), "prefs.profile_exported"); - } - }); - } - if ui_kit::button(ui, crate::i18n::tr_label("Import…"), ui_kit::Button::Normal).clicked() { - app.pick_open_for(&["json"], |app, path| { - if let Some(path) = path { - act(app, "settings.profile.import", json!({ "path": path }), "prefs.profile_imported"); - } - }); + crate::scenario::publish_rect(&format!("prefs:profiles:{key}:row"), row.response.rect); } }); let locale = crate::i18n::Locale::for_language(&app.preferences.language); @@ -605,49 +692,88 @@ pub(crate) fn section(app: &mut SolveApp, ui: &mut egui::Ui, labels: f32) { }); } +/// Run before the Preferences window so Escape belongs to this editor, including in a scrolled +/// page. A separate popup container keeps the shared rename menu accessible and unclipped. +pub(crate) fn rename_popup(app: &mut SolveApp, ctx: &egui::Context) { + if !app.prefs_window.open || app.prefs_window.section != prefs::PAGE_GENERAL || !app.prefs_window.filter.is_empty() { + app.settings_profiles.rename = None; + return; + } + let Some(mut rename) = app.settings_profiles.rename.take() else { return }; + crate::prefs_search::note_key_sink(ctx); + let mut done = None; + let popup = ui_kit::Container::new(ctx, ui_kit::Surface::Popup) + .area(egui::Id::new("sc_profile_rename")) + .fixed_pos(rename.at) + .constrain(true) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + done = ui_kit::rename_menu(ui, &mut rename.text, &mut rename.focused); + }); + crate::scenario::publish_rect("prefs:profile:rename_popup", popup.response.rect); + for action in ["ok", "cancel"] { + if let Some(rect) = crate::scenario::rect_of(&format!("dialog:{action}")) { + crate::scenario::publish_target(&format!("prefs:profile:rename:{action}"), rect); + } + } + if ctx.input(|i| i.pointer.any_pressed()) && !popup.response.contains_pointer() { + done = Some(false); + } + ctx.input_mut(|input| { + for key in [egui::Key::Escape, egui::Key::Enter, egui::Key::Tab] { + input.consume_key(egui::Modifiers::NONE, key); + } + }); + match done { + Some(true) if profile_name(&rename.text) != Some(rename.name.as_str()) => { + act(app, "settings.profile.rename", json!({"name": rename.name, "to": rename.text}), "prefs.profile_renamed"); + } + Some(_) => {} + None => app.settings_profiles.rename = Some(rename), + } +} + /// The pinned profile switcher on every Preferences page. Actions use the same commands as /// General > Profiles; saving a new name remains in that existing section. pub(crate) fn sidebar(ui: &mut egui::Ui, app: &mut SolveApp) { - ui_kit::divider(ui, "profile_sidebar"); - ui_kit::text(ui, app.tr("prefs.structure.profile"), ui_kit::Text::Heading("profile_sidebar")); let active = app.settings_profiles.active.clone(); let edited = active.as_ref().is_some_and(|name| app.settings_profiles.get(name).is_ok_and(|doc| *doc != capture(app, name))); let current = active.clone().unwrap_or_else(|| app.tr_label("None").to_string()); let names = app.settings_profiles.names(); let mut apply = None; let mut save = false; - ui.horizontal(|ui| { - let t = crate::theme::Tokens::get(); - let width = ui.available_width() - if edited { t.space_16 + ui.spacing().item_spacing.x } else { 0.0 }; - let response = ui_kit::settings_control_width(ui, width, |ui| { - ui_kit::dropdown_menu(ui, "profile_sidebar", ¤t, |ui| { - for name in &names { - if ui_kit::list_picker(ui, name, name, active.as_ref() == Some(name)).clicked() { - apply = Some(name.clone()); + let label = app.tr("prefs.structure.profile"); + let changed_hint = app.tr("prefs.structure.profile_edited"); + let menu = ui + .push_id("profile_sidebar", |ui| { + ui_kit::Menu::button_with( + ui, + |ui, _| ui_kit::settings_profile_button(ui, label, ¤t, edited, changed_hint), + |ui| { + for name in &names { + if ui_kit::list_picker(ui, name, name, active.as_ref() == Some(name)).clicked() { + apply = Some(name.clone()); + } } - } - if let Some(name) = &active - && ui_kit::button(ui, app.tr("prefs.profile_save"), ui_kit::Button::Normal).clicked() - { - save = true; - apply = None; - app.settings_profiles.draft = name.clone(); - ui.close(); - } - if ui_kit::button(ui, app.tr("prefs.profiles"), ui_kit::Button::Normal).clicked() { - prefs::open_profiles(app); - ui.close(); - } - }) - }); - crate::scenario::publish_target("prefs:profile", response.rect); - if edited { - let dot = ui_kit::status_line(ui, "●", ui_kit::StatusTone::Warning).on_hover_text(app.tr("prefs.structure.profile_edited")); - crate::scenario::publish_target("prefs:profile:edited", dot.rect); - } - }); + if active.is_some() && ui_kit::button(ui, app.tr("prefs.profile_save"), ui_kit::Button::Normal).clicked() { + save = true; + apply = None; + ui.close(); + } + if ui_kit::button(ui, app.tr("prefs.profiles"), ui_kit::Button::Normal).clicked() { + prefs::open_profiles(app); + ui.close(); + } + }, + ) + }) + .inner; + if menu.inner.is_some() && ui.ctx().input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + // The standard menu has handled Escape; the Preferences footer must not also cancel. + menu.response.request_focus(); + } + crate::scenario::publish_target("prefs:profile", menu.response.rect); if let Some(name) = apply { - app.settings_profiles.draft = name.clone(); act(app, "settings.profile.apply", json!({"name":name}), "prefs.profile_applied"); } else if save && let Some(name) = active { act(app, "settings.profile.save", json!({"name":name}), "prefs.profile_saved"); @@ -656,7 +782,11 @@ pub(crate) fn sidebar(ui: &mut egui::Ui, app: &mut SolveApp) { /// Page-title tag. Membership is descriptive; the stable key list above governs storage. pub(crate) fn tag(ui: &mut egui::Ui) { - let response = - ui_kit::workspace_chip(ui, crate::i18n::tr("prefs.structure.in_profile")).on_hover_text(crate::i18n::tr("prefs.structure.profile_hover")); + let response = ui_kit::settings_chip( + ui, + crate::i18n::tr("prefs.structure.in_profile"), + ui_kit::SettingsChip { icon: Some("settings_profile"), compact: true, ..Default::default() }, + ) + .on_hover_text(crate::i18n::tr("prefs.structure.profile_hover")); crate::scenario::publish_target("prefs:in_profile", response.rect); } diff --git a/crates/ui-egui/src/settings_profiles_tests.rs b/crates/ui-egui/src/settings_profiles_tests.rs index bacbd7428f..b84aea45ef 100644 --- a/crates/ui-egui/src/settings_profiles_tests.rs +++ b/crates/ui-egui/src/settings_profiles_tests.rs @@ -338,7 +338,7 @@ fn typing_a_profile_name_keeps_focus_out_of_preferences_search() { h.frames(3); h.step(&json!({"text": "profiles"})).expect("find Settings profiles"); h.frames(3); - h.step(&json!({"click": {"handle": "dialog:Name"}})).expect("focus the profile name"); + h.step(&json!({"click": {"handle": "dialog:Profile name"}})).expect("focus the profile name"); h.frames(2); let name_focus = h.ctx.memory(|memory| memory.focused()).expect("the name field has focus"); for text in ["Lap", "top"] { @@ -348,7 +348,7 @@ fn typing_a_profile_name_keeps_focus_out_of_preferences_search() { assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(name_focus), "the name field keeps focus"); } assert!(h.widget("Laptop").is_some(), "the name field contains the typed name"); - h.step(&json!({"widget": "Save"})).expect("save the profile"); + h.step(&json!({"widget": "Save as new"})).expect("save the profile"); h.frames(2); assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Laptop")); assert_eq!(h.app.settings_profiles.names(), ["Laptop"]); @@ -365,14 +365,347 @@ fn choosing_a_profile_in_preferences_applies_it() { assert!(!h.app.preferences.zoom_reverse); h.call("ui.prefs", json!({"open": "General"})); h.frames(3); - h.step(&json!({"click": {"handle": "dialog:Active profile"}})).unwrap(); - h.frames(3); - h.step(&json!({"click": {"handle": "dialog:list:Large monitor"}})).unwrap(); + h.step(&json!({"click": {"handle": "prefs:profile:Large monitor:apply"}})).unwrap(); h.frames(3); assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Large monitor")); assert!(h.app.preferences.zoom_reverse); } +#[test] +fn general_profile_rows_save_replace_apply_and_show_real_state_in_both_themes() { + for theme in ["light", "dark"] { + let mut h = Harness::new(); + h.app.home.open = false; + h.call("ui.prefs", json!({"language":"en", "theme":theme, "open":"General"})); + h.frames(3); + assert!(h.app.settings_profiles.list.is_empty()); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + let heading = crate::scenario::rect_of("prefs:page_heading").unwrap(); + let subtitle = crate::scenario::rect_of("dialog:hint:settings_heading").unwrap(); + let profiles = crate::scenario::rect_of("prefs:section:profiles").unwrap(); + assert!(heading.contains_rect(subtitle)); + { + let title = h + .shapes + .iter() + .find_map(|shape| { + if let egui::Shape::Text(text) = &shape.shape + && text.galley.job.text == "PROFILES" + && profiles.contains(text.pos) + { + return Some(egui::Rect::from_min_size(text.pos, text.galley.size())); + } + None + }) + .unwrap(); + let count = crate::scenario::rect_of("dialog:section:Profiles:count").unwrap(); + assert!((count.left() - title.right() - 12.0).abs() < 1.1, "{theme}: count follows the painted title"); + let t = crate::theme::Tokens::get(); + let rule = h + .shapes + .iter() + .find_map(|shape| { + if let egui::Shape::LineSegment { points, stroke } = &shape.shape + && stroke.color == t.dialog_rule + && points.iter().all(|point| (point.y - profiles.center().y).abs() < 0.5) + { + return Some(*points); + } + None + }) + .unwrap(); + assert!((rule[0].x - count.right() - 12.0).abs() < 0.5, "{theme}: the rule begins after the count"); + assert!((rule[1].x - profiles.right()).abs() < 0.5); + for (key, text) in [("held", "Held"), ("computer", "This computer")] { + let label = crate::scenario::rect_of(&format!("prefs:profiles:{key}:label")).unwrap(); + let first = crate::scenario::rect_of(&format!("prefs:profiles:{key}:first_chip")).unwrap(); + let painted = h + .shapes + .iter() + .find_map(|shape| { + if let egui::Shape::Text(galley) = &shape.shape + && galley.galley.job.text == text + && label.contains(galley.pos) + { + return Some(egui::Rect::from_min_size(galley.pos, galley.galley.size())); + } + None + }) + .unwrap(); + assert!((painted.left() - body.left()).abs() < 0.75, "{theme}: {text} starts at the body's left edge"); + assert!((first.left() - body.left() - 90.0).abs() < 0.5, "{theme}: {text} reserves the full caption column and gap"); + assert!((first.height() - 24.0).abs() < 0.5); + } + let held = crate::scenario::rect_of("prefs:profiles:held:row").unwrap(); + let computer = crate::scenario::rect_of("prefs:profiles:computer:row").unwrap(); + assert!((computer.top() - held.bottom() - 8.0).abs() < 0.5); + } + assert!((profiles.top() - heading.bottom() - 38.0).abs() < 0.5, "{theme}: the first section has the same breathing room as later sections"); + for (previous, next) in [("profiles", "language"), ("language", "saving")] { + let previous_body = crate::scenario::rect_of(&format!("prefs:section:{previous}:body")).unwrap(); + let next_header = crate::scenario::rect_of(&format!("prefs:section:{next}")).unwrap(); + assert!((next_header.top() - previous_body.bottom() - 38.0).abs() < 0.5, "{theme}: {previous} to {next}"); + assert!((next_header.height() - 30.0).abs() < 0.5); + let next_body = crate::scenario::rect_of(&format!("prefs:section:{next}:body")).unwrap(); + assert!((next_body.top() - next_header.bottom() - 10.0).abs() < 0.5); + } + let name_field = crate::scenario::rect_of("dialog:widget:Profile name").unwrap(); + assert!((name_field.width() - 220.0).abs() < 0.5, "{theme}: bounded name entry"); + assert!((name_field.left() - body.left()).abs() < 0.5, "{theme}: no separate label column before name entry"); + assert!(body.contains_rect(name_field)); + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + let status = h.app.status.clone(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.status, status, "empty Enter cannot submit a save"); + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"text":"x".repeat(65)})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.status, status, "invalid Enter cannot submit a save"); + assert!(h.app.settings_profiles.list.is_empty()); + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":" Laptop "})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile:save"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Laptop"]); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Laptop")); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some(" Laptop "), + "saving keeps exactly the typed text while the command normalizes its name" + ); + assert!(crate::scenario::rect_of("prefs:profile:Laptop:active").is_some()); + assert!(crate::scenario::rect_of("prefs:profile:Laptop:apply").is_none()); + h.app.preferences.zoom_reverse = true; + h.frames(3); + assert!(crate::scenario::rect_of("prefs:profile:Laptop:edited").is_some()); + assert!(crate::scenario::rect_of("prefs:profile:edited").is_some()); + // The new label must keep save's replacement semantics, not create a duplicate. + h.step(&json!({"click":{"handle":"prefs:profile:save"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.list.len(), 1); + assert_eq!(h.app.settings_profiles.list[0]["preferences"]["zoom_reverse"], true); + assert!(crate::scenario::rect_of("prefs:profile:Laptop:edited").is_none()); + assert!(crate::scenario::rect_of("prefs:profile:edited").is_none()); + h.app.preferences.zoom_reverse = false; + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"Desk"})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile:save"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Laptop", "Desk"]); + assert!(crate::scenario::rect_of("prefs:profile:Desk:apply").is_none()); + let laptop = crate::scenario::rect_of("prefs:profile:Laptop:card").unwrap(); + let desk = crate::scenario::rect_of("prefs:profile:Desk:card").unwrap(); + assert!((laptop.height() - 42.0).abs() < 0.5 && (desk.height() - 42.0).abs() < 0.5, "{theme}: compact saved cards"); + assert!((desk.top() - laptop.bottom() - 8.0).abs() < 0.5, "{theme}: saved card gap"); + assert_eq!((laptop.left(), laptop.right()), (desk.left(), desk.right())); + for (name, card) in [("Laptop", laptop), ("Desk", desk)] { + assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains_rect(card)); + for action in ["export", "rename", "delete"] { + let target = crate::scenario::rect_of(&format!("prefs:profile:{name}:{action}")).unwrap(); + assert!(card.contains_rect(target), "{theme}: {name} {action} stays inside its card"); + assert!((target.width() - 24.0).abs() < 0.5 && (target.height() - 24.0).abs() < 0.5); + } + } + let active = crate::scenario::rect_of("prefs:profile:Desk:active").unwrap(); + assert!(desk.contains_rect(active)); + assert!((active.height() - 18.0).abs() < 0.5); + let t = crate::theme::Tokens::get(); + assert!(h.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.rect == desk && rect.fill == t.selected_bg && rect.stroke.color == t.form_primary))); + assert!(h.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if desk.contains(text.pos) && text.galley.job.text == "Desk" && text.galley.job.sections.iter().all(|section| section.format.color == t.selected_ink && section.format.font_id == crate::fonts::semibold(t.font_label))))); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + assert!(h.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if body.contains(text.pos) && text.galley.job.text == "Appearance" && text.galley.job.sections.iter().all(|section| section.format.color == t.selected_ink && section.format.font_id == crate::fonts::semibold(t.font_caption))))); + assert!(laptop.contains_rect(crate::scenario::rect_of("prefs:profile:Laptop:apply").unwrap())); + h.step(&json!({"click":{"handle":"prefs:profile:Laptop:apply"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Laptop")); + assert!(h.app.preferences.zoom_reverse); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some("Desk"), + "applying a different row preserves the user-typed draft" + ); + h.app.preferences.zoom_reverse = false; + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"Laptop"})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile:save"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.list.len(), 2, "typing an existing name still replaces it"); + assert_eq!(h.app.settings_profiles.list[0]["preferences"]["zoom_reverse"], false); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some("Laptop"), + "replacement keeps the typed name too" + ); + assert!(h.app.prefs_window.filter.is_empty(), "name entry must not type into search"); + assert!(h.app.prefs_window.open); + } +} + +#[test] +fn general_inactive_profile_rows_export_rename_import_and_delete_without_applying() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.preferences.zoom_reverse = true; + run(&mut h, "settings.profile.save", json!({"name":"Laptop"})); + h.app.preferences.zoom_reverse = false; + run(&mut h, "settings.profile.save", json!({"name":"Desk"})); + h.call("ui.prefs", json!({"language":"en", "open":"General"})); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some(""), + "the name entry starts empty even with an active profile" + ); + h.step(&json!({"click":{"handle":"prefs:profile:Laptop:export"}})).unwrap(); + h.frames(6); + let path = h.dir.join("Laptop.solidcraft-profile.json"); + let exported: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(exported["name"], "Laptop"); + assert_eq!(exported["preferences"]["zoom_reverse"], true); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert!(!h.app.preferences.zoom_reverse); + + h.step(&json!({"click":{"handle":"prefs:profile:Laptop:rename"}})).unwrap(); + h.frames(3); + assert!(h.widget("Laptop").is_some(), "the shared editor is prefilled"); + h.step(&json!({"text":"Travel"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Travel", "Desk"]); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert!(!h.app.preferences.zoom_reverse); + assert!(h.app.prefs_window.open && h.app.prefs_window.filter.is_empty()); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some(""), + "the row rename editor does not fill the independent name entry" + ); + + h.step(&json!({"choose_file":"Laptop.solidcraft-profile.json"})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile:import"}})).unwrap(); + h.frames(6); + assert_eq!(h.app.settings_profiles.names(), ["Travel", "Desk", "Laptop"]); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert!(!h.app.preferences.zoom_reverse, "import keeps the active settings"); + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"text":"Next profile"})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile:Travel:delete"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Desk", "Laptop"]); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + h.step(&json!({"click":{"handle":"prefs:profile:Desk:delete"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.active, None); + assert!(!h.app.preferences.zoom_reverse, "deleting never applies another row"); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some("Next profile"), + "deleting either an inactive or active row retains the draft" + ); +} + +#[test] +fn general_profile_row_rename_commits_or_cancels_without_closing_preferences() { + for action in ["Enter", "Tab", "ok", "Escape", "cancel", "outside"] { + let mut h = Harness::new(); + h.app.home.open = false; + run(&mut h, "settings.profile.save", json!({"name":"Laptop"})); + run(&mut h, "settings.profile.save", json!({"name":"Desk"})); + h.call("ui.prefs", json!({"language":"en", "open":"General"})); + h.step(&json!({"click":{"handle":"prefs:profile:Laptop:rename"}})).unwrap(); + h.frames(3); + let popup = crate::scenario::rect_of("prefs:profile:rename_popup").unwrap(); + let editor = crate::scenario::rect_of("rename:name").unwrap(); + assert!(popup.contains_rect(editor), "the editor is outside the page's scroll clip"); + h.step(&json!({"text":" Travel "})).unwrap(); + match action { + "ok" | "cancel" => h.step(&json!({"click":{"handle":format!("prefs:profile:rename:{action}")}})).unwrap(), + "outside" => { + let page = crate::scenario::rect_of("prefs:page_body").unwrap(); + let at = egui::pos2(page.right() - 4.0, page.top() + 4.0); + assert!(!popup.contains(at)); + h.step(&json!({"click":[at.x, at.y]})).unwrap(); + } + _ => h.step(&json!({"key":action})).unwrap(), + } + h.frames(3); + let committed = matches!(action, "Enter" | "Tab" | "ok"); + assert_eq!(h.app.settings_profiles.names(), [if committed { "Travel" } else { "Laptop" }, "Desk"], "{action}"); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert!(h.app.prefs_window.open && h.app.prefs_window.filter.is_empty(), "{action}"); + assert!(crate::scenario::rect_of("prefs:profile:rename_popup").is_none()); + } +} + +#[test] +fn general_profile_row_rename_keeps_validation_and_unchanged_name_noop() { + let mut h = Harness::new(); + h.app.home.open = false; + run(&mut h, "settings.profile.save", json!({"name":"Laptop"})); + run(&mut h, "settings.profile.save", json!({"name":"Desk"})); + h.call("ui.prefs", json!({"language":"en", "open":"General"})); + for to in ["Laptop".to_string(), "Desk".to_string(), "x".repeat(65)] { + h.step(&json!({"click":{"handle":"prefs:profile:Laptop:rename"}})).unwrap(); + h.frames(3); + if to != "Laptop" { + h.step(&json!({"text":to})).unwrap(); + } + h.step(&json!({"key":"Enter"})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Laptop", "Desk"]); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert!(h.app.prefs_window.open); + if to == "Desk" { + assert!(h.widget("another settings profile is called `Desk`").is_some(), "the existing act outcome surfaces duplicates"); + } + } +} + #[test] fn profile_keys_preserve_the_literal_pre_split_baseline() { let expected = [ @@ -395,6 +728,8 @@ fn profile_keys_preserve_the_literal_pre_split_baseline() { "history_in_browser", "history_play_button", "history_sketches", + "home_fit_size", + "invert_navigator_arrows", "keep_work_on_tab_switch", "language", "layout", @@ -410,6 +745,7 @@ fn profile_keys_preserve_the_literal_pre_split_baseline() { "nav_bridge_enabled", "nav_bridge_port", "navigation_bar", + "navigator_fit", "navigator_style", "orbit_around", "orbit_reverse", @@ -421,6 +757,7 @@ fn profile_keys_preserve_the_literal_pre_split_baseline() { "project_options_dialog", "quick_measure", "radial_context_menu", + "ribbon_click_ends_tool", "ribbon_icon_grid", "ribbon_labels", "ribbon_menus_skip_pinned", @@ -435,6 +772,7 @@ fn profile_keys_preserve_the_literal_pre_split_baseline() { "sketch_conflict_colour", "sketch_construction_lines", "sketch_exit_view", + "sketch_face_activates_part", "sketch_open_look_at", "sketch_recent_snap", "sketch_state_icons", @@ -472,7 +810,7 @@ fn profile_keys_preserve_the_literal_pre_split_baseline() { ]; let mut actual: Vec<_> = keys().collect(); actual.sort_unstable(); - assert_eq!(expected.len(), 93); + assert_eq!(expected.len(), 98); assert_eq!(actual, expected); } @@ -493,15 +831,37 @@ fn moved_profile_settings_survive_saved_roundtrip_and_apply() { h.app.preferences.viewport_analysis_status = false; h.app.preferences.keep_work_on_tab_switch = true; h.app.preferences.line_hover_tangent_arc = true; + h.app.preferences.navigator_fit = true; + h.app.preferences.invert_navigator_arrows = true; + h.app.preferences.home_fit_size = 0.5; + h.app.preferences.ribbon_click_ends_tool = false; + h.app.preferences.sketch_face_activates_part = true; run(&mut h, "settings.profile.save", json!({"name":"Moved"})); let saved = h.app.settings_profiles.prefs(); let doc = h.app.settings_profiles.list[0].clone(); assert_eq!(doc["preferences"]["keep_work_on_tab_switch"], true); assert_eq!(doc["preferences"]["line_hover_tangent_arc"], true); + for (key, value) in [ + ("home_fit_size", json!(0.5)), + ("invert_navigator_arrows", json!(true)), + ("navigator_fit", json!(true)), + ("ribbon_click_ends_tool", json!(false)), + ("sketch_face_activates_part", json!(true)), + ] { + assert_eq!(doc["preferences"][key], value, "capture retains {key}"); + } assert!(doc["preferences"].get("dark_parts_shading").is_none()); assert_eq!(sanitize(&doc).unwrap(), doc); h.app.settings_profiles.load(&serde_json::from_str::(&serde_json::to_string(&saved).unwrap()).unwrap()); assert_eq!(h.app.settings_profiles.list[0], doc); + let path = h.dir.join("moved.solidcraft-profile.json"); + let exported = run(&mut h, "settings.profile.export", json!({"name":"Moved", "path":path})); + assert_eq!(exported["profile"], doc); + let written: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(written, doc); + run(&mut h, "settings.profile.delete", json!({"name":"Moved"})); + assert_eq!(run(&mut h, "settings.profile.import", json!({"path":path})), json!({"name":"Moved"})); + assert_eq!(h.app.settings_profiles.list[0], doc, "file import retains the complete profile"); h.app.preferences.nav_bridge_enabled = false; h.app.preferences.nav_bridge_port = 8765; h.app.preferences.origin_plane_names = crate::prefs::OriginPlaneNames::Xyz; @@ -510,28 +870,64 @@ fn moved_profile_settings_survive_saved_roundtrip_and_apply() { h.app.preferences.viewport_analysis_status = true; h.app.preferences.keep_work_on_tab_switch = false; h.app.preferences.line_hover_tangent_arc = false; + h.app.preferences.navigator_fit = defaults.navigator_fit; + h.app.preferences.invert_navigator_arrows = defaults.invert_navigator_arrows; + h.app.preferences.home_fit_size = defaults.home_fit_size; + h.app.preferences.ribbon_click_ends_tool = defaults.ribbon_click_ends_tool; + h.app.preferences.sketch_face_activates_part = defaults.sketch_face_activates_part; let out = run(&mut h, "settings.profile.apply", json!({"name":"Moved"})); assert_eq!(out["skipped"], json!([])); assert!(h.app.preferences.keep_work_on_tab_switch && h.app.preferences.line_hover_tangent_arc); assert_eq!(capture(&h.app, "Moved"), doc); + for value in [0.05, 1.1] { + let mut invalid = doc.clone(); + invalid["preferences"]["home_fit_size"] = json!(value); + let imported = run(&mut h, "settings.profile.import", json!({"profile":invalid, "name":"Invalid fit"})); + let out = run(&mut h, "settings.profile.apply", json!({"name":imported["name"]})); + assert_eq!(out["skipped"], json!(["home_fit_size"])); + assert_eq!(h.app.preferences.home_fit_size, 0.5, "an out-of-range profile value is refused"); + } } #[test] fn profile_sidebar_switches_saves_and_marks_edits_with_existing_commands() { let mut h = Harness::new(); h.app.home.open = false; + h.app.preferences.language = "en".into(); run(&mut h, "settings.profile.save", json!({"name":"Laptop"})); h.app.preferences.zoom_reverse = true; run(&mut h, "settings.profile.save", json!({"name":"Desk"})); h.call("ui.prefs", json!({"open":"Tools"})); h.frames(3); assert!(crate::scenario::rect_of("prefs:profile:edited").is_none()); - h.step(&json!({"click":{"handle":"prefs:profile"}})).unwrap(); + let card = crate::scenario::rect_of("prefs:profile").unwrap(); + h.step(&json!({"click":[card.left() + 3.0, card.center().y]})).unwrap(); h.frames(2); + assert!(crate::scenario::rect_of("dialog:list:Laptop").is_some(), "the card's padding opens its real menu"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.prefs_window.open); + assert!(crate::scenario::rect_of("dialog:list:Laptop").is_none(), "Escape dismisses the sidebar menu itself"); + h.step(&json!({"key":"Enter"})).unwrap(); + h.frames(2); + assert!(crate::scenario::rect_of("dialog:list:Laptop").is_some(), "the same trigger opens from the keyboard"); h.step(&json!({"click":{"handle":"dialog:list:Laptop"}})).unwrap(); h.frames(3); assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Laptop")); assert!(!h.app.preferences.zoom_reverse); + h.step(&json!({"widget":"General"})).unwrap(); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some(""), + "sidebar Apply does not fill the independent name entry" + ); + h.step(&json!({"widget":"Tools"})).unwrap(); h.app.preferences.direct_interaction = true; h.frames(3); assert!(crate::scenario::rect_of("prefs:profile:edited").is_some()); @@ -549,4 +945,52 @@ fn profile_sidebar_switches_saves_and_marks_edits_with_existing_commands() { h.step(&json!({"widget":profiles_label})).unwrap(); h.frames(3); assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_GENERAL); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some(""), + "sidebar Save does not fill the independent name entry either" + ); + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"text":"Next profile"})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile"}})).unwrap(); + h.frames(2); + h.step(&json!({"click":{"handle":"dialog:list:Desk"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some("Next profile"), + "selecting another sidebar profile preserves typed input" + ); + h.step(&json!({"click":{"handle":"prefs:profile"}})).unwrap(); + h.frames(2); + h.step(&json!({"widget":"Save"})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Laptop", "Desk"]); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some("Next profile"), + "sidebar Save preserves typed input and saves the selected profile" + ); } diff --git a/crates/ui-egui/src/shell_tests.rs b/crates/ui-egui/src/shell_tests.rs index 2cb1a4062a..2daf18131a 100644 --- a/crates/ui-egui/src/shell_tests.rs +++ b/crates/ui-egui/src/shell_tests.rs @@ -576,7 +576,9 @@ fn tool_sheet_dropdowns_and_preferences_still_open_above_the_sheet() { let overlap = sheet.intersect(preferences); assert!(overlap.is_positive(), "Preferences overlaps the feature dialog"); assert_eq!(h.ctx.layer_id_at(overlap.center()).unwrap().id, egui::Id::new("sc_prefs"), "Preferences receives input above the sheet"); - h.step(&json!({"widget":"Cancel"})).unwrap(); + let cancel = rect_of("prefs:cancel").expect("Preferences Cancel"); + assert_eq!(h.ctx.layer_id_at(cancel.center()).unwrap().id, egui::Id::new("sc_prefs"), "Preferences owns its Cancel target"); + h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap(); assert!(!h.app.prefs_window.open && h.app.dialog.is_some(), "Cancel closes Preferences, leaving the feature dialog open"); h.step(&json!({"widget":"Cancel"})).unwrap(); assert!(h.app.dialog.is_none()); diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index eb9c399fa3..ec45ea6110 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -942,6 +942,7 @@ mod tests { #[test] fn feature_options_show_every_mode_of_the_running_tool_and_switch_between_them() { let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); h.app.start("sketch.circle.center"); h.settle(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index da0952af44..81e45380f3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -964,6 +964,8 @@ pub struct Tokens { pub font_subtitle: f32, /// Introductory text on the home page. pub font_intro: f32, + /// Preferences page title, independent of dialog and article headings. + pub font_page_heading: f32, /// Top-level headings in assistant Markdown. pub font_article_heading: f32, /// Application title in the About dialog. @@ -1191,6 +1193,10 @@ pub struct Tokens { pub figure_ground: Color32, /// Outlines and reference lines of Preferences figures. pub figure_line: Color32, + /// Neutral light reflected by a model in a Preferences figure, independent of theme polarity. + pub figure_highlight: Color32, + /// Neutral shadow cast by a model in a Preferences figure, independent of theme polarity. + pub figure_shadow: Color32, /// Top face of the Preferences figure cube. pub figure_face_top: Color32, /// Left face of the Preferences figure cube. @@ -1215,8 +1221,10 @@ pub struct Tokens { pub figure_axis_z: Color32, /// Done or fully constrained mark in a Preferences figure. pub figure_ok: Color32, - /// Warning mark in a Preferences figure. + /// Under-constrained sketch mark in a Preferences figure. pub figure_warn: Color32, + /// Conflicting sketch geometry or state glyph in a Preferences figure. + pub figure_conflict: Color32, /// Segmented control well and tint behind figures in Preferences. pub pref_well: Color32, /// Warning badge text and stroke in Preferences. @@ -1444,11 +1452,20 @@ impl Tokens { /// These tokens with a colour vision mode's values on top (the tables in `palettes.rs`); Off /// gives them back unchanged. pub fn with_vision(self, dark: bool, mode: ColorVision) -> Tokens { - match mode { - ColorVision::Off => self, + let mut t = match mode { + ColorVision::Off => return self, ColorVision::Protan | ColorVision::Deutan => self.red_green(dark), ColorVision::Tritan => self.blue_yellow(dark), - } + }; + // Figure axes carry the same X/Y/Z meanings as the viewport and its navigation triad. + t.figure_axis_x = t.axis_x; + t.figure_axis_y = t.axis_y; + t.figure_axis_z = t.axis_z; + // Match SketchState::ink: fully constrained, under constrained, and conflicting. + t.figure_ok = t.sketch_fixed; + t.figure_warn = t.sketch; + t.figure_conflict = t.error; + t } /// Fade idle cube colours into the viewport while retaining opaque facets. @@ -1792,6 +1809,27 @@ mod tests { assert_eq!(ctx.style_of(theme).visuals, expected, "the new theme's visuals must be in place"); } + #[test] + fn preferences_foreground_pairs_hold_small_text_contrast() { + for dark in [false, true] { + for vision in ColorVision::ALL { + let t = Tokens::themed(dark, vision); + for (ink_name, ink, surface_name, surface) in [ + ("pref_note", t.pref_note, "pref_note_fill", t.pref_note_fill), + ("pref_ok", t.pref_ok, "pref_ok_fill", t.pref_ok_fill), + ("pref_on_accent", t.pref_on_accent, "figure_accent", t.figure_accent), + ("figure_accent", t.figure_accent, "figure_card", t.figure_card), + ("figure_accent", t.figure_accent, "selected_bg", t.selected_bg), + ] { + let contrast = contrast_ratio(ink, surface); + assert!(contrast >= 4.5, "dark={dark} {vision:?}: {ink_name} on {surface_name}: {contrast:.2}"); + } + assert_eq!(t.figure_highlight, Color32::WHITE, "a model highlight stays light in either theme"); + assert_eq!(t.figure_shadow, Color32::BLACK, "a model shadow stays dark in either theme"); + } + } + } + #[test] fn file_view_foreground_pairs_hold_small_text_contrast_without_changing_accent() { for dark in [false, true] { diff --git a/crates/ui-egui/src/theme_files.rs b/crates/ui-egui/src/theme_files.rs index ecd761a620..fd82e2fde6 100644 --- a/crates/ui-egui/src/theme_files.rs +++ b/crates/ui-egui/src/theme_files.rs @@ -465,12 +465,12 @@ macro_rules! theme_keys { space_12: _, space_14: _, space_16: _, space_24: _, radius_sm: _, radius_md: _, radius_lg: _, radius_xl: _, font_badge: _, font_micro: _, font_panel: _, font_small: _, font_caption: _, font_body: _, font_label: _, font_button: _, font_heading: _, font_triad: _, - font_compass: _, font_subtitle: _, font_intro: _, font_article_heading: _, font_title: _, font_display: _, + font_compass: _, font_subtitle: _, font_intro: _, font_page_heading: _, font_article_heading: _, font_title: _, font_display: _, control_height: _, control_radius: _, form_label_width: _, form_gap_y: _, form_footer_height: _, form_header_height: _, form_card_width: _, form_icon_size: _, form_close_size: _, form_inset: _, form_icon_inset: _, form_section_icon_size: _, form_checkbox_height: _, form_button_height: _, sketch_palette_width: _, sketch_mode_size: _, sketch_mode_radius: _, sketch_mode_border: _, sketch_palette_alpha: _, sketch_mode_tint: _, sketch_footer_icon_size: _, font_section_tracking: _, - figure_ground: _, figure_line: _, figure_face_top: _, figure_face_left: _, figure_face_right: _, figure_card: _, figure_white: _, figure_black: _, figure_accent: _, figure_reference: _, figure_axis_x: _, figure_axis_y: _, figure_axis_z: _, figure_ok: _, figure_warn: _, pref_well: _, pref_note: _, pref_note_fill: _, pref_on_accent: _, pref_ok: _, pref_ok_fill: _, pref_disabled: _, + figure_ground: _, figure_line: _, figure_highlight: _, figure_shadow: _, figure_face_top: _, figure_face_left: _, figure_face_right: _, figure_card: _, figure_white: _, figure_black: _, figure_accent: _, figure_reference: _, figure_axis_x: _, figure_axis_y: _, figure_axis_z: _, figure_ok: _, figure_warn: _, figure_conflict: _, pref_well: _, pref_note: _, pref_note_fill: _, pref_on_accent: _, pref_ok: _, pref_ok_fill: _, pref_disabled: _, } = t; let IconColors { $($ione: _,)* } = &t.icons; } diff --git a/crates/ui-egui/src/ui_kit/actions.rs b/crates/ui-egui/src/ui_kit/actions.rs index e0c9ea418c..662bad908c 100644 --- a/crates/ui-egui/src/ui_kit/actions.rs +++ b/crates/ui-egui/src/ui_kit/actions.rs @@ -567,6 +567,9 @@ pub fn button(ui: &mut Ui, label: impl Into, options: Button) return crate::prefs_search::hidden_response(ui); } let response = match options { + Button::Enabled(enabled) if super::settings_page_active(ui) => { + ui.add_enabled(enabled, button_widget(crate::i18n::tr_label(&key).into(), options)) + } Button::Enabled(enabled) => ui.add_enabled_ui(enabled, |ui| ui.add(button_widget(crate::i18n::tr_label(&key).into(), options))).inner, _ => ui.add(button_widget(label, options)), }; @@ -760,60 +763,60 @@ pub(crate) fn selection_actions(ui: &mut Ui, name: &str, actions: &[super::Stage fn icon_label_presentation(ui: &mut Ui, label: &str, icon: Option<&str>, enabled: bool, primary: bool, danger: bool, dense: bool) -> Response { let t = Tokens::get(); let width = icon_label_width(ui, label, icon, primary); - let response = ui - .add_enabled_ui(enabled, |ui| { - let (rect, response) = - ui.allocate_exact_size(vec2(width, if dense { t.control_height - t.space_2 } else { t.control_height }), egui::Sense::click()); - let ink = if danger { - t.form_error - } else if primary { - t.on_accent + let paint = |ui: &mut Ui| { + let (rect, response) = + ui.allocate_exact_size(vec2(width, if dense { t.control_height - t.space_2 } else { t.control_height }), egui::Sense::click()); + let ink = if danger { + t.form_error + } else if primary { + t.on_accent + } else { + t.form_ink + }; + ui.painter().rect( + rect, + t.control_radius, + if primary { + t.form_primary + } else if response.hovered() { + t.hover } else { - t.form_ink + t.control_fill + }, + if primary { egui::Stroke::NONE } else { t.control_border_stroke(t.control_border) }, + egui::StrokeKind::Inside, + ); + let mut left = rect.left() + + if primary { + t.space_16 + } else if icon.is_some() { + t.space_10 + } else { + t.space_12 }; - ui.painter().rect( - rect, - t.control_radius, - if primary { - t.form_primary - } else if response.hovered() { - t.hover - } else { - t.control_fill - }, - if primary { egui::Stroke::NONE } else { t.control_border_stroke(t.control_border) }, - egui::StrokeKind::Inside, - ); - let mut left = rect.left() - + if primary { - t.space_16 - } else if icon.is_some() { - t.space_10 - } else { - t.space_12 - }; - if let Some(icon) = icon { - crate::icons::paint_ink( - ui.painter(), - egui::Rect::from_center_size(egui::pos2(left + t.space_14 / 2.0, rect.center().y), vec2(t.space_14, t.space_14)), - icon, - ink, - ); - left += t.space_14 + t.space_6; - } - super::text::paint_text( - ui, - egui::Rect::from_min_max(egui::pos2(left, rect.top()), rect.max), - label, - if primary { crate::fonts::semibold(t.font_button) } else { egui::FontId::proportional(t.font_label) }, + if let Some(icon) = icon { + crate::icons::paint_ink( + ui.painter(), + egui::Rect::from_center_size(egui::pos2(left + t.space_14 / 2.0, rect.center().y), vec2(t.space_14, t.space_14)), + icon, ink, - false, ); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); - pointer_on_hover(ui, &response); - response - }) - .inner; + left += t.space_14 + t.space_6; + } + super::text::paint_text( + ui, + egui::Rect::from_min_max(egui::pos2(left, rect.top()), rect.max), + label, + if primary { crate::fonts::semibold(t.font_button) } else { egui::FontId::proportional(t.font_label) }, + ink, + false, + ); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + pointer_on_hover(ui, &response); + response + }; + // A scope cannot reserve its natural width in the parent's wrapping row. + let response = if super::settings_page_active(ui) { ui.add_enabled(enabled, paint) } else { ui.add_enabled_ui(enabled, paint).inner }; let key = format!("dialog:button:{}:{label}", super::layout::label(ui)); scenario::publish_handle(&key, response.rect.center()); scenario::publish_rect(&key, response.rect); diff --git a/crates/ui-egui/src/ui_kit/gallery.rs b/crates/ui-egui/src/ui_kit/gallery.rs index 1897f41770..3bf12296c8 100644 --- a/crates/ui-egui/src/ui_kit/gallery.rs +++ b/crates/ui-egui/src/ui_kit/gallery.rs @@ -472,6 +472,33 @@ const ELEMENTS: &[Element] = &[ }); }); }), + element!("below_row_control", |ui, state| { + settings_form(ui, Tokens::get().form_label_width, |ui| { + row(ui, "Autosave every", |ui| { + field(ui, &mut state.value); + }); + below_row_control(ui, "Autosave every", 260.0, |ui| settings_autosave_timeline(ui, 5.0)); + }); + }), + element!("named_control", "dialog:widget:Profile name", Some("dialog:Profile name"), |ui, state| { + named_control(ui, "Profile name", |ui| { + text_field(ui, &mut state.value, "Name this setup"); + }); + }), + element!("settings_row_card", |ui, _| { + let t = Tokens::get(); + for selected in [true, false] { + settings_row_card(ui, selected, t.control_height + t.space_14, |ui| { + text(ui, "Default", if selected { Text::SelectedNameCell } else { Text::NameCell(false) }); + if selected { + settings_badge(ui, "Active", false); + } else { + button(ui, "Apply", Button::Normal); + } + }); + ui.add_space(t.space_8); + } + }), element!("settings_composition", |ui, _| { settings_heading(ui, "Interface", "Appearance and workspace"); settings_section(ui, "gallery_settings", "Layout", |ui| { diff --git a/crates/ui-egui/src/ui_kit/layout.rs b/crates/ui-egui/src/ui_kit/layout.rs index a76da8b194..12bb94de06 100644 --- a/crates/ui-egui/src/ui_kit/layout.rs +++ b/crates/ui-egui/src/ui_kit/layout.rs @@ -279,11 +279,34 @@ fn sized_row_body(ui: &mut Ui, text: &str, height: f32, control: impl FnOnce( // Measuring takes the original text and prepares it the same way, so column and row agree. let label_width = label_column(ui, text, width); let label_rect = egui::Rect::from_min_size(rect.min, vec2(label_width, rect.height())); - ui.scope_builder(egui::UiBuilder::new().max_rect(label_rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { - // Left aligned in the label column, as in the design. - ui.add(egui::Label::new(RichText::new(shown.clone()).size(t.font_label).color(t.dialog_label)).truncate()).on_hover_text(shown); - }); - let control_rect = egui::Rect::from_min_max(egui::pos2((label_rect.right() + label_gap(ui, width)).min(rect.right()), rect.top()), rect.max); + let help = ui.stack().iter().find_map(|node| node.tags().get_downcast::(super::preferences_small::ROW_HELP)).cloned(); + if label_width > 0.0 { + ui.scope_builder(egui::UiBuilder::new().max_rect(label_rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + // Left aligned in the label column, as in the design. + if let Some(help) = &help { + ui.spacing_mut().item_spacing.x = t.space_4; + let width = (label_width - t.space_16 - t.space_4).max(0.0); + let label = ui + .scope_builder( + egui::UiBuilder::new() + .max_rect(egui::Rect::from_min_size(label_rect.min, vec2(width, height))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + ui.add(egui::Label::new(RichText::new(shown.clone()).size(t.font_label).color(t.dialog_label)).truncate()) + .on_hover_text(&shown) + }, + ) + .inner; + let help = super::help_icon(ui, help); + scenario::publish_rect(&format!("dialog:row_label:{text}"), label.rect); + scenario::publish_rect(&format!("dialog:row_help:{text}"), help.rect); + } else { + ui.add(egui::Label::new(RichText::new(shown.clone()).size(t.font_label).color(t.dialog_label)).truncate()).on_hover_text(shown); + } + }); + } + let gutter = if label_width > 0.0 { label_gap(ui, width) } else { 0.0 }; + let control_rect = egui::Rect::from_min_max(egui::pos2((label_rect.right() + gutter).min(rect.right()), rect.top()), rect.max); let result = ui .scope_builder( egui::UiBuilder::new() @@ -321,6 +344,18 @@ pub(crate) fn caption(ui: &mut Ui, aligned_to: &str, text: &str) { scenario::publish_rect(&format!("dialog:caption:{aligned_to}"), text_rect); } +/// Natural-height content aligned beneath an indexed row's control, bounded independently of the form. +pub(crate) fn below_row_control(ui: &mut Ui, label: &str, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + let available = dimension(ui.available_width()); + let inset = (label_column(ui, label, available) + label_gap(ui, available)).min(available); + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(inset); + ui.allocate_ui_with_layout(vec2(width.min((available - inset).max(0.0)), 0.0), egui::Layout::top_down(egui::Align::Min), add).inner + }) + .inner +} + /// Where a [`figure_rows`] field went (in the order given): the field's rect, and whether it is /// hovered or has the keyboard (its part of the figure lights up). pub(crate) struct FigureSlot { @@ -467,7 +502,16 @@ fn section_with_badge( let id = ui.make_persistent_id(id); let mut open = !pending && (searching || ui.ctx().data_mut(|data| data.get_persisted::(id)).unwrap_or(default_open)); let (rect, response) = ui.allocate_exact_size( - vec2(dimension(ui.available_width()), if compact { t.control_height - t.space_4 } else { row_height() }), + vec2( + dimension(ui.available_width()), + if compact { + t.control_height - t.space_4 + } else if super::settings_page_active(ui) { + t.control_height + t.space_2 + } else { + row_height() + }, + ), egui::Sense::click(), ); crate::prefs_search::follow(ui, title, rect); @@ -637,7 +681,7 @@ pub(crate) fn divider(ui: &mut Ui, key: &str) { /// Give a compound row's subfield its accessible name without allocating layout space. /// Name a subfield without allocating another row. Tokens: `none`. -pub(super) fn named_control(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R { +pub(crate) fn named_control(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R { ui.scope_builder(egui::UiBuilder::new().id_salt(label).ui_stack_info(egui::UiStackInfo::default().with_tag_value(LABEL, label.to_owned())), add) .inner } @@ -679,6 +723,7 @@ pub(crate) enum Text<'a> { Inline, Cell, NameCell(bool), + SelectedNameCell, MonoCell, Value(&'a str, bool), } @@ -721,16 +766,17 @@ fn text_impl(ui: &mut Ui, text: &str, style: Text<'_>, hover_hint: Option<&str>) } } let t = Tokens::get(); - let fixed = matches!(style, Text::Cell | Text::NameCell(_) | Text::MonoCell | Text::Value(_, _)); + let fixed = matches!(style, Text::Cell | Text::NameCell(_) | Text::SelectedNameCell | Text::MonoCell | Text::Value(_, _)); let ink = match style { Text::Hint(_) | Text::Note(_) | Text::MonoNote(_) => t.form_dim, Text::Inline if in_catalog(ui) => t.form_dim, Text::Report(_, true) | Text::Value(_, true) | Text::Error(_) => t.form_error, + Text::SelectedNameCell => t.selected_ink, _ => t.form_ink, }; let font = match style { Text::Inline if in_catalog(ui) => crate::fonts::semibold(t.font_small), - Text::NameCell(true) => crate::fonts::semibold(t.font_label), + Text::NameCell(true) | Text::SelectedNameCell => crate::fonts::semibold(t.font_label), Text::NameCell(false) => crate::fonts::medium(t.font_label), Text::Heading(_) => crate::fonts::semibold(t.font_button), Text::MonoCell | Text::Value(_, _) => egui::FontId::monospace(t.font_body), @@ -782,7 +828,7 @@ fn text_impl(ui: &mut Ui, text: &str, style: Text<'_>, hover_hint: Option<&str>) Text::Note(key) | Text::MonoNote(key) => ("note", key), Text::Report(key, _) | Text::Value(key, _) | Text::Error(key) | Text::Heading(key) => ("report", key), Text::Inline => ("inline", text), - Text::Cell | Text::MonoCell | Text::NameCell(_) => ("cell", text), + Text::Cell | Text::MonoCell | Text::NameCell(_) | Text::SelectedNameCell => ("cell", text), }; let id = format!("dialog:{kind}:{key}"); scenario::publish_rect(&id, response.rect); diff --git a/crates/ui-egui/src/ui_kit/panel.rs b/crates/ui-egui/src/ui_kit/panel.rs index d9bd34b29a..e130e24b43 100644 --- a/crates/ui-egui/src/ui_kit/panel.rs +++ b/crates/ui-egui/src/ui_kit/panel.rs @@ -41,7 +41,7 @@ fn header_actions( collapsed: Option<&mut bool>, closeable: bool, on_close: impl FnOnce(), - actions: Option<&mut dyn FnMut(&mut Ui)>, + actions: Option<(f32, &mut dyn FnMut(&mut Ui))>, ) { let t = Tokens::get(); let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), header_height()), egui::Sense::hover()); @@ -68,13 +68,21 @@ fn header_actions( on_close(); } } - let right = if let Some(actions) = actions { + let right = if let Some((width, actions)) = actions { + let end = if closeable { close_rect.left() - t.space_8 } else { rect.right() - t.space_8 }; + let width = if closeable { width.min((end - icon_rect.right() - t.space_24 * 4.0).max(0.0)) } else { width }; let action_rect = egui::Rect::from_center_size( - egui::pos2(rect.right() - t.space_8 - t.control_height, rect.center().y), - vec2(t.control_height * 2.0, t.control_height), + egui::pos2(end - width / 2.0, rect.center().y), + vec2(width, if closeable { t.form_close_size } else { t.control_height }), ); - let mut action_ui = ui.new_child(egui::UiBuilder::new().max_rect(action_rect)); + let builder = egui::UiBuilder::new().max_rect(action_rect); + let builder = if closeable { builder.layout(egui::Layout::left_to_right(egui::Align::Center)) } else { builder }; + let mut action_ui = ui.new_child(builder); + if closeable { + action_ui.set_clip_rect(ui.clip_rect().intersect(action_rect)); + } actions(&mut action_ui); + scenario::publish_rect("dialog:header:accessory", action_rect); action_rect.left() - t.space_8 } else if let Some(collapsed) = collapsed { let fold = egui::Rect::from_min_size(egui::pos2(close_rect.left() - t.form_close_size - t.space_4, close_rect.top()), close_rect.size()); @@ -432,7 +440,15 @@ impl<'a> Panel<'a> { width, false, |ui, options| { - header_actions(ui, (options.icon, None), options.title, None, false, || {}, actions); + header_actions( + ui, + (options.icon, None), + options.title, + None, + false, + || {}, + actions.map(|actions| (Tokens::get().control_height * 2.0, actions)), + ); }, add, ) @@ -940,6 +956,7 @@ pub struct Window<'a> { page_padding: egui::Margin, page_chrome: bool, left_slot: bool, + page_header_in_titlebar: bool, } impl<'a> Window<'a> { @@ -964,6 +981,7 @@ impl<'a> Window<'a> { page_padding: egui::Margin::same(Tokens::get().space_16 as i8), page_chrome: false, left_slot: false, + page_header_in_titlebar: false, } } @@ -1031,6 +1049,12 @@ impl<'a> Window<'a> { self } + /// Place the existing page-header callback beside the title; other windows retain their body header. + pub(crate) fn page_header_in_titlebar(mut self) -> Self { + self.page_header_in_titlebar = true; + self + } + /// A required choice, such as web recovery, has no dismiss action. Existing data remains /// pending until the body explicitly handles a choice. pub fn required(mut self) -> Self { @@ -1068,7 +1092,9 @@ impl<'a> Window<'a> { let t = Tokens::get(); ui.spacing_mut().item_spacing.y = 0.0; let mut close = false; - header_impl(ui, (self.icon, self.page_chrome.then_some(t.form_primary)), self.title, None, self.closeable, || close = true); + if !self.page_header_in_titlebar { + header_impl(ui, (self.icon, self.page_chrome.then_some(t.form_primary)), self.title, None, self.closeable, || close = true); + } egui::Frame::NONE.inner_margin(if self.body_padding { t.space_12 } else { 0.0 }).show(ui, |ui| { ui.spacing_mut().item_spacing.y = t.form_gap_y; if self.body_padding { @@ -1154,7 +1180,46 @@ impl<'a> Window<'a> { self.page_chrome = true; let rows = self.page_rows; let padding = self.page_padding; - self.show_with_left(ctx, true, |ui| page_list_sized(ui, selected, pages, state, rows, padding, header, on_select, sidebar_footer), left) + let in_titlebar = self.page_header_in_titlebar; + let (icon, title, closeable) = (self.icon, self.title, self.closeable); + let header_closed = std::cell::Cell::new(false); + let result = self.show_with_left( + ctx, + true, + |ui| { + if in_titlebar { + ui.spacing_mut().item_spacing.y = 0.0; + let mut accessory = |ui: &mut Ui| { + if let Some(header) = header { + header(ui, state); + } + }; + header_actions( + ui, + (icon, Some(Tokens::get().form_primary)), + title, + None, + closeable, + || header_closed.set(true), + Some((Tokens::get().space_24 * 9.0 + Tokens::get().space_14, &mut accessory)), + ); + } + page_list_sized( + ui, + selected, + pages, + state, + rows, + padding, + if in_titlebar { None } else { header }, + on_select, + sidebar_footer, + header.is_some(), + ); + }, + left, + ); + (result.0 || header_closed.get(), result.1) } /// The same bounded card frame, either movable or an input-blocking modal. @@ -1222,7 +1287,7 @@ pub(crate) struct WindowPage { #[cfg(test)] pub(super) fn page_list(ui: &mut Ui, selected: &mut usize, pages: &[WindowPage], state: &mut S) { - page_list_sized(ui, selected, pages, state, 16.0, egui::Margin::same(Tokens::get().space_16 as i8), None, None, None); + page_list_sized(ui, selected, pages, state, 16.0, egui::Margin::same(Tokens::get().space_16 as i8), None, None, None, false); } fn page_list_sized( @@ -1235,6 +1300,7 @@ fn page_list_sized( header: Option, on_select: Option, sidebar_footer: Option<(f32, fn(&mut Ui, &mut S))>, + search_scroll: bool, ) { let t = Tokens::get(); // The search strip, the gap under it, and the following item spacing sit above the page. @@ -1254,7 +1320,11 @@ fn page_list_sized( t.secondary_vline(ui.painter(), nav.right(), nav.y_range(), t.dialog_rule); scenario::publish_rect("dialog:pages", nav); *selected = (*selected).min(pages.len().saturating_sub(1)); - let nav_inner = nav.shrink2(vec2(t.space_8, t.space_6)); + let nav_inner = if sidebar_footer.is_some() { + egui::Rect::from_min_max(nav.min + vec2(t.space_8, t.space_6), nav.max - vec2(t.space_8, t.space_10)) + } else { + nav.shrink2(vec2(t.space_8, t.space_6)) + }; let footer_height = sidebar_footer.map_or(0.0, |(height, _)| dimension(height).min(nav_inner.height())); let list = egui::Rect::from_min_max(nav_inner.min, egui::pos2(nav_inner.right(), nav_inner.bottom() - footer_height)); ui.scope_builder(egui::UiBuilder::new().max_rect(list), |ui| { @@ -1339,7 +1409,7 @@ fn page_list_sized( if let Some(header) = header { header(ui, state); } - let force_scroll = header.is_some() && crate::prefs_search::force_scroll(ui.ctx()); + let force_scroll = search_scroll && crate::prefs_search::force_scroll(ui.ctx()); if let Some(page) = pages.get(*selected) { if page.scroll || force_scroll { let scroll = super::settings_scroll(ui, ("sc_prefs_page", *selected), |ui| (page.draw)(ui, state)); diff --git a/crates/ui-egui/src/ui_kit/panel_tests.rs b/crates/ui-egui/src/ui_kit/panel_tests.rs index e0bf02cd4a..5c7cb5d2aa 100644 --- a/crates/ui-egui/src/ui_kit/panel_tests.rs +++ b/crates/ui-egui/src/ui_kit/panel_tests.rs @@ -1173,14 +1173,33 @@ fn preferences_preserves_every_page_and_footer_in_both_themes() { let row = scenario::rect_of(&format!("prefs:page:{page}")).unwrap(); assert!(nav.contains_rect(row), "{dark}: {page} stays visible"); let window = scenario::rect_of("prefs:window").unwrap(); + let header = scenario::rect_of("dialog:header").unwrap(); + let search = scenario::rect_of("prefs:search:frame").unwrap(); + assert!((header.height() - 40.0).abs() < 0.5); + assert!(header.contains_rect(search), "{dark}: {page}: search stays inside the titlebar"); + assert!((search.width() - 230.0).abs() < 0.5 && (search.height() - 26.0).abs() < 0.5); + assert!(search.left() > header.center().x); for action in ["prefs:ok", "prefs:apply", "prefs:cancel"] { assert!(window.contains_rect(scenario::rect_of(action).unwrap()), "{dark}: {page}: {action}"); } assert!(scenario::rect_of("prefs:page_body").is_some()); - assert!(h.widget(h.app.tr("ui.restore_defaults")).is_some()); + assert!(h.widget(h.app.tr("prefs.restore_page_defaults")).is_some()); let footer = scenario::rect_of("dialog:pages_footer").expect("pinned profile footer"); assert!(nav.contains_rect(footer)); - assert!(footer.contains_rect(scenario::rect_of("prefs:profile").expect("real switcher"))); + let profile_card = scenario::rect_of("prefs:profile:sidebar_card").expect("bordered profile card"); + assert!(footer.contains_rect(profile_card)); + assert!((profile_card.height() - 48.0).abs() < 0.5); + assert_eq!(profile_card, scenario::rect_of("prefs:profile").expect("the whole card is the real switcher")); + let icon = scenario::rect_of("prefs:profile:icon").unwrap(); + let label = scenario::rect_of("prefs:profile:label").unwrap(); + let name = scenario::rect_of("prefs:profile:name").unwrap(); + let chevron = scenario::rect_of("prefs:profile:chevron").unwrap(); + for part in [icon, label, name, chevron] { + assert!(profile_card.contains_rect(part)); + } + assert!(icon.right() < label.left() && label.bottom() <= name.top() && name.right() < chevron.left()); + assert!((label.left() - name.left()).abs() < 0.5); + assert!((icon.center().y - profile_card.center().y).abs() < 0.5); let tagged = [ crate::prefs::PAGE_GENERAL, crate::prefs::PAGE_APPEARANCE, @@ -1191,6 +1210,12 @@ fn preferences_preserves_every_page_and_footer_in_both_themes() { ] .contains(&index); assert_eq!(scenario::rect_of("prefs:in_profile").is_some(), tagged, "{page}"); + if index == crate::prefs::PAGE_GENERAL { + let tag = scenario::rect_of("prefs:in_profile").unwrap(); + let body = scenario::rect_of("prefs:page_body").unwrap(); + assert!((tag.height() - 22.0).abs() < 0.5); + assert!((tag.right() - body.right()).abs() < 0.5, "{dark}: the profile tag aligns to the page's right edge"); + } } } crate::theme::set_dark(previous); diff --git a/crates/ui-egui/src/ui_kit/preferences.rs b/crates/ui-egui/src/ui_kit/preferences.rs index 470345cf08..8404ab02e9 100644 --- a/crates/ui-egui/src/ui_kit/preferences.rs +++ b/crates/ui-egui/src/ui_kit/preferences.rs @@ -363,23 +363,40 @@ pub(crate) fn settings_heading_with_tag(ui: &mut Ui, title: &str, description: & return; } let t = Tokens::get(); - ui.scope(|ui| { + let page = super::settings_page_active(ui); + let heading = ui.scope(|ui| { ui.spacing_mut().item_spacing.y = t.space_3; - let (rect, response) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.space_16 + t.space_3), Sense::hover()); + let (rect, response) = + ui.allocate_exact_size(vec2(dimension(ui.available_width()), if page { t.space_24 } else { t.space_16 + t.space_3 }), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), title)); - paint_text(ui, rect, title, crate::fonts::semibold(t.font_intro), t.form_ink, false); + paint_text(ui, rect, title, crate::fonts::semibold(if page { t.font_page_heading } else { t.font_intro }), t.form_ink, false); if let Some(tag) = tag { - let width = ui.painter().layout_no_wrap(title.to_owned(), crate::fonts::semibold(t.font_intro), t.form_ink).size().x; + let width = ui + .painter() + .layout_no_wrap(title.to_owned(), crate::fonts::semibold(if page { t.font_page_heading } else { t.font_intro }), t.form_ink) + .size() + .x; let start = (rect.left() + width + t.space_8).min(rect.right()); let tag_rect = Rect::from_min_max(egui::pos2(start, rect.top()), rect.max); - ui.scope_builder(egui::UiBuilder::new().max_rect(tag_rect), tag); + ui.scope_builder( + egui::UiBuilder::new().max_rect(tag_rect).layout(if page { egui::Layout::right_to_left(egui::Align::Center) } else { *ui.layout() }), + tag, + ); } super::text(ui, description, super::Text::Hint("settings_heading")); }); + if page { + scenario::publish_rect("prefs:page_heading", heading.response.rect); + } } /// Settings reuse the same disclosure, uppercase typography and rule as the design system. -pub(crate) fn settings_section(ui: &mut Ui, key: &str, title: &str, mut add: impl FnMut(&mut Ui) -> R) -> Option { +pub(crate) fn settings_section(ui: &mut Ui, key: &str, title: &str, add: impl FnMut(&mut Ui) -> R) -> Option { + settings_section_count(ui, key, title, None, add) +} + +/// A settings disclosure with a real item count, using the shared section badge. +pub(crate) fn settings_section_count(ui: &mut Ui, key: &str, title: &str, count: Option<&str>, mut add: impl FnMut(&mut Ui) -> R) -> Option { if crate::prefs_search::querying(ui) { crate::prefs_search::begin_section(ui, title); if !ui.is_visible() { @@ -408,14 +425,31 @@ pub(crate) fn settings_section(ui: &mut Ui, key: &str, title: &str, mut add: return Some(result); } let t = Tokens::get(); - ui.add_space(t.space_8); - super::section(ui, key, title, true, |ui| { + let page = super::settings_page_active(ui); + if page { + // The page's 10 pt item gap plus the shared disclosure's 2 pt inset complete 38 pt. + ui.add_space(t.space_24 + t.space_14 - t.space_10 - t.space_2); + } else { + ui.add_space(t.space_8); + } + let gap = ui.spacing().item_spacing.y; + if page { + ui.spacing_mut().item_spacing.y = t.space_10; + } + let result = super::section_count(ui, key, title, true, count, |ui| { if let Some(rect) = scenario::rect_of(&format!("dialog:section:{title}")) { scenario::publish_rect(&format!("prefs:section:{key}"), rect); } - ui.spacing_mut().item_spacing.y = t.space_8; - add(ui) - }) + ui.spacing_mut().item_spacing.y = if page { t.space_14 } else { t.space_8 }; + let start = ui.next_widget_position(); + let result = add(ui); + if page { + scenario::publish_rect(&format!("prefs:section:{key}:body"), Rect::from_min_max(start, ui.min_rect().max)); + } + result + }); + ui.spacing_mut().item_spacing.y = gap; + result } /// Full-width settings detail group. Container owns its surface; content cannot shrink the card. @@ -619,6 +653,10 @@ pub(crate) fn settings_badge(ui: &mut Ui, text: &str, warning: bool) { return; } let t = Tokens::get(); + if super::settings_page_active(ui) { + super::settings_state_badge(ui, text, if warning { super::SettingsBadgeTone::Warning } else { super::SettingsBadgeTone::Success }); + return; + } let ink = if warning { if crate::theme::contrast_ratio(t.warning, t.dialog_body) >= 4.5 { t.warning } else { t.form_ink } } else { t.form_success }; let fill = if warning { t.warning.gamma_multiply(0.1) } else { t.form_success_fill }; @@ -989,7 +1027,11 @@ pub(crate) fn settings_inline_note(ui: &mut Ui, text: &str) { return; } let t = Tokens::get(); - let galley = ui.painter().layout_no_wrap(text.to_owned(), FontId::proportional(t.font_body), t.form_dim); + let galley = if super::settings_page_active(ui) && ui.layout().main_wrap() { + ui.painter().layout(text.to_owned(), FontId::proportional(t.font_body), t.form_dim, dimension(ui.max_rect().width())) + } else { + ui.painter().layout_no_wrap(text.to_owned(), FontId::proportional(t.font_body), t.form_dim) + }; let (r, response) = ui.allocate_exact_size(galley.size(), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), text)); ui.painter().galley(r.min, galley, t.form_dim); diff --git a/crates/ui-egui/src/ui_kit/preferences_cards.rs b/crates/ui-egui/src/ui_kit/preferences_cards.rs index 7b227d209f..4f59499c54 100644 --- a/crates/ui-egui/src/ui_kit/preferences_cards.rs +++ b/crates/ui-egui/src/ui_kit/preferences_cards.rs @@ -86,6 +86,29 @@ fn gap(t: &Tokens) -> f32 { (t.space_4 + t.space_6) * 0.5 } +/// A bounded settings row/card using the same selection treatment as picture cards. +pub(crate) fn settings_row_card(ui: &mut Ui, selected: bool, height: f32, add: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse { + let t = Tokens::get(); + let (rect, response) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), height), Sense::hover()); + let look = card_look(selected, ui.is_enabled(), false, false); + ui.painter().rect( + rect, + t.radius_lg, + if selected { t.selected_bg } else { t.control_fill }, + Stroke::new(look.border, if selected { t.form_primary } else { t.dialog_border }), + StrokeKind::Inside, + ); + let inner = rect.shrink2(vec2(t.space_10, 0.0)); + let result = ui + .scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + ui.set_clip_rect(ui.clip_rect().intersect(inner)); + ui.set_min_size(inner.size()); + add(ui) + }) + .inner; + egui::InnerResponse { inner: result, response } +} + fn paint_card(ui: &mut Ui, card: &Card, width: f32, tile: bool) -> Response { let t = Tokens::get(); let width = dimension(width); diff --git a/crates/ui-egui/src/ui_kit/preferences_figure_ops.rs b/crates/ui-egui/src/ui_kit/preferences_figure_ops.rs index b3656dc3f4..cd8aa00c85 100644 --- a/crates/ui-egui/src/ui_kit/preferences_figure_ops.rs +++ b/crates/ui-egui/src/ui_kit/preferences_figure_ops.rs @@ -27,10 +27,15 @@ pub(super) enum Ink { Border, ControlBorder, Control, + /// Legacy SVG role for conflicting sketch geometry and the conflict-state glyph. Note, NoteFill, + /// Legacy SVG ink: lettering uses accent foreground; strokes use model highlights. White, + /// Legacy SVG ink: hatch strokes use figure lines; filled silhouettes use model shadows. Black, + Highlight, + Shadow, /// Colour vision sample colours: Z axis blue, projected, X axis, bend up and bend down. Cv0, Cv1, @@ -181,10 +186,12 @@ impl Ctx<'_> { Ink::Border => t.dialog_border, Ink::ControlBorder => t.control_border, Ink::Control => t.control_fill, - Ink::Note => t.pref_note, + Ink::Note => t.figure_conflict, Ink::NoteFill => t.pref_note_fill, Ink::White => t.figure_white, Ink::Black => t.figure_black, + Ink::Highlight => t.figure_highlight, + Ink::Shadow => t.figure_shadow, Ink::Cv0 => t.axis_z, Ink::Cv1 => t.sketch_projected, Ink::Cv2 => t.axis_x, @@ -200,7 +207,11 @@ impl Ctx<'_> { } fn stroke(&self, line: &L) -> Stroke { - Stroke::new((line.w * self.scale).clamp(0.0, 64.0), self.ink(line.ink, line.a)) + let ink = match line.ink { + Ink::White => Ink::Highlight, + ink => ink, + }; + Stroke::new((line.w * self.scale).clamp(0.0, 64.0), self.ink(ink, line.a)) } } @@ -278,7 +289,7 @@ fn draw(p: &Painter, ctx: &Ctx, op: &Op, clip: Option<&[Pos2]>) { Op::Glow { c, a } => { let centre = ctx.at([c[0], c[1]]); let radius = vec2(c[2] * ctx.scale, c[3] * ctx.scale); - glow(p, centre, radius, ctx.ink(Ink::Black, *a)); + glow(p, centre, radius, ctx.ink(Ink::Shadow, *a)); } Op::Text { at, size, anchor, ink, a, s, .. } => { if clip.is_some() { @@ -326,7 +337,11 @@ fn poly(p: &Painter, ctx: &Ctx, pts: &[Pos2], closed: bool, fill: &Option

, gr let span = (y1 - y0).max(1e-3); fill_polygon(p, shape, |q| top.lerp_to_gamma(bottom, ((q.y - y0) / span).clamp(0.0, 1.0))); } else if let Some(f) = fill { - let c = ctx.ink(f.ink, f.a); + let ink = match f.ink { + Ink::Black => Ink::Shadow, + ink => ink, + }; + let c = ctx.ink(ink, f.a); fill_polygon(p, shape, |_| c); } } diff --git a/crates/ui-egui/src/ui_kit/preferences_small.rs b/crates/ui-egui/src/ui_kit/preferences_small.rs index 23d8e0827c..f533071d32 100644 --- a/crates/ui-egui/src/ui_kit/preferences_small.rs +++ b/crates/ui-egui/src/ui_kit/preferences_small.rs @@ -2,6 +2,190 @@ use crate::theme::Tokens; use egui::{Align, FontId, Layout, Rect, Sense, Stroke, Ui, pos2, vec2}; +const SETTINGS_PAGE: &str = "sc_settings_page"; +pub(super) const ROW_HELP: &str = "sc_settings_row_help"; + +/// Opt-in page rhythm; other forms retain their existing spacing and heading sizes. +pub(crate) fn settings_page(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.scope_builder(egui::UiBuilder::new().ui_stack_info(egui::UiStackInfo::default().with_tag(SETTINGS_PAGE)), |ui| { + ui.spacing_mut().item_spacing.y = Tokens::get().space_10; + add(ui) + }) + .inner +} + +pub(super) fn settings_page_active(ui: &Ui) -> bool { + ui.stack().iter().any(|node| node.tags().contains(SETTINGS_PAGE)) +} + +/// Indexed standard row with guidance attached to its label, rather than an orphan help row. +pub(crate) fn settings_row_help(ui: &mut Ui, label: &str, help: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.scope_builder(egui::UiBuilder::new().ui_stack_info(egui::UiStackInfo::default().with_tag_value(ROW_HELP, help.to_owned())), |ui| { + super::row(ui, label, add) + }) + .inner +} + +/// A compound form's independently indexed editor, with a short caption above its control. +pub(crate) fn settings_compound_field(ui: &mut Ui, label: &str, caption: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + let t = Tokens::get(); + super::settings_form(ui, 0.0, |ui| { + super::layout::sized_row(ui, label, t.control_height + t.space_16 + t.space_4, |ui| { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = t.space_4; + super::settings_inline_note(ui, caption); + ui.horizontal(add).inner + }) + .inner + }) + }) +} + +#[derive(Clone, Copy)] +pub(crate) enum SettingsBadgeTone { + Neutral, + Accent, + Warning, + Success, +} + +/// A named change indicator; its hover and accessible text explain the dot's meaning. +pub(crate) fn settings_change_dot(ui: &mut Ui, label: &str) -> egui::Response { + let t = Tokens::get(); + let (rect, response) = ui.allocate_exact_size(vec2(t.space_8, t.space_8), Sense::hover()); + ui.painter().circle_filled(rect.center(), (t.space_8 - t.space_2 / 2.0) / 2.0, t.pref_note); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + response.on_hover_text(label) +} + +/// One compact card trigger for the pinned profile menu, without a nested input frame. +pub(crate) fn settings_profile_button(ui: &mut Ui, label: &str, name: &str, edited: bool, changed_hint: &str) -> egui::Response { + let t = Tokens::get(); + let card = super::settings_row_card(ui, false, t.space_24 * 2.0, |ui| { + let rect = ui.max_rect(); + let side = t.space_16 + t.space_2 / 2.0; + let icon = Rect::from_center_size(pos2(rect.left() + side / 2.0, rect.center().y), vec2(side, side)); + crate::icons::paint_ink(ui.painter(), icon, "settings_profile", t.form_dim); + let chevron = Rect::from_center_size(pos2(rect.right() - t.space_14 / 2.0, rect.center().y), vec2(t.space_14, t.space_14)); + super::dropdown_indicator(ui.painter(), chevron, t.form_dim); + let text_left = (icon.right() + t.space_8).min(chevron.left()); + let text_right = (chevron.left() - t.space_6 - if edited { t.space_8 + t.space_6 } else { 0.0 }).max(text_left); + let overline = Rect::from_min_max(pos2(text_left, rect.center().y - t.space_16), pos2(text_right, rect.center().y - t.space_2)); + let value = Rect::from_min_max(pos2(text_left, overline.bottom()), pos2(text_right, rect.center().y + t.space_16)); + super::text::paint_text(ui, overline, &label.to_uppercase(), crate::fonts::semibold(t.font_panel), t.form_dim, true); + super::text::paint_text(ui, value, name, crate::fonts::semibold(t.font_label), t.form_ink, false); + crate::scenario::publish_rect("prefs:profile:icon", icon); + crate::scenario::publish_rect("prefs:profile:label", overline); + crate::scenario::publish_rect("prefs:profile:name", value); + crate::scenario::publish_rect("prefs:profile:chevron", chevron); + if edited { + let dot = Rect::from_min_size(pos2(text_right + t.space_6, value.center().y - t.space_4), vec2(t.space_8, t.space_8)); + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(dot), |ui| settings_change_dot(ui, changed_hint)).inner; + crate::scenario::publish_target("prefs:profile:edited", response.rect); + } + }); + let response = ui.interact(card.response.rect, ui.make_persistent_id("profile_card"), Sense::click()); + if response.clicked() { + response.request_focus(); + } + if response.hovered() || response.has_focus() { + ui.painter().rect_stroke( + response.rect, + t.radius_lg, + Stroke::new(if response.has_focus() { t.space_2 } else { 1.0 }, t.form_primary), + egui::StrokeKind::Inside, + ); + } + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), name)); + crate::scenario::publish_rect("prefs:profile:sidebar_card", response.rect); + response.on_hover_text(label) +} + +/// Compact 18 pt informational badge, with semantic foreground/background pairs. +pub(crate) fn settings_state_badge(ui: &mut Ui, label: &str, tone: SettingsBadgeTone) -> egui::Response { + let t = Tokens::get(); + let (ink, fill) = match tone { + SettingsBadgeTone::Neutral => (t.form_dim, t.dialog_footer), + SettingsBadgeTone::Accent => (t.selected_ink, t.selected_bg), + SettingsBadgeTone::Warning => (t.pref_note, t.pref_note_fill), + SettingsBadgeTone::Success => (t.pref_ok, t.pref_ok_fill), + }; + let galley = ui.painter().layout_no_wrap(label.to_owned(), crate::fonts::semibold(t.font_small), ink); + let natural = galley.size().x + t.space_16; + let width = if settings_page_active(ui) { natural.min(super::metrics::dimension(ui.max_rect().width())) } else { natural }; + let clipped = width < natural; + let (rect, response) = ui.allocate_exact_size(vec2(width, t.space_16 + t.space_2), Sense::hover()); + ui.painter().rect_filled(rect, t.radius_xl, fill); + if clipped { + super::text::paint_text(ui, rect.shrink2(vec2(t.space_8.min(width / 2.0), 0.0)), label, crate::fonts::semibold(t.font_small), ink, false); + } else { + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, ink); + } + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + if clipped { response.on_hover_text(label) } else { response } +} + +#[derive(Default)] +pub(crate) struct SettingsChip<'a> { + pub key: Option<&'a str>, + pub icon: Option<&'a str>, + pub accent: bool, + pub monospace: bool, + pub remove: Option<&'a str>, + pub compact: bool, +} + +/// Bounded informational pill; an optional removal target lives inside the same outline. +pub(crate) fn settings_chip(ui: &mut Ui, label: &str, chip: SettingsChip<'_>) -> egui::Response { + let t = Tokens::get(); + let ink = if chip.accent { t.selected_ink } else { t.form_dim }; + let font = if chip.accent { + crate::fonts::semibold(t.font_caption) + } else if chip.monospace { + FontId::monospace(t.font_caption) + } else { + FontId::proportional(t.font_caption) + }; + let icon_side = t.space_12; + let icon_width = if chip.icon.is_some() { icon_side + t.space_4 } else { 0.0 }; + let remove_width = if chip.remove.is_some() { t.space_24 } else { 0.0 }; + let natural = ui.painter().layout_no_wrap(label.to_owned(), font.clone(), ink).size().x + t.space_16 + icon_width + remove_width; + let height = if chip.compact { t.space_24 - t.space_2 } else { t.space_24 }; + let (rect, response) = ui.allocate_exact_size(vec2(natural.min(super::metrics::dimension(ui.max_rect().width())), height), Sense::hover()); + ui.painter().rect( + rect, + t.radius_xl, + if chip.accent { t.selected_bg } else { t.control_fill }, + Stroke::new(1.0, if chip.accent { t.form_primary } else { t.dialog_border }), + egui::StrokeKind::Inside, + ); + if let Some(icon) = chip.icon { + let at = Rect::from_center_size(pos2(rect.left() + t.space_8 + icon_side / 2.0, rect.center().y), vec2(icon_side, icon_side)); + if icon == "lock" { + crate::icons::glyphs::badge(ui.painter(), at, "lock", ink); + } else { + crate::icons::paint_ink(ui.painter(), at, icon, ink); + } + } + let text_left = (rect.left() + t.space_8 + icon_width).min(rect.right()); + let text_rect = Rect::from_min_max(pos2(text_left, rect.top()), pos2((rect.right() - t.space_8 - remove_width).max(text_left), rect.bottom())); + super::text::paint_text(ui, text_rect, label, font, ink, false); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + if let Some(remove) = chip.remove { + let target = Rect::from_min_max(pos2((rect.right() - t.space_24).max(rect.left()), rect.top()), rect.max); + if let Some(key) = chip.key { + // Allocate the pill in the wrapping parent before giving its action a unique identity. + let mut action_ui = ui.new_child(egui::UiBuilder::new().id_salt(key).max_rect(target)); + return super::quiet_icon_button_at(&mut action_ui, target, "close", remove).on_hover_text(label); + } + return super::quiet_icon_button_at(ui, target, "close", remove).on_hover_text(label); + } + response.on_hover_text(label) +} + /// A labelled form block whose control column can contain several natural-height rows. pub(crate) fn settings_block(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { settings_block_content(ui, label, None, label_width, add) @@ -81,6 +265,9 @@ pub(crate) fn settings_autosave_timeline(ui: &mut Ui, minutes: f64) { let t = Tokens::get(); let (r, _) = ui.allocate_exact_size(vec2(super::metrics::dimension(ui.available_width()), t.space_24 + t.space_12 + t.space_2), Sense::hover()); let line = r; + if settings_page_active(ui) { + crate::scenario::publish_rect("prefs:autosave_timeline", r); + } ui.painter().hline(line.x_range(), r.top() + t.space_10, Stroke::new(2.0, t.dialog_rule)); if minutes.is_finite() && minutes > 0.0 { let ticks = (30.0 / minutes).floor().clamp(0.0, 60.0) as u32; @@ -90,6 +277,9 @@ pub(crate) fn settings_autosave_timeline(ui: &mut Ui, minutes: f64) { } } for (share, key) in [(0.0, "prefs.timeline_half_hour"), (0.5, "prefs.timeline_quarter_hour"), (1.0, "prefs.timeline_now")] { + if settings_page_active(ui) && share == 0.5 { + continue; + } ui.painter().text( pos2(line.left() + line.width() * share, r.top() + t.space_24), if share == 0.0 { diff --git a/crates/ui-egui/src/ui_kit/search.rs b/crates/ui-egui/src/ui_kit/search.rs index 0eea5586c8..709726e3a6 100644 --- a/crates/ui-egui/src/ui_kit/search.rs +++ b/crates/ui-egui/src/ui_kit/search.rs @@ -156,7 +156,7 @@ impl CommandSearch { response } - fn key(ui: &Ui, right: egui::Pos2, label: &str) -> f32 { + pub(super) fn key(ui: &Ui, right: egui::Pos2, label: &str) -> f32 { Self::key_with_padding(ui, right, label, Tokens::get().space_10) } diff --git a/crates/ui-egui/src/ui_kit/shortcuts.rs b/crates/ui-egui/src/ui_kit/shortcuts.rs index e4b27f96ba..5d23e0fa2f 100644 --- a/crates/ui-egui/src/ui_kit/shortcuts.rs +++ b/crates/ui-egui/src/ui_kit/shortcuts.rs @@ -36,7 +36,7 @@ pub(crate) fn search_actions_row( let filter_width = (width.max(minimum) - action_width).max(0.0); let response = ui .allocate_ui_with_layout(vec2(filter_width, t.control_height), egui::Layout::left_to_right(Align::Center), |ui| { - search_with_icon(ui, id, value, hint) + search_with_icon(ui, id, value, hint, false) }) .inner; let mut action = None; @@ -59,9 +59,14 @@ pub(crate) fn search_actions_row( } /// Native text editing with the same token frame and leading magnifier as command search. -fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -> Response { +pub(crate) fn preferences_search_field(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -> Response { + search_with_icon(ui, id, value, hint, true) +} + +fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str, titlebar: bool) -> Response { let t = Tokens::get(); - let (r, frame) = ui.allocate_exact_size(vec2(super::metrics::dimension(ui.available_width()), t.control_height), Sense::click()); + let height = if titlebar { t.form_close_size } else { t.control_height }; + let (r, frame) = ui.allocate_exact_size(vec2(super::metrics::dimension(ui.available_width()), height), Sense::click()); let focused = ui.memory(|m| m.has_focus(id)); ui.painter().rect( r, @@ -70,10 +75,20 @@ fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) - if focused { Stroke::new(1.0, t.form_primary) } else { t.control_border_stroke(t.control_border) }, egui::StrokeKind::Inside, ); - let icon = Rect::from_center_size(pos2(r.left() + t.space_8 + t.space_16 * 0.5, r.center().y), vec2(t.space_16, t.space_16)); + let side = if titlebar { t.space_14 } else { t.space_16 }; + let icon = Rect::from_center_size(pos2(r.left() + t.space_8 + side * 0.5, r.center().y), vec2(side, side)); icons::paint_ink(ui.painter(), icon, "zoom", t.form_dim); let show_clear = !value.is_empty(); - let clear_side = if show_clear { t.control_height } else { 0.0 }; + let clear_side = if show_clear { + height + } else if titlebar { + super::CommandSearch::key(ui, r.right_center() - vec2(t.space_6, 0.0), "Ctrl F") + t.space_12 + } else { + 0.0 + }; + if titlebar { + crate::scenario::publish_rect("prefs:search:frame", r); + } let editor = Rect::from_min_max(pos2((icon.right() + t.space_6).min(r.right()), r.top()), pos2((r.right() - clear_side).max(icon.right()), r.bottom())); let response = ui @@ -96,7 +111,7 @@ fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) - .inner; let mut cleared = false; if show_clear { - let clear_rect = Rect::from_center_size(pos2(r.right() - t.control_height * 0.5, r.center().y), vec2(t.control_height, t.control_height)); + let clear_rect = Rect::from_center_size(pos2(r.right() - height * 0.5, r.center().y), vec2(height, height)); let clear = ui.push_id(id.with("clear"), |ui| super::quiet_icon_button_at(ui, clear_rect, "close", "Clear")).inner; if id == egui::Id::new("prefs_search") { crate::scenario::publish_handle("prefs:search:clear", clear_rect.center()); diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index 9f27e25c8b..f9c40eacbe 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -1162,30 +1162,6 @@ enum State { pub fn prefs_rows(app: &mut SolveApp, ui: &mut egui::Ui) { let can = available(app); let kind = app.updater.kind().clone(); - // Where nothing can check (the browser app, development builds, the Flatpak), the box and - // Check now would do nothing: the note below says how this copy updates instead. - if can { - ui_kit::checkbox(ui, crate::i18n::tr("update.check_automatically"), &mut app.preferences.check_updates); - ui.horizontal_wrapped(|ui| { - let busy = app.updater.status == Status::Checking; - let (status, warning) = match (&app.updater.status, offered_or_newer(app)) { - (Status::Checking, _) => (crate::i18n::tr("update.checking").to_string(), false), - (Status::Failed(e), _) => (crate::i18n::format("update.failed", &[("error", e)]), true), - (_, Some(v)) => (crate::i18n::format("update.available", &[("version", &v)]), false), - (Status::UpToDate, None) => (crate::i18n::tr("update.up_to_date").to_string(), false), - (Status::Idle, None) => (String::new(), false), - }; - if !status.is_empty() { - ui_kit::settings_badge(ui, &status, warning); - } - let r = ui_kit::button(ui, crate::i18n::tr("update.check_now"), ui_kit::Button::Enabled(!busy)); - crate::scenario::publish_rect("prefs:check_now", r.rect); - if r.clicked() { - let ctx = ui.ctx().clone(); - check_now(app, &ctx); - } - }); - } let note = if kind == InstallKind::Web { crate::i18n::tr("update.prefs_web").to_string() } else if can && let Some(manager) = app.updater.managed_by().map(str::to_string) { @@ -1197,7 +1173,41 @@ pub fn prefs_rows(app: &mut SolveApp, ui: &mut egui::Ui) { } else { crate::i18n::format("update.prefs_dev", &[("version", current_version())]) }; - ui_kit::text(ui, ¬e, ui_kit::Text::Hint("updates")); + ui.horizontal_wrapped(|ui| { + if can { + let (status, warning) = match (&app.updater.status, offered_or_newer(app)) { + (Status::Checking, _) => (crate::i18n::tr("update.checking").to_string(), false), + (Status::Failed(e), _) => (crate::i18n::format("update.failed", &[("error", e)]), true), + (_, Some(v)) => (crate::i18n::format("update.available", &[("version", &v)]), false), + (Status::UpToDate, None) => (crate::i18n::tr("update.up_to_date").to_string(), false), + (Status::Idle, None) => (String::new(), false), + }; + if !status.is_empty() { + ui_kit::settings_badge(ui, &status, warning); + } + let last = app.preferences.update.last_check; + if last.is_finite() && last > 0.0 { + let time = solidcraft_engine::crash::utc(last as u64); + let checked = crate::i18n::format("prefs.general.last_check", &[("time", &time)]); + ui_kit::settings_inline_note(ui, &checked); + } + } else { + ui_kit::settings_inline_note(ui, crate::i18n::tr("update.check_unavailable")); + } + let busy = app.updater.status == Status::Checking; + let r = ui_kit::button(ui, crate::i18n::tr("update.check_now"), ui_kit::Button::Enabled(can && !busy)); + crate::scenario::publish_target("prefs:check_now", r.rect); + if can && r.clicked() { + let ctx = ui.ctx().clone(); + check_now(app, &ctx); + } + let help = ui_kit::help_icon(ui, ¬e); + crate::scenario::publish_target("prefs:updates_help", help.rect); + }); + ui.add_enabled_ui(can, |ui| { + let check = ui_kit::checkbox(ui, crate::i18n::tr("update.check_automatically"), &mut app.preferences.check_updates); + crate::scenario::publish_target("prefs:check_updates", check.rect); + }); if let Some(v) = offered_or_newer(app) && offered(app).is_none() && ui_kit::button(ui, crate::i18n::format("update.show", &[("version", &v)]), ui_kit::Button::Normal).clicked() diff --git a/crates/ui-egui/src/update/tests.rs b/crates/ui-egui/src/update/tests.rs index 0f2606a465..23647d0c98 100644 --- a/crates/ui-egui/src/update/tests.rs +++ b/crates/ui-egui/src/update/tests.rs @@ -674,20 +674,103 @@ fn the_version_links_to_its_release_notes() { } #[test] -fn web_preferences_explain_reload_without_update_controls() { - let mut h = crate::scenario::Harness::new(); - h.app.updater.kind = Some(InstallKind::Web); - let mut texts = Vec::new(); - for _ in 0..2 { - let mut output = h.ctx.run_ui(egui::RawInput::default(), |ui| prefs_rows(&mut h.app, ui)); - output.textures_delta.clear(); - texts = - output.shapes.iter().filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t.galley.text().to_string()) } else { None }).collect(); +fn unavailable_preferences_show_disabled_update_controls_and_real_values() { + use egui::accesskit::{Role, Toggled}; + let _language = crate::i18n::scope("en"); + for kind in [InstallKind::Dev, InstallKind::Web, InstallKind::Flatpak("io.solidcraft.SolidCraft".into())] { + for checked in [false, true] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.language = "en".into(); + h.app.preferences.check_updates = checked; + h.app.updater.kind = Some(kind.clone()); + h.app.updater.feed = None; + assert!(!available(&mut h.app)); + let memo = h.app.preferences.update.clone(); + let launch_checked = h.app.updater.launch_checked; + let last_manual = h.app.updater.last_manual; + h.call("ui.prefs", json!({"open":"General"})); + crate::prefs::reveal_preference(&mut h, "prefs:check_updates"); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + let button = crate::scenario::rect_of("prefs:check_now").unwrap(); + assert!(body.contains_rect(button)); + assert!(body.contains_rect(crate::scenario::rect_of("prefs:check_updates").unwrap())); + let tree = h.output.accesskit_update.as_ref().unwrap(); + for (label, role) in [("Check now", Role::Button), ("Check at start and once a day", Role::CheckBox)] { + let controls: Vec<_> = tree.nodes.iter().filter(|(_, node)| node.role() == role && node.label() == Some(label)).collect(); + assert_eq!(controls.len(), 1, "{kind:?}: one {label} control"); + assert!(controls[0].1.is_disabled(), "{kind:?}: {label} is disabled"); + if role == Role::CheckBox { + assert_eq!(controls[0].1.toggled(), Some(if checked { Toggled::True } else { Toggled::False })); + } + } + let reason = crate::i18n::tr("update.check_unavailable"); + assert_eq!(tree.nodes.iter().filter(|(_, node)| node.role() == Role::Label && node.value() == Some(reason)).count(), 1); + let texts: Vec<_> = h + .shapes + .iter() + .filter_map(|shape| if let egui::Shape::Text(text) = &shape.shape { Some(text.galley.text()) } else { None }) + .collect(); + assert!(texts.contains(&reason), "{kind:?}: the unavailable reason is painted"); + assert!(h.widget("Up to date").is_none(), "unavailability is not a successful check"); + let note = match &kind { + InstallKind::Web => crate::i18n::tr("update.prefs_web").to_string(), + InstallKind::Flatpak(id) => { + crate::i18n::format("update.prefs_flatpak", &[("version", current_version()), ("command", &format!("flatpak update {id}"))]) + } + _ => crate::i18n::format("update.prefs_dev", &[("version", current_version())]), + }; + assert!( + tree.nodes.iter().any(|(_, node)| node.role() == Role::Label && node.value() == Some(note.as_str())), + "the platform help stays accessible" + ); + assert!(!texts.contains(¬e.as_str()), "the longer explanation stays in hover help"); + assert!(crate::scenario::rect_of("prefs:updates_help").is_some()); + h.step(&json!({"click":{"handle":"prefs:check_now"}})).unwrap(); + h.step(&json!({"widget":"Check at start and once a day"})).unwrap(); + assert_eq!(h.app.preferences.check_updates, checked); + assert_eq!(h.app.preferences.update, memo); + assert_eq!(h.app.updater.status, Status::Idle); + assert_eq!(h.app.updater.launch_checked, launch_checked); + assert_eq!(h.app.updater.last_manual, last_manual); + assert!(h.app.updater.rx.is_none() && h.app.updater.job.is_none() && h.app.updater.release.is_none()); + assert!(!h.app.updater.open, "attempting the disabled action does not open the checker"); + } } - assert!(!texts.iter().any(|t| t.contains("development build")), "{texts:?}"); - assert!(!texts.iter().any(|t| t == crate::i18n::tr("update.check_automatically")), "{texts:?}"); - assert!(!texts.iter().any(|t| t == crate::i18n::tr("update.check_now")), "{texts:?}"); - assert!(texts.iter().any(|t| t == crate::i18n::tr("update.prefs_web")), "{texts:?}"); +} + +#[test] +fn general_updates_show_real_status_and_time_and_check_now_uses_the_existing_feed() { + let dir = scratch(); + let (feed, version) = next_release_feed(&dir.0); + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.language = "en".into(); + h.app.preferences.check_updates = false; + h.app.preferences.update.last_check = 1_700_000_000.0; + h.app.updater.feed = Some(feed); + h.app.updater.kind = Some(InstallKind::WindowsMsi); + h.app.updater.managed = Some(None); + h.app.updater.status = Status::UpToDate; + h.call("ui.prefs", json!({"open":"General"})); + crate::prefs::reveal_preference(&mut h, "prefs:check_now"); + let tree = h.output.accesskit_update.as_ref().unwrap(); + for (label, role) in [("Check now", egui::accesskit::Role::Button), ("Check at start and once a day", egui::accesskit::Role::CheckBox)] { + let controls: Vec<_> = tree.nodes.iter().filter(|(_, node)| node.role() == role && node.label() == Some(label)).collect(); + assert_eq!(controls.len(), 1); + assert!(!controls[0].1.is_disabled(), "available update controls stay enabled"); + if role == egui::accesskit::Role::CheckBox { + assert_eq!(controls[0].1.toggled(), Some(egui::accesskit::Toggled::False)); + } + } + let checked = crate::i18n::format("prefs.general.last_check", &[("time", &solidcraft_engine::crash::utc(1_700_000_000))]); + assert!(h.widget(&checked).is_some(), "the persisted check time is shown"); + let button = crate::scenario::rect_of("prefs:check_now").unwrap(); + h.step(&json!({"click":[button.center().x, button.center().y]})).unwrap(); + wait_for_check(&mut h); + assert_eq!(h.app.updater.release.as_ref().map(|r| r.version().to_string()), Some(version)); + assert!(h.app.preferences.update.last_check > 1_700_000_000.0); + assert!(!h.app.preferences.check_updates, "a manual check keeps the schedule preference"); } fn no_env(_: &str) -> Option { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 12fd046c54..301242ae8a 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -11764,6 +11764,7 @@ mod tests { fn colour_vision_mode_reaches_the_frame_and_the_view_caches() { use crate::theme::ColorVision; let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; let keys = |app: &SolveApp| (scene_key(app), highlight_key(app)); h.call("ui.prefs", json!({"open": "Interface"})); diff --git a/crates/ui-egui/src/viewport_appearance_tests.rs b/crates/ui-egui/src/viewport_appearance_tests.rs index bcf840eb5b..f572f3bb3c 100644 --- a/crates/ui-egui/src/viewport_appearance_tests.rs +++ b/crates/ui-egui/src/viewport_appearance_tests.rs @@ -404,6 +404,7 @@ fn cancelling_theme_preview_restores_viewport_palette_keys() { #[test] fn inspect_menu_toggles_component_colors_without_a_dialog_and_refreshes_scene() { let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.frames(3); let command = solidcraft_engine::find_command("inspect.colored_components").unwrap(); let info = command.info(&h.app.session); diff --git a/crates/ui-egui/tests/scenarios/colour_vision_setting.json b/crates/ui-egui/tests/scenarios/colour_vision_setting.json index dab7d3ef57..b7c263fb2c 100644 --- a/crates/ui-egui/tests/scenarios/colour_vision_setting.json +++ b/crates/ui-egui/tests/scenarios/colour_vision_setting.json @@ -1,8 +1,9 @@ [ - {"note": "Colour vision lives on Interface: choices apply immediately; Cancel restores them, OK keeps them, and Interface defaults reset them. Page scrolling preserves the fixed window width."}, + {"note": "Colour vision lives on Appearance: choices apply immediately; Cancel restores them, OK keeps them, and Appearance defaults reset them. Use English for exact captions. Page scrolling preserves the fixed window width."}, + {"call": "ui.prefs", "params": {"language": "en"}}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"prefs": {"dialog": "General", "color_vision": "off"}, "same_width": "prefs:window"}}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"call": "ui.scroll", "params": {"x": 1000, "y": 500, "delta": -5000}}, {"call": "ui.inspect", "params": {}}, {"expect": {"shown": "Color vision", "prefs": {"dialog": "Appearance"}, "same_width": "prefs:window"}}, @@ -11,18 +12,18 @@ {"widget": "Cancel"}, {"expect": {"prefs": {"dialog": null, "color_vision": "off"}}}, {"key": "Comma", "cmd": true}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"call": "ui.scroll", "params": {"x": 1000, "y": 500, "delta": -5000}}, {"call": "ui.inspect", "params": {}}, {"widget": "Tritan"}, {"widget": "OK"}, {"expect": {"prefs": {"dialog": null, "color_vision": "tritan"}}}, {"key": "Comma", "cmd": true}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"call": "ui.scroll", "params": {"x": 1000, "y": 500, "delta": -5000}}, {"call": "ui.inspect", "params": {}}, {"expect": {"shown": "Tritan", "same_width": "prefs:window"}}, - {"widget": "Restore Defaults"}, + {"widget": "Restore page defaults"}, {"expect": {"prefs": {"color_vision": "off"}}}, {"widget": "OK"}, {"expect": {"prefs": {"dialog": null, "color_vision": "off"}}} diff --git a/crates/ui-egui/tests/scenarios/preferences_search.json b/crates/ui-egui/tests/scenarios/preferences_search.json index 5c653fd3ff..c26c57e6fc 100644 --- a/crates/ui-egui/tests/scenarios/preferences_search.json +++ b/crates/ui-egui/tests/scenarios/preferences_search.json @@ -1,7 +1,7 @@ [ {"note": "With Preferences open and nothing being edited, typing focuses Search preferences and the letters are the query. Enter opens that setting on its own page."}, {"click": {"handle": "titlebar:preferences"}}, - {"expect": {"shown": "Restore Defaults", "prefs": {"dialog": "General"}}}, + {"expect": {"shown": "Restore page defaults", "prefs": {"dialog": "General"}}}, {"text": "Reverse zoom direction"}, {"frames": 3}, {"expect": {"shown": "Reverse zoom direction", "not_shown": "Units of new designs"}}, @@ -16,5 +16,5 @@ {"frames": 2}, {"expect": {"shown": "Direct interaction", "prefs": {"dialog": "Tools"}}}, {"key": "Escape"}, - {"expect": {"not_shown": "Restore Defaults", "prefs": {"dialog": null}}} + {"expect": {"not_shown": "Restore page defaults", "prefs": {"dialog": null}}} ] diff --git a/crates/ui-egui/tests/scenarios/prefs_theme_files.json b/crates/ui-egui/tests/scenarios/prefs_theme_files.json index f9e4d5a571..76a5b1ae6b 100644 --- a/crates/ui-egui/tests/scenarios/prefs_theme_files.json +++ b/crates/ui-egui/tests/scenarios/prefs_theme_files.json @@ -2,7 +2,7 @@ {"note": "#2255: theme tiles offer the built-in themes; choosing one applies it immediately, and Cancel restores the previous theme."}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"prefs": {"dialog": "General", "theme": "dark", "dark": true}}}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"click": {"handle": "prefs:theme:light"}}, {"expect": {"prefs": {"theme": "light", "dark": false}, "shown_handles": ["prefs:theme"]}}, {"click": {"handle": "prefs:theme:gruvbox-dark"}}, diff --git a/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json b/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json index ab63a0ab3c..1e5ee20ce2 100644 --- a/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json +++ b/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json @@ -1,7 +1,7 @@ [ {"note": "#4146: under the theme tiles, Viewport draws the 3D view in the Light or Dark theme's colours whatever the window uses; it applies at once, and Cancel puts it back."}, {"click": {"handle": "titlebar:preferences"}}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"expect": {"prefs": {"theme": "dark", "dark": true, "viewport_theme": "theme", "viewport_dark": true}}}, {"widget": "Light", "near": "Viewport"}, {"expect": {"prefs": {"theme": "dark", "dark": true, "viewport_theme": "light", "viewport_dark": false}}}, diff --git a/crates/ui-egui/tests/scenarios/s44_about_licences.json b/crates/ui-egui/tests/scenarios/s44_about_licences.json index 72d338fb3c..8b005b928e 100644 --- a/crates/ui-egui/tests/scenarios/s44_about_licences.json +++ b/crates/ui-egui/tests/scenarios/s44_about_licences.json @@ -1,5 +1,6 @@ [ {"note": "Help ▸ About ▸ Licenses shows SolidCraft's licenses and every third-party crate's, built into the program."}, + {"call": "ui.prefs", "params": {"language": "en"}}, {"call": "ui.help", "params": {"item": "about"}}, {"widget": "Licenses"}, {"expect": {"shown": "Third-party licenses"}}, diff --git a/crates/ui-egui/tests/scenarios/s45_preferences.json b/crates/ui-egui/tests/scenarios/s45_preferences.json index 9dc110b346..c6054c863e 100644 --- a/crates/ui-egui/tests/scenarios/s45_preferences.json +++ b/crates/ui-egui/tests/scenarios/s45_preferences.json @@ -1,25 +1,28 @@ [ {"note": "#36: one Preferences dialog, from the gear at the top right (and Ctrl+,). Changes apply at once; Cancel puts them back, OK keeps them."}, {"click": {"handle": "titlebar:preferences"}}, - {"expect": {"shown": "Restore Defaults", "prefs": {"dialog": "General", "theme": "dark", "dark": true}}}, - {"widget": "Appearance"}, + {"expect": {"shown": "Restore page defaults", "prefs": {"dialog": "General", "theme": "dark", "dark": true}}}, + {"expect": {"shown": "Profiles"}}, + {"widget": "Reopen the designs that were open last time"}, + {"expect": {"prefs": {"reopen_designs": false}}}, + {"click": {"handle": "prefs:page:Appearance"}}, {"click": {"handle": "prefs:theme"}}, {"click": {"handle": "prefs:theme:light"}}, {"expect": {"prefs": {"theme": "light", "dark": false}}}, {"widget": "Cancel"}, - {"expect": {"not_shown": "Restore Defaults", "prefs": {"dialog": null, "theme": "dark", "dark": true}}}, + {"expect": {"not_shown": "Restore page defaults", "prefs": {"dialog": null, "theme": "dark", "dark": true, "reopen_designs": true}}}, {"note": "Ctrl+, opens it again; the shortcuts page is in the same dialog."}, {"key": "Comma", "cmd": true}, {"expect": {"prefs": {"dialog": "General"}}}, - {"widget": "Shortcuts"}, + {"click": {"handle": "prefs:page:Shortcuts"}}, {"expect": {"shown": "Reset All", "prefs": {"dialog": "Shortcuts"}}}, - {"widget": "Navigation"}, + {"click": {"handle": "prefs:page:Navigation"}}, {"widget": "Fusion"}, {"widget": "Tinkercad"}, {"expect": {"prefs": {"nav": "tinkercad"}}}, - {"widget": "Design"}, + {"click": {"handle": "prefs:page:Design"}}, {"widget": "Y up"}, {"expect": {"prefs": {"up_axis": "y", "dialog": "Design"}}}, {"widget": "OK"}, - {"expect": {"not_shown": "Restore Defaults", "prefs": {"up_axis": "y", "dialog": null}}} + {"expect": {"not_shown": "Restore page defaults", "prefs": {"up_axis": "y", "dialog": null}}} ] diff --git a/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json b/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json index a6ca40c8db..babd22c115 100644 --- a/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json +++ b/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json @@ -2,7 +2,7 @@ {"note": "#290: Preferences keeps one size on every page; descriptions wrap inside it, and the kit footer keeps Cancel / Apply / OK flush right inside its action inset."}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"prefs": {"dialog": "General"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}, "spans": {"inner": "prefs:footer", "outer": "prefs:window", "axis": "x", "tol": 12}}}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"expect": {"prefs": {"dialog": "Appearance"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, {"widget": "Layout"}, {"expect": {"prefs": {"dialog": "Layout"}, "same_width": "prefs:window"}}, diff --git a/docs/design/preferences/pages/general.md b/docs/design/preferences/pages/general.md index 64b8243652..3f0cbe8e12 100644 --- a/docs/design/preferences/pages/general.md +++ b/docs/design/preferences/pages/general.md @@ -7,6 +7,16 @@ not in a profile). Not shown on the board: everything in the Page title and tag: "General", "In profile" (hover "Saved in settings profiles"). +Layout clarification from the supplied HTML version of the same design: saved profiles are +42 px rounded, bordered cards with 8 px gaps and a selected fill/accent border on the active +card. Export, Rename and Delete are quiet compact icon actions. The save row starts with the +220 px independent name field (placeholder "Name this setup"), then primary Save as new and +Import. Held and This computer use aligned 84 px label columns: Held pills have accent outlines +and page icons; machine pills and removable host pills have neutral outlines, with lock icons +on machine pills. Section heads have no pictograms. Autosave's 260 px timeline sits under its +control column, not across the page. Search sits in the window title bar; the shared footer +retains Apply alongside Cancel and OK for the existing transaction behavior. + ## Profiles (3 saved) Source: today's General > Profiles section (`crates/ui-egui/src/settings_profiles.rs`, #6237). Section @@ -16,7 +26,7 @@ title "Profiles", with the count "3 saved" at the right of the title. |---|---|---|---|---|---|---| | Profile rows (Laptop, Large monitor, Presentation) | List | `app.settings_profiles` (saved list) | none saved | Icon buttons: "Export", "Rename", "Delete" | none (17 px profile icon) | The active row shows badges "Edited" (when changed since saved) and "Active", and no Apply. Other rows show "Apply". Names are sample. | | Apply | Small button per row | settings profile apply | n/a | none | none | Applying replaces what a profile holds (see README). | -| Profile name | Text field (220 wide) | the name for "Save as new" | empty | none | none | aria-label "Profile name". | +| Profile name | Text field (220 wide) | the name for "Save as new" | empty | none | none | Placeholder "Name this setup"; aria-label "Profile name"; no separate label column. | | Save as new | Button | settings profile save | n/a | none | none | | | Import... | Button | settings profile import | n/a | none | none | | | Held / This computer chips | Two chip rows | none (information) | n/a | none | none | "Held": Appearance, Layout, Navigation, Sketch, Shortcuts, Language. "This computer": 3D mouse, Graphics, Assistant. Matches `settings_profiles::PAGES` plus language, shortcuts, toolbar, S-box favourites and the imported theme; mismatch: the board lists page names, the code lists the Interface, Navigation and Sketch pages (`settings_profiles::PAGES`), so after the page split map Appearance and Layout to the old Interface fields. | @@ -25,7 +35,7 @@ title "Profiles", with the count "3 saved" at the right of the title. | Label | Control | Edits | Default | Hover | Figure | Notes | |---|---|---|---|---|---|---| -| Interface language | Dropdown, 280 wide ("English (United Kingdom)") | `Prefs.language` | `"en"` | none | none | The dropdown lists `i18n::LOCALES`. Page: 0 (General). | +| Interface language | Dropdown, 280 wide ("English (United Kingdom)") | `Prefs.language` | `"system"` (System) | none | none | The dropdown includes System and `i18n::LOCALES`; the effective language follows the operating system when System is selected. Page: 0 (General). | ## Start and save @@ -38,8 +48,7 @@ title "Profiles", with the count "3 saved" at the right of the title. | Label | Control | Edits | Default | Hover | Figure | Notes | |---|---|---|---|---|---|---| -| Largest file to open: STEP and import | Number field, "MB" (72 wide) | `Prefs.max_step_file_mb` | `DEFAULT_STEP_MB` | none | none | Moves here from Design/General. | -| Largest file to open: Design | Number field, "MB" | `Prefs.max_design_file_mb` | `DEFAULT_DESIGN_MB` | none | none | | +| Largest file to open | Two side-by-side 72 px number fields, each with MB | `Prefs.max_step_file_mb`, `Prefs.max_design_file_mb` | `DEFAULT_STEP_MB`, `DEFAULT_DESIGN_MB` | existing file-size guidance | none | One group label; short captions "STEP and import" and "Design" above the controls. Wrap on narrow pages. Each editor retains its full independent accessible name and search result. | | Allow network locations in designs | Checkbox | `Prefs.network_locations` | `false` | "For organisations that keep designs on file servers. Off: only servers you allow." | none | Moves from Design (`PAGE_DESIGN`). | | `\\files.studio`, `\\nas01` | Chip list with a remove icon (11 px) | `Prefs.network_hosts` | empty | none | none | Shown only when network locations is on. Names are sample. | | Open files in a slicer that is already running | Checkbox | `Prefs.slicer_single_instance` | `true` | "For slicers that load a file into a running window." | none | |