Adding files previously missed, also adding theme selector
@@ -0,0 +1,5 @@
|
||||
edition = "2021"
|
||||
max_width = 100
|
||||
use_small_heuristics = "Default"
|
||||
imports_granularity = "Crate"
|
||||
group_imports = "StdExternalCrate"
|
||||
@@ -587,6 +587,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sqlparser",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"toml",
|
||||
@@ -613,6 +614,8 @@ dependencies = [
|
||||
"pulldown-cmark",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syntect",
|
||||
"two-face",
|
||||
"winit",
|
||||
@@ -3751,6 +3754,15 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparser"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05a528114c392209b3264855ad491fcce534b94a38771b0a0b97a79379275ce8"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -6,13 +6,15 @@ ByteDraft is a specialized, performance-oriented editor designed for viewing, ed
|
||||
|
||||
- **Multi-Document Interface**: Tabbed editing with a clean, modern dark UI.
|
||||
- **Smart Formatting**: Built-in support for multiple languages with one-click formatting (`Ctrl+Alt+L`).
|
||||
- **Syntax Highlighting**: Full token-level highlighting for Rust, Python, JS/TS, Go, SQL, JSON, YAML, TOML, XML, Markdown, Shell, C/C++, and Java.
|
||||
- **Theme System**: Five built-in colour presets (Slate Dark, Ocean Dark, Mocha Dark, Solarized Dark, Solarized Light) with per-colour customisation. Open **Preferences** (`Ctrl+,` / `⌘,`) to switch themes or fine-tune individual colours. Settings persist across sessions.
|
||||
- **Workspace Explorer**: Integrated folder tree for navigating project structures.
|
||||
- **Live Diagnostics**: Real-time linting and error reporting for supported file types.
|
||||
- **Advanced Find/Replace**: Support for Regular Expressions and Case Sensitivity.
|
||||
- **Native Reliability**:
|
||||
- **Windows**: Background-threaded file dialogs to prevent UI freezing.
|
||||
- **macOS/Linux**: Native system integration for a consistent platform feel.
|
||||
- **Session Persistence**: Automatically remembers your open tabs and workspace state between launches.
|
||||
- **Session Persistence**: Automatically remembers your open tabs, workspace state, and active theme between launches.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -45,6 +47,7 @@ cargo run -p byte_draft_desktop --release
|
||||
| **Format Document** | `Ctrl + Alt + L` |
|
||||
| **Find/Replace** | `Ctrl + F` |
|
||||
| **Toggle Sidebar** | `Ctrl + B` |
|
||||
| **Preferences** | `Ctrl + ,` |
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
msrv = "1.84"
|
||||
@@ -14,6 +14,7 @@ serde_yaml = "0.9"
|
||||
thiserror = "1"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
sqlparser = "0.53"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Text encodings for load/save. Extend [`TextEncoding::ALL`] when adding new presets.
|
||||
|
||||
use std::io;
|
||||
|
||||
use encoding_rs::{Encoding, UTF_16BE, UTF_16LE, WINDOWS_1252};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Declared encoding for a tab (read/write on disk). Add variants here and list them in [`Self::ALL`].
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TextEncoding {
|
||||
#[default]
|
||||
Utf8,
|
||||
Utf16,
|
||||
Ascii,
|
||||
Iso8859_1,
|
||||
Windows1252,
|
||||
}
|
||||
|
||||
impl TextEncoding {
|
||||
/// Ordered list for UI dropdowns; append new encodings here.
|
||||
pub const ALL: &'static [TextEncoding] = &[
|
||||
TextEncoding::Utf8,
|
||||
TextEncoding::Utf16,
|
||||
TextEncoding::Ascii,
|
||||
TextEncoding::Iso8859_1,
|
||||
TextEncoding::Windows1252,
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
pub const fn status_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Utf8 => "UTF-8",
|
||||
Self::Utf16 => "UTF-16",
|
||||
Self::Ascii => "ASCII",
|
||||
Self::Iso8859_1 => "ISO-8859-1",
|
||||
Self::Windows1252 => "Windows-1252",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_utf8_bom(bytes: &[u8]) -> &[u8] {
|
||||
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||||
&bytes[3..]
|
||||
} else {
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_utf16(bytes: &[u8]) -> io::Result<String> {
|
||||
if bytes.len() % 2 == 1 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"UTF-16 byte length must be even",
|
||||
));
|
||||
}
|
||||
let (enc, data): (&'static Encoding, &[u8]) = if bytes.starts_with(&[0xFF, 0xFE]) {
|
||||
(UTF_16LE, &bytes[2..])
|
||||
} else if bytes.starts_with(&[0xFE, 0xFF]) {
|
||||
(UTF_16BE, &bytes[2..])
|
||||
} else {
|
||||
(UTF_16LE, bytes)
|
||||
};
|
||||
enc.decode_without_bom_handling_and_without_replacement(data)
|
||||
.map(|c| c.into_owned())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid UTF-16"))
|
||||
}
|
||||
|
||||
fn decode_ascii(bytes: &[u8]) -> io::Result<String> {
|
||||
if bytes.iter().any(|&b| b > 127) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"file contains non-ASCII bytes",
|
||||
));
|
||||
}
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "internal: non-UTF-8 after ASCII check")
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode raw file bytes to a Unicode string (strict; no replacement characters).
|
||||
pub fn decode_bytes(bytes: &[u8], enc: TextEncoding) -> io::Result<String> {
|
||||
match enc {
|
||||
TextEncoding::Utf8 => {
|
||||
let b = strip_utf8_bom(bytes);
|
||||
String::from_utf8(b.to_vec()).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("file is not valid UTF-8: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
TextEncoding::Utf16 => decode_utf16(bytes),
|
||||
TextEncoding::Ascii => decode_ascii(bytes),
|
||||
TextEncoding::Iso8859_1 => Ok(encoding_rs::mem::decode_latin1(bytes).into_owned()),
|
||||
TextEncoding::Windows1252 => WINDOWS_1252
|
||||
.decode_without_bom_handling_and_without_replacement(bytes)
|
||||
.map(|c| c.into_owned())
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"file is not valid Windows-1252",
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_iso8859_1_strict(s: &str) -> io::Result<Vec<u8>> {
|
||||
let mut v = Vec::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
let cp = u32::from(c);
|
||||
if cp > 255 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"character not representable in ISO-8859-1",
|
||||
));
|
||||
}
|
||||
v.push(cp as u8);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn encode_ascii_strict(s: &str) -> io::Result<Vec<u8>> {
|
||||
let mut v = Vec::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
let cp = u32::from(c);
|
||||
if cp > 127 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"character not representable in ASCII",
|
||||
));
|
||||
}
|
||||
v.push(cp as u8);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn encode_utf16_le_bom(s: &str) -> io::Result<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(2 + s.encode_utf16().count() * 2);
|
||||
out.extend_from_slice(&[0xFF, 0xFE]);
|
||||
for unit in s.encode_utf16() {
|
||||
out.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn encode_windows_1252_strict(s: &str) -> io::Result<Vec<u8>> {
|
||||
use encoding_rs::EncoderResult;
|
||||
|
||||
let mut encoder = WINDOWS_1252.new_encoder();
|
||||
let mut out = Vec::with_capacity(s.len());
|
||||
let (result, _) = encoder.encode_from_utf8_to_vec_without_replacement(s, &mut out, true);
|
||||
match result {
|
||||
EncoderResult::InputEmpty => Ok(out),
|
||||
EncoderResult::Unmappable(_) => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"character not representable in Windows-1252",
|
||||
)),
|
||||
EncoderResult::OutputFull => Err(io::Error::other(
|
||||
"Windows-1252 encoder output buffer full",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a Unicode string to on-disk bytes for the given encoding.
|
||||
pub fn encode_string(s: &str, enc: TextEncoding) -> io::Result<Vec<u8>> {
|
||||
match enc {
|
||||
TextEncoding::Utf8 => Ok(s.as_bytes().to_vec()),
|
||||
TextEncoding::Utf16 => encode_utf16_le_bom(s),
|
||||
TextEncoding::Ascii => encode_ascii_strict(s),
|
||||
TextEncoding::Iso8859_1 => encode_iso8859_1_strict(s),
|
||||
TextEncoding::Windows1252 => encode_windows_1252_strict(s),
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ pub fn format_document(language: LanguageId, text: &str) -> Result<String, Vec<D
|
||||
| LanguageId::C
|
||||
| LanguageId::Cpp
|
||||
| LanguageId::Java
|
||||
| LanguageId::Sql
|
||||
| LanguageId::Unknown => Err(vec![Diagnostic::new(NO_FORMATTER_PLAIN_MSG)]),
|
||||
}
|
||||
}
|
||||
@@ -195,6 +196,116 @@ fn format_xml(text: &str) -> Result<String, Vec<Diagnostic>> {
|
||||
pub fn lint_document(language: LanguageId, text: &str) -> Vec<Diagnostic> {
|
||||
match language {
|
||||
LanguageId::Plain | LanguageId::Unknown | LanguageId::Markdown => Vec::new(),
|
||||
LanguageId::Sql => lint_sql(text),
|
||||
_ => format_document(language, text).err().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse SQL and return any syntax errors as diagnostics.
|
||||
/// Uses `sqlparser` with the generic dialect so it accepts the broadest set of SQL variants.
|
||||
fn lint_sql(text: &str) -> Vec<Diagnostic> {
|
||||
use sqlparser::dialect::GenericDialect;
|
||||
use sqlparser::parser::Parser;
|
||||
|
||||
if text.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
match Parser::parse_sql(&GenericDialect {}, text) {
|
||||
Ok(_) => Vec::new(),
|
||||
Err(e) => {
|
||||
// sqlparser embeds location in the message as " at Line: N, Column: M".
|
||||
// Parse it out so the status bar can display a clean "N:M: message".
|
||||
let full = e.to_string();
|
||||
let (clean_msg, line, col) = extract_sql_location(&full);
|
||||
let mut diag = Diagnostic::new(clean_msg);
|
||||
if let (Some(l), Some(c)) = (line, col) {
|
||||
diag = diag.with_line_col(l, c);
|
||||
}
|
||||
vec![diag]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a sqlparser error string like `"Expected: …, found: j at Line: 4, Column: 14"`
|
||||
/// into `(message_without_location, line, column)`.
|
||||
fn extract_sql_location(msg: &str) -> (String, Option<u32>, Option<u32>) {
|
||||
// Look for the trailing " at Line: N, Column: M" appended by sqlparser's Location Display.
|
||||
if let Some(at) = msg.rfind(" at Line: ") {
|
||||
let rest = &msg[at + " at Line: ".len()..];
|
||||
if let Some(comma) = rest.find(", Column: ") {
|
||||
let line = rest[..comma].trim().parse::<u32>().ok();
|
||||
let col = rest[comma + ", Column: ".len()..].trim().parse::<u32>().ok();
|
||||
return (msg[..at].to_string(), line, col);
|
||||
}
|
||||
}
|
||||
(msg.to_string(), None, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sql_valid_select_no_errors() {
|
||||
let sql = "SELECT * FROM thing WHERE condition = true AND (condition2 = true) ORDER BY column DESC;";
|
||||
assert!(lint_sql(sql).is_empty(), "valid SQL must produce no diagnostics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_multiline_valid_no_errors() {
|
||||
let sql = "select *\nfrom thing\nwhere condition = true\n and (condition2 = true)\norder by column desc;";
|
||||
assert!(lint_sql(sql).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_stray_token_produces_error() {
|
||||
// The `j` after the closing paren is invalid SQL
|
||||
let sql = "select *\nfrom thing\nwhere condition = true\n and (condition2 = true) j\norder by column desc;";
|
||||
let diags = lint_sql(sql);
|
||||
assert!(!diags.is_empty(), "invalid SQL must produce at least one diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_error_has_line_and_column() {
|
||||
let sql = "select *\nfrom thing\nwhere condition = true\n and (condition2 = true) j\norder by column desc;";
|
||||
let diags = lint_sql(sql);
|
||||
assert!(!diags.is_empty());
|
||||
let d = &diags[0];
|
||||
assert!(d.line.is_some(), "diagnostic should carry a line number");
|
||||
assert!(d.column.is_some(), "diagnostic should carry a column number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_empty_input_no_errors() {
|
||||
assert!(lint_sql("").is_empty());
|
||||
assert!(lint_sql(" \n ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_extract_location_parses_correctly() {
|
||||
let msg = "Expected: end of statement, found: j at Line: 4, Column: 28";
|
||||
let (clean, line, col) = extract_sql_location(msg);
|
||||
assert_eq!(clean, "Expected: end of statement, found: j");
|
||||
assert_eq!(line, Some(4));
|
||||
assert_eq!(col, Some(28));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_extract_location_no_location() {
|
||||
let msg = "Some error without location";
|
||||
let (clean, line, col) = extract_sql_location(msg);
|
||||
assert_eq!(clean, "Some error without location");
|
||||
assert!(line.is_none());
|
||||
assert!(col.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_document_sql_routes_to_sql_lint() {
|
||||
let valid = "SELECT 1;";
|
||||
assert!(lint_document(LanguageId::Sql, valid).is_empty());
|
||||
|
||||
let invalid = "SELECT FROM;";
|
||||
assert!(!lint_document(LanguageId::Sql, invalid).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub enum LanguageId {
|
||||
C,
|
||||
Cpp,
|
||||
Java,
|
||||
Sql,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
@@ -44,6 +45,7 @@ impl LanguageId {
|
||||
LanguageId::C,
|
||||
LanguageId::Cpp,
|
||||
LanguageId::Java,
|
||||
LanguageId::Sql,
|
||||
];
|
||||
|
||||
/// Alias for [`Self::PICKER`] — every concrete language id used in menus and format dispatch.
|
||||
@@ -67,6 +69,7 @@ impl LanguageId {
|
||||
Self::C => "C",
|
||||
Self::Cpp => "C++",
|
||||
Self::Java => "Java",
|
||||
Self::Sql => "SQL",
|
||||
Self::Unknown => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ pub struct SessionData {
|
||||
#[serde(default)]
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
pub tabs: Vec<Tab>,
|
||||
/// Name of the active colour theme preset. `None` = use the app default.
|
||||
#[serde(default)]
|
||||
pub theme_name: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionData {
|
||||
@@ -36,6 +39,7 @@ impl SessionData {
|
||||
split_secondary: None,
|
||||
workspace_root: None,
|
||||
tabs: vec![Tab::new_untitled()],
|
||||
theme_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Canonical user commands for menus, native shortcuts, and future keybinding config.
|
||||
|
||||
/// Actions surfaced in the app menu, native menu bar, and in-process shortcut dispatch.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MenuCommand {
|
||||
NewTab,
|
||||
CloseTab,
|
||||
OpenFile,
|
||||
OpenFileAsHex,
|
||||
OpenFolder,
|
||||
CloseFolder,
|
||||
Save,
|
||||
SaveAs,
|
||||
FormatDocument,
|
||||
ToggleFind,
|
||||
ToggleSidebar,
|
||||
ToggleWordWrap,
|
||||
ToggleShowLineEndings,
|
||||
Preferences,
|
||||
}
|
||||
|
||||
impl MenuCommand {
|
||||
/// Stable id for native menu wiring (`muda::MenuId`).
|
||||
#[must_use]
|
||||
pub const fn id_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NewTab => "byte_draft.menu.new_tab",
|
||||
Self::CloseTab => "byte_draft.menu.close_tab",
|
||||
Self::OpenFile => "byte_draft.menu.open_file",
|
||||
Self::OpenFileAsHex => "byte_draft.menu.open_file_as_hex",
|
||||
Self::OpenFolder => "byte_draft.menu.open_folder",
|
||||
Self::CloseFolder => "byte_draft.menu.close_folder",
|
||||
Self::Save => "byte_draft.menu.save",
|
||||
Self::SaveAs => "byte_draft.menu.save_as",
|
||||
Self::FormatDocument => "byte_draft.menu.format",
|
||||
Self::ToggleFind => "byte_draft.menu.find",
|
||||
Self::ToggleSidebar => "byte_draft.menu.toggle_sidebar",
|
||||
Self::ToggleWordWrap => "byte_draft.menu.toggle_word_wrap",
|
||||
Self::ToggleShowLineEndings => "byte_draft.menu.toggle_show_line_endings",
|
||||
Self::Preferences => "byte_draft.menu.preferences",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MenuCommand;
|
||||
|
||||
#[test]
|
||||
fn command_eq() {
|
||||
assert_eq!(MenuCommand::NewTab, MenuCommand::NewTab);
|
||||
assert_ne!(MenuCommand::NewTab, MenuCommand::CloseTab);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub fn detect_language(content: &str, extension_hint: Option<&str>) -> LanguageI
|
||||
"c" | "h" => return LanguageId::C,
|
||||
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => return LanguageId::Cpp,
|
||||
"java" => return LanguageId::Java,
|
||||
"sql" => return LanguageId::Sql,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +76,12 @@ pub fn detect_from_content(content: &str) -> LanguageId {
|
||||
return LanguageId::Xml;
|
||||
}
|
||||
|
||||
// SQL: check before YAML/TOML because `key = value` patterns in WHERE clauses
|
||||
// would otherwise score as TOML.
|
||||
if looks_like_sql(trimmed) {
|
||||
return LanguageId::Sql;
|
||||
}
|
||||
|
||||
// YAML-like: contains colon-space patterns typical of YAML mappings
|
||||
// but only if it doesn't look like JSON
|
||||
if looks_like_yaml(trimmed) {
|
||||
@@ -124,6 +131,36 @@ fn looks_like_yaml(content: &str) -> bool {
|
||||
yaml_score >= 2
|
||||
}
|
||||
|
||||
/// Heuristic: content looks like SQL (starts with or contains SQL DML/DDL keywords).
|
||||
/// Checked before TOML/YAML because WHERE/SET clauses use `=` which would score as TOML.
|
||||
fn looks_like_sql(content: &str) -> bool {
|
||||
// The very first non-empty token is a strong signal.
|
||||
let first_word = content
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_uppercase();
|
||||
const LEADING_KEYWORDS: &[&str] = &[
|
||||
"SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER",
|
||||
"WITH", "EXPLAIN", "TRUNCATE", "MERGE", "CALL", "EXEC", "EXECUTE",
|
||||
"BEGIN", "COMMIT", "ROLLBACK",
|
||||
];
|
||||
if LEADING_KEYWORDS.contains(&first_word.as_str()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Secondary check: multiple SQL clause keywords across the first 15 lines.
|
||||
let upper = content.to_ascii_uppercase();
|
||||
const CLAUSE_KEYWORDS: &[&str] = &[
|
||||
"SELECT ", "FROM ", "WHERE ", "ORDER BY", "GROUP BY", "HAVING ",
|
||||
"JOIN ", "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "ON ",
|
||||
"INSERT INTO", "UPDATE ", "SET ", "DELETE FROM",
|
||||
"CREATE TABLE", "DROP TABLE", "ALTER TABLE",
|
||||
];
|
||||
let hits = CLAUSE_KEYWORDS.iter().filter(|&&kw| upper.contains(kw)).count();
|
||||
hits >= 2
|
||||
}
|
||||
|
||||
/// Heuristic: content looks like TOML (key = value patterns)
|
||||
fn looks_like_toml(content: &str) -> bool {
|
||||
let lines: Vec<&str> = content.lines().take(10).collect();
|
||||
@@ -354,6 +391,32 @@ mod tests {
|
||||
assert_eq!(detect_language(content, Some("md")), LanguageId::Markdown);
|
||||
}
|
||||
|
||||
// === SQL detection tests ===
|
||||
|
||||
#[test]
|
||||
fn sql_select_multiline() {
|
||||
let content = "select *\nfrom thing\nwhere condition = true\n and (condition2 = true)\norder by column desc;";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Sql);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_select_uppercase() {
|
||||
let content = "SELECT id, name FROM users WHERE active = 1 ORDER BY name;";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Sql);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_insert() {
|
||||
let content = "INSERT INTO users (name, email) VALUES ('Alice', 'a@b.com');";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Sql);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_create_table() {
|
||||
let content = "CREATE TABLE foo (\n id INT PRIMARY KEY,\n name TEXT\n);";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Sql);
|
||||
}
|
||||
|
||||
// === Edge cases ===
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Round-trip and edge-case tests for `document::encoding`.
|
||||
|
||||
use byte_draft::document::encoding::{decode_bytes, encode_string};
|
||||
use byte_draft::TextEncoding;
|
||||
|
||||
#[test]
|
||||
fn utf8_round_trip() {
|
||||
let s = "hello 世界";
|
||||
let b = encode_string(s, TextEncoding::Utf8).unwrap();
|
||||
assert_eq!(decode_bytes(&b, TextEncoding::Utf8).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_bom_stripped_on_decode() {
|
||||
let mut b = vec![0xEF, 0xBB, 0xBF];
|
||||
b.extend_from_slice(b"hi");
|
||||
assert_eq!(decode_bytes(&b, TextEncoding::Utf8).unwrap(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_le_bom_round_trip() {
|
||||
let s = "abcΔ";
|
||||
let b = encode_string(s, TextEncoding::Utf16).unwrap();
|
||||
assert!(b.starts_with(&[0xFF, 0xFE]));
|
||||
assert_eq!(decode_bytes(&b, TextEncoding::Utf16).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_odd_length_errors() {
|
||||
let err = decode_bytes(&[0x41], TextEncoding::Utf16).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_round_trip() {
|
||||
let s = "AZ09";
|
||||
let b = encode_string(s, TextEncoding::Ascii).unwrap();
|
||||
assert_eq!(decode_bytes(&b, TextEncoding::Ascii).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_rejects_high_byte() {
|
||||
let err = encode_string("é", TextEncoding::Ascii).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8859_1_round_trip() {
|
||||
let bytes = [0xE9u8]; // é in Latin-1
|
||||
let s = decode_bytes(&bytes, TextEncoding::Iso8859_1).unwrap();
|
||||
assert_eq!(s, "é");
|
||||
assert_eq!(encode_string(&s, TextEncoding::Iso8859_1).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8859_1_encode_rejects_non_latin1_char() {
|
||||
let err = encode_string("日本", TextEncoding::Iso8859_1).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_1252_round_trip_euro() {
|
||||
// U+20AC in Windows-1252 is 0x80
|
||||
let s = "€";
|
||||
let b = encode_string(s, TextEncoding::Windows1252).unwrap();
|
||||
assert_eq!(b, vec![0x80]);
|
||||
assert_eq!(decode_bytes(&b, TextEncoding::Windows1252).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_be_bom_decode() {
|
||||
let mut v = vec![0xFE, 0xFF];
|
||||
v.extend_from_slice(&[0x00, 0x41]); // 'A'
|
||||
assert_eq!(decode_bytes(&v, TextEncoding::Utf16).unwrap(), "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_le_without_bom_defaults_to_le() {
|
||||
// 'a' U+0061, 'β' U+03B2 — little-endian code units without BOM.
|
||||
let v = vec![0x61, 0x00, 0xB2, 0x03];
|
||||
assert_eq!(decode_bytes(&v, TextEncoding::Utf16).unwrap(), "aβ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_rejects_invalid_sequence() {
|
||||
let err = decode_bytes(&[0xFF, 0xFE, 0xFD], TextEncoding::Utf8).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_encode_round_trip_without_bom_prefix_in_output() {
|
||||
let s = "plain";
|
||||
let b = encode_string(s, TextEncoding::Utf8).unwrap();
|
||||
assert_eq!(b, b"plain");
|
||||
assert_eq!(decode_bytes(&b, TextEncoding::Utf8).unwrap(), s);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
ByteDraft encoding demos (fixtures for manual checks)
|
||||
|
||||
Location: crates/byte_draft/tests/fixtures/encoding_demo/
|
||||
|
||||
1) encoding_diff_demo_utf8.txt ← start here
|
||||
- Open this file in ByteDraft (UTF-8).
|
||||
- Line 1 shows: Hello © € é (Unicode).
|
||||
- Save the tab (no edits), then pick ISO-8859-1 or Windows-1252 in the status bar encoding
|
||||
dropdown: the file reloads from disk and those UTF-8 bytes are reinterpreted as
|
||||
single-byte characters, so line 1 becomes mojibake. Switch back to UTF-8 to restore.
|
||||
|
||||
2) encoding_diff_demo.bin
|
||||
- Raw bytes (not valid UTF-8): Hello + 0xA9 + 0x80 + 0xE9 + CRLF.
|
||||
- Open… tries UTF-8 first; if that fails, the file is opened as ISO-8859-1 (every byte maps to
|
||||
a character). The status bar should show ISO-8859-1. Switch to Windows-1252 (clean tab) to
|
||||
reload: the middle byte 0x80 becomes € instead of U+0080. © and é match in both encodings.
|
||||
- Turn on View → Show line endings to see the CRLF badge.
|
||||
@@ -0,0 +1 @@
|
||||
Hello ©€é
|
||||
@@ -0,0 +1,3 @@
|
||||
UTF-8 reference (default after Open):
|
||||
Hello © € é
|
||||
ASCII second line.
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Integration tests: full detect → lint pipeline.
|
||||
|
||||
use byte_draft::{detect_language, lint_document, LanguageId, NO_FORMATTER_PLAIN_MSG};
|
||||
|
||||
// ── valid documents produce no diagnostics ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn valid_json_no_diagnostics() {
|
||||
let text = r#"{"name": "test", "value": 42, "tags": ["a", "b"]}"#;
|
||||
let lang = detect_language(text, Some("json"));
|
||||
assert_eq!(lang, LanguageId::Json);
|
||||
assert!(lint_document(lang, text).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_yaml_no_diagnostics() {
|
||||
let text = "name: test\nversion: 1.0\ntags:\n - a\n - b\n";
|
||||
let lang = detect_language(text, Some("yaml"));
|
||||
assert_eq!(lang, LanguageId::Yaml);
|
||||
assert!(lint_document(lang, text).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_toml_no_diagnostics() {
|
||||
let text = "[package]\nname = \"test\"\nversion = \"0.1.0\"\n";
|
||||
let lang = detect_language(text, Some("toml"));
|
||||
assert_eq!(lang, LanguageId::Toml);
|
||||
assert!(lint_document(lang, text).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_xml_no_diagnostics() {
|
||||
let text = r#"<?xml version="1.0"?><root><item id="1">value</item></root>"#;
|
||||
let lang = detect_language(text, Some("xml"));
|
||||
assert_eq!(lang, LanguageId::Xml);
|
||||
assert!(lint_document(lang, text).is_empty());
|
||||
}
|
||||
|
||||
// ── plain / code languages produce no diagnostics (no formatter) ──────────────
|
||||
|
||||
#[test]
|
||||
fn plain_text_no_diagnostics() {
|
||||
let text = "This is just plain text.\nNothing special here.\n";
|
||||
let lang = detect_language(text, None);
|
||||
assert_eq!(lang, LanguageId::Plain);
|
||||
assert!(lint_document(lang, text).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_source_only_no_formatter_diagnostic() {
|
||||
// Rust has no built-in formatter, so lint_document returns exactly the
|
||||
// "no formatter" sentinel — the same one the UI filters from the status bar.
|
||||
let text = "fn main() { println!(\"hello\"); }\n";
|
||||
let lang = detect_language(text, Some("rs"));
|
||||
assert_eq!(lang, LanguageId::Rust);
|
||||
let diags = lint_document(lang, text);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].message, NO_FORMATTER_PLAIN_MSG);
|
||||
}
|
||||
|
||||
// ── invalid documents produce diagnostics ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn invalid_json_produces_diagnostic() {
|
||||
let text = r#"{"key": }"#;
|
||||
let lang = detect_language(text, Some("json"));
|
||||
assert_eq!(lang, LanguageId::Json);
|
||||
let diags = lint_document(lang, text);
|
||||
assert!(!diags.is_empty(), "invalid JSON must produce at least one diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_yaml_produces_diagnostic() {
|
||||
let text = "foo: [unclosed\n";
|
||||
let lang = detect_language(text, Some("yaml"));
|
||||
assert_eq!(lang, LanguageId::Yaml);
|
||||
let diags = lint_document(lang, text);
|
||||
assert!(!diags.is_empty(), "invalid YAML must produce at least one diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_toml_produces_diagnostic() {
|
||||
let text = "[package\nname = \"missing closing bracket\"\n";
|
||||
let lang = detect_language(text, Some("toml"));
|
||||
assert_eq!(lang, LanguageId::Toml);
|
||||
let diags = lint_document(lang, text);
|
||||
assert!(!diags.is_empty(), "invalid TOML must produce at least one diagnostic");
|
||||
}
|
||||
|
||||
// ── extension wins over content ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extension_overrides_json_content_for_rust() {
|
||||
let text = r#"{"key": 1}"#;
|
||||
let lang = detect_language(text, Some("rs"));
|
||||
assert_eq!(lang, LanguageId::Rust);
|
||||
// Rust has no formatter; the only diagnostic is the "no formatter" sentinel.
|
||||
let diags = lint_document(lang, text);
|
||||
assert!(diags.iter().all(|d| d.message == NO_FORMATTER_PLAIN_MSG));
|
||||
}
|
||||
|
||||
// ── detect → lint is deterministic ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn detect_lint_is_deterministic() {
|
||||
let text = r#"{"x": 1}"#;
|
||||
let lang = detect_language(text, Some("json"));
|
||||
let d1 = lint_document(lang, text);
|
||||
let d2 = lint_document(lang, text);
|
||||
assert_eq!(d1, d2);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ fn normalize_empty_tabs() {
|
||||
split_secondary: None,
|
||||
workspace_root: None,
|
||||
tabs: vec![],
|
||||
theme_name: None,
|
||||
};
|
||||
s.normalize();
|
||||
assert_eq!(s.tabs.len(), 1);
|
||||
@@ -52,6 +53,7 @@ fn normalize_clamps_active_tab() {
|
||||
split_secondary: None,
|
||||
workspace_root: None,
|
||||
tabs: vec![Tab::new_untitled(), Tab::new_untitled()],
|
||||
theme_name: None,
|
||||
};
|
||||
s.normalize();
|
||||
assert_eq!(s.active_tab, 1);
|
||||
@@ -65,6 +67,7 @@ fn normalize_clears_invalid_split() {
|
||||
split_secondary: Some(0),
|
||||
workspace_root: None,
|
||||
tabs: vec![Tab::new_untitled(), Tab::new_untitled()],
|
||||
theme_name: None,
|
||||
};
|
||||
s.normalize();
|
||||
assert_eq!(s.split_secondary, None);
|
||||
@@ -75,6 +78,7 @@ fn normalize_clears_invalid_split() {
|
||||
split_secondary: Some(5),
|
||||
workspace_root: None,
|
||||
tabs: vec![Tab::new_untitled()],
|
||||
theme_name: None,
|
||||
};
|
||||
s2.normalize();
|
||||
assert_eq!(s2.split_secondary, None);
|
||||
|
||||
@@ -25,6 +25,8 @@ pulldown-cmark = { version = "0.13", default-features = false }
|
||||
env_logger = "0.11"
|
||||
log = "0.4"
|
||||
directories = "5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||
two-face = { version = "0.4", default-features = false, features = ["syntect-default-fancy"] }
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Bootstrap Icons (font file: resources/fonts/bootstrap-icons.ttf)
|
||||
- Source: https://github.com/twbs/icons
|
||||
- Version: 1.11.3
|
||||
- License: MIT (https://github.com/twbs/icons/blob/main/LICENSE)
|
||||
|
||||
The TTF was produced locally from the project’s official WOFF2 using fonttools
|
||||
for embedding in egui (WOFF2 is not loaded directly by the UI stack).
|
||||
@@ -0,0 +1 @@
|
||||
Hello ©€é
|
||||
@@ -0,0 +1,15 @@
|
||||
ByteDraft encoding demos (in this folder)
|
||||
|
||||
1) encoding_diff_demo_utf8.txt ← start here
|
||||
- Open this file in ByteDraft (UTF-8).
|
||||
- Line 1 shows: Hello © € é (Unicode).
|
||||
- Save the tab (no edits), then pick ISO-8859-1 or Windows-1252 in the status bar encoding
|
||||
dropdown: the file reloads from disk and those UTF-8 bytes are reinterpreted as
|
||||
single-byte characters, so line 1 becomes mojibake. Switch back to UTF-8 to restore.
|
||||
|
||||
2) encoding_diff_demo.bin
|
||||
- Raw bytes (not valid UTF-8): Hello + 0xA9 + 0x80 + 0xE9 + CRLF.
|
||||
- Open… tries UTF-8 first; if that fails, the file is opened as ISO-8859-1 (every byte maps to
|
||||
a character). The status bar should show ISO-8859-1. Switch to Windows-1252 (clean tab) to
|
||||
reload: the middle byte 0x80 becomes € instead of U+0080. © and é match in both encodings.
|
||||
- Turn on View → Show line endings to see the CRLF badge.
|
||||
@@ -0,0 +1,3 @@
|
||||
UTF-8 reference (default after Open):
|
||||
Hello © € é
|
||||
ASCII second line.
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M9.758.648a.75.75 0 0 1 .75.75v16.8a.75.75 0 0 1-1.5 0v-16.8a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M1.011 9.758a.75.75 0 0 1 .75-.75H17.82a.75.75 0 0 1 0 1.5H1.76a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-opacity=".8" fill-rule="evenodd">
|
||||
<polygon fill="#9AA7B0" points="7 1 3 5 7 5"/>
|
||||
<polygon fill="#9AA7B0" points="8 1 8 6 3 6 3 15 13 15 13 1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="m9.973 6-.44-.446L7.505 3.5H3.1a.498.498 0 0 0-.37.187.964.964 0 0 0-.23.646v10.334c0 .28.102.504.23.646.126.14.259.187.37.187h8.186v-1h1.8v1H16.9a.498.498 0 0 0 .37-.187.964.964 0 0 0 .23-.646V7a1 1 0 0 0-1-1h-1.614v1.5h-1.8V6H9.973Zm3.113 3.25h-1.8V7.5h1.8v1.75Zm0 0h1.8V11h-1.8V9.25Zm0 3.5v1.75h1.8v-1.75h-1.8Zm0 0h-1.8V11h1.8v1.75ZM10.6 4.5h5.9A2.5 2.5 0 0 1 19 7v7.667C19 15.955 18.06 17 16.9 17H3.1C1.94 17 1 15.955 1 14.667V4.333C1 3.045 1.94 2 3.1 2h4.51c.335 0 .656.134.89.372L10.6 4.5Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 661 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.5 7.25v-.187L9.03 2.5h.221v3.625c0 .621-.504 1.125-1.125 1.125H4.5Zm0 1.5v7.75a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-13a1 1 0 0 0-1-1h-3.75v3.625A2.625 2.625 0 0 1 8.125 8.75H4.5ZM3 16.5V6.96c0-.33.13-.647.363-.88l4.674-4.71A1.25 1.25 0 0 1 8.925 1H14.5A2.5 2.5 0 0 1 17 3.5v13a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 3 16.5Zm4-4.75a.75.75 0 0 1 .75-.75h4.75a.75.75 0 0 1 0 1.5H7.75a.75.75 0 0 1-.75-.75ZM7.75 14a.75.75 0 0 0 0 1.5h4.75a.75.75 0 0 0 0-1.5H7.75Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M9.25 2.5h-.221L4.5 7.063v.187h3.625c.621 0 1.125-.504 1.125-1.125V2.5Zm-4.75 14V8.75h3.625a2.625 2.625 0 0 0 2.625-2.625V2.5h3.75a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1ZM3 6.96v9.54A2.5 2.5 0 0 0 5.5 19h9a2.5 2.5 0 0 0 2.5-2.5v-13A2.5 2.5 0 0 0 14.5 1H8.925a1.25 1.25 0 0 0-.888.37L3.363 6.08A1.25 1.25 0 0 0 3 6.96ZM7.75 11a.75.75 0 0 0 0 1.5h4.75a.75.75 0 0 0 0-1.5H7.75ZM7 14.75a.75.75 0 0 1 .75-.75h4.75a.75.75 0 0 1 0 1.5H7.75a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3.275 5.13a2.357 2.357 0 0 0 0 3.333l7.467 7.467a3.737 3.737 0 1 0 5.285-5.285L9.452 4.07a.75.75 0 1 1 1.06-1.06l6.575 6.574a5.237 5.237 0 1 1-7.405 7.406L2.215 9.524A3.856 3.856 0 1 1 7.668 4.07l6.826 6.826a2.454 2.454 0 0 1-3.47 3.471L5.088 8.433a.75.75 0 1 1 1.061-1.06l5.934 5.933a.954.954 0 1 0 1.35-1.35L6.607 5.132a2.356 2.356 0 0 0-3.332 0Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 515 B |
@@ -0,0 +1,8 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<path fill="#9AA7B0" fill-opacity=".8" d="M7.99940849,14.9997 L3.0004,14.9997 L3.0004,5.9997 L8.0004,5.9997 L8.0004,0.9997 L13.0004,0.9997 L13.0004,6.9997 L13.0004,7.10009933 C12.6771661,7.03445868 12.3426072,7 12,7 C9.23857625,7 7,9.23857625 7,12 C7,13.1254712 7.37185581,14.1640909 7.99940849,14.9997 Z"/>
|
||||
<path fill="#40B6E0" d="M14.9038963,12.5790317 L15.8135595,13.2316899 C15.6384896,13.7731744 15.3510977,14.2641457 14.9788518,14.6771359 L13.9588158,14.2169755 C13.668497,14.4727984 13.3275303,14.6724899 12.9528891,14.7990766 L12.8417488,15.9127236 C12.5724461,15.9699115 12.2931267,16 12.0067798,16 C11.7204328,16 11.4411134,15.9699115 11.1718107,15.9127236 L11.0606704,14.7990765 C10.6860293,14.6724898 10.3450626,14.4727983 10.0547438,14.2169754 L9.03470769,14.6771359 C8.66246178,14.2641457 8.37506991,13.7731744 8.2,13.2316899 L9.10966347,12.5790315 C9.07244924,12.3917905 9.05293363,12.1981744 9.05293363,12.0000001 C9.05293363,11.8018258 9.07244926,11.6082096 9.10966352,11.4209686 L8.2,10.7683101 C8.37506991,10.2268256 8.66246178,9.73585432 9.03470769,9.32286405 L10.0547441,9.78302467 C10.3450628,9.52720191 10.6860293,9.32751047 11.0606704,9.20092378 L11.1718107,8.08727644 C11.4411134,8.03008853 11.7204328,8 12.0067798,8 C12.2931267,8 12.5724461,8.03008853 12.8417488,8.08727644 L12.9528891,9.20092368 C13.3275302,9.32751034 13.6684969,9.5272018 13.9588156,9.78302459 L14.9788518,9.32286405 C15.3510977,9.73585432 15.6384896,10.2268256 15.8135595,10.7683101 L14.9038962,11.4209684 C14.9411105,11.6082095 14.9606262,11.8018258 14.9606262,12.0000001 C14.9606262,12.1981744 14.9411105,12.3917906 14.9038963,12.5790317 Z M12.0067796,13.4156281 C12.7884749,13.4156281 13.4221642,12.7818843 13.4221642,12.0001217 C13.4221642,11.2183591 12.7884749,10.5846153 12.0067796,10.5846153 C11.2250843,10.5846153 10.591395,11.2183591 10.591395,12.0001217 C10.591395,12.7818843 11.2250843,13.4156281 12.0067796,13.4156281 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,17 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="css">
|
||||
<path id="Fill 1" fill-rule="evenodd" clip-rule="evenodd" d="M7 1L3 5H7V1Z" fill="#9AA7B0" fill-opacity="0.8"/>
|
||||
<path id="Fill 3" fill-rule="evenodd" clip-rule="evenodd" d="M8 1V6H3V8H13V1H8Z" fill="#9AA7B0" fill-opacity="0.8"/>
|
||||
<path id="Fill 5" fill-rule="evenodd" clip-rule="evenodd" d="M1 16H16V9H1V16Z" fill="#F98B9E" fill-opacity="0.6"/>
|
||||
<g id="⌘/alphabet/C">
|
||||
<path id="⌘/alphabet/C_2" fill-rule="evenodd" clip-rule="evenodd" d="M2 12.501C2 11 2.931 10 4.256 10C5.20239 10 5.55 10.311 5.969 10.753L5.4267 11.4271C5.0767 11.0681 4.75 11 4.25 11C3.418 11 3 11.7379 3 12.487C3 13.2361 3.412 14 4.25 14C4.787 14 5.0517 13.8934 5.4267 13.5064L6 14.144C5.544 14.669 5.19733 15 4.225 15C2.949 15 2 14.002 2 12.501Z" fill="#231F20" fill-opacity="0.7"/>
|
||||
</g>
|
||||
<g id="⌘/alphabet/S">
|
||||
<path id="⌘/alphabet/S_2" fill-rule="evenodd" clip-rule="evenodd" d="M7.97277 11.5015C7.97277 11.1331 8.284 11 8.845 11C8.853 11 10 11 10 11V10C10 10 8.894 10 8.86 10C7.778 10 7 10.4592 7 11.45C7 12.3145 7.41956 12.6905 8.47125 12.9161C9.24159 13.0813 9.49616 13.2286 9.49616 13.548C9.49616 13.8674 9.13843 14 8.47125 14C8.45525 14 7.3 14 7.3 14V15C7.3 15 8.46325 15 8.47125 15C10.5 15 10.5 14 10.5 13.548C10.5 12.9161 10.0203 12.4207 9.15869 12.1469C8.29712 11.873 7.97277 11.87 7.97277 11.5015Z" fill="#231F20" fill-opacity="0.7"/>
|
||||
</g>
|
||||
<g id="⌘/alphabet/S_3">
|
||||
<path id="⌘/alphabet/S_4" fill-rule="evenodd" clip-rule="evenodd" d="M11.9728 11.5015C11.9728 11.1331 12.284 11 12.845 11C12.853 11 14 11 14 11V10C14 10 12.894 10 12.86 10C11.778 10 11 10.4592 11 11.45C11 12.3145 11.4196 12.6905 12.4713 12.9161C13.2416 13.0813 13.4962 13.2286 13.4962 13.548C13.4962 13.8674 13.1384 14 12.4713 14C12.4553 14 11.3 14 11.3 14V15C11.3 15 12.4633 15 12.4713 15C14.5 15 14.5 14 14.5 13.548C14.5 12.9161 14.0203 12.4207 13.1587 12.1469C12.2971 11.873 11.9728 11.87 11.9728 11.5015Z" fill="#231F20" fill-opacity="0.7"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3.75 7.47c0-.324.127-.637.354-.87l4.103-4.221A1.25 1.25 0 0 1 9.103 2h4.647a2.5 2.5 0 0 1 2.5 2.5v11.41a2.5 2.5 0 0 1-2.5 2.5h-7.5a2.5 2.5 0 0 1-2.5-2.5V7.47Zm1.5 1.605v6.834a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V4.5a1 1 0 0 0-1-1h-3.117v2.919a2.656 2.656 0 0 1-2.657 2.656H5.25Zm3.82-5.432-3.762 3.87h2.668c.604 0 1.094-.49 1.094-1.094V3.643Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 504 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="#9AA7B0" fill-opacity=".8" fill-rule="evenodd" d="M1,13 L15,13 L15,4 L7.98457,4 L6.69633,2.71149 C6.22161957,2.28559443 5.61570121,2.03457993 4.97888,2 L1.05128,2 C1.02295884,2 1,2.02295884 1,2.05128 L1,13 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 463 B |
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path fill="#9AA7B0" fill-opacity=".8" d="M15,7.99963381 C14.1643395,7.37194482 13.1256059,7 12,7 C9.23857625,7 7,9.23857625 7,12 C7,12.3424658 7.03443026,12.6768901 7.10001812,13 L1,13 L1,2.05128 C1,2.02295884 1.02295884,2 1.05128,2 L4.97888,2 C5.61570121,2.03457993 6.22161957,2.28559443 6.69633,2.71149 L7.98457,4 L15,4 L15,7.99963381 Z"/>
|
||||
<path fill="#40B6E0" d="M14.9038963,12.5790317 L15.8135595,13.2316899 C15.6384896,13.7731744 15.3510977,14.2641457 14.9788518,14.6771359 L13.9588158,14.2169755 C13.668497,14.4727984 13.3275303,14.6724899 12.9528891,14.7990766 L12.8417488,15.9127236 C12.5724461,15.9699115 12.2931267,16 12.0067798,16 C11.7204328,16 11.4411134,15.9699115 11.1718107,15.9127236 L11.0606704,14.7990765 C10.6860293,14.6724898 10.3450626,14.4727983 10.0547438,14.2169754 L9.03470769,14.6771359 C8.66246178,14.2641457 8.37506991,13.7731744 8.2,13.2316899 L9.10966347,12.5790315 C9.07244924,12.3917905 9.05293363,12.1981744 9.05293363,12.0000001 C9.05293363,11.8018258 9.07244926,11.6082096 9.10966352,11.4209686 L8.2,10.7683101 C8.37506991,10.2268256 8.66246178,9.73585432 9.03470769,9.32286405 L10.0547441,9.78302467 C10.3450628,9.52720191 10.6860293,9.32751047 11.0606704,9.20092378 L11.1718107,8.08727644 C11.4411134,8.03008853 11.7204328,8 12.0067798,8 C12.2931267,8 12.5724461,8.03008853 12.8417488,8.08727644 L12.9528891,9.20092368 C13.3275302,9.32751034 13.6684969,9.5272018 13.9588156,9.78302459 L14.9788518,9.32286405 C15.3510977,9.73585432 15.6384896,10.2268256 15.8135595,10.7683101 L14.9038962,11.4209684 C14.9411105,11.6082095 14.9606262,11.8018258 14.9606262,12.0000001 C14.9606262,12.1981744 14.9411105,12.3917906 14.9038963,12.5790317 Z M12.0067796,13.4156281 C12.7884749,13.4156281 13.4221642,12.7818843 13.4221642,12.0001217 C13.4221642,11.2183591 12.7884749,10.5846153 12.0067796,10.5846153 C11.2250843,10.5846153 10.591395,11.2183591 10.591395,12.0001217 C10.591395,12.7818843 11.2250843,13.4156281 12.0067796,13.4156281 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path fill="#6E6E6E" d="M9,13 L9,10 L7,10 L7,13 L4,13 L4,7 L12,7 L12,13 L9,13 Z"/>
|
||||
<polygon fill="#6E6E6E" points="8 2 15 8 1 8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 422 B |
@@ -0,0 +1,11 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="1 13 3 13 3 3 1 3"/>
|
||||
<polygon fill="#62B543" points="4 13 6 13 6 3 4 3"/>
|
||||
<polygon fill="#40B6E0" points="10 8 12 8 12 3 10 3"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 13 9 13 9 .999 7 .999"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="13 8 15 8 15 5 13 5"/>
|
||||
<polygon fill="#9AA7B0" points="14.613 9 14.613 13.744 11.306 9 10 9 10 16 11.386 16 11.386 11.295 14.819 16 16 16 16 9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 759 B |
@@ -0,0 +1,9 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path fill="#231F20" fill-opacity=".7" d="M11,15 L15,15 L15,10 L11,10 L11,15 Z M10,16 L16,16 L16,9 L10,9 L10,16 Z"/>
|
||||
<polygon fill="#231F20" fill-opacity=".7" points="12 12 14 12 14 11 12 11"/>
|
||||
<polygon fill="#231F20" fill-opacity=".7" points="12 14 14 14 14 13 12 13"/>
|
||||
<path fill="#9AA7B0" fill-opacity=".8" d="M1,13 L8.94562097,13 L8.94562097,8 L15,8 L15,4 L7.98457,4 L6.69633,2.71149 C6.22161957,2.28559443 5.61570121,2.03457993 4.97888,2 L1.05128,2 C1.02295884,2 1,2.02295884 1,2.05128 L1,13 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 801 B |
@@ -0,0 +1,8 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path fill="#9AA7B0" fill-opacity=".8" d="M15.0005,10.517559 L12.5,8 L10.5,8 L6,12.5 L6.5002,13.0002 L1.0005,13.0002 L1.0005,2.0512 C1.0005,2.0232 1.0235,2.0002 1.0505,2.0002 L4.9785,2.0002 C5.5325,2.0002 6.3045,2.3202 6.6965,2.7112 L7.9845,4.0002 L15.0005,4.0002 L15.0005,10.517559 Z"/>
|
||||
<polygon fill="#62B543" points="13.75 10.75 17.25 14.25 10.25 14.25" transform="rotate(90 13.75 12.5)"/>
|
||||
<polygon fill="#F26522" points="9.25 10.75 12.75 14.25 5.75 14.25" transform="rotate(-90 9.25 12.5)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 791 B |
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path fill="#9AA7B0" fill-opacity=".8" d="M7.9082,10.4053 C7.3982,10.9143 6.7212,11.1953 6.0002,11.1953 C4.5122,11.1953 3.3022,9.9853 3.3002,8.4993 C3.3002,7.7783 3.5802,7.1003 4.0892,6.5903 C4.5982,6.0813 5.2762,5.8003 5.9972,5.8003 C7.4852,5.8003 8.6962,7.0093 8.6982,8.4973 C8.6982,9.2183 8.4182,9.8953 7.9082,10.4053 Z M7.9842,4.0003 L6.6962,2.7113 C6.3042,2.3203 5.5322,2.0003 4.9782,2.0003 L1.0512,2.0003 C1.0232,2.0003 1.0002,2.0233 1.0002,2.0513 L1.0002,13.0003 L15.0002,13.0003 L15.0002,4.0003 L7.9842,4.0003 Z"/>
|
||||
<path fill="#40B6E0" d="M6,10.4951 C4.896,10.4951 4.001,9.6001 4,8.4981 C3.999,7.3941 4.894,6.5001 5.997,6.5001 C7.102,6.5001 7.997,7.3941 7.998,8.4981 C7.999,9.6001 7.104,10.4951 6,10.4951"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1007 B |
@@ -0,0 +1,64 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<defs>
|
||||
<linearGradient id="htaccess-a" x1="16.835%" x2="159.599%" y1="63.497%" y2="19.863%">
|
||||
<stop offset="0%" stop-color="#F69923"/>
|
||||
<stop offset="31.23%" stop-color="#F79A23"/>
|
||||
<stop offset="83.83%" stop-color="#E97826"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="htaccess-b" x1="-217.367%" x2="74.968%" y1="394.12%" y2="13.671%">
|
||||
<stop offset="32.33%" stop-color="#9E2064"/>
|
||||
<stop offset="63.02%" stop-color="#C92037"/>
|
||||
<stop offset="75.14%" stop-color="#CD2335"/>
|
||||
<stop offset="100%" stop-color="#E97826"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="htaccess-c" x1="-20.305%" x2="125.697%" y1="192.631%" y2="-136.581%">
|
||||
<stop offset="0%" stop-color="#282662"/>
|
||||
<stop offset="9.548%" stop-color="#662E8D"/>
|
||||
<stop offset="78.82%" stop-color="#9F2064"/>
|
||||
<stop offset="94.87%" stop-color="#CD2032"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="htaccess-d" x1="-79.867%" x2="146.442%" y1="170.503%" y2="-44.384%">
|
||||
<stop offset="32.33%" stop-color="#9E2064"/>
|
||||
<stop offset="63.02%" stop-color="#C92037"/>
|
||||
<stop offset="75.14%" stop-color="#CD2335"/>
|
||||
<stop offset="100%" stop-color="#E97826"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="htaccess-e" x1="-18.376%" x2="164.728%" y1="77.298%" y2="-41.36%">
|
||||
<stop offset="0%" stop-color="#282662"/>
|
||||
<stop offset="9.548%" stop-color="#662E8D"/>
|
||||
<stop offset="78.82%" stop-color="#9F2064"/>
|
||||
<stop offset="94.87%" stop-color="#CD2032"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="htaccess-f" x1="-34.831%" x2="110.897%" y1="187.637%" y2="-51.127%">
|
||||
<stop offset="32.33%" stop-color="#9E2064"/>
|
||||
<stop offset="63.02%" stop-color="#C92037"/>
|
||||
<stop offset="75.14%" stop-color="#CD2335"/>
|
||||
<stop offset="100%" stop-color="#E97826"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="htaccess-g" x1="-129.474%" x2="66.599%" y1="465.394%" y2="17.067%">
|
||||
<stop offset="32.33%" stop-color="#9E2064"/>
|
||||
<stop offset="63.02%" stop-color="#C92037"/>
|
||||
<stop offset="75.14%" stop-color="#CD2335"/>
|
||||
<stop offset="100%" stop-color="#E97826"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<path fill="#9AA7B0" fill-opacity=".8" d="M5.64528351,15 L3,15 L3,6 L8,6 L8,1 L13,1 L13,2.02283476 C11.7544188,2.98523303 10.274714,4.52517608 8.63599928,6.44873312 C5.80505854,9.7717495 5.16384082,13.5577805 5.73487738,14.8241621 L5.64528351,15 Z M13,10.1340458 L13,15 L8.78885494,15 C10.209598,14.1635289 11.8357822,12.4506622 13,10.1340458 Z"/>
|
||||
<g transform="rotate(5 -20.357 67.846)">
|
||||
<path fill="url(#htaccess-a)" fill-rule="nonzero" d="M8.04923869,0.105771142 C7.75152712,0.259620077 7.26636753,0.687512426 6.67645757,1.31252372 L7.21674893,2.19715509 C7.59164498,1.72599273 7.97756738,1.30290816 8.36348979,0.942324723 C8.39105567,0.913478048 8.4075952,0.89905471 8.4075952,0.89905471 C8.39105567,0.913478048 8.38002932,0.927901386 8.36348979,0.942324723 C8.23668671,1.0625192 7.85627749,1.44714154 7.28842024,2.21638621 C7.83973795,2.19234732 8.68876724,2.09619173 9.37791439,1.99042059 C9.58190194,0.985594736 9.17392683,0.524047933 9.17392683,0.524047933 C9.17392683,0.524047933 8.65568818,-0.206734506 8.04923869,0.105771142 Z"/>
|
||||
<path d="M7.5994438 5.1539393C7.6378055 5.1539393 7.6378055 5.1539393 7.6761672 5.1539393L7.13910338 5.26099022C7.10074167 5.26099022 7.06237997 5.31451568 7.06237997 5.31451568 7.21582678 5.20746476 7.40763529 5.20746476 7.5994438 5.1539393zM7.06971771 6.74050644C6.86512197 6.84755736 6.66052623 6.90108282 6.45593048 6.95460828 6.66052623 6.90108282 6.86512197 6.7940319 7.06971771 6.74050644zM2.86133896 10.2934553C2.86685214 10.2790319 2.87236531 10.2646086 2.87787849 10.254993 2.99365521 9.98094962 3.11494511 9.71652176 3.23072183 9.46170947 3.36303808 9.17324271 3.48984116 8.88958374 3.61664423 8.62034811 3.74896048 8.33188135 3.88127674 8.05783794 4.01359299 7.7886023 4.15142242 7.50975111 4.28373867 7.2357077 4.41605492 6.97608762 4.52631847 6.76454533 4.63106883 6.55781083 4.7358192 6.36069188 4.76889826 6.29338297 4.8074905 6.23088184 4.84056957 6.16357293 4.90672769 6.0337629 4.978399 5.90876064 5.04455712 5.78375838 5.10520207 5.66837168 5.16584702 5.55779276 5.22649197 5.45202161 5.24854468 5.41355938 5.26508421 5.37990493 5.28713692 5.34144269 5.2926501 5.33663491 5.2926501 5.33182713 5.29816327 5.32221157L5.23200515 5.32701935 5.18238655 5.23567155C5.17687337 5.24528711 5.1713602 5.25490267 5.16584702 5.25971045 5.07212301 5.42317494 4.978399 5.58663943 4.88467498 5.7549117 4.82954321 5.85106729 4.77992462 5.94722287 4.72479285 6.04337845 4.57593706 6.31261409 4.43259446 6.58184973 4.28925185 6.85589314 4.14590924 7.12993655 4.00256663 7.40878775 3.86473721 7.68763894 3.72690778 7.96168236 3.59459152 8.23572577 3.46778845 8.50496141 3.3354722 8.77900482 3.20866912 9.04824045 3.08737922 9.31747609 2.96057615 9.59632728 2.83377307 9.87517848 2.71248317 10.1540297 2.68491729 10.2165308 2.6573514 10.2790319 2.62978552 10.3415331 2.53054833 10.5626909 2.43682431 10.7838487 2.3431003 11.0001988L2.42579796 11.1444322 2.49746926 11.1396244C2.49746926 11.1348166 2.50298244 11.1252011 2.50298244 11.1203933 2.62427234 10.8415421 2.74004906 10.5626909 2.86133896 10.2934553z"/>
|
||||
<path fill="#BE202E" fill-rule="nonzero" d="M6.64520307,7.53859779 C6.45137552,7.64564871 6.24139568,7.75269963 6.03141584,7.91327601 C6.03141584,7.91327601 6.03141584,7.91327601 6.03141584,7.91327601 C6.14448191,7.85975055 6.24139568,7.80622509 6.35446175,7.75269963 C6.45137552,7.64564871 6.5482893,7.59212325 6.64520307,7.53859779 Z"/>
|
||||
<path fill="#BE202E" fill-rule="nonzero" d="M6.64520307,7.53859779 C6.45137552,7.64564871 6.24139568,7.75269963 6.03141584,7.91327601 C6.03141584,7.91327601 6.03141584,7.91327601 6.03141584,7.91327601 C6.14448191,7.85975055 6.24139568,7.80622509 6.35446175,7.75269963 C6.45137552,7.64564871 6.5482893,7.59212325 6.64520307,7.53859779 Z" opacity=".35"/>
|
||||
<path fill="#BE202E" fill-rule="nonzero" d="M6.42285142,7.00813374 C6.42285142,7.00813374 6.42285142,7.00813374 6.42285142,7.00813374 C6.42285142,7.00813374 6.42285142,7.00813374 6.42285142,7.00813374 C6.46377057,7.00813374 6.50468972,7.00813374 6.54560886,6.95460828 C6.70928546,6.90108282 6.87296205,6.7940319 7.03663865,6.74050644 C6.83204291,6.7940319 6.62744716,6.90108282 6.42285142,7.00813374 Z"/>
|
||||
<path fill="#BE202E" fill-rule="nonzero" d="M6.42285142,7.00813374 C6.42285142,7.00813374 6.42285142,7.00813374 6.42285142,7.00813374 C6.42285142,7.00813374 6.42285142,7.00813374 6.42285142,7.00813374 C6.46377057,7.00813374 6.50468972,7.00813374 6.54560886,6.95460828 C6.70928546,6.90108282 6.87296205,6.7940319 7.03663865,6.74050644 C6.83204291,6.7940319 6.62744716,6.90108282 6.42285142,7.00813374 Z" opacity=".35"/>
|
||||
<path fill="url(#htaccess-b)" fill-rule="nonzero" d="M5.6289539,4.48085021 C5.79434922,4.21642236 5.95974453,3.9519945 6.12513985,3.70198998 C6.30156152,3.43756213 6.47247001,3.18274983 6.65440486,2.93755309 C6.66543121,2.92312975 6.67645757,2.90870642 6.68748392,2.89428308 C6.86390559,2.65389412 7.04032726,2.41831294 7.21674893,2.19715509 L6.67645757,1.31252372 C6.63786533,1.35579373 6.59375991,1.39906375 6.55516767,1.44233376 C6.40079871,1.61060603 6.24091657,1.79330164 6.07552126,1.98561281 C5.88807323,2.20196287 5.70062521,2.43273628 5.50215083,2.67793301 C5.32021598,2.90389864 5.13828113,3.13467204 4.95634629,3.37986878 C4.80197733,3.58660328 4.64760837,3.79814557 4.4932394,4.01930341 C4.48772623,4.02891897 4.48221305,4.03372675 4.47669987,4.04334231 L5.17687337,5.25009489 C5.32572916,4.98566703 5.47458494,4.73085473 5.6289539,4.48085021 Z"/>
|
||||
<path fill="url(#htaccess-c)" fill-rule="nonzero" d="M2.44233749,11.2694344 C2.34861348,11.4905923 2.25488947,11.7165579 2.16116546,11.9473313 C2.16116546,11.9521391 2.16116546,11.9521391 2.15565228,11.9569469 C2.14462592,11.9906013 2.12808639,12.0242558 2.11706004,12.0531025 C2.05641509,12.2069514 2.00128332,12.346377 1.87448024,12.6684982 C2.0784678,12.7502304 3.93089533,12.3511848 4.38297586,11.7021346 C4.4215681,11.6444412 4.46016034,11.5819401 4.4932394,11.519439 C4.28925185,11.7454046 4.03013252,11.8415602 3.55048611,11.8223291 C4.25617279,11.5482856 4.60901613,11.2838578 4.92326722,10.8415421 C4.99493853,10.735771 5.07212301,10.625192 5.14379431,10.5001898 C4.52631847,11.0530844 3.80960543,11.2117411 3.05430016,11.0915466 L2.48644291,11.1444322 C2.48092973,11.1877022 2.45887702,11.2309722 2.44233749,11.2694344 Z"/>
|
||||
<path fill="url(#htaccess-d)" fill-rule="nonzero" d="M2.70697,10.168453 C2.82825989,9.89440959 2.95506297,9.6155584 3.08186605,9.33189943 C3.20315594,9.06266379 3.32995902,8.79342816 3.46227527,8.51938474 C3.59459152,8.24534133 3.72690778,7.97610569 3.85922403,7.70206228 C3.99705346,7.42321109 4.14039606,7.14435989 4.28373867,6.87031648 C4.42708128,6.59627306 4.57042388,6.32703743 4.71927967,6.05780179 C4.77441144,5.96164621 4.82403004,5.86549062 4.87916181,5.76933504 C4.97288582,5.60106277 5.06660983,5.43759828 5.16033384,5.27413378 C5.16584702,5.26451822 5.1713602,5.25490267 5.17687337,5.25009489 L4.47669987,4.03853453 C4.46567352,4.05295787 4.45464716,4.07218898 4.44362081,4.08661232 C4.27822549,4.31738572 4.11834336,4.55777468 3.95846122,4.79816364 C3.79857908,5.04336038 3.63869694,5.2933649 3.48432798,5.54336942 C3.35201173,5.7549117 3.22520865,5.97126177 3.10391875,6.18761183 C3.07635287,6.23088184 3.05430016,6.27415186 3.03224745,6.31742187 C2.87787849,6.59146528 2.74004906,6.85589314 2.61875916,7.11070544 C2.47541656,7.39917219 2.35412666,7.6732156 2.24386311,7.93283568 C2.17219181,8.10591573 2.10603368,8.26938022 2.04538873,8.42803694 C1.99577014,8.56265476 1.95166472,8.70208035 1.90755931,8.83669817 C1.80280894,9.1540116 1.7145981,9.4761328 1.63741362,9.79344623 L2.33758713,11.0098144 C2.43131114,10.7934643 2.52503515,10.5723065 2.62427234,10.3511486 C2.65183823,10.2934553 2.67940411,10.2309541 2.70697,10.168453 Z"/>
|
||||
<path fill="url(#htaccess-e)" fill-rule="nonzero" d="M1.62638727,9.84152402 C1.53817643,10.2309541 1.47753149,10.6155765 1.44445242,11.0001988 C1.44445242,11.0146221 1.39133446,12.3101017 1.49407102,12.5434959 C1.37533405,12.8374681 1.19665661,13.2944248 0.958038697,13.914366 L1.21841202,13.7910609 C1.49734395,13.0774975 1.71970881,12.5190118 1.8855066,12.1156036 C1.91307248,12.0531025 1.93512519,11.9906013 1.96269108,11.9281002 C1.96820425,11.9088691 1.97923061,11.8944457 1.98474379,11.8752146 C2.0784678,11.649249 2.17770499,11.4136678 2.27694218,11.1780866 C2.29899489,11.1252011 2.32104759,11.0723155 2.34861348,11.0146221 C2.34861348,11.0146221 2.11522231,10.6091661 1.64843998,9.79825401 C1.63190045,9.81748513 1.63190045,9.83190846 1.62638727,9.84152402 Z"/>
|
||||
<path fill="url(#htaccess-f)" fill-rule="nonzero" d="M5.28713692,5.34625047 C5.26508421,5.37990493 5.24854468,5.41836716 5.22649197,5.45682939 C5.16584702,5.56740831 5.10520207,5.67798724 5.04455712,5.78856616 C4.978399,5.91356842 4.91224087,6.03857068 4.84056957,6.16838071 C4.8074905,6.23088184 4.76889826,6.29819075 4.7358192,6.36549966 C4.63106883,6.56261861 4.52631847,6.76935311 4.41605492,6.9808954 C4.28373867,7.24051548 4.15142242,7.50975111 4.01359299,7.79341008 C3.88127674,8.06264572 3.74896048,8.33668913 3.61664423,8.62515589 C3.48984116,8.8991993 3.36303808,9.17805049 3.23072183,9.46651725 C3.11494511,9.72613732 2.99916839,9.99056518 2.87787849,10.2598008 C2.87236531,10.2742242 2.86685214,10.2838397 2.86133896,10.298263 C2.74556224,10.5674987 2.62427234,10.8463499 2.50849562,11.1300088 C2.50849562,11.1348166 2.50298244,11.1444322 2.50298244,11.14924 L3.07083969,11.0963544 C3.05981334,11.0963544 3.04878698,11.0915466 3.03776063,11.0915466 C3.71588142,11.0194299 4.6145293,10.5771142 5.19341291,10.0338352 C5.46355859,9.78383067 5.70613838,9.48574836 5.92666547,9.13958826 C6.09206079,8.87996818 6.25194293,8.59630921 6.40079871,8.27899578 C6.53311496,8.00014459 7.98859374,4.91355034 7.99962009,4.87508811 C7.98491829,4.88470367 7.08259495,5.03374482 5.2926501,5.32221157 C5.2926501,5.33663491 5.28713692,5.34144269 5.28713692,5.34625047 Z"/>
|
||||
<path fill="url(#htaccess-g)" fill-rule="nonzero" d="M7.28290706,2.21157843 C7.12302492,2.42312072 6.95211643,2.66350968 6.7646684,2.93755309 C6.75364205,2.95197643 6.74261569,2.96639977 6.73710252,2.9808231 C6.57722038,3.21640428 6.40631189,3.47602436 6.22989022,3.75968333 C6.07552126,4.00488007 5.91563912,4.26450015 5.74473062,4.54815912 C5.60138802,4.79335586 5.44701906,5.05297594 5.2926501,5.33182713 C7.08168622,5.04019105 7.98406352,4.88935151 7.99978199,4.8793085 C8.01473846,4.86975235 8.58953005,3.73564444 8.74941219,3.4664088 C8.90378115,3.20198095 9.04161058,2.94236087 9.14636095,2.70677969 C9.21251907,2.55773853 9.26765084,2.41831294 9.30072991,2.29811846 C9.33380897,2.19234732 9.35586168,2.08657617 9.37791439,1.99042059 C8.68325406,2.09138395 7.83422478,2.18753954 7.28290706,2.21157843 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,9 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#62B543" fill-opacity=".7" points="1 16 16 16 16 9 1 9"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="8 1 8 6 3 6 3 8 13 8 13 1"/>
|
||||
<polygon fill="#231F20" fill-opacity=".7" points="0 0 1 0 1 2 3 2 3 0 4 0 4 5 3 5 3 3 1 3 1 5 0 5" transform="translate(3 10)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 645 B |
@@ -0,0 +1,11 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<path fill="#9AA7B0" fill-opacity=".8" d="M8,1 L8,6 L3,6 L3,15 L13,15 L13,1 L8,1 Z M12,14 L4,14 L4,7 L12,7 L12,14 Z"/>
|
||||
<g transform="translate(5 8)">
|
||||
<path fill="#62B543" fill-opacity=".7" d="M6,5 L0,5 L0,3.33333333 C0,3.33333333 1.76880857,1.7536 2.57142857,2.5 C3.5627607,3.31115884 4.73773287,3.88232587 6,4.16666667 L6,5 Z"/>
|
||||
<path fill="#40B6E0" fill-opacity=".7" d="M0,0 L6,0 L6,3.33333333 C4.73770486,3.04906817 3.56271528,2.47789268 2.57142857,1.66666667 C1.76869714,0.920408333 1.66533454e-16,2.5 1.66533454e-16,2.5 L0,0 Z M4.5,2 C4.77614237,2 5,1.77614237 5,1.5 C5,1.22385763 4.77614237,1 4.5,1 C4.22385763,1 4,1.22385763 4,1.5 C4,1.77614237 4.22385763,2 4.5,2 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,9 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#40B6E0" fill-opacity=".7" points="1 16 16 16 16 9 1 9"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="8 1 8 6 3 6 3 8 13 8 13 1"/>
|
||||
<path fill="#231F20" fill-opacity=".7" d="M1.39509277,3.58770752 C1.62440186,3.83789062 1.83782861,4 2.28682861,4 C2.81318359,4 3,3.58770752 3,3.29760742 L3,0 L4,0 L4,3.58770752 C4,4.31964111 3.32670898,5 2.45,5 C1.629,5 1.15,4.76264111 0.8,4.31964111 L1.39509277,3.58770752 Z" transform="translate(2 10)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 824 B |
@@ -0,0 +1,10 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#F4AF3D" fill-opacity=".7" points="1 16 16 16 16 9 1 9"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="8 1 8 6 3 6 3 8 13 8 13 1"/>
|
||||
<path fill="#231F20" fill-opacity=".7" d="M1.39509277,3.58770752 C1.62440186,3.83789062 1.83782861,4 2.28682861,4 C2.81318359,4 3,3.58770752 3,3.29760742 L3,0 L4,0 L4,3.58770752 C4,4.31964111 3.32670898,5 2.45,5 C1.629,5 1.15,4.76264111 0.8,4.31964111 L1.39509277,3.58770752 Z" transform="translate(1 10)"/>
|
||||
<path fill="#231F20" fill-opacity=".7" d="M0.972767969,1.50152588 C0.972767969,1.13305664 1.284,1 1.845,1 C1.85033333,1 2.23533333,1 3,1 L3,0 C2.26266667,0 1.88266667,0 1.86,0 C0.778,0 0,0.45916748 0,1.45 C0,2.31452637 0.419555664,2.69049072 1.47125244,2.91607666 C2.24158869,3.08131157 2.496155,3.22862939 2.496155,3.548 C2.496155,3.86737061 2.13842773,4 1.47125244,4 C1.46058577,4 1.07016829,4 0.3,4 L0.3,5 C1.07550163,5 1.46591911,5 1.47125244,5 C3.5,5 3.5,4 3.5,3.548 C3.5,2.91607666 3.02026367,2.42071533 2.15869141,2.14685059 C1.29711914,1.87298584 0.972767969,1.86999512 0.972767969,1.50152588 Z" transform="translate(6 10)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,9 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path fill="#231F20" fill-opacity=".7" d="M8.416 13.8965C8.416 13.7395 8.437 13.3185 8.437 13.1395 8.437 12.3195 8.127 11.9835 7.269 11.9835L7 11.9835 7 11.0155 7.269 11.0155C8.127 11.0155 8.437 10.6685 8.437 9.8485 8.437 9.6695 8.416 9.2505 8.416 9.0915 8.416 8.0195 8.882 7.3465 10.765 7.0005L10.981 7.8405C9.74 8.1885 9.575 8.5135 9.575 9.2815 9.575 9.4925 9.596 9.8385 9.596 10.0595 9.596 10.8795 9.203 11.2785 8.52 11.5005 9.213 11.7305 9.596 12.1205 9.596 12.9405 9.596 13.1615 9.575 13.5075 9.575 13.7185 9.575 14.4855 9.74 14.8115 10.981 15.1585L10.765 16.0005C8.882 15.6525 8.416 14.9795 8.416 13.8965M12.0186 15.1582C13.2596 14.8112 13.4246 14.4852 13.4246 13.7192 13.4246 13.5082 13.4046 13.1612 13.4046 12.9402 13.4046 12.1202 13.7966 11.7202 14.4796 11.5002 13.7876 11.2682 13.4046 10.8802 13.4046 10.0592 13.4046 9.8392 13.4246 9.4922 13.4246 9.2812 13.4246 8.5132 13.2596 8.1882 12.0186 7.8412L12.2356 7.0002C14.1186 7.3462 14.5836 8.0192 14.5836 9.0922 14.5836 9.2502 14.5626 9.6702 14.5626 9.8482 14.5626 10.6692 14.8726 11.0152 15.7316 11.0152L15.9996 11.0152 15.9996 11.9832 15.7316 11.9832C14.8726 11.9832 14.5626 12.3192 14.5626 13.1392 14.5626 13.3182 14.5836 13.7392 14.5836 13.8962 14.5836 14.9792 14.1186 15.6522 12.2356 16.0002L12.0186 15.1582z"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<path fill="#9AA7B0" fill-opacity=".8" d="M10.5957,12.9404 C10.5957,13.0624 10.5847,13.9504 10.5907,13.9754 C10.5907,13.9744 10.5927,13.9734 10.5927,13.9734 C10.6227,13.9734 11.4997,14.2654 11.4997,14.2654 C11.4997,14.2654 12.3717,13.9764 12.4247,13.9404 C12.4177,13.9274 12.4047,13.0624 12.4047,12.9404 C12.4047,12.4914 12.4937,11.9654 12.8087,11.5034 C12.5397,11.1134 12.4047,10.6304 12.4047,10.0594 C12.4047,9.9374 12.4147,9.0494 12.4087,9.0244 C12.4087,9.0254 12.4077,9.0254 12.4077,9.0254 C12.3767,9.0254 11.4997,8.7344 11.4997,8.7344 C11.4997,8.7344 10.6277,9.0234 10.5747,9.0584 C10.5817,9.0724 10.5957,9.9374 10.5957,10.0594 C10.5957,10.5084 10.5057,11.0334 10.1917,11.4964 C10.4597,11.8854 10.5957,12.3684 10.5957,12.9404"/>
|
||||
<path fill="#9AA7B0" fill-opacity=".8" d="M7.416,13.8965 C7.416,13.8125 7.435,13.0285 7.432,12.9905 C7.392,12.9865 6,12.9835 6,12.9835 L6,10.0155 C6,10.0155 7.391,10.0125 7.431,10.0075 C7.435,9.9685 7.416,9.1765 7.416,9.0915 C7.416,6.8825 9.108,6.2885 10.584,6.0165 L11.5,5.8475 L12.416,6.0165 C12.608,6.0515 12.805,6.0935 13,6.1425 L13,1.0005 L8,1.0005 L8,6.0005 L3,6.0005 L3,15.0005 L7.609,15.0005 C7.496,14.6815 7.416,14.3285 7.416,13.8965"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,10 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#B99BF8" fill-opacity=".7" points="1 16 16 16 16 9 1 9"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="8 1 8 6 3 6 3 8 13 8 13 1"/>
|
||||
<polygon fill="#231F20" fill-opacity=".7" points="0 0 .936 0 2.5 2 3.979 0 5 0 5 5 4 5 4 1.7 2.5 3.5 1 1.7 1 5 0 5" transform="translate(3 10)"/>
|
||||
<polygon fill="#231F20" fill-opacity=".7" points="1 1.001 3 1.001 3 0 0 0 0 5 1 5 1 3 2.8 3 2.8 2 1 2" transform="translate(9 10)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 799 B |
@@ -0,0 +1,8 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="8 1 8 6 3 6 3 15 13 15 13 1"/>
|
||||
<path fill="#231F20" fill-opacity=".7" d="M5,12 L9,12 L9,11 L5,11 L5,12 Z M5,10 L11,10 L11,9 L5,9 L5,10 Z M5,8 L11,8 L11,7 L5,7 L5,8 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 578 B |
@@ -0,0 +1,10 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 10 10 10 10 8 13 8 13 6 13 1 8 1 8 6 3 6 3 15 7 15"/>
|
||||
<polygon fill="#62B543" points="14 16 16 16 16 7 14 7"/>
|
||||
<polygon fill="#F4AF3D" points="11 16 13 16 13 9 11 9"/>
|
||||
<polygon fill="#F26522" points="8 16 10 16 10 11 8 11"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 645 B |
@@ -0,0 +1 @@
|
||||
404: Not Found
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity=".7">
|
||||
<rect x="1" y="9" width="15" height="7" fill="#bc9662" opacity="1"/>
|
||||
<path d="m8 1v5h-5v2h10v-7z" fill="#99a8b0" opacity="1"/>
|
||||
<path d="m7 1v4h-4z" fill="#99a8b0" opacity="1"/>
|
||||
<g transform="matrix(1.18549 0 0 1.18549 .0214974 -2.54905)" fill="#231f20" opacity="1">
|
||||
<path d="m3.03106 12.3961h0.645396c0.387238 0 0.645396 0 0.774906-0.04345 0.085622-0.04259 0.171675-0.08562 0.257728-0.171675 0.086483-0.08606 0.086483-0.215132 0.086483-0.344212 0-0.129509-0.043457-0.258158-0.129079-0.344642-0.086053-0.08562-0.172536-0.128648-0.301185-0.171675h-1.33425zm-0.860528 2.49553v-4.30264h1.76451c0.430264 0 0.774045 0.04259 0.989177 0.129079 0.215132 0.08606 0.387668 0.215132 0.473291 0.430264 0.129509 0.215133 0.172106 0.430265 0.172106 0.688423 0 0.344211-0.086053 0.602369-0.257728 0.817501-0.172536 0.215133-0.473721 0.343781-0.817932 0.387238 0.172536 0.12908 0.344212 0.215132 0.473291 0.344212 0.12951 0.129079 0.301185 0.344211 0.473721 0.687992l0.516317 0.860529h-1.03306l-0.60237-0.946151c-0.215132-0.344642-0.386808-0.559774-0.430264-0.645397-0.085623-0.129509-0.171675-0.172105-0.258158-0.215132-0.086053-0.043028-0.215132-0.043028-0.430264-0.043028h-0.172106v1.80711z" opacity="1"/>
|
||||
<path d="m7.62676 13.0956c0.817072 0.171675 0.989608 0.344211 0.989608 0.645396s-0.301185 0.516317-0.774476 0.516317h-1.67803v0.688423h1.63457c0.947012 0 1.59241-0.473291 1.59241-1.2482 0-0.687992-0.473291-0.989177-1.41987-1.20431-0.860529-0.172105-1.03306-0.344211-1.03306-0.645395 0-0.258159 0.258589-0.516748 0.73188-0.516748h1.20431v-0.687992h-1.20431c-0.860528 0-1.50592 0.516317-1.50592 1.20474 0 0.774045 0.558913 1.03263 1.4629 1.24777" opacity="1"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,8 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="8 1 8 6 3 6 3 15 13 15 13 1"/>
|
||||
<path fill="#231F20" fill-opacity=".7" d="M5,12 L9,12 L9,11 L5,11 L5,12 Z M5,10 L11,10 L11,9 L5,9 L5,10 Z M5,8 L11,8 L11,7 L5,7 L5,8 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 579 B |
@@ -0,0 +1 @@
|
||||
404: Not Found
|
||||
@@ -0,0 +1,8 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<path fill="#9AA7B0" fill-opacity=".8" d="M10.9997,15 L2.9997,15 L2.9997,6 L7.9997,6 L7.9997,1 L12.9997,1 C12.9914069,2.84997911 12.9838819,4.51666712 12.9771251,6.00006403 C10.7785117,6.01237564 9,7.79849099 9,10 C9,10.231552 9.04249042,10.5667624 9.12747127,11.0056312 C9.7627201,11.0056312 10.3867963,11.0056312 10.9997,11.0056312 L10.9997,15 Z"/>
|
||||
<path fill="#40B6E0" d="M12.25,16 L13.75,16 L13.75,14.5 L12.25,14.5 L12.25,16 Z M13,7 C11.3425,7 10,8.3425 10,10 L11.5,10 C11.5,9.175 12.175,8.5 13,8.5 C13.825,8.5 14.5,9.175 14.5,10 C14.5,11.5 12.25,11.3125 12.25,13.75 L13.75,13.75 C13.75,12.0625 16,11.875 16,10 C16,8.3425 14.6575,7 13,7 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1006 B |
@@ -0,0 +1,10 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#F26522" fill-opacity=".7" points="1 16 16 16 16 9 1 9"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="8 1 8 6 3 6 3 8 13 8 13 1"/>
|
||||
<polygon fill="#231F20" fill-opacity=".7" points="3 13 3 12 6 10 6 11 3.8 12.5 6 14 6 15"/>
|
||||
<polygon fill="#231F20" fill-opacity=".7" points="8 14 10.2 12.5 8 11 8 10 11 12 11 13 8 15"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 707 B |
@@ -0,0 +1,11 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<polygon fill="#F98B9E" fill-opacity=".7" points="1 16 16 16 16 9 1 9"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="7 1 3 5 7 5"/>
|
||||
<polygon fill="#9AA7B0" fill-opacity=".8" points="8 1 8 6 3 6 3 8 13 8 13 1"/>
|
||||
<polygon fill="#231F20" fill-opacity=".7" points="1.996 5 1.996 3.029 0 0 1.05 0 2.496 2.207 3.95 0 5 0 3 3.007 3 5" transform="translate(1 10)"/>
|
||||
<polygon fill="#231F20" fill-opacity=".7" points="0 0 .936 0 2.5 2 3.979 0 5 0 5 5 4 5 4 1.7 2.5 3.5 1 1.7 1 5 0 5" transform="translate(6 10)"/>
|
||||
<polygon fill="#231F20" fill-opacity=".7" points="0 0 1 0 1 4 3.5 4 3.5 5 0 5" transform="translate(12 10)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 927 B |
@@ -0,0 +1,111 @@
|
||||
//! macOS: break a startup deadlock between eframe and egui.
|
||||
//!
|
||||
//! eframe creates the native window with [`winit::window::Window::set_visible`]`(false)` until the first
|
||||
//! successful frame ([`eframe` PR 3631](https://github.com/emilk/egui/pull/3631)). While the window is
|
||||
//! still hidden, winit may emit [`winit::event::WindowEvent::Occluded`]`(true)`. egui then treats the
|
||||
//! viewport as not visible ([`egui::ViewportInfo::visible`]) and **skips painting**, so
|
||||
//! [`eframe::native::epi_integration::EpiIntegration::post_rendering`] never runs and the window is
|
||||
//! never shown. The process keeps running (Dock icon) with no UI.
|
||||
//!
|
||||
//! [`App::logic`](eframe::App::logic) still runs in that state. We send
|
||||
//! [`egui::ViewportCommand::Visible`]`(true)` (applied even when painting is skipped), order the
|
||||
//! `NSWindow` forward, and activate the app.
|
||||
//!
|
||||
//! Invisible windows are repainted on a **throttled** schedule (~10 Hz), so a fixed “240 frames” cap
|
||||
//! can expire in wall-clock time before enough kicks land. We therefore keep kicking until AppKit
|
||||
//! reports the window visible for several consecutive logic passes (or a large safety limit).
|
||||
//!
|
||||
//! Set `BYTE_DRAFT_SKIP_MACOS_WINDOW_KICK=1` to disable.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use egui::{Context, ViewportCommand};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
|
||||
/// Stop after this many [`kick_stuck_eframe_window`] calls (failsafe).
|
||||
const MAX_LOGIC_PASSES: u32 = 20_000;
|
||||
/// Require this many consecutive passes where AppKit says the window is visible.
|
||||
const VISIBLE_STREAK_TO_STOP: u32 = 120;
|
||||
|
||||
static LOGIC_PASSES: AtomicU32 = AtomicU32::new(0);
|
||||
static VISIBLE_STREAK: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
pub fn kick_stuck_eframe_window(frame: &eframe::Frame, ctx: &Context) {
|
||||
if std::env::var_os("BYTE_DRAFT_SKIP_MACOS_WINDOW_KICK").is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
if VISIBLE_STREAK.load(Ordering::Relaxed) >= VISIBLE_STREAK_TO_STOP {
|
||||
return;
|
||||
}
|
||||
|
||||
// `handle_viewport_output` runs even when egui skips painting for “invisible” viewports, so this
|
||||
// reaches winit’s `set_visible` and breaks the hidden-until-first-paint / occluded deadlock.
|
||||
ctx.send_viewport_cmd(ViewportCommand::Visible(true));
|
||||
let pass = LOGIC_PASSES.fetch_add(1, Ordering::Relaxed);
|
||||
if pass < 25 {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Focus);
|
||||
}
|
||||
if pass >= MAX_LOGIC_PASSES {
|
||||
if pass == MAX_LOGIC_PASSES {
|
||||
log::warn!(
|
||||
target: "byte_draft_desktop",
|
||||
"macOS window kick: gave up after {MAX_LOGIC_PASSES} logic passes; window may stay hidden"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let visible_after = try_order_front(frame);
|
||||
if visible_after.unwrap_or(false) {
|
||||
let streak = VISIBLE_STREAK.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if streak == VISIBLE_STREAK_TO_STOP {
|
||||
log::debug!(
|
||||
target: "byte_draft_desktop",
|
||||
"macOS: window visible streak reached {VISIBLE_STREAK_TO_STOP}; stopping bootstrap kicks"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
VISIBLE_STREAK.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Returns `Some(true)` if AppKit reports the window visible after ordering it forward.
|
||||
fn try_order_front(frame: &eframe::Frame) -> Option<bool> {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSView};
|
||||
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
log::warn!(target: "byte_draft_desktop", "macOS window kick: not on main thread");
|
||||
return None;
|
||||
};
|
||||
|
||||
let handle = frame.window_handle().ok()?;
|
||||
|
||||
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let view_ptr = appkit.ns_view.as_ptr();
|
||||
if view_ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// SAFETY: `ns_view` is a live AppKit view for the lifetime of `Frame`, per `raw-window-handle`.
|
||||
let view = unsafe { &*view_ptr.cast::<NSView>() };
|
||||
|
||||
let window = view.window()?;
|
||||
|
||||
window.orderFrontRegardless();
|
||||
window.makeKeyAndOrderFront(None);
|
||||
window.display();
|
||||
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
// Still the common activation path for CLI-launched apps; replacement `NSApp.activate` needs newer SDK wiring.
|
||||
#[allow(deprecated)]
|
||||
app.activateIgnoringOtherApps(true);
|
||||
|
||||
Some(window.isVisible())
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Trait for applying [`MenuCommand`] without tying `menu_model` to egui.
|
||||
|
||||
use eframe::Frame;
|
||||
|
||||
use crate::menu_model::MenuCommand;
|
||||
|
||||
/// Host that can execute menu commands (e.g. [`crate::ui::ByteDraftApp`]).
|
||||
pub trait MenuHost {
|
||||
fn handle_menu_command(&mut self, cmd: MenuCommand, frame: Option<&Frame>);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeHost {
|
||||
log: Vec<MenuCommand>,
|
||||
}
|
||||
|
||||
impl MenuHost for FakeHost {
|
||||
fn handle_menu_command(&mut self, cmd: MenuCommand, _frame: Option<&Frame>) {
|
||||
self.log.push(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_host_records_commands() {
|
||||
let mut h = FakeHost::default();
|
||||
h.handle_menu_command(MenuCommand::NewTab, None);
|
||||
h.handle_menu_command(MenuCommand::OpenFile, None);
|
||||
assert_eq!(h.log.len(), 2);
|
||||
assert_eq!(h.log[0], MenuCommand::NewTab);
|
||||
assert_eq!(h.log[1], MenuCommand::OpenFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Build egui menu bar from [`crate::menu_model`].
|
||||
|
||||
use eframe::egui;
|
||||
use eframe::Frame;
|
||||
|
||||
use crate::menu_dispatch::MenuHost;
|
||||
use crate::menu_model::{
|
||||
surfaces, MenuCommand, MenuItemSpec, MenuRowSpec, MenuShortcut, ROOT_MENUS,
|
||||
};
|
||||
|
||||
fn shortcut_hint(sc: MenuShortcut) -> String {
|
||||
match sc {
|
||||
MenuShortcut::Primary { key } => {
|
||||
let mod_label = if cfg!(target_os = "macos") {
|
||||
"⌘"
|
||||
} else {
|
||||
"Ctrl+"
|
||||
};
|
||||
let letter = key.to_ascii_uppercase();
|
||||
format!("{mod_label}{letter}")
|
||||
}
|
||||
MenuShortcut::PrimaryShift { key } => {
|
||||
let mod_label = if cfg!(target_os = "macos") {
|
||||
"⌘⇧"
|
||||
} else {
|
||||
"Ctrl+Shift+"
|
||||
};
|
||||
format!("{mod_label}{}", key.to_ascii_uppercase())
|
||||
}
|
||||
MenuShortcut::FormatDocument => {
|
||||
if cfg!(target_os = "macos") {
|
||||
"⌘⌥L".to_string()
|
||||
} else {
|
||||
"Ctrl+Alt+L".to_string()
|
||||
}
|
||||
}
|
||||
MenuShortcut::Preferences => {
|
||||
if cfg!(target_os = "macos") {
|
||||
"⌘,".to_string()
|
||||
} else {
|
||||
"Ctrl+,".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ViewMenuChecks {
|
||||
word_wrap: bool,
|
||||
show_line_endings: bool,
|
||||
}
|
||||
|
||||
fn item_label(spec: &MenuItemSpec, checks: ViewMenuChecks) -> String {
|
||||
let prefix = |on: bool| -> &'static str {
|
||||
if on {
|
||||
"✓ "
|
||||
} else {
|
||||
" "
|
||||
}
|
||||
};
|
||||
match spec.cmd {
|
||||
MenuCommand::ToggleWordWrap => {
|
||||
format!("{}Word wrap", prefix(checks.word_wrap))
|
||||
}
|
||||
MenuCommand::ToggleShowLineEndings => {
|
||||
format!("{}Show line endings", prefix(checks.show_line_endings))
|
||||
}
|
||||
_ => match spec.shortcut {
|
||||
Some(sc) if spec.cmd == MenuCommand::ToggleSidebar => {
|
||||
format!("{} ({})", spec.label, shortcut_hint(sc))
|
||||
}
|
||||
Some(_) => spec.label.to_string(),
|
||||
None => spec.label.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw top-level menus for items that include [`surfaces::IN_WINDOW_STRIP`].
|
||||
pub fn draw_menu_bar_from_manifest(
|
||||
ui: &mut egui::Ui,
|
||||
host: &mut impl MenuHost,
|
||||
frame: &Frame,
|
||||
workspace_has_root: bool,
|
||||
word_wrap: bool,
|
||||
show_line_endings: bool,
|
||||
) {
|
||||
let checks = ViewMenuChecks {
|
||||
word_wrap,
|
||||
show_line_endings,
|
||||
};
|
||||
for submenu in ROOT_MENUS {
|
||||
ui.menu_button(submenu.title, |ui| {
|
||||
for row in submenu.rows {
|
||||
match row {
|
||||
MenuRowSpec::Sep => {
|
||||
ui.separator();
|
||||
}
|
||||
MenuRowSpec::Item(spec) => {
|
||||
if spec.surfaces & surfaces::IN_WINDOW_STRIP == 0 {
|
||||
continue;
|
||||
}
|
||||
let enabled = !spec.requires_workspace || workspace_has_root;
|
||||
let label = item_label(spec, checks);
|
||||
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
|
||||
host.handle_menu_command(spec.cmd, Some(frame));
|
||||
ui.close();
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
if !enabled && spec.requires_workspace {
|
||||
// hover on disabled already shows nothing; optional tooltip on row
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Declarative app menu: commands, shortcuts, and surface flags. No UI crates.
|
||||
|
||||
pub use byte_draft::MenuCommand;
|
||||
|
||||
/// Logical shortcut: primary modifier is Cmd on macOS, Ctrl elsewhere.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MenuShortcut {
|
||||
/// Primary + letter (e.g. Cmd/Ctrl+T).
|
||||
Primary { key: char },
|
||||
/// Primary + Shift + letter (e.g. Save As).
|
||||
PrimaryShift { key: char },
|
||||
/// Format: Primary + Alt + L.
|
||||
FormatDocument,
|
||||
/// Preferences: Primary + Comma.
|
||||
Preferences,
|
||||
}
|
||||
|
||||
impl MenuShortcut {
|
||||
/// For accelerator collision checks and docs (mac-style names). Used by unit tests.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Primary { key } => match key {
|
||||
't' => "Cmd/Ctrl+T",
|
||||
'w' => "Cmd/Ctrl+W",
|
||||
'o' => "Cmd/Ctrl+O",
|
||||
's' => "Cmd/Ctrl+S",
|
||||
'f' => "Cmd/Ctrl+F",
|
||||
'b' => "Cmd/Ctrl+B",
|
||||
_ => "shortcut",
|
||||
},
|
||||
Self::PrimaryShift { .. } => "Cmd/Ctrl+Shift+S",
|
||||
Self::FormatDocument => "Cmd/Ctrl+Alt+L",
|
||||
Self::Preferences => "Cmd/Ctrl+,",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bit flags: where a menu row appears.
|
||||
pub mod surfaces {
|
||||
pub type Raw = u8;
|
||||
pub const IN_WINDOW_STRIP: Raw = 1;
|
||||
pub const NATIVE_MENU_BAR: Raw = 2;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MenuItemSpec {
|
||||
pub cmd: MenuCommand,
|
||||
pub label: &'static str,
|
||||
pub shortcut: Option<MenuShortcut>,
|
||||
pub surfaces: surfaces::Raw,
|
||||
/// When true, the item is disabled if there is no workspace folder open.
|
||||
pub requires_workspace: bool,
|
||||
}
|
||||
|
||||
pub enum MenuRowSpec {
|
||||
Sep,
|
||||
Item(MenuItemSpec),
|
||||
}
|
||||
|
||||
pub struct SubmenuSpec {
|
||||
pub title: &'static str,
|
||||
pub rows: &'static [MenuRowSpec],
|
||||
}
|
||||
|
||||
/// Root menus (File / Edit / View) in display order.
|
||||
pub static ROOT_MENUS: &[SubmenuSpec] = &[
|
||||
SubmenuSpec {
|
||||
title: "File",
|
||||
rows: &[
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::NewTab,
|
||||
label: "New tab",
|
||||
shortcut: Some(MenuShortcut::Primary { key: 't' }),
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: false,
|
||||
}),
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::CloseTab,
|
||||
label: "Close tab",
|
||||
shortcut: Some(MenuShortcut::Primary { key: 'w' }),
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: false,
|
||||
}),
|
||||
MenuRowSpec::Sep,
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::OpenFile,
|
||||
label: "Open…",
|
||||
shortcut: Some(MenuShortcut::Primary { key: 'o' }),
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: false,
|
||||
}),
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::OpenFolder,
|
||||
label: "Open folder…",
|
||||
shortcut: None,
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: false,
|
||||
}),
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::CloseFolder,
|
||||
label: "Close folder",
|
||||
shortcut: None,
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: true,
|
||||
}),
|
||||
MenuRowSpec::Sep,
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::Save,
|
||||
label: "Save",
|
||||
shortcut: Some(MenuShortcut::Primary { key: 's' }),
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: false,
|
||||
}),
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::SaveAs,
|
||||
label: "Save as…",
|
||||
shortcut: Some(MenuShortcut::PrimaryShift { key: 's' }),
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: false,
|
||||
}),
|
||||
MenuRowSpec::Sep,
|
||||
// Preferences only appears in the in-window File menu (Windows/Linux).
|
||||
// On macOS it is placed in the ByteDraft app menu by platform_menu::macos.
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::Preferences,
|
||||
label: "Preferences…",
|
||||
shortcut: Some(MenuShortcut::Preferences),
|
||||
surfaces: surfaces::IN_WINDOW_STRIP,
|
||||
requires_workspace: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
SubmenuSpec {
|
||||
title: "Edit",
|
||||
rows: &[MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::FormatDocument,
|
||||
label: "Format document",
|
||||
shortcut: Some(MenuShortcut::FormatDocument),
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: false,
|
||||
})],
|
||||
},
|
||||
SubmenuSpec {
|
||||
title: "View",
|
||||
rows: &[
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::ToggleFind,
|
||||
label: "Find…",
|
||||
shortcut: Some(MenuShortcut::Primary { key: 'f' }),
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: false,
|
||||
}),
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::ToggleSidebar,
|
||||
label: "Toggle folder tree",
|
||||
shortcut: Some(MenuShortcut::Primary { key: 'b' }),
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: true,
|
||||
}),
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::ToggleWordWrap,
|
||||
label: "Word wrap",
|
||||
shortcut: None,
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: false,
|
||||
}),
|
||||
MenuRowSpec::Item(MenuItemSpec {
|
||||
cmd: MenuCommand::ToggleShowLineEndings,
|
||||
label: "Show line endings",
|
||||
shortcut: None,
|
||||
surfaces: surfaces::IN_WINDOW_STRIP | surfaces::NATIVE_MENU_BAR,
|
||||
requires_workspace: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // Used by `#[cfg(test)]` manifest tests; kept for tooling / future UI.
|
||||
pub fn item_spec_for_command(cmd: MenuCommand) -> Option<&'static MenuItemSpec> {
|
||||
for menu in ROOT_MENUS {
|
||||
for row in menu.rows {
|
||||
if let MenuRowSpec::Item(spec) = row {
|
||||
if spec.cmd == cmd {
|
||||
return Some(spec);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Hotkey table: one entry per command that has a keyboard shortcut.
|
||||
/// Referenced by unit tests; intended for future keybinding settings UI.
|
||||
#[allow(dead_code)]
|
||||
pub static MENU_SHORTCUTS: &[(MenuShortcut, MenuCommand)] = &[
|
||||
(MenuShortcut::Primary { key: 't' }, MenuCommand::NewTab),
|
||||
(MenuShortcut::Primary { key: 'w' }, MenuCommand::CloseTab),
|
||||
(MenuShortcut::Primary { key: 'o' }, MenuCommand::OpenFile),
|
||||
(
|
||||
MenuShortcut::PrimaryShift { key: 's' },
|
||||
MenuCommand::SaveAs,
|
||||
),
|
||||
(MenuShortcut::Primary { key: 's' }, MenuCommand::Save),
|
||||
(MenuShortcut::FormatDocument, MenuCommand::FormatDocument),
|
||||
(MenuShortcut::Primary { key: 'f' }, MenuCommand::ToggleFind),
|
||||
(MenuShortcut::Primary { key: 'b' }, MenuCommand::ToggleSidebar),
|
||||
(MenuShortcut::Preferences, MenuCommand::Preferences),
|
||||
];
|
||||
|
||||
// --- Manifest validation (unit tests) ---
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn collect_commands(mask: surfaces::Raw) -> Vec<MenuCommand> {
|
||||
let mut v = Vec::new();
|
||||
for menu in ROOT_MENUS {
|
||||
for row in menu.rows {
|
||||
if let MenuRowSpec::Item(spec) = row {
|
||||
if spec.surfaces & mask != 0 {
|
||||
v.push(spec.cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_duplicate_command_per_surface() {
|
||||
for (name, mask) in [
|
||||
("IN_WINDOW", surfaces::IN_WINDOW_STRIP),
|
||||
("NATIVE_BAR", surfaces::NATIVE_MENU_BAR),
|
||||
] {
|
||||
let cmds = collect_commands(mask);
|
||||
let mut seen = HashSet::new();
|
||||
for c in cmds {
|
||||
assert!(
|
||||
seen.insert(c),
|
||||
"duplicate {c:?} for surface {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_unique_by_logical_binding() {
|
||||
let mut seen: HashSet<&'static str> = HashSet::new();
|
||||
for (sc, _) in MENU_SHORTCUTS {
|
||||
let d = sc.description();
|
||||
assert!(
|
||||
seen.insert(d),
|
||||
"duplicate shortcut description {d} in MENU_SHORTCUTS"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shortcut_maps_to_item_with_shortcut() {
|
||||
for (sc, cmd) in MENU_SHORTCUTS {
|
||||
let spec = item_spec_for_command(*cmd).expect("command in manifest");
|
||||
assert_eq!(
|
||||
spec.shortcut.as_ref(),
|
||||
Some(sc),
|
||||
"shortcut table mismatch for {cmd:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_tab_in_manifest() {
|
||||
assert!(item_spec_for_command(MenuCommand::CloseTab).is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Native macOS menu bar via `muda`.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use egui::Context;
|
||||
use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers};
|
||||
use muda::{
|
||||
CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
|
||||
};
|
||||
|
||||
use crate::menu_model::{surfaces, MenuCommand, MenuRowSpec, MenuShortcut, ROOT_MENUS};
|
||||
|
||||
static INSTALL_GUARD: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
static PENDING_COMMANDS: OnceLock<Arc<Mutex<Vec<MenuCommand>>>> = OnceLock::new();
|
||||
|
||||
thread_local! {
|
||||
/// `MenuItem` is not `Sync`; keep handles on the AppKit main thread only.
|
||||
static WORKSPACE_GATED_ITEMS: RefCell<Vec<MenuItem>> = const { RefCell::new(Vec::new()) };
|
||||
static WORD_WRAP_CHECK: RefCell<Option<CheckMenuItem>> = const { RefCell::new(None) };
|
||||
static LINE_ENDINGS_CHECK: RefCell<Option<CheckMenuItem>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // Public hook for UI: hide in-window menu when native bar is used.
|
||||
pub fn use_in_window_menu_strip() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn native_menu_owns_keyboard_shortcuts() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn char_to_code(key: char) -> Option<Code> {
|
||||
Some(match key.to_ascii_lowercase() {
|
||||
't' => Code::KeyT,
|
||||
'w' => Code::KeyW,
|
||||
'o' => Code::KeyO,
|
||||
's' => Code::KeyS,
|
||||
'f' => Code::KeyF,
|
||||
'b' => Code::KeyB,
|
||||
',' => Code::Comma,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn muda_accelerator(sc: MenuShortcut) -> Option<Accelerator> {
|
||||
match sc {
|
||||
MenuShortcut::Primary { key } => {
|
||||
let code = char_to_code(key)?;
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), code))
|
||||
}
|
||||
MenuShortcut::PrimaryShift { key } => {
|
||||
let code = char_to_code(key)?;
|
||||
Some(Accelerator::new(
|
||||
Some(CMD_OR_CTRL | Modifiers::SHIFT),
|
||||
code,
|
||||
))
|
||||
}
|
||||
MenuShortcut::FormatDocument => Some(Accelerator::new(
|
||||
Some(CMD_OR_CTRL | Modifiers::ALT),
|
||||
Code::KeyL,
|
||||
)),
|
||||
MenuShortcut::Preferences => {
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn command_for_menu_id(id: &muda::MenuId) -> Option<MenuCommand> {
|
||||
use MenuCommand::*;
|
||||
[
|
||||
NewTab,
|
||||
CloseTab,
|
||||
OpenFile,
|
||||
OpenFileAsHex,
|
||||
OpenFolder,
|
||||
CloseFolder,
|
||||
Save,
|
||||
SaveAs,
|
||||
FormatDocument,
|
||||
ToggleFind,
|
||||
ToggleSidebar,
|
||||
ToggleWordWrap,
|
||||
ToggleShowLineEndings,
|
||||
Preferences,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|c| id.as_ref() == c.id_str())
|
||||
}
|
||||
|
||||
fn append_rows_native(
|
||||
submenu: &Submenu,
|
||||
rows: &[MenuRowSpec],
|
||||
workspace_gated: &mut Vec<MenuItem>,
|
||||
) -> Result<(), muda::Error> {
|
||||
for row in rows {
|
||||
match row {
|
||||
MenuRowSpec::Sep => submenu.append(&PredefinedMenuItem::separator())?,
|
||||
MenuRowSpec::Item(spec) => {
|
||||
use MenuCommand::*;
|
||||
match spec.cmd {
|
||||
ToggleWordWrap => {
|
||||
if spec.surfaces & surfaces::NATIVE_MENU_BAR == 0 {
|
||||
continue;
|
||||
}
|
||||
let accel = spec.shortcut.and_then(muda_accelerator);
|
||||
let enabled = !spec.requires_workspace;
|
||||
let item = CheckMenuItem::with_id(
|
||||
muda::MenuId::new(spec.cmd.id_str()),
|
||||
spec.label,
|
||||
enabled,
|
||||
false,
|
||||
accel,
|
||||
);
|
||||
submenu.append(&item)?;
|
||||
WORD_WRAP_CHECK.with(|c| {
|
||||
*c.borrow_mut() = Some(item);
|
||||
});
|
||||
}
|
||||
ToggleShowLineEndings => {
|
||||
if spec.surfaces & surfaces::NATIVE_MENU_BAR == 0 {
|
||||
continue;
|
||||
}
|
||||
let accel = spec.shortcut.and_then(muda_accelerator);
|
||||
let enabled = !spec.requires_workspace;
|
||||
let item = CheckMenuItem::with_id(
|
||||
muda::MenuId::new(spec.cmd.id_str()),
|
||||
spec.label,
|
||||
enabled,
|
||||
false,
|
||||
accel,
|
||||
);
|
||||
submenu.append(&item)?;
|
||||
LINE_ENDINGS_CHECK.with(|c| {
|
||||
*c.borrow_mut() = Some(item);
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
if spec.surfaces & surfaces::NATIVE_MENU_BAR == 0 {
|
||||
continue;
|
||||
}
|
||||
let accel = spec.shortcut.and_then(muda_accelerator);
|
||||
let enabled = !spec.requires_workspace;
|
||||
let item = MenuItem::with_id(
|
||||
muda::MenuId::new(spec.cmd.id_str()),
|
||||
spec.label,
|
||||
enabled,
|
||||
accel,
|
||||
);
|
||||
submenu.append(&item)?;
|
||||
if spec.requires_workspace {
|
||||
workspace_gated.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_file_edit_view(workspace_gated: &mut Vec<MenuItem>) -> Result<Vec<Submenu>, muda::Error> {
|
||||
let mut out = Vec::new();
|
||||
for spec in ROOT_MENUS {
|
||||
let sm = Submenu::new(spec.title, true);
|
||||
append_rows_native(&sm, spec.rows, workspace_gated)?;
|
||||
out.push(sm);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Install the app menu and File / Edit / View. Safe to call once from the main thread.
|
||||
pub fn install(ctx: &Context) {
|
||||
if INSTALL_GUARD.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
let _ = PENDING_COMMANDS.set(pending.clone());
|
||||
|
||||
let menu = Menu::new();
|
||||
|
||||
// Build the ByteDraft app menu imperatively so we can insert the Preferences item
|
||||
// at the standard position (between About and Services).
|
||||
let app_menu = Submenu::new("ByteDraft", true);
|
||||
let prefs_item = MenuItem::with_id(
|
||||
muda::MenuId::new(MenuCommand::Preferences.id_str()),
|
||||
"Preferences…",
|
||||
true,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma)),
|
||||
);
|
||||
app_menu.append(&PredefinedMenuItem::about(Some("About ByteDraft"), None)).ok();
|
||||
app_menu.append(&PredefinedMenuItem::separator()).ok();
|
||||
app_menu.append(&prefs_item).ok();
|
||||
app_menu.append(&PredefinedMenuItem::separator()).ok();
|
||||
app_menu.append(&PredefinedMenuItem::services(None)).ok();
|
||||
app_menu.append(&PredefinedMenuItem::separator()).ok();
|
||||
app_menu.append(&PredefinedMenuItem::hide(None)).ok();
|
||||
app_menu.append(&PredefinedMenuItem::hide_others(None)).ok();
|
||||
app_menu.append(&PredefinedMenuItem::show_all(None)).ok();
|
||||
app_menu.append(&PredefinedMenuItem::separator()).ok();
|
||||
app_menu.append(&PredefinedMenuItem::quit(None)).ok();
|
||||
menu.append(&app_menu).expect("append app menu");
|
||||
|
||||
let mut workspace_gated = Vec::new();
|
||||
let built = build_file_edit_view(&mut workspace_gated).expect("file/edit/view");
|
||||
for sm in built {
|
||||
menu.append(&sm).expect("append submenu");
|
||||
}
|
||||
WORKSPACE_GATED_ITEMS.with(|cell| {
|
||||
*cell.borrow_mut() = workspace_gated;
|
||||
});
|
||||
|
||||
menu.init_for_nsapp();
|
||||
|
||||
let ctx = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |ev: MenuEvent| {
|
||||
if let Some(cmd) = command_for_menu_id(ev.id()) {
|
||||
if let Ok(mut g) = pending.lock() {
|
||||
g.push(cmd);
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}));
|
||||
|
||||
// `muda` stores raw `MenuChild` pointers in `MudaMenuItem` ivars. If we drop the root `Menu`
|
||||
// when this function returns, that graph is freed while AppKit still dispatches actions →
|
||||
// use-after-free (often crashing inside `NSString::from_str` in `fire_menu_item_click`).
|
||||
std::mem::forget(menu);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn poll_menu_commands() -> Vec<MenuCommand> {
|
||||
let Some(arc) = PENDING_COMMANDS.get() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(mut g) = arc.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
std::mem::take(&mut *g)
|
||||
}
|
||||
|
||||
/// Keep native item enablement in sync with app state (e.g. workspace folder open).
|
||||
pub fn sync_workspace_gated_items(workspace_has_root: bool) {
|
||||
WORKSPACE_GATED_ITEMS.with(|cell| {
|
||||
for item in cell.borrow().iter() {
|
||||
item.set_enabled(workspace_has_root);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep View menu checkmarks in sync with editor state.
|
||||
pub fn sync_view_menu_checks(word_wrap: bool, show_line_endings: bool) {
|
||||
WORD_WRAP_CHECK.with(|cell| {
|
||||
if let Some(ref item) = *cell.borrow() {
|
||||
item.set_checked(word_wrap);
|
||||
}
|
||||
});
|
||||
LINE_ENDINGS_CHECK.with(|cell| {
|
||||
if let Some(ref item) = *cell.borrow() {
|
||||
item.set_checked(show_line_endings);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! OS-specific native menus. macOS uses `muda`; other targets keep in-window egui menus only.
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unused_imports)] // `use_in_window_menu_strip` is part of the public macOS surface.
|
||||
pub use macos::{
|
||||
install, native_menu_owns_keyboard_shortcuts, poll_menu_commands,
|
||||
sync_view_menu_checks, sync_workspace_gated_items, use_in_window_menu_strip,
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn install(_ctx: &egui::Context) {}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn poll_menu_commands() -> Vec<crate::menu_model::MenuCommand> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn use_in_window_menu_strip() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn native_menu_owns_keyboard_shortcuts() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn sync_workspace_gated_items(_workspace_has_root: bool) {}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn sync_view_menu_checks(_word_wrap: bool, _show_line_endings: bool) {}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Frameless window title bar: host detection and layout constants (no egui types).
|
||||
|
||||
/// Target look-and-feel for embedded window controls (UI convention only).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum TitleBarHost {
|
||||
Windows,
|
||||
Macos,
|
||||
Linux,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn detect_title_bar_host() -> TitleBarHost {
|
||||
if cfg!(target_os = "macos") {
|
||||
TitleBarHost::Macos
|
||||
} else if cfg!(target_os = "windows") {
|
||||
TitleBarHost::Windows
|
||||
} else {
|
||||
TitleBarHost::Linux
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame title bar configuration.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TitleBarPlan {
|
||||
pub host: TitleBarHost,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl Default for TitleBarPlan {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: detect_title_bar_host(),
|
||||
title: "ByteDraft".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const TITLE_BAR_H: f32 = 32.0;
|
||||
/// Second row under the title strip (Windows / Linux): menu bar height.
|
||||
pub const WINDOWS_MENU_BAR_ROW_H: f32 = 26.0;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_title_bar_host_matches_cfg() {
|
||||
let h = detect_title_bar_host();
|
||||
if cfg!(target_os = "windows") {
|
||||
assert_eq!(h, TitleBarHost::Windows);
|
||||
} else if cfg!(target_os = "macos") {
|
||||
assert_eq!(h, TitleBarHost::Macos);
|
||||
} else {
|
||||
assert_eq!(h, TitleBarHost::Linux);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_plan_default_title() {
|
||||
let p = TitleBarPlan::default();
|
||||
assert_eq!(p.title, "ByteDraft");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Native file dialog enums and cross-platform `rfd` entry points.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use eframe::Frame;
|
||||
|
||||
/// Run native dialogs outside menu closures so clicks are not lost (egui menus + modal timing).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum PendingFileOp {
|
||||
OpenFile,
|
||||
OpenFileAsHex,
|
||||
OpenFolder,
|
||||
Save { pick_path: bool },
|
||||
}
|
||||
|
||||
/// Windows: `rfd` runs on a worker thread; results are polled in `poll_native_file_dialog`.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum NativeFileDialogResult {
|
||||
Open(Option<PathBuf>),
|
||||
OpenHex(Option<PathBuf>),
|
||||
Folder(Option<PathBuf>),
|
||||
Save(Option<PathBuf>),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum WindowsFileDialogOp {
|
||||
OpenFile,
|
||||
OpenFileHex,
|
||||
PickFolder,
|
||||
Save,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
impl WindowsFileDialogOp {
|
||||
pub(crate) fn busy_warning(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenFile => "open file dialog skipped: another native dialog is already in progress",
|
||||
Self::OpenFileHex => {
|
||||
"open file as hex dialog skipped: another native dialog is already in progress"
|
||||
}
|
||||
Self::PickFolder => {
|
||||
"pick folder dialog skipped: another native dialog is already in progress"
|
||||
}
|
||||
Self::Save => "save dialog skipped: another native dialog is already in progress",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn schedule_log(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenFile => "scheduling native pick_file on worker thread",
|
||||
Self::OpenFileHex => "scheduling native pick_file (hex) on worker thread",
|
||||
Self::PickFolder => "scheduling native pick_folder on worker thread",
|
||||
Self::Save => "scheduling native save_file on worker thread",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn worker_enter(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenFile => "worker: pick_file() enter",
|
||||
Self::OpenFileHex => "worker: pick_file (hex) enter",
|
||||
Self::PickFolder => "worker: pick_folder() enter",
|
||||
Self::Save => "worker: save_file() enter",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn worker_leave_log(self, has_path: bool) -> String {
|
||||
match self {
|
||||
Self::OpenFile => format!("worker: pick_file() leave has_path={has_path}"),
|
||||
Self::OpenFileHex => format!("worker: pick_file (hex) leave has_path={has_path}"),
|
||||
Self::PickFolder => format!("worker: pick_folder() leave has_path={has_path}"),
|
||||
Self::Save => format!("worker: save_file() leave has_path={has_path}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pick(self) -> Option<PathBuf> {
|
||||
let d = rfd::FileDialog::new();
|
||||
match self {
|
||||
Self::OpenFile | Self::OpenFileHex => d.pick_file(),
|
||||
Self::PickFolder => d.pick_folder(),
|
||||
Self::Save => d.save_file(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_native_result(self, path: Option<PathBuf>) -> NativeFileDialogResult {
|
||||
match self {
|
||||
Self::OpenFile => NativeFileDialogResult::Open(path),
|
||||
Self::OpenFileHex => NativeFileDialogResult::OpenHex(path),
|
||||
Self::PickFolder => NativeFileDialogResult::Folder(path),
|
||||
Self::Save => NativeFileDialogResult::Save(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS / Linux / etc.: modal `rfd` on the UI thread (with parent window where supported).
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
pub(crate) fn pick_file_dialog(frame: &Frame) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_parent(frame).pick_file()
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
pub(crate) fn pick_folder_dialog(frame: &Frame) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_parent(frame).pick_folder()
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
pub(crate) fn save_file_dialog(frame: &Frame) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_parent(frame).save_file()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn pick_file_dialog(_frame: &Frame) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().pick_file()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn pick_folder_dialog(_frame: &Frame) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().pick_folder()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn save_file_dialog(_frame: &Frame) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().save_file()
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! File-explorer panel — state and drawing, separated from ByteDraftApp.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use super::{file_icons, paint};
|
||||
|
||||
const MAX_CONCURRENT_SCANS: usize = 4;
|
||||
|
||||
pub(crate) enum ExplorerDirState {
|
||||
Loading,
|
||||
Ready(Vec<Arc<PathBuf>>),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Action the explorer requests from ByteDraftApp after a draw frame.
|
||||
pub(crate) enum ExplorerAction {
|
||||
None,
|
||||
OpenFile(PathBuf),
|
||||
OpenFileHex(PathBuf),
|
||||
}
|
||||
|
||||
pub(crate) struct ExplorerState {
|
||||
pub selected: Option<PathBuf>,
|
||||
pub expanded: HashSet<PathBuf>,
|
||||
children_cache: HashMap<PathBuf, ExplorerDirState>,
|
||||
scan_inflight: HashSet<PathBuf>,
|
||||
scan_permits: Arc<Mutex<usize>>,
|
||||
render_limit: HashMap<PathBuf, usize>,
|
||||
scan_tx: Sender<(PathBuf, Result<Vec<PathBuf>, String>)>,
|
||||
scan_rx: Receiver<(PathBuf, Result<Vec<PathBuf>, String>)>,
|
||||
root_seen: Option<PathBuf>,
|
||||
pub keyboard_active: bool,
|
||||
scroll_to_selected: bool,
|
||||
/// Set during draw; consumed and returned by `draw()` at frame end.
|
||||
pending_action: Option<(PathBuf, bool)>,
|
||||
}
|
||||
|
||||
impl ExplorerState {
|
||||
pub fn new() -> Self {
|
||||
let (scan_tx, scan_rx) = mpsc::channel();
|
||||
Self {
|
||||
selected: None,
|
||||
expanded: HashSet::new(),
|
||||
children_cache: HashMap::new(),
|
||||
scan_inflight: HashSet::new(),
|
||||
scan_permits: Arc::new(Mutex::new(0)),
|
||||
render_limit: HashMap::new(),
|
||||
scan_tx,
|
||||
scan_rx,
|
||||
root_seen: None,
|
||||
keyboard_active: false,
|
||||
scroll_to_selected: false,
|
||||
pending_action: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all cached state when workspace is closed (root set to None).
|
||||
pub fn clear(&mut self) {
|
||||
self.selected = None;
|
||||
self.expanded.clear();
|
||||
self.children_cache.clear();
|
||||
self.scan_inflight.clear();
|
||||
self.render_limit.clear();
|
||||
self.root_seen = None;
|
||||
self.keyboard_active = false;
|
||||
self.scroll_to_selected = false;
|
||||
self.pending_action = None;
|
||||
}
|
||||
|
||||
/// Reset cached state when root changes to a new directory.
|
||||
fn reset_for_root(&mut self, root: &Path) {
|
||||
self.children_cache.clear();
|
||||
self.expanded.clear();
|
||||
self.scan_inflight.clear();
|
||||
self.render_limit.clear();
|
||||
self.expanded.insert(root.to_path_buf());
|
||||
self.selected = None;
|
||||
self.root_seen = Some(root.to_path_buf());
|
||||
}
|
||||
|
||||
/// Draw the explorer. `root` must be a valid directory. Returns any file-open action.
|
||||
pub fn draw(&mut self, ui: &mut egui::Ui, root: &Path) -> ExplorerAction {
|
||||
self.pending_action = None;
|
||||
if self.root_seen.as_deref() != Some(root) {
|
||||
self.reset_for_root(root);
|
||||
}
|
||||
self.poll_scan_results();
|
||||
|
||||
let focus_id = ui.id().with("explorer_focus");
|
||||
let click_catcher = ui.interact(ui.max_rect(), focus_id, egui::Sense::click());
|
||||
if click_catcher.clicked() {
|
||||
self.keyboard_active = true;
|
||||
}
|
||||
self.handle_keyboard(ui.ctx(), root, focus_id);
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([true, true])
|
||||
.show(ui, |ui| {
|
||||
self.draw_root(ui, root, focus_id);
|
||||
});
|
||||
|
||||
match self.pending_action.take() {
|
||||
Some((path, true)) => ExplorerAction::OpenFileHex(path),
|
||||
Some((path, false)) => ExplorerAction::OpenFile(path),
|
||||
None => ExplorerAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn request_scan(&mut self, dir: &Path) {
|
||||
if self.scan_inflight.contains(dir) || self.children_cache.contains_key(dir) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let Ok(mut count) = self.scan_permits.lock() else { return; };
|
||||
if *count >= MAX_CONCURRENT_SCANS {
|
||||
return;
|
||||
}
|
||||
*count += 1;
|
||||
}
|
||||
self.scan_inflight.insert(dir.to_path_buf());
|
||||
self.children_cache.insert(dir.to_path_buf(), ExplorerDirState::Loading);
|
||||
let tx = self.scan_tx.clone();
|
||||
let dir_path = dir.to_path_buf();
|
||||
let permits = Arc::clone(&self.scan_permits);
|
||||
std::thread::spawn(move || {
|
||||
let out = match std::fs::read_dir(&dir_path) {
|
||||
Ok(read) => {
|
||||
let mut v: Vec<PathBuf> =
|
||||
read.filter_map(|e| e.ok()).map(|e| e.path()).collect();
|
||||
v.sort_by(|a, b| {
|
||||
let da = a.is_dir();
|
||||
let db = b.is_dir();
|
||||
da.cmp(&db).reverse().then_with(|| {
|
||||
a.file_name()
|
||||
.unwrap_or_default()
|
||||
.cmp(b.file_name().unwrap_or_default())
|
||||
})
|
||||
});
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
let _ = tx.send((dir_path, out));
|
||||
if let Ok(mut count) = permits.lock() {
|
||||
*count = count.saturating_sub(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn poll_scan_results(&mut self) {
|
||||
while let Ok((dir, res)) = self.scan_rx.try_recv() {
|
||||
self.scan_inflight.remove(&dir);
|
||||
match res {
|
||||
Ok(v) => {
|
||||
self.children_cache.insert(
|
||||
dir,
|
||||
ExplorerDirState::Ready(v.into_iter().map(Arc::new).collect()),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.children_cache.insert(dir, ExplorerDirState::Error(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted_children(&mut self, dir: &Path) -> Vec<Arc<PathBuf>> {
|
||||
match self.children_cache.get(dir) {
|
||||
Some(ExplorerDirState::Ready(v)) => return v.clone(),
|
||||
Some(ExplorerDirState::Loading) | Some(ExplorerDirState::Error(_)) => {
|
||||
return Vec::new();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
self.request_scan(dir);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn collect_visible_nodes(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
depth: usize,
|
||||
out: &mut Vec<(PathBuf, usize, bool)>,
|
||||
) {
|
||||
let children = self.sorted_children(path);
|
||||
let limit = *self.render_limit.entry(path.to_path_buf()).or_insert(500);
|
||||
for child in children.into_iter().take(limit) {
|
||||
let child_path = child.as_ref().as_path();
|
||||
let is_dir = child_path.is_dir();
|
||||
out.push((child.as_ref().clone(), depth, is_dir));
|
||||
if is_dir && self.expanded.contains(child_path) {
|
||||
self.collect_visible_nodes(child_path, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_keyboard(&mut self, ctx: &egui::Context, root: &Path, _focus_id: egui::Id) {
|
||||
if !self.keyboard_active {
|
||||
return;
|
||||
}
|
||||
let mut nodes = Vec::new();
|
||||
nodes.push((root.to_path_buf(), 0, true));
|
||||
self.collect_visible_nodes(root, 0, &mut nodes);
|
||||
if nodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut idx = self
|
||||
.selected
|
||||
.as_ref()
|
||||
.and_then(|p| nodes.iter().position(|(n, _, _)| n == p))
|
||||
.unwrap_or(0);
|
||||
if self.selected.is_none() {
|
||||
self.selected = Some(nodes[0].0.clone());
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::ArrowDown)) {
|
||||
idx = (idx + 1).min(nodes.len() - 1);
|
||||
self.selected = Some(nodes[idx].0.clone());
|
||||
self.scroll_to_selected = true;
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::ArrowUp)) {
|
||||
idx = idx.saturating_sub(1);
|
||||
self.selected = Some(nodes[idx].0.clone());
|
||||
self.scroll_to_selected = true;
|
||||
}
|
||||
let Some(selected) = self.selected.clone() else { return; };
|
||||
let selected_is_dir = selected.is_dir();
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) && selected_is_dir {
|
||||
if !self.expanded.contains(&selected) {
|
||||
self.expanded.insert(selected.clone());
|
||||
} else if let Some((first_child, _, _)) = nodes.iter().find(|(p, _, _)| {
|
||||
p.parent().is_some_and(|parent| parent == selected.as_path())
|
||||
}) {
|
||||
self.selected = Some(first_child.clone());
|
||||
self.scroll_to_selected = true;
|
||||
}
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) {
|
||||
if selected_is_dir && self.expanded.contains(&selected) {
|
||||
self.expanded.remove(&selected);
|
||||
} else if let Some(parent) = selected.parent() {
|
||||
if parent.starts_with(root) {
|
||||
self.selected = Some(parent.to_path_buf());
|
||||
self.scroll_to_selected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
if selected_is_dir {
|
||||
if self.expanded.contains(&selected) {
|
||||
self.expanded.remove(&selected);
|
||||
} else {
|
||||
self.expanded.insert(selected);
|
||||
}
|
||||
} else {
|
||||
self.pending_action = Some((selected, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_root(&mut self, ui: &mut egui::Ui, root: &Path, focus_id: egui::Id) {
|
||||
let root_name = root
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| root.display().to_string());
|
||||
let expanded = self.expanded.contains(root);
|
||||
ui.horizontal(|ui| {
|
||||
let (car_rect, car_resp) =
|
||||
ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click());
|
||||
paint::paint_folder_tree_caret(ui, car_rect, expanded);
|
||||
if car_resp.clicked() {
|
||||
if expanded {
|
||||
self.expanded.remove(root);
|
||||
} else {
|
||||
self.expanded.insert(root.to_path_buf());
|
||||
}
|
||||
self.keyboard_active = true;
|
||||
}
|
||||
let _ = file_icons::draw_icon(ui, file_icons::folder_icon(root), 16.0);
|
||||
let selected = self.selected.as_ref().is_some_and(|p| p.as_path() == root);
|
||||
let resp = draw_row_label(ui, &root_name, selected);
|
||||
if selected && self.scroll_to_selected {
|
||||
resp.scroll_to_me(Some(egui::Align::Center));
|
||||
self.scroll_to_selected = false;
|
||||
}
|
||||
if resp.clicked() {
|
||||
self.selected = Some(root.to_path_buf());
|
||||
self.keyboard_active = true;
|
||||
}
|
||||
});
|
||||
if expanded {
|
||||
self.draw_node(ui, root, 1, focus_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::only_used_in_recursion)]
|
||||
fn draw_node(&mut self, ui: &mut egui::Ui, path: &Path, depth: usize, focus_id: egui::Id) {
|
||||
let children = self.sorted_children(path);
|
||||
let limit = *self.render_limit.entry(path.to_path_buf()).or_insert(500);
|
||||
for child in children.iter().take(limit) {
|
||||
let child_path = child.as_ref().as_path();
|
||||
let name = child_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if child_path.is_dir() {
|
||||
let expanded = self.expanded.contains(child_path);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space((depth as f32) * 14.0);
|
||||
let (car_rect, car_resp) =
|
||||
ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click());
|
||||
paint::paint_folder_tree_caret(ui, car_rect, expanded);
|
||||
if car_resp.clicked() {
|
||||
if expanded {
|
||||
self.expanded.remove(child_path);
|
||||
} else {
|
||||
self.expanded.insert(child.as_ref().clone());
|
||||
}
|
||||
self.keyboard_active = true;
|
||||
}
|
||||
let _ = file_icons::draw_icon(ui, file_icons::folder_icon(child_path), 16.0);
|
||||
let selected = self.selected.as_ref().is_some_and(|p| p == child_path);
|
||||
let resp = draw_row_label(ui, &name, selected);
|
||||
if selected && self.scroll_to_selected {
|
||||
resp.scroll_to_me(Some(egui::Align::Center));
|
||||
self.scroll_to_selected = false;
|
||||
}
|
||||
if resp.clicked() {
|
||||
self.selected = Some(child.as_ref().clone());
|
||||
self.keyboard_active = true;
|
||||
}
|
||||
});
|
||||
if expanded {
|
||||
self.draw_node(ui, child_path, depth + 1, focus_id);
|
||||
}
|
||||
} else {
|
||||
let mut file_action: Option<(PathBuf, bool)> = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space((depth as f32) * 14.0 + 20.0);
|
||||
let _ = file_icons::draw_icon(ui, file_icons::file_icon(child_path), 16.0);
|
||||
let selected = self.selected.as_ref().is_some_and(|p| p == child_path);
|
||||
let resp = draw_row_label(ui, &name, selected);
|
||||
if selected && self.scroll_to_selected {
|
||||
resp.scroll_to_me(Some(egui::Align::Center));
|
||||
self.scroll_to_selected = false;
|
||||
}
|
||||
if resp.clicked() {
|
||||
self.selected = Some(child.as_ref().clone());
|
||||
file_action = Some((child.as_ref().clone(), false));
|
||||
self.keyboard_active = true;
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Open").clicked() {
|
||||
file_action = Some((child.as_ref().clone(), false));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Open in hex view").clicked() {
|
||||
file_action = Some((child.as_ref().clone(), true));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
if self.pending_action.is_none() {
|
||||
self.pending_action = file_action;
|
||||
}
|
||||
}
|
||||
}
|
||||
if children.len() > limit {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space((depth as f32) * 14.0 + 20.0);
|
||||
if ui
|
||||
.button(format!("Show {} more…", (children.len() - limit).min(500)))
|
||||
.clicked()
|
||||
{
|
||||
self.render_limit.insert(path.to_path_buf(), limit + 500);
|
||||
}
|
||||
});
|
||||
} else if matches!(
|
||||
self.children_cache.get(path),
|
||||
Some(ExplorerDirState::Loading)
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space((depth as f32) * 14.0 + 20.0);
|
||||
ui.label(egui::RichText::new("Loading…").weak());
|
||||
});
|
||||
} else if let Some(ExplorerDirState::Error(err)) = self.children_cache.get(path) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space((depth as f32) * 14.0 + 20.0);
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Failed to read folder: {err}"))
|
||||
.small()
|
||||
.color(egui::Color32::from_rgb(210, 130, 130)),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_row_label(ui: &mut egui::Ui, text: &str, selected: bool) -> egui::Response {
|
||||
let avail = ui.available_width().max(10.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(avail, 20.0), egui::Sense::click());
|
||||
if selected {
|
||||
ui.painter().rect_filled(
|
||||
rect,
|
||||
2.0,
|
||||
egui::Color32::from_rgba_unmultiplied(120, 160, 255, 40),
|
||||
);
|
||||
}
|
||||
let painter = ui.painter().with_clip_rect(rect);
|
||||
painter.text(
|
||||
egui::pos2(rect.left() + 4.0, rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
text,
|
||||
egui::TextStyle::Body.resolve(ui.style()),
|
||||
if selected { ui.visuals().text_color() } else { ui.visuals().weak_text_color() },
|
||||
);
|
||||
resp
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! File and folder icons from [JetBrains Icons](https://github.com/JetBrains/icons) (Apache 2.0).
|
||||
//! Icons are stored in `resources/icons/file_icons/` as SVG files.
|
||||
//! Mapping uses file extensions for identification.
|
||||
//!
|
||||
//! URIs are **unique per filesystem path** so egui's byte loader never maps two different
|
||||
//! files to one texture (duplicate `bytes://` keys previously showed broken / warning icons).
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::Path;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
macro_rules! icon_bytes {
|
||||
($name:expr) => {
|
||||
include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/resources/icons/file_icons/",
|
||||
$name,
|
||||
".svg"
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
static FOLDER_SVG: &[u8] = icon_bytes!("folder-20px");
|
||||
static ARCHIVE_SVG: &[u8] = icon_bytes!("archive-20px");
|
||||
static ATTACHMENT_SVG: &[u8] = icon_bytes!("attachment-20px");
|
||||
static IMAGE_SVG: &[u8] = icon_bytes!("image-20px");
|
||||
static XML_SVG: &[u8] = icon_bytes!("xml-20px");
|
||||
static MANIFEST_SVG: &[u8] = icon_bytes!("manifest-20px");
|
||||
static HTACCESS_SVG: &[u8] = icon_bytes!("htaccess-20px");
|
||||
static RUST_SVG: &[u8] = icon_bytes!("rust-20px");
|
||||
static PYTHON_SVG: &[u8] = icon_bytes!("python-20px");
|
||||
static JAVASCRIPT_SVG: &[u8] = icon_bytes!("javascript-20px");
|
||||
static TYPESCRIPT_SVG: &[u8] = icon_bytes!("typescript-20px");
|
||||
static JAVA_SVG: &[u8] = icon_bytes!("java-20px");
|
||||
static HTML_SVG: &[u8] = icon_bytes!("html-20px");
|
||||
static CSS_SVG: &[u8] = icon_bytes!("css-20px");
|
||||
static JSON_SVG: &[u8] = icon_bytes!("json-20px");
|
||||
static YAML_SVG: &[u8] = icon_bytes!("yaml-20px");
|
||||
static TEXT_SVG: &[u8] = icon_bytes!("text-20px");
|
||||
static PROPERTIES_SVG: &[u8] = icon_bytes!("properties-20px");
|
||||
static CONFIG_SVG: &[u8] = icon_bytes!("config-20px");
|
||||
static FOLDER_TEST_SVG: &[u8] = icon_bytes!("folder-test-20px");
|
||||
static FOLDER_CONFIG_SVG: &[u8] = icon_bytes!("folder-config-20px");
|
||||
static FOLDER_WEB_SVG: &[u8] = icon_bytes!("folder-web-20px");
|
||||
static FOLDER_LOG_SVG: &[u8] = icon_bytes!("folder-log-20px");
|
||||
static FOLDER_HOME_SVG: &[u8] = icon_bytes!("folder-home-20px");
|
||||
static FOLDER_LIB_SVG: &[u8] = icon_bytes!("folder-lib-20px");
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IconSpec {
|
||||
pub source: egui::ImageSource<'static>,
|
||||
pub tint: Option<egui::Color32>,
|
||||
}
|
||||
|
||||
const BRIGHT_ICON_TINT: egui::Color32 = egui::Color32::from_rgb(236, 236, 236);
|
||||
|
||||
fn unique_svg_uri(kind: &'static str, path: &Path) -> String {
|
||||
let mut h = DefaultHasher::new();
|
||||
kind.hash(&mut h);
|
||||
path.hash(&mut h);
|
||||
format!("bytes://bd_icon_{kind}_{:016x}.svg", h.finish())
|
||||
}
|
||||
|
||||
/// Register image loaders on the egui context (same as `egui_extras::install_image_loaders`).
|
||||
#[allow(dead_code)]
|
||||
pub fn install_icons(ctx: &egui::Context) {
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn folder_icon(path: &Path) -> IconSpec {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let icon_bytes = match name.as_str() {
|
||||
"src" | "tests" | "test" | "__tests__" | "spec" | "specs" => FOLDER_TEST_SVG,
|
||||
".git" | ".github" | ".vscode" | ".idea" | "config" | ".config" => FOLDER_CONFIG_SVG,
|
||||
"web" | "www" | "public" | "static" | "assets" => FOLDER_WEB_SVG,
|
||||
"logs" | "log" => FOLDER_LOG_SVG,
|
||||
"home" => FOLDER_HOME_SVG,
|
||||
"lib" | "libs" | "vendor" | "node_modules" => FOLDER_LIB_SVG,
|
||||
_ => FOLDER_SVG,
|
||||
};
|
||||
let uri = unique_svg_uri("folder", path);
|
||||
IconSpec {
|
||||
source: egui::ImageSource::Bytes {
|
||||
uri: uri.into(),
|
||||
bytes: icon_bytes.into(),
|
||||
},
|
||||
// Tinting SVGs through the image pipeline often fails (red placeholder); folders use plain SVG.
|
||||
tint: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn file_icon(path: &Path) -> IconSpec {
|
||||
let name_lower = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
if name_lower == "cargo.lock" || name_lower.ends_with(".lock") {
|
||||
let uri = unique_svg_uri("lock", path);
|
||||
return IconSpec {
|
||||
source: egui::ImageSource::Bytes {
|
||||
uri: uri.into(),
|
||||
bytes: MANIFEST_SVG.into(),
|
||||
},
|
||||
tint: None,
|
||||
};
|
||||
}
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
let no_ext = path.extension().is_none();
|
||||
let (icon_bytes, tint) = match ext.as_deref() {
|
||||
Some("rs") => (RUST_SVG, None),
|
||||
Some("py") | Some("pyi") | Some("pyw") => (PYTHON_SVG, None),
|
||||
Some("js") | Some("mjs") | Some("cjs") => (JAVASCRIPT_SVG, None),
|
||||
Some("ts") | Some("tsx") => (TYPESCRIPT_SVG, None),
|
||||
Some("java") => (JAVA_SVG, None),
|
||||
Some("html") | Some("htm") => (HTML_SVG, None),
|
||||
Some("css") | Some("scss") | Some("sass") | Some("less") => (CSS_SVG, None),
|
||||
Some("json") => (JSON_SVG, None),
|
||||
Some("yaml") | Some("yml") => (YAML_SVG, None),
|
||||
Some("xml") | Some("xsd") | Some("xsl") => (XML_SVG, None),
|
||||
Some("iml") => (XML_SVG, None),
|
||||
Some("toml") => (CONFIG_SVG, None),
|
||||
Some("ini") | Some("conf") | Some("cfg") | Some("properties") => (PROPERTIES_SVG, None),
|
||||
Some("ps1") | Some("sh") | Some("bash") | Some("zsh") | Some("fish") => (TEXT_SVG, None),
|
||||
Some("md") | Some("mdx") | Some("markdown") => (TEXT_SVG, None),
|
||||
Some("zip") | Some("tar") | Some("gz") | Some("bz2") | Some("xz") | Some("7z") | Some("rar") => {
|
||||
(ARCHIVE_SVG, Some(BRIGHT_ICON_TINT))
|
||||
}
|
||||
Some("txt") | Some("log") => (TEXT_SVG, None),
|
||||
Some("ttf") | Some("otf") | Some("woff") | Some("woff2") => (ATTACHMENT_SVG, Some(BRIGHT_ICON_TINT)),
|
||||
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") | Some("bmp") => {
|
||||
(IMAGE_SVG, None)
|
||||
}
|
||||
Some("svg") => (XML_SVG, None),
|
||||
Some("manifest") => (MANIFEST_SVG, None),
|
||||
Some("pdf") | Some("doc") | Some("docx") | Some("xls") | Some("xlsx") | Some("ppt") | Some("pptx") => {
|
||||
(ATTACHMENT_SVG, Some(BRIGHT_ICON_TINT))
|
||||
}
|
||||
Some("lock") => (ARCHIVE_SVG, Some(BRIGHT_ICON_TINT)),
|
||||
_ => {
|
||||
// Handle common no-extension and dotfiles before final generic fallback.
|
||||
if name_lower == "readme"
|
||||
|| name_lower == "license"
|
||||
|| name_lower == "copying"
|
||||
|| name_lower == "changelog"
|
||||
|| no_ext
|
||||
{
|
||||
// Extensionless files, well-known text files, and named README/LICENSE etc.
|
||||
(TEXT_SVG, None)
|
||||
} else if matches!(
|
||||
name_lower.as_str(),
|
||||
"audit.toml"
|
||||
| "deny.toml"
|
||||
| "tools.yaml"
|
||||
| "tools.yml"
|
||||
| "rustfmt.toml"
|
||||
| "clippy.toml"
|
||||
) || name_lower == ".gitignore"
|
||||
|| name_lower == ".gitattributes"
|
||||
|| name_lower == ".editorconfig"
|
||||
|| name_lower == ".env"
|
||||
|| name_lower == ".env.local"
|
||||
|| name_lower == ".env.example"
|
||||
{
|
||||
(CONFIG_SVG, None)
|
||||
} else if name_lower == ".htaccess" {
|
||||
(HTACCESS_SVG, None)
|
||||
} else {
|
||||
// Guaranteed visible fallback for unknown/binary-like files.
|
||||
(TEXT_SVG, None)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let uri = unique_svg_uri("file", path);
|
||||
IconSpec {
|
||||
source: egui::ImageSource::Bytes {
|
||||
uri: uri.into(),
|
||||
bytes: icon_bytes.into(),
|
||||
},
|
||||
tint,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn draw_icon(ui: &mut egui::Ui, icon: IconSpec, size: f32) -> egui::Response {
|
||||
let mut image = egui::Image::new(icon.source)
|
||||
.max_width(size)
|
||||
.max_height(size);
|
||||
if let Some(tint) = icon.tint {
|
||||
image = image.tint(tint);
|
||||
}
|
||||
ui.add(image)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! File open / save operations.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use byte_draft::{
|
||||
lint_document, read_binary_file_capped, read_text_file_open_best_effort,
|
||||
write_text_file_atomic_with_encoding, Diagnostic, Tab, ViewKind,
|
||||
};
|
||||
use eframe::Frame;
|
||||
|
||||
use super::{dialogs, helpers, hex_view, md_preview, ByteDraftApp};
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::dialogs::WindowsFileDialogOp;
|
||||
|
||||
impl ByteDraftApp {
|
||||
pub(super) fn switch_tab_to_hex_view(&mut self, idx: usize) {
|
||||
let Some(path) = self.session.tabs.get(idx).and_then(|t| t.path.clone()) else {
|
||||
return;
|
||||
};
|
||||
let dump = match read_binary_file_capped(&path) {
|
||||
Ok(bytes) => hex_view::format_hex_dump(&bytes),
|
||||
Err(e) => format!("// Failed to read file for hex view: {e}\n"),
|
||||
};
|
||||
if let Some(tab) = self.session.tabs.get_mut(idx) {
|
||||
tab.text = dump;
|
||||
tab.view_kind = ViewKind::Hex;
|
||||
tab.dirty = false;
|
||||
tab.diagnostics.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn switch_tab_to_default_view(&mut self, idx: usize) {
|
||||
let Some(path) = self.session.tabs.get(idx).and_then(|t| t.path.clone()) else {
|
||||
return;
|
||||
};
|
||||
if helpers::is_image_path(&path) {
|
||||
if let Some(tab) = self.session.tabs.get_mut(idx) {
|
||||
tab.view_kind = ViewKind::Image;
|
||||
tab.dirty = false;
|
||||
tab.diagnostics.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
match read_text_file_open_best_effort(&path) {
|
||||
Ok((text, enc)) => {
|
||||
if let Some(tab) = self.session.tabs.get_mut(idx) {
|
||||
tab.text = text;
|
||||
tab.encoding = enc;
|
||||
tab.view_kind = if md_preview::markdown_path(path.as_path()) {
|
||||
ViewKind::MarkdownPreview
|
||||
} else {
|
||||
ViewKind::Text
|
||||
};
|
||||
tab.dirty = false;
|
||||
tab.language_override = None;
|
||||
tab.diagnostics = lint_document(tab.effective_language(), &tab.text);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(tab) = self.session.tabs.get_mut(idx) {
|
||||
tab.diagnostics = vec![Diagnostic::new(format!("Open failed: {e}"))];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If `path` is already open, focus and switch view if requested.
|
||||
pub(super) fn focus_existing_tab_for_path(&mut self, path: &Path, force_hex: bool) -> bool {
|
||||
for (i, t) in self.session.tabs.iter().enumerate() {
|
||||
let Some(p) = t.path.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if helpers::paths_point_to_same_file(p, path) {
|
||||
self.session.active_tab = i;
|
||||
if force_hex {
|
||||
self.switch_tab_to_hex_view(i);
|
||||
} else {
|
||||
self.switch_tab_to_default_view(i);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn open_file_dialog(&mut self, frame: &Frame, force_hex: bool) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = frame;
|
||||
let op = if force_hex {
|
||||
WindowsFileDialogOp::OpenFileHex
|
||||
} else {
|
||||
WindowsFileDialogOp::OpenFile
|
||||
};
|
||||
self.windows_enqueue_dialog(op);
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
log::info!(
|
||||
target: "byte_draft_desktop",
|
||||
"open_file_dialog (sync): calling rfd pick_file"
|
||||
);
|
||||
match dialogs::pick_file_dialog(frame) {
|
||||
Some(path) => {
|
||||
log::info!(target: "byte_draft_desktop", "open_file_dialog: got path");
|
||||
self.open_document(path, force_hex);
|
||||
}
|
||||
None => {
|
||||
log::info!(
|
||||
target: "byte_draft_desktop",
|
||||
"open_file_dialog: cancelled or rfd returned None"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_folder_dialog(&mut self, frame: &Frame) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = frame;
|
||||
self.windows_enqueue_dialog(WindowsFileDialogOp::PickFolder);
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
log::info!(
|
||||
target: "byte_draft_desktop",
|
||||
"open_folder_dialog (sync): calling rfd pick_folder"
|
||||
);
|
||||
match dialogs::pick_folder_dialog(frame) {
|
||||
Some(path) => {
|
||||
self.set_workspace_root(Some(path));
|
||||
}
|
||||
None => {
|
||||
log::info!(
|
||||
target: "byte_draft_desktop",
|
||||
"open_folder_dialog: cancelled or rfd returned None"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn open_path(&mut self, path: PathBuf) {
|
||||
self.open_document(path, false);
|
||||
}
|
||||
|
||||
pub(super) fn open_document(&mut self, path: PathBuf, force_hex: bool) {
|
||||
if self.focus_existing_tab_for_path(&path, force_hex) {
|
||||
return;
|
||||
}
|
||||
if force_hex {
|
||||
let dump = match read_binary_file_capped(&path) {
|
||||
Ok(bytes) => hex_view::format_hex_dump(&bytes),
|
||||
Err(e) => format!("// Failed to read file for hex view: {e}\n"),
|
||||
};
|
||||
let mut tab = Tab::from_hex_path(path);
|
||||
tab.text = dump;
|
||||
self.session.tabs.push(tab);
|
||||
self.session.active_tab = self.session.tabs.len() - 1;
|
||||
self.touch_edit();
|
||||
return;
|
||||
}
|
||||
if helpers::is_image_path(&path) {
|
||||
self.session.tabs.push(Tab::from_image_path(path));
|
||||
self.session.active_tab = self.session.tabs.len() - 1;
|
||||
self.touch_edit();
|
||||
return;
|
||||
}
|
||||
match read_text_file_open_best_effort(&path) {
|
||||
Ok((text, enc)) => {
|
||||
let mut tab = Tab::from_path_and_text(path, text);
|
||||
tab.encoding = enc;
|
||||
self.session.tabs.push(tab);
|
||||
self.session.active_tab = self.session.tabs.len() - 1;
|
||||
self.touch_edit();
|
||||
}
|
||||
Err(e) => {
|
||||
let mut t = Tab::new_untitled();
|
||||
t.text = format!("// Failed to open file: {e}\n");
|
||||
t.dirty = true;
|
||||
self.session.tabs.push(t);
|
||||
self.session.active_tab = self.session.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_save_to_path(&mut self, path: PathBuf) {
|
||||
let idx = self.session.active_tab;
|
||||
let Some(tab) = self.session.tabs.get_mut(idx) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = write_text_file_atomic_with_encoding(&path, &tab.text, tab.encoding) {
|
||||
tab.diagnostics = vec![Diagnostic::new(format!("Save failed: {e}"))];
|
||||
return;
|
||||
}
|
||||
tab.path = Some(path);
|
||||
tab.dirty = false;
|
||||
self.touch_edit();
|
||||
}
|
||||
|
||||
pub(super) fn save_active(&mut self, pick_path: bool, frame: &Frame) {
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = frame;
|
||||
|
||||
let idx = self.session.active_tab;
|
||||
let need_dialog = match self.session.tabs.get(idx) {
|
||||
Some(t) => pick_path || t.path.is_none(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
if need_dialog {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.windows_enqueue_dialog(WindowsFileDialogOp::Save);
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
log::info!(
|
||||
target: "byte_draft_desktop",
|
||||
"save_active (sync): opening save dialog"
|
||||
);
|
||||
let chosen = dialogs::save_file_dialog(frame);
|
||||
log::info!(
|
||||
target: "byte_draft_desktop",
|
||||
"save_active (sync): dialog returned path={}",
|
||||
chosen.is_some()
|
||||
);
|
||||
let Some(path) = chosen else { return };
|
||||
self.finish_save_to_path(path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(path) = self
|
||||
.session
|
||||
.tabs
|
||||
.get(idx)
|
||||
.and_then(|t| t.path.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.finish_save_to_path(path);
|
||||
}
|
||||
|
||||
pub(super) fn save_as_dialog(&mut self, frame: &Frame) {
|
||||
self.save_active(true, frame);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Floating find/replace panel.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use super::ByteDraftApp;
|
||||
|
||||
impl ByteDraftApp {
|
||||
pub(super) fn draw_find_panel(&mut self, ctx: &egui::Context) {
|
||||
if !self.find.open {
|
||||
return;
|
||||
}
|
||||
let top_pad = self.title_chrome_height() + 6.0;
|
||||
let sr = ctx.content_rect();
|
||||
let panel_w = if self.find.show_replace {
|
||||
540.0_f32
|
||||
} else {
|
||||
480.0_f32
|
||||
};
|
||||
let margin = 8.0_f32;
|
||||
let chevron_w = 24.0_f32;
|
||||
let edit_w = 200.0_f32;
|
||||
|
||||
let mut keep_open = true;
|
||||
egui::Window::new("byte_draft_find")
|
||||
.title_bar(false)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.fixed_pos(egui::pos2(
|
||||
(sr.right() - panel_w - margin).max(sr.left() + margin),
|
||||
sr.top() + top_pad,
|
||||
))
|
||||
.default_width(panel_w)
|
||||
.min_width(panel_w)
|
||||
.max_width(panel_w)
|
||||
.frame(
|
||||
egui::Frame::window(ctx.global_style().as_ref())
|
||||
.corner_radius(egui::CornerRadius::same(6)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
let (ranges, parse_err) = self.find.compute_matches(&self.session.tabs, self.session.active_tab);
|
||||
let can_replace = parse_err.is_none() && !self.find.query.is_empty();
|
||||
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
let expand_icon = if self.find.show_replace { "▼" } else { "▶" };
|
||||
if ui
|
||||
.add_sized(
|
||||
[chevron_w, 22.0],
|
||||
egui::Button::new(egui::RichText::new(expand_icon).monospace()),
|
||||
)
|
||||
.on_hover_text("Show or hide replace")
|
||||
.clicked()
|
||||
{
|
||||
self.find.show_replace = !self.find.show_replace;
|
||||
}
|
||||
ui.add_sized(
|
||||
[edit_w, 22.0],
|
||||
egui::TextEdit::singleline(&mut self.find.query)
|
||||
.hint_text("Find")
|
||||
.margin(egui::vec2(6.0, 4.0)),
|
||||
);
|
||||
let case_sel = ui
|
||||
.selectable_label(self.find.match_case, "Aa")
|
||||
.on_hover_text("Match case");
|
||||
if case_sel.clicked() {
|
||||
self.find.match_case = !self.find.match_case;
|
||||
}
|
||||
let re_sel = ui
|
||||
.selectable_label(self.find.use_regex, ".*")
|
||||
.on_hover_text("Use regular expression");
|
||||
if re_sel.clicked() {
|
||||
self.find.use_regex = !self.find.use_regex;
|
||||
}
|
||||
if let Some(ref err) = parse_err {
|
||||
ui.label(
|
||||
egui::RichText::new(err)
|
||||
.small()
|
||||
.color(egui::Color32::from_rgb(255, 120, 120)),
|
||||
);
|
||||
} else {
|
||||
let n = ranges.len();
|
||||
let status = if self.find.query.is_empty() {
|
||||
String::new()
|
||||
} else if n == 0 {
|
||||
"No results".to_string()
|
||||
} else if n == 1 {
|
||||
"1 match".to_string()
|
||||
} else {
|
||||
format!("{n} matches")
|
||||
};
|
||||
if !status.is_empty() {
|
||||
ui.label(egui::RichText::new(status).small().weak());
|
||||
}
|
||||
}
|
||||
if ui.small_button("✕").on_hover_text("Close (Esc)").clicked() {
|
||||
keep_open = false;
|
||||
}
|
||||
});
|
||||
if self.find.show_replace {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
ui.add_space(chevron_w + 6.0);
|
||||
ui.add_sized(
|
||||
[edit_w, 22.0],
|
||||
egui::TextEdit::singleline(&mut self.find.replace_with)
|
||||
.hint_text("Replace")
|
||||
.margin(egui::vec2(6.0, 4.0)),
|
||||
);
|
||||
ui.add_enabled_ui(can_replace, |ui| {
|
||||
if ui.small_button("Replace").clicked() {
|
||||
if self.find.apply_replace_one(&mut self.session.tabs, self.session.active_tab) {
|
||||
self.refresh_lint_active_tab();
|
||||
self.touch_edit();
|
||||
}
|
||||
}
|
||||
if ui.small_button("Replace all").clicked() {
|
||||
if self.find.apply_replace_all(&mut self.session.tabs, self.session.active_tab) > 0 {
|
||||
self.refresh_lint_active_tab();
|
||||
self.touch_edit();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
if !keep_open {
|
||||
self.find.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Find / replace panel state, decoupled from ByteDraftApp.
|
||||
|
||||
use byte_draft::{collect_find_matches, replace_all_matches, replace_first_match, Tab};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FindReplaceState {
|
||||
pub open: bool,
|
||||
pub query: String,
|
||||
pub match_case: bool,
|
||||
pub use_regex: bool,
|
||||
pub replace_with: String,
|
||||
pub show_replace: bool,
|
||||
}
|
||||
|
||||
impl FindReplaceState {
|
||||
/// Returns (match spans, Option<regex error message>).
|
||||
pub fn compute_matches(
|
||||
&self,
|
||||
tabs: &[Tab],
|
||||
active_tab: usize,
|
||||
) -> (Vec<(usize, usize)>, Option<String>) {
|
||||
if !self.open || self.query.is_empty() {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
let Some(tab) = tabs.get(active_tab) else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
match collect_find_matches(&tab.text, &self.query, self.match_case, self.use_regex) {
|
||||
Ok(v) => (v, None),
|
||||
Err(e) => (Vec::new(), Some(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the first match; returns true if anything changed.
|
||||
pub fn apply_replace_one(&mut self, tabs: &mut [Tab], active_tab: usize) -> bool {
|
||||
let Some(tab) = tabs.get_mut(active_tab) else {
|
||||
return false;
|
||||
};
|
||||
match replace_first_match(
|
||||
&mut tab.text,
|
||||
&self.query,
|
||||
&self.replace_with,
|
||||
self.match_case,
|
||||
self.use_regex,
|
||||
) {
|
||||
Ok(true) => {
|
||||
tab.dirty = true;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace all matches; returns the number replaced.
|
||||
pub fn apply_replace_all(&mut self, tabs: &mut [Tab], active_tab: usize) -> usize {
|
||||
let Some(tab) = tabs.get_mut(active_tab) else {
|
||||
return 0;
|
||||
};
|
||||
match replace_all_matches(
|
||||
&mut tab.text,
|
||||
&self.query,
|
||||
&self.replace_with,
|
||||
self.match_case,
|
||||
self.use_regex,
|
||||
) {
|
||||
Ok(n) if n > 0 => {
|
||||
tab.dirty = true;
|
||||
n
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Path / cursor helpers shared by UI modules.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn paths_point_to_same_file(a: &Path, b: &Path) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
|
||||
(Ok(ca), Ok(cb)) => ca == cb,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ccursor_index` is egui's character index (Unicode scalar values), not byte offset.
|
||||
#[must_use]
|
||||
pub(crate) fn line_col_from_ccursor_index(text: &str, ccursor_index: usize) -> (usize, usize) {
|
||||
let mut line = 1usize;
|
||||
let mut col = 1usize;
|
||||
for (i, ch) in text.chars().enumerate() {
|
||||
if i >= ccursor_index {
|
||||
break;
|
||||
}
|
||||
if ch == '\n' {
|
||||
line += 1;
|
||||
col = 1;
|
||||
} else {
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
(line, col)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn is_image_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| {
|
||||
matches!(
|
||||
e.to_ascii_lowercase().as_str(),
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "svg"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_image_path, line_col_from_ccursor_index, paths_point_to_same_file};
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn line_col_counts_unicode_scalars() {
|
||||
let s = "a\nβc";
|
||||
assert_eq!(line_col_from_ccursor_index(s, 0), (1, 1));
|
||||
assert_eq!(line_col_from_ccursor_index(s, 1), (1, 2));
|
||||
assert_eq!(line_col_from_ccursor_index(s, 2), (2, 1));
|
||||
assert_eq!(line_col_from_ccursor_index(s, 3), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_col_cursor_at_end_of_last_line() {
|
||||
// "abc\ndef" — char indices: a=0 b=1 c=2 \n=3 d=4 e=5 f=6
|
||||
let s = "abc\ndef";
|
||||
assert_eq!(line_col_from_ccursor_index(s, 6), (2, 3)); // AT 'f'
|
||||
assert_eq!(line_col_from_ccursor_index(s, 7), (2, 4)); // one past last char
|
||||
// Index beyond text length returns the same end position.
|
||||
assert_eq!(line_col_from_ccursor_index(s, 100), (2, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_col_with_crlf_line_endings() {
|
||||
// egui normalises CRLF → LF before indexing, but the raw function
|
||||
// sees \r\n. The \r is NOT \n so col increments; \n triggers line break.
|
||||
// "a\r\nb" → chars: 'a'(0) '\r'(1) '\n'(2) 'b'(3)
|
||||
let s = "a\r\nb";
|
||||
assert_eq!(line_col_from_ccursor_index(s, 0), (1, 1)); // 'a'
|
||||
assert_eq!(line_col_from_ccursor_index(s, 1), (1, 2)); // '\r' — treated as col
|
||||
assert_eq!(line_col_from_ccursor_index(s, 2), (1, 3)); // at '\n' — not yet past it
|
||||
assert_eq!(line_col_from_ccursor_index(s, 3), (2, 1)); // 'b' on line 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_col_with_emoji() {
|
||||
// Emoji are single Unicode scalar values (one egui character index step).
|
||||
// "Hi 🦀\nok" — chars: H(0) i(1) ' '(2) 🦀(3) \n(4) o(5) k(6)
|
||||
let s = "Hi 🦀\nok";
|
||||
assert_eq!(line_col_from_ccursor_index(s, 3), (1, 4)); // at crab emoji
|
||||
assert_eq!(line_col_from_ccursor_index(s, 4), (1, 5)); // at '\n'
|
||||
assert_eq!(line_col_from_ccursor_index(s, 5), (2, 1)); // 'o'
|
||||
assert_eq!(line_col_from_ccursor_index(s, 6), (2, 2)); // 'k'
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_col_empty_string() {
|
||||
assert_eq!(line_col_from_ccursor_index("", 0), (1, 1));
|
||||
assert_eq!(line_col_from_ccursor_index("", 5), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_col_single_newline() {
|
||||
// "\n" — cursor before \n is (1,1), after is (2,1)
|
||||
let s = "\n";
|
||||
assert_eq!(line_col_from_ccursor_index(s, 0), (1, 1));
|
||||
assert_eq!(line_col_from_ccursor_index(s, 1), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_path_detection_is_case_insensitive() {
|
||||
assert!(is_image_path(std::path::Path::new("cat.PNG")));
|
||||
assert!(is_image_path(std::path::Path::new("icon.svg")));
|
||||
assert!(!is_image_path(std::path::Path::new("readme.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_file_via_canonicalization() {
|
||||
let unique = format!(
|
||||
"bytedraft_helpers_test_{}_{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock")
|
||||
.as_nanos()
|
||||
);
|
||||
let dir = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
let p = dir.join("x.txt");
|
||||
fs::write(&p, "x").expect("write");
|
||||
assert!(paths_point_to_same_file(&p, &p));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Read-only hex dump pane.
|
||||
|
||||
use byte_draft::{read_binary_file_capped, Tab};
|
||||
use eframe::egui;
|
||||
|
||||
pub(crate) fn format_hex_dump(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write;
|
||||
|
||||
const ROW: usize = 16;
|
||||
if bytes.is_empty() {
|
||||
return "(empty file)\n".to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(bytes.len() * 3 + bytes.len() / ROW * 8);
|
||||
for (row_idx, chunk) in bytes.chunks(ROW).enumerate() {
|
||||
let offset = row_idx * ROW;
|
||||
write!(&mut out, "{offset:08x} ").unwrap();
|
||||
for i in 0..ROW {
|
||||
if i == 8 {
|
||||
out.push(' ');
|
||||
}
|
||||
if i < chunk.len() {
|
||||
write!(&mut out, "{:02x} ", chunk[i]).unwrap();
|
||||
} else {
|
||||
out.push_str(" ");
|
||||
}
|
||||
}
|
||||
out.push(' ');
|
||||
for &b in chunk {
|
||||
let c = if (32..127).contains(&b) { b as char } else { '.' };
|
||||
out.push(c);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn draw_hex_pane(ui: &mut egui::Ui, tab: &mut Tab, height: f32, tab_idx: usize) {
|
||||
let h = height.max(80.0);
|
||||
if tab.text.is_empty() {
|
||||
if let Some(p) = tab.path.clone() {
|
||||
if let Ok(bytes) = read_binary_file_capped(&p) {
|
||||
tab.text = format_hex_dump(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
let w = ui.available_width();
|
||||
let scroll_id = egui::Id::new("byte_draft_hex_scroll").with(tab_idx);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(scroll_id)
|
||||
.max_height(h)
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut tab.text)
|
||||
.font(egui::TextStyle::Monospace.resolve(ui.style()))
|
||||
.desired_width(w)
|
||||
.interactive(false)
|
||||
.frame(egui::Frame::NONE),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::format_hex_dump;
|
||||
|
||||
#[test]
|
||||
fn empty_bytes_show_empty_message() {
|
||||
assert_eq!(format_hex_dump(&[]), "(empty file)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_row_format_has_offset_hex_and_ascii() {
|
||||
let out = format_hex_dump(b"ABC");
|
||||
assert!(out.starts_with("00000000"));
|
||||
assert!(out.contains("41 42 43"));
|
||||
assert!(out.contains("ABC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_row_advances_offset() {
|
||||
let data = vec![b'x'; 17];
|
||||
let out = format_hex_dump(&data);
|
||||
assert!(out.contains("00000000"));
|
||||
assert!(out.contains("00000010"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Image preview tab (bytes → texture via egui loaders).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use byte_draft::{read_binary_file_capped, Tab};
|
||||
use eframe::egui;
|
||||
|
||||
pub(crate) fn draw_image_pane(
|
||||
ui: &mut egui::Ui,
|
||||
ctx: &egui::Context,
|
||||
tab: &Tab,
|
||||
height: f32,
|
||||
tab_idx: usize,
|
||||
) {
|
||||
let h = height.max(80.0);
|
||||
let Some(path) = tab.path.as_ref() else {
|
||||
ui.label("No image path.");
|
||||
return;
|
||||
};
|
||||
let id = egui::Id::new(("byte_draft_tab_img", path, tab_idx));
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("png");
|
||||
let bytes: Arc<[u8]> = ctx.data_mut(|d| {
|
||||
if let Some(b) = d.get_persisted::<Arc<[u8]>>(id) {
|
||||
return b;
|
||||
}
|
||||
let v = read_binary_file_capped(path).unwrap_or_default();
|
||||
let a: Arc<[u8]> = v.into();
|
||||
d.insert_persisted(id, a.clone());
|
||||
a
|
||||
});
|
||||
let uri: String = format!("bytes://tab_img_{}_{:x}.{ext}", tab_idx, id.value());
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.add(
|
||||
egui::Image::new(egui::ImageSource::Bytes {
|
||||
uri: uri.into(),
|
||||
bytes: egui::load::Bytes::from(bytes),
|
||||
})
|
||||
.max_width(ui.available_width())
|
||||
.max_height(h),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Markdown preview, split view, and source / split / preview toolbar.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use byte_draft::{detect_language, Tab, ViewKind};
|
||||
use egui_commonmark::{CommonMarkCache, CommonMarkViewer};
|
||||
use eframe::egui;
|
||||
|
||||
use super::{paint, text_editor, theme::ResolvedTheme};
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn markdown_path(p: &Path) -> bool {
|
||||
p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "md" | "markdown" | "mdx"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn tab_is_markdown_file(tab: &Tab) -> bool {
|
||||
tab.path
|
||||
.as_ref()
|
||||
.is_some_and(|p| markdown_path(p.as_path()))
|
||||
}
|
||||
|
||||
/// Toolbar: source-only, split, preview-only (VS Code-style).
|
||||
pub(crate) fn draw_markdown_mode_toolbar(ui: &mut egui::Ui, tab: &mut Tab) {
|
||||
if !tab_is_markdown_file(tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let edit_on = matches!(tab.view_kind, ViewKind::Text);
|
||||
let split_on = matches!(tab.view_kind, ViewKind::MarkdownPreview);
|
||||
let prev_on = matches!(tab.view_kind, ViewKind::MarkdownPreviewOnly);
|
||||
|
||||
let sz = egui::vec2(30.0, 26.0);
|
||||
let prev_resp = ui
|
||||
.add_sized(sz, egui::Button::selectable(prev_on, ""))
|
||||
.on_hover_text("Preview");
|
||||
paint::paint_markdown_toolbar_icon_preview(
|
||||
ui.painter(),
|
||||
prev_resp.rect.shrink(6.0),
|
||||
ui.visuals().text_color(),
|
||||
);
|
||||
if prev_resp.clicked() {
|
||||
tab.view_kind = ViewKind::MarkdownPreviewOnly;
|
||||
}
|
||||
let split_resp = ui
|
||||
.add_sized(sz, egui::Button::selectable(split_on, ""))
|
||||
.on_hover_text("Split preview");
|
||||
paint::paint_markdown_toolbar_icon_split(
|
||||
ui.painter(),
|
||||
split_resp.rect.shrink(6.0),
|
||||
ui.visuals().text_color(),
|
||||
);
|
||||
if split_resp.clicked() {
|
||||
tab.view_kind = ViewKind::MarkdownPreview;
|
||||
}
|
||||
let edit_resp = ui
|
||||
.add_sized(sz, egui::Button::selectable(edit_on, ""))
|
||||
.on_hover_text("Source");
|
||||
paint::paint_markdown_toolbar_icon_lines(
|
||||
ui.painter(),
|
||||
edit_resp.rect.shrink(6.0),
|
||||
ui.visuals().text_color(),
|
||||
);
|
||||
if edit_resp.clicked() {
|
||||
tab.view_kind = ViewKind::Text;
|
||||
tab.detected_language = detect_language(&tab.text, tab.extension_hint());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn draw_markdown_split(
|
||||
ui: &mut egui::Ui,
|
||||
tab: &mut Tab,
|
||||
markdown_cache: &mut CommonMarkCache,
|
||||
find_ranges: &[(usize, usize)],
|
||||
width: f32,
|
||||
height: f32,
|
||||
word_wrap: bool,
|
||||
show_line_endings: bool,
|
||||
tab_idx: usize,
|
||||
theme: &ResolvedTheme,
|
||||
) -> (bool, Option<usize>) {
|
||||
let h = height.max(80.0);
|
||||
let mut edited = false;
|
||||
let mut caret = None;
|
||||
|
||||
let src_scroll = egui::Id::new("byte_draft_md_src_scroll").with(tab_idx);
|
||||
let preview_scroll = egui::Id::new("byte_draft_md_preview_scroll").with(tab_idx);
|
||||
let sync_src_to_prev = egui::Id::new("byte_draft_md_sync_src_to_prev").with(tab_idx);
|
||||
let sync_prev_to_src = egui::Id::new("byte_draft_md_sync_prev_to_src").with(tab_idx);
|
||||
let prev_src_y_id = egui::Id::new("byte_draft_md_prev_src_y").with(tab_idx);
|
||||
let prev_prev_y_id = egui::Id::new("byte_draft_md_prev_prev_y").with(tab_idx);
|
||||
|
||||
let _ = width;
|
||||
let forced_src_y = ui
|
||||
.ctx()
|
||||
.data(|d| d.get_temp::<Option<f32>>(sync_prev_to_src))
|
||||
.flatten();
|
||||
let forced_prev_y = ui
|
||||
.ctx()
|
||||
.data(|d| d.get_temp::<Option<f32>>(sync_src_to_prev))
|
||||
.flatten();
|
||||
// One-shot consume: prevent feedback loops where forced offsets re-trigger sync each frame.
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(sync_prev_to_src, None::<f32>);
|
||||
d.insert_temp(sync_src_to_prev, None::<f32>);
|
||||
});
|
||||
ui.columns(2, |cols| {
|
||||
cols[0].set_min_height(h);
|
||||
cols[1].set_min_height(h);
|
||||
let left_w = cols[0].available_width();
|
||||
|
||||
let (e, c, src_scroll_off, src_max_scroll_y) = text_editor::draw_text_editor_inner(
|
||||
&mut cols[0],
|
||||
tab,
|
||||
find_ranges,
|
||||
left_w,
|
||||
h,
|
||||
word_wrap,
|
||||
show_line_endings,
|
||||
src_scroll,
|
||||
forced_src_y,
|
||||
theme,
|
||||
);
|
||||
edited = e;
|
||||
caret = c;
|
||||
|
||||
let mut preview_area = egui::ScrollArea::both()
|
||||
.id_salt(preview_scroll)
|
||||
.max_height(h)
|
||||
.auto_shrink([false, false]);
|
||||
if let Some(y) = forced_prev_y {
|
||||
preview_area = preview_area.vertical_scroll_offset(y.max(0.0));
|
||||
}
|
||||
let prev_out = preview_area.show(&mut cols[1], |ui| {
|
||||
CommonMarkViewer::new().show(ui, markdown_cache, &tab.text);
|
||||
});
|
||||
let prev_max_scroll_y = (prev_out.content_size.y - prev_out.inner_rect.height()).max(0.0);
|
||||
|
||||
let prev_src_y = cols[0]
|
||||
.ctx()
|
||||
.data(|d| d.get_temp::<f32>(prev_src_y_id))
|
||||
.unwrap_or(0.0);
|
||||
let prev_prev_y = cols[0]
|
||||
.ctx()
|
||||
.data(|d| d.get_temp::<f32>(prev_prev_y_id))
|
||||
.unwrap_or(0.0);
|
||||
let src_y = src_scroll_off.y;
|
||||
let prev_y = prev_out.state.offset.y;
|
||||
let src_changed = (src_y - prev_src_y).abs() > 0.5;
|
||||
let prev_changed = (prev_y - prev_prev_y).abs() > 0.5;
|
||||
let src_hover = cols[0].rect_contains_pointer(cols[0].max_rect());
|
||||
let prev_hover = cols[1].rect_contains_pointer(cols[1].max_rect());
|
||||
let src_forced_this_frame = forced_src_y.is_some();
|
||||
let prev_forced_this_frame = forced_prev_y.is_some();
|
||||
let src_natural_change = src_changed && !src_forced_this_frame;
|
||||
let prev_natural_change = prev_changed && !prev_forced_this_frame;
|
||||
let src_driving = src_natural_change && (src_hover || !prev_natural_change);
|
||||
let prev_driving = prev_natural_change && (prev_hover || !src_natural_change);
|
||||
let progress = |offset: f32, max_scroll: f32| {
|
||||
if max_scroll <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(offset / max_scroll).clamp(0.0, 1.0)
|
||||
}
|
||||
};
|
||||
let src_progress = progress(src_y, src_max_scroll_y);
|
||||
let prev_progress = progress(prev_y, prev_max_scroll_y);
|
||||
let target_prev_y = (src_progress * prev_max_scroll_y).clamp(0.0, prev_max_scroll_y);
|
||||
let target_src_y = (prev_progress * src_max_scroll_y).clamp(0.0, src_max_scroll_y);
|
||||
let deadband_px: f32 = 2.0;
|
||||
|
||||
cols[0].ctx().data_mut(|d| {
|
||||
d.insert_temp(prev_src_y_id, src_y);
|
||||
d.insert_temp(prev_prev_y_id, prev_y);
|
||||
if src_driving && (target_prev_y - prev_y).abs() > deadband_px {
|
||||
d.insert_temp(sync_src_to_prev, Some(target_prev_y));
|
||||
} else if prev_driving && (target_src_y - src_y).abs() > deadband_px {
|
||||
d.insert_temp(sync_prev_to_src, Some(target_src_y));
|
||||
}
|
||||
});
|
||||
});
|
||||
(edited, caret)
|
||||
}
|
||||
|
||||
pub(crate) fn draw_markdown_preview_only(
|
||||
ui: &mut egui::Ui,
|
||||
markdown_cache: &mut CommonMarkCache,
|
||||
source: &str,
|
||||
height: f32,
|
||||
tab_idx: usize,
|
||||
) {
|
||||
let h = height.max(80.0);
|
||||
let id = egui::Id::new("byte_draft_md_preview_only").with(tab_idx);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(id)
|
||||
.max_height(h)
|
||||
.show(ui, |ui| {
|
||||
CommonMarkViewer::new().show(ui, markdown_cache, source);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Source-line ↔ preview scroll mapping for markdown split view.
|
||||
//! Compiled only for `cargo test` until wired into the live split view.
|
||||
//!
|
||||
//! [`egui_commonmark`](egui_commonmark) does not expose per-block widget positions. We follow the
|
||||
//! usual “row index” approach: split the source into pulldown block spans, estimate each block’s
|
||||
//! rendered height (wrap + headings + code), map a **1-based source line** to an estimated Y, then
|
||||
//! **scale** that Y to the real preview [`ScrollArea`](egui::ScrollArea) range after layout.
|
||||
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag};
|
||||
|
||||
fn parser_options() -> Options {
|
||||
Options::ENABLE_TABLES
|
||||
| Options::ENABLE_TASKLISTS
|
||||
| Options::ENABLE_STRIKETHROUGH
|
||||
| Options::ENABLE_FOOTNOTES
|
||||
| Options::ENABLE_DEFINITION_LIST
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum BlockKind {
|
||||
Paragraph,
|
||||
Heading(HeadingLevel),
|
||||
CodeBlock,
|
||||
HtmlBlock,
|
||||
Table,
|
||||
DefinitionTitle,
|
||||
DefinitionDef,
|
||||
Footnote,
|
||||
Rule,
|
||||
}
|
||||
|
||||
fn byte_to_line(source: &str, byte: usize) -> usize {
|
||||
let b = byte.min(source.len());
|
||||
1 + source.as_bytes()[..b].iter().filter(|&&c| c == b'\n').count()
|
||||
}
|
||||
|
||||
fn source_line_count(source: &str) -> usize {
|
||||
if source.is_empty() {
|
||||
1
|
||||
} else {
|
||||
source.as_bytes().iter().filter(|&&c| c == b'\n').count() + 1
|
||||
}
|
||||
}
|
||||
|
||||
fn heading_scale(level: HeadingLevel) -> f32 {
|
||||
match level {
|
||||
HeadingLevel::H1 => 1.85,
|
||||
HeadingLevel::H2 => 1.55,
|
||||
HeadingLevel::H3 => 1.35,
|
||||
HeadingLevel::H4 => 1.2,
|
||||
HeadingLevel::H5 => 1.1,
|
||||
HeadingLevel::H6 => 1.02,
|
||||
}
|
||||
}
|
||||
|
||||
fn wrapped_text_lines(text: &str, wrap_width: f32, char_w: f32) -> usize {
|
||||
let cpl = (wrap_width / char_w).max(4.0).floor().max(1.0) as usize;
|
||||
let mut total = 0usize;
|
||||
for line in text.split('\n') {
|
||||
let ch = line.chars().count().max(1);
|
||||
total += (ch + cpl - 1) / cpl;
|
||||
}
|
||||
total.max(1)
|
||||
}
|
||||
|
||||
fn estimate_block_height(
|
||||
slice: &str,
|
||||
kind: BlockKind,
|
||||
wrap_width: f32,
|
||||
body_h: f32,
|
||||
code_h: f32,
|
||||
) -> f32 {
|
||||
const BODY_CHAR_W: f32 = 7.2;
|
||||
const CODE_CHAR_W: f32 = 8.0;
|
||||
let spacing = body_h * 0.45;
|
||||
|
||||
match kind {
|
||||
BlockKind::CodeBlock => {
|
||||
let n = slice.matches('\n').count() + 1;
|
||||
n as f32 * code_h + spacing + 8.0
|
||||
}
|
||||
BlockKind::Heading(level) => {
|
||||
let scale = heading_scale(level);
|
||||
let lines = wrapped_text_lines(slice, wrap_width, BODY_CHAR_W);
|
||||
scale * body_h * lines as f32 + spacing * 0.5
|
||||
}
|
||||
BlockKind::Rule => body_h * 0.55 + spacing,
|
||||
BlockKind::Table => {
|
||||
let rows = slice.matches('\n').count() + 1;
|
||||
rows as f32 * body_h * 1.05 + spacing
|
||||
}
|
||||
BlockKind::HtmlBlock => {
|
||||
let lines = wrapped_text_lines(slice, wrap_width, CODE_CHAR_W.min(BODY_CHAR_W));
|
||||
lines as f32 * body_h * 0.92 + spacing
|
||||
}
|
||||
BlockKind::Footnote | BlockKind::DefinitionTitle | BlockKind::DefinitionDef => {
|
||||
let lines = wrapped_text_lines(slice, wrap_width, BODY_CHAR_W);
|
||||
lines as f32 * body_h + spacing
|
||||
}
|
||||
BlockKind::Paragraph => {
|
||||
let lines = wrapped_text_lines(slice, wrap_width, BODY_CHAR_W);
|
||||
lines as f32 * body_h + spacing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MdScrollMap {
|
||||
/// 1-based source line at each block start.
|
||||
pub anchor_lines: Vec<usize>,
|
||||
pub block_y: Vec<f32>,
|
||||
pub block_h: Vec<f32>,
|
||||
pub total_height: f32,
|
||||
pub source_line_count: usize,
|
||||
}
|
||||
|
||||
impl MdScrollMap {
|
||||
pub fn build(source: &str, wrap_width: f32, body_h: f32, code_h: f32) -> Self {
|
||||
let mut raw: Vec<(usize, BlockKind)> = Vec::new();
|
||||
let parser = Parser::new_ext(source, parser_options());
|
||||
for (event, range) in parser.into_offset_iter() {
|
||||
match event {
|
||||
Event::Start(Tag::Paragraph) => raw.push((range.start, BlockKind::Paragraph)),
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
raw.push((range.start, BlockKind::Heading(level)));
|
||||
}
|
||||
Event::Start(Tag::CodeBlock(_)) => raw.push((range.start, BlockKind::CodeBlock)),
|
||||
Event::Start(Tag::HtmlBlock) => raw.push((range.start, BlockKind::HtmlBlock)),
|
||||
Event::Start(Tag::Table(_)) => raw.push((range.start, BlockKind::Table)),
|
||||
Event::Start(Tag::DefinitionListTitle) => {
|
||||
raw.push((range.start, BlockKind::DefinitionTitle));
|
||||
}
|
||||
Event::Start(Tag::DefinitionListDefinition) => {
|
||||
raw.push((range.start, BlockKind::DefinitionDef));
|
||||
}
|
||||
Event::Start(Tag::FootnoteDefinition(_)) => {
|
||||
raw.push((range.start, BlockKind::Footnote));
|
||||
}
|
||||
Event::Rule => raw.push((range.start, BlockKind::Rule)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
raw.sort_by_key(|(b, _)| *b);
|
||||
raw.dedup_by_key(|(b, _)| *b);
|
||||
|
||||
let sl = source_line_count(source);
|
||||
|
||||
if raw.is_empty() {
|
||||
let h = body_h + body_h * 0.45;
|
||||
return Self {
|
||||
anchor_lines: vec![1],
|
||||
block_y: vec![0.0],
|
||||
block_h: vec![h],
|
||||
total_height: h,
|
||||
source_line_count: sl,
|
||||
};
|
||||
}
|
||||
|
||||
let mut anchor_lines = Vec::new();
|
||||
let mut block_h = Vec::new();
|
||||
let mut block_y = Vec::new();
|
||||
let mut y = 0.0_f32;
|
||||
let n = raw.len();
|
||||
for i in 0..n {
|
||||
let (start, kind) = raw[i];
|
||||
let end = if i + 1 < n { raw[i + 1].0 } else { source.len() };
|
||||
let slice = source.get(start..end).unwrap_or("");
|
||||
let h = estimate_block_height(slice, kind, wrap_width, body_h, code_h);
|
||||
anchor_lines.push(byte_to_line(source, start));
|
||||
block_y.push(y);
|
||||
block_h.push(h);
|
||||
y += h;
|
||||
}
|
||||
|
||||
Self {
|
||||
anchor_lines,
|
||||
block_y,
|
||||
block_h,
|
||||
total_height: y,
|
||||
source_line_count: sl,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimated content Y for aligning `line` (1-based) with the top of the viewport.
|
||||
///
|
||||
/// The last source line maps to [`Self::total_height`] so scaling to the real scroll range can
|
||||
/// reach the preview bottom (single-line final blocks used to yield only the block top → stuck
|
||||
/// short of max scroll).
|
||||
pub fn estimated_offset_for_line(&self, line: usize) -> f32 {
|
||||
if self.anchor_lines.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let max_line = self.source_line_count.max(1);
|
||||
let line = line.clamp(1, max_line);
|
||||
if line >= max_line {
|
||||
return self.total_height;
|
||||
}
|
||||
let mut i = 0usize;
|
||||
for (idx, &al) in self.anchor_lines.iter().enumerate() {
|
||||
if al <= line {
|
||||
i = idx;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let cur_anchor = self.anchor_lines[i];
|
||||
let next_anchor = self
|
||||
.anchor_lines
|
||||
.get(i + 1)
|
||||
.copied()
|
||||
.unwrap_or(self.source_line_count.saturating_add(1));
|
||||
// Source lines covered by this block: cur_anchor .. next_anchor-1 (inclusive).
|
||||
let line_span = next_anchor.saturating_sub(cur_anchor).max(1);
|
||||
let frac = if line_span <= 1 {
|
||||
0.0
|
||||
} else {
|
||||
((line - cur_anchor) as f32 / (line_span - 1) as f32).clamp(0.0, 1.0)
|
||||
};
|
||||
let top = self.block_y[i];
|
||||
let bh = self.block_h.get(i).copied().unwrap_or(0.0);
|
||||
top + frac * bh
|
||||
}
|
||||
|
||||
/// Inverse of [`Self::estimated_offset_for_line`] in estimated Y space.
|
||||
pub fn line_for_estimated_offset(&self, est_offset: f32) -> usize {
|
||||
if self.anchor_lines.is_empty() {
|
||||
return 1;
|
||||
}
|
||||
let max_line = self.source_line_count.max(1);
|
||||
let est = est_offset.max(0.0);
|
||||
if est + 0.5 >= self.total_height {
|
||||
return max_line;
|
||||
}
|
||||
let mut i = 0usize;
|
||||
for idx in 0..self.block_y.len() {
|
||||
if self.block_y[idx] <= est {
|
||||
i = idx;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let top = self.block_y[i];
|
||||
let bh = self.block_h[i].max(1e-6);
|
||||
let cur_anchor = self.anchor_lines[i];
|
||||
let next_anchor = self
|
||||
.anchor_lines
|
||||
.get(i + 1)
|
||||
.copied()
|
||||
.unwrap_or(self.source_line_count.saturating_add(1));
|
||||
let frac = ((est - top) / bh).clamp(0.0, 1.0);
|
||||
let line_span = next_anchor.saturating_sub(cur_anchor).max(1);
|
||||
let line = if line_span <= 1 {
|
||||
cur_anchor as f32
|
||||
} else {
|
||||
cur_anchor as f32 + frac * (line_span - 1) as f32
|
||||
};
|
||||
line.round().max(1.0).min(max_line as f32) as usize
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_source_single_block() {
|
||||
let m = MdScrollMap::build("", 400.0, 18.0, 16.0);
|
||||
assert_eq!(m.anchor_lines, vec![1]);
|
||||
assert!(m.total_height > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_create_multiple_anchors() {
|
||||
let s = "# A\n\nB para\n\n## C\n";
|
||||
let m = MdScrollMap::build(s, 400.0, 18.0, 16.0);
|
||||
assert!(m.anchor_lines.len() >= 2);
|
||||
let y0 = m.estimated_offset_for_line(1);
|
||||
let y2 = m.estimated_offset_for_line(4);
|
||||
assert!(y2 > y0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_source_line_maps_to_total_height() {
|
||||
let s = "only one line";
|
||||
let m = MdScrollMap::build(s, 400.0, 18.0, 16.0);
|
||||
assert!(
|
||||
(m.estimated_offset_for_line(1) - m.total_height).abs() < 1e-3,
|
||||
"single-line doc: top line should map to full estimated height for preview max scroll"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_line_last_line_maps_to_total_height() {
|
||||
let s = "a\nb\nc";
|
||||
let m = MdScrollMap::build(s, 400.0, 18.0, 16.0);
|
||||
let y_last = m.estimated_offset_for_line(3);
|
||||
assert!(
|
||||
(y_last - m.total_height).abs() < 1e-3,
|
||||
"last line must reach total_height"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimated_offset_round_trips_through_line_for_estimated_offset() {
|
||||
let s = "line one\n\nline two\n";
|
||||
let m = MdScrollMap::build(s, 400.0, 18.0, 16.0);
|
||||
for line in 1..=3 {
|
||||
let y = m.estimated_offset_for_line(line);
|
||||
let back = m.line_for_estimated_offset(y);
|
||||
assert!(
|
||||
(back as isize - line as isize).abs() <= 1,
|
||||
"line {line}: y={y} mapped back to {back}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ mod helpers;
|
||||
mod image_tab;
|
||||
mod md_preview;
|
||||
mod paint;
|
||||
mod preferences;
|
||||
mod resize_geometry;
|
||||
mod status_bar;
|
||||
mod syntax;
|
||||
@@ -21,6 +22,8 @@ mod text_editor;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use theme::{preset_idx_for_name, ResolvedTheme, ThemeOverrides, PRESETS};
|
||||
|
||||
use byte_draft::{
|
||||
lint_document, Diagnostic,
|
||||
HandlerRegistry, SessionData, Tab, ViewKind, Workspace, NO_FORMATTER_PLAIN_MSG,
|
||||
@@ -133,7 +136,15 @@ pub fn run() -> eframe::Result<()> {
|
||||
"ByteDraft",
|
||||
options,
|
||||
Box::new(|cc| {
|
||||
theme::apply_dark_theme(&cc.egui_ctx);
|
||||
// Initial theme applied once; each frame thereafter `resolved.apply()` keeps it in sync.
|
||||
let init_preset = session::load_session(&session_file_path())
|
||||
.ok()
|
||||
.and_then(|s| s.theme_name)
|
||||
.map(|n| theme::preset_idx_for_name(&n))
|
||||
.unwrap_or(theme::DEFAULT_PRESET_IDX);
|
||||
let init_ov = load_theme_overrides(&theme_overrides_path());
|
||||
theme::PRESETS[init_preset].resolve(&init_ov).apply(&cc.egui_ctx);
|
||||
|
||||
if std::env::var_os("BYTE_DRAFT_SKIP_NATIVE_MENU").is_none() {
|
||||
platform_menu::install(&cc.egui_ctx);
|
||||
}
|
||||
@@ -150,6 +161,44 @@ fn session_file_path() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from("session.json"))
|
||||
}
|
||||
|
||||
fn theme_overrides_path() -> PathBuf {
|
||||
ProjectDirs::from("com", "ByteDraft", "ByteDraft")
|
||||
.map(|p| p.data_local_dir().join("theme_overrides.json"))
|
||||
.unwrap_or_else(|| PathBuf::from("theme_overrides.json"))
|
||||
}
|
||||
|
||||
fn custom_themes_path() -> PathBuf {
|
||||
ProjectDirs::from("com", "ByteDraft", "ByteDraft")
|
||||
.map(|p| p.data_local_dir().join("custom_themes.json"))
|
||||
.unwrap_or_else(|| PathBuf::from("custom_themes.json"))
|
||||
}
|
||||
|
||||
fn load_theme_overrides(path: &PathBuf) -> ThemeOverrides {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_theme_overrides(path: &PathBuf, ov: &ThemeOverrides) {
|
||||
if let Ok(json) = serde_json::to_string_pretty(ov) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_custom_themes(path: &PathBuf) -> Vec<theme::CustomTheme> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_custom_themes(path: &PathBuf, themes: &[theme::CustomTheme]) {
|
||||
if let Ok(json) = serde_json::to_string_pretty(themes) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ByteDraftApp {
|
||||
registry: HandlerRegistry,
|
||||
session: SessionData,
|
||||
@@ -176,6 +225,26 @@ pub struct ByteDraftApp {
|
||||
native_file_rx: Option<std::sync::mpsc::Receiver<NativeFileDialogResult>>,
|
||||
/// File explorer panel state.
|
||||
explorer: explorer::ExplorerState,
|
||||
/// Index into `theme::PRESETS` for the active built-in preset.
|
||||
preset_idx: usize,
|
||||
/// Per-colour overrides applied on top of the active preset.
|
||||
theme_overrides: ThemeOverrides,
|
||||
/// Merged preset + overrides — re-computed whenever either changes.
|
||||
resolved: ResolvedTheme,
|
||||
/// Path to the theme_overrides.json file.
|
||||
theme_overrides_path: PathBuf,
|
||||
/// Whether the Preferences window is open.
|
||||
show_preferences: bool,
|
||||
/// Buffer for the "import from clipboard" text field in the preferences window.
|
||||
import_buffer: String,
|
||||
/// Index into the combined (presets + custom_themes) list shown in the theme dropdown.
|
||||
combo_idx: usize,
|
||||
/// User-saved named themes.
|
||||
custom_themes: Vec<theme::CustomTheme>,
|
||||
/// Path to custom_themes.json.
|
||||
custom_themes_path: PathBuf,
|
||||
/// Name input buffer for the "Save as named theme" field in the preferences window.
|
||||
new_theme_name: String,
|
||||
}
|
||||
|
||||
impl ByteDraftApp {
|
||||
@@ -193,6 +262,30 @@ impl ByteDraftApp {
|
||||
for tab in &mut sess.tabs {
|
||||
tab.diagnostics = lint_document(tab.effective_language(), &tab.text);
|
||||
}
|
||||
|
||||
let ct_path = custom_themes_path();
|
||||
let custom_themes = load_custom_themes(&ct_path);
|
||||
|
||||
// Resolve the active theme: first check if theme_name matches a preset,
|
||||
// then check custom themes (which carry their own base preset + overrides).
|
||||
let ov_path = theme_overrides_path();
|
||||
let (preset_idx, theme_overrides, combo_idx) = {
|
||||
let saved_name = sess.theme_name.as_deref().unwrap_or("");
|
||||
if let Some(ci) = custom_themes.iter().position(|ct| ct.name == saved_name) {
|
||||
// Restore from a saved custom theme.
|
||||
let ct = &custom_themes[ci];
|
||||
let pi = preset_idx_for_name(&ct.preset);
|
||||
let ov = ct.overrides.clone();
|
||||
(pi, ov, PRESETS.len() + ci)
|
||||
} else {
|
||||
// Restore from a built-in preset (or default).
|
||||
let pi = preset_idx_for_name(saved_name);
|
||||
let ov = load_theme_overrides(&ov_path);
|
||||
(pi, ov, pi)
|
||||
}
|
||||
};
|
||||
let resolved = PRESETS[preset_idx].resolve(&theme_overrides);
|
||||
|
||||
Self {
|
||||
registry,
|
||||
session: sess,
|
||||
@@ -213,6 +306,16 @@ impl ByteDraftApp {
|
||||
#[cfg(target_os = "windows")]
|
||||
native_file_rx: None,
|
||||
explorer: explorer::ExplorerState::new(),
|
||||
preset_idx,
|
||||
theme_overrides,
|
||||
resolved,
|
||||
theme_overrides_path: ov_path,
|
||||
show_preferences: false,
|
||||
import_buffer: String::new(),
|
||||
combo_idx,
|
||||
custom_themes,
|
||||
custom_themes_path: ct_path,
|
||||
new_theme_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +486,9 @@ impl ByteDraftApp {
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::F) && primary) {
|
||||
self.find.open = !self.find.open;
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Comma) && primary) {
|
||||
self.show_preferences = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn new_tab(&mut self) {
|
||||
@@ -510,6 +616,23 @@ impl ByteDraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-compute `resolved` from the current preset + overrides, and persist the selection.
|
||||
fn apply_theme_change(&mut self) {
|
||||
self.resolved = PRESETS[self.preset_idx].resolve(&self.theme_overrides);
|
||||
// Persist the active theme name: use the custom theme name when one is selected.
|
||||
let active_name = if self.combo_idx >= PRESETS.len() {
|
||||
self.custom_themes
|
||||
.get(self.combo_idx - PRESETS.len())
|
||||
.map(|ct| ct.name.clone())
|
||||
.unwrap_or_else(|| PRESETS[self.preset_idx].name.to_string())
|
||||
} else {
|
||||
PRESETS[self.preset_idx].name.to_string()
|
||||
};
|
||||
self.session.theme_name = Some(active_name);
|
||||
save_theme_overrides(&self.theme_overrides_path, &self.theme_overrides);
|
||||
save_custom_themes(&self.custom_themes_path, &self.custom_themes);
|
||||
}
|
||||
|
||||
fn autosave_session(&mut self) {
|
||||
const INTERVAL: Duration = Duration::from_secs(2);
|
||||
if self.last_autosave.elapsed() < INTERVAL {
|
||||
@@ -561,6 +684,11 @@ impl ByteDraftApp {
|
||||
ui.close();
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Preferences… (Ctrl+,)").clicked() {
|
||||
self.show_preferences = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.menu_button("Edit", |ui| {
|
||||
if ui.button("Format document").clicked() {
|
||||
@@ -692,9 +820,9 @@ impl ByteDraftApp {
|
||||
let title = Tab::tab_label(&self.session.tabs, i);
|
||||
|
||||
let text_color = if active {
|
||||
theme::TEXT_PRIMARY
|
||||
self.resolved.text_primary
|
||||
} else {
|
||||
theme::TEXT_MUTED
|
||||
self.resolved.text_muted
|
||||
};
|
||||
|
||||
let font_id = egui::TextStyle::Body.resolve(ui.style());
|
||||
@@ -906,6 +1034,7 @@ impl ByteDraftApp {
|
||||
self.show_line_endings,
|
||||
editor_scroll_id,
|
||||
None,
|
||||
&self.resolved,
|
||||
)
|
||||
} else {
|
||||
(false, None, egui::Vec2::ZERO, 0.0)
|
||||
@@ -923,6 +1052,7 @@ impl ByteDraftApp {
|
||||
self.word_wrap,
|
||||
self.show_line_endings,
|
||||
idx,
|
||||
&self.resolved,
|
||||
);
|
||||
(edited, caret, egui::Vec2::ZERO, 0.0)
|
||||
} else {
|
||||
@@ -1006,6 +1136,9 @@ impl ByteDraftApp {
|
||||
MenuCommand::ToggleShowLineEndings => {
|
||||
self.show_line_endings = !self.show_line_endings;
|
||||
}
|
||||
MenuCommand::Preferences => {
|
||||
self.show_preferences = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1039,6 +1172,36 @@ impl eframe::App for ByteDraftApp {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
// Apply the resolved theme each frame (cheap — egui diffs visuals internally).
|
||||
self.resolved.apply(&ctx);
|
||||
|
||||
// Draw the preferences window if open, and re-resolve if anything changed.
|
||||
if self.show_preferences {
|
||||
let prev_preset = self.preset_idx;
|
||||
let prev_ov = self.theme_overrides.clone();
|
||||
let prev_combo = self.combo_idx;
|
||||
let prev_custom_len = self.custom_themes.len();
|
||||
preferences::draw_preferences_window(
|
||||
&ctx,
|
||||
&mut self.show_preferences,
|
||||
&mut self.preset_idx,
|
||||
&mut self.theme_overrides,
|
||||
&self.resolved.clone(),
|
||||
&mut self.import_buffer,
|
||||
&mut self.combo_idx,
|
||||
&mut self.custom_themes,
|
||||
&mut self.new_theme_name,
|
||||
);
|
||||
if self.preset_idx != prev_preset
|
||||
|| self.combo_idx != prev_combo
|
||||
|| self.custom_themes.len() != prev_custom_len
|
||||
|| serde_json::to_string(&self.theme_overrides).ok()
|
||||
!= serde_json::to_string(&prev_ov).ok()
|
||||
{
|
||||
self.apply_theme_change();
|
||||
}
|
||||
}
|
||||
|
||||
platform_menu::sync_workspace_gated_items(self.workspace.root.is_some());
|
||||
platform_menu::sync_view_menu_checks(self.word_wrap, self.show_line_endings);
|
||||
let cmds = platform_menu::poll_menu_commands();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Carets, toolbar glyphs, and line-ending badges.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
/// Small expand/collapse caret (Unicode triangles often render as squares with bundled fonts).
|
||||
/// Collapsed: points **right** (▶). Expanded: points **down** (▼). Y grows downward in egui.
|
||||
pub(crate) fn paint_folder_tree_caret(ui: &egui::Ui, rect: egui::Rect, expanded: bool) {
|
||||
let color = ui.visuals().widgets.inactive.text_color();
|
||||
let c = rect.center();
|
||||
let painter = ui.painter_at(rect);
|
||||
let pts = if expanded {
|
||||
vec![
|
||||
c + egui::vec2(0.0, 3.5),
|
||||
c + egui::vec2(-4.0, -2.5),
|
||||
c + egui::vec2(4.0, -2.5),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
c + egui::vec2(4.0, 0.0),
|
||||
c + egui::vec2(-3.5, -4.0),
|
||||
c + egui::vec2(-3.5, 4.0),
|
||||
]
|
||||
};
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
pts,
|
||||
color,
|
||||
egui::Stroke::new(0.0, egui::Color32::TRANSPARENT),
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn paint_markdown_toolbar_icon_lines(
|
||||
painter: &egui::Painter,
|
||||
rect: egui::Rect,
|
||||
color: egui::Color32,
|
||||
) {
|
||||
let h = rect.height();
|
||||
let n = 4usize;
|
||||
let v0 = rect.top() + h * 0.16;
|
||||
let step = (h * 0.68) / ((n - 1).max(1) as f32);
|
||||
for i in 0..n {
|
||||
let y = v0 + step * i as f32;
|
||||
let frac = if i == 0 { 1.0 } else { 0.76 };
|
||||
let lw = rect.width() * frac;
|
||||
let x0 = rect.left() + (rect.width() - lw) * 0.5;
|
||||
painter.line_segment(
|
||||
[egui::pos2(x0, y), egui::pos2(x0 + lw, y)],
|
||||
egui::Stroke::new(1.2, color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn paint_markdown_toolbar_icon_split(
|
||||
painter: &egui::Painter,
|
||||
rect: egui::Rect,
|
||||
color: egui::Color32,
|
||||
) {
|
||||
let r = rect.shrink(1.0);
|
||||
painter.rect_stroke(
|
||||
r,
|
||||
2.0,
|
||||
egui::Stroke::new(1.0, color),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let cx = r.center().x;
|
||||
painter.line_segment(
|
||||
[
|
||||
egui::pos2(cx, r.top() + 3.0),
|
||||
egui::pos2(cx, r.bottom() - 3.0),
|
||||
],
|
||||
egui::Stroke::new(1.0, color),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn paint_markdown_toolbar_icon_preview(
|
||||
painter: &egui::Painter,
|
||||
rect: egui::Rect,
|
||||
color: egui::Color32,
|
||||
) {
|
||||
let sun_r = rect.width() * 0.14;
|
||||
painter.circle_stroke(
|
||||
egui::pos2(rect.right() - sun_r * 1.25, rect.top() + sun_r * 1.15),
|
||||
sun_r,
|
||||
egui::Stroke::new(1.0, color),
|
||||
);
|
||||
let base = rect.bottom() - 3.0;
|
||||
let pts = [
|
||||
egui::pos2(rect.left() + 3.0, base),
|
||||
egui::pos2(rect.left() + rect.width() * 0.32, base - rect.height() * 0.42),
|
||||
egui::pos2(rect.left() + rect.width() * 0.48, base - rect.height() * 0.22),
|
||||
egui::pos2(rect.left() + rect.width() * 0.68, base - rect.height() * 0.48),
|
||||
egui::pos2(rect.right() - 3.0, base),
|
||||
];
|
||||
for w in pts.windows(2) {
|
||||
painter.line_segment([w[0], w[1]], egui::Stroke::new(1.0, color));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Preferences window: theme dropdown, per-colour customizer, clipboard share, and custom theme naming.
|
||||
|
||||
use eframe::egui;
|
||||
use egui::color_picker::{color_edit_button_srgba, Alpha};
|
||||
|
||||
use super::theme::{
|
||||
color32_to_arr, CustomTheme, ResolvedTheme, ThemeExport, ThemeOverrides, PRESETS,
|
||||
preset_idx_for_name,
|
||||
};
|
||||
|
||||
// Layout constants — every row uses these so columns snap to the same x positions.
|
||||
const LABEL_W: f32 = 112.0;
|
||||
const PICKER_W: f32 = 26.0;
|
||||
const RESET_W: f32 = 26.0;
|
||||
const ROW_H: f32 = 22.0;
|
||||
|
||||
// ── public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
/// Draw the preferences floating window.
|
||||
///
|
||||
/// - `combo_idx`: index into the combined (presets + custom themes) list.
|
||||
/// - `custom_themes`: user-saved named themes.
|
||||
/// - `new_theme_name`: buffer for the "Save as…" name input in the Import section.
|
||||
/// - `import_buf`: persists the user's clipboard paste text between frames.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn draw_preferences_window(
|
||||
ctx: &egui::Context,
|
||||
open: &mut bool,
|
||||
preset_idx: &mut usize,
|
||||
overrides: &mut ThemeOverrides,
|
||||
resolved: &ResolvedTheme,
|
||||
import_buf: &mut String,
|
||||
combo_idx: &mut usize,
|
||||
custom_themes: &mut Vec<CustomTheme>,
|
||||
new_theme_name: &mut String,
|
||||
) {
|
||||
let mut keep_open = *open;
|
||||
|
||||
egui::Window::new("Preferences")
|
||||
.open(&mut keep_open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.min_width(540.0)
|
||||
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
section_label(ui, "Theme", resolved.text_primary);
|
||||
ui.add_space(8.0);
|
||||
|
||||
// ── Theme dropdown ────────────────────────────────────────────────
|
||||
let total_items = PRESETS.len() + custom_themes.len();
|
||||
// Clamp in case a custom theme was deleted externally.
|
||||
if *combo_idx >= total_items {
|
||||
*combo_idx = *preset_idx;
|
||||
}
|
||||
let selected_label = combo_label(*combo_idx, custom_themes);
|
||||
|
||||
egui::ComboBox::from_id_salt("theme_combo")
|
||||
.width(220.0)
|
||||
.selected_text(selected_label)
|
||||
.show_ui(ui, |ui| {
|
||||
// Built-in presets
|
||||
for (i, preset) in PRESETS.iter().enumerate() {
|
||||
if ui.selectable_value(combo_idx, i, preset.name).changed() {
|
||||
*preset_idx = i;
|
||||
*overrides = ThemeOverrides::default();
|
||||
}
|
||||
}
|
||||
// Custom themes (if any)
|
||||
if !custom_themes.is_empty() {
|
||||
ui.separator();
|
||||
for (ci, ct) in custom_themes.iter().enumerate() {
|
||||
let idx = PRESETS.len() + ci;
|
||||
if ui.selectable_value(combo_idx, idx, &ct.name).changed() {
|
||||
*preset_idx = preset_idx_for_name(&ct.preset);
|
||||
*overrides = ct.overrides.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
|
||||
// ── Customizer ────────────────────────────────────────────────────
|
||||
let preset = &PRESETS[*preset_idx];
|
||||
let mut changed = false;
|
||||
|
||||
egui::CollapsingHeader::new(
|
||||
egui::RichText::new("Customize")
|
||||
.size(13.0)
|
||||
.color(resolved.text_primary),
|
||||
)
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(6.0);
|
||||
|
||||
changed |= color_row(ui, "Background", resolved.text_muted, preset.bg_base, &mut overrides.bg_base);
|
||||
changed |= color_row(ui, "Panel", resolved.text_muted, preset.bg_raised, &mut overrides.bg_raised);
|
||||
changed |= color_row(ui, "Selection", resolved.text_muted, preset.selection, &mut overrides.selection);
|
||||
changed |= color_row(ui, "Text", resolved.text_muted, preset.text_primary, &mut overrides.text_primary);
|
||||
changed |= color_row(ui, "Muted text", resolved.text_muted, preset.text_muted, &mut overrides.text_muted);
|
||||
changed |= color_row(ui, "Editor bg", resolved.text_muted, preset.editor_bg, &mut overrides.editor_bg);
|
||||
changed |= color_row(ui, "Find highlight", resolved.text_muted, preset.find_match, &mut overrides.find_match);
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled(!overrides.is_empty(), egui::Button::new("Reset to preset"))
|
||||
.on_disabled_hover_text("No overrides active")
|
||||
.clicked()
|
||||
{
|
||||
*overrides = ThemeOverrides::default();
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let _ = changed;
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
|
||||
// ── Share (clipboard) ─────────────────────────────────────────────
|
||||
egui::CollapsingHeader::new(
|
||||
egui::RichText::new("Share / Import")
|
||||
.size(13.0)
|
||||
.color(resolved.text_primary),
|
||||
)
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(6.0);
|
||||
|
||||
// Export
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Copy theme to clipboard").clicked() {
|
||||
let s = ThemeExport::from_state(*preset_idx, overrides)
|
||||
.to_clipboard_string();
|
||||
ui.ctx().copy_text(s);
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new("Copies a JSON string others can import.")
|
||||
.size(11.0)
|
||||
.color(resolved.text_muted),
|
||||
);
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Import paste
|
||||
ui.label(
|
||||
egui::RichText::new("Paste a theme string below and click Apply:")
|
||||
.size(12.0)
|
||||
.color(resolved.text_muted),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(import_buf)
|
||||
.hint_text("Paste theme JSON here…")
|
||||
.desired_width(380.0),
|
||||
);
|
||||
let can_apply = !import_buf.is_empty();
|
||||
if ui
|
||||
.add_enabled(can_apply, egui::Button::new("Apply"))
|
||||
.clicked()
|
||||
{
|
||||
if let Some(exp) = ThemeExport::from_clipboard_string(import_buf) {
|
||||
*preset_idx = preset_idx_for_name(&exp.preset);
|
||||
*overrides = exp.overrides;
|
||||
*combo_idx = *preset_idx;
|
||||
import_buf.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(exp) = ThemeExport::from_clipboard_string(import_buf) {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"Will apply: {}{}",
|
||||
exp.preset,
|
||||
if exp.overrides.is_empty() { String::new() }
|
||||
else { " + custom overrides".to_string() }
|
||||
))
|
||||
.size(11.0)
|
||||
.color(resolved.text_muted),
|
||||
);
|
||||
} else if !import_buf.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new("Could not parse — check the string.")
|
||||
.size(11.0)
|
||||
.color(egui::Color32::from_rgb(220, 100, 80)),
|
||||
);
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(6.0);
|
||||
|
||||
// Save as named custom theme
|
||||
ui.label(
|
||||
egui::RichText::new("Save current theme as a named entry in the dropdown:")
|
||||
.size(12.0)
|
||||
.color(resolved.text_muted),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(new_theme_name)
|
||||
.hint_text("Theme name…")
|
||||
.desired_width(280.0),
|
||||
);
|
||||
let can_save = !new_theme_name.trim().is_empty();
|
||||
if ui
|
||||
.add_enabled(can_save, egui::Button::new("Save as named theme"))
|
||||
.clicked()
|
||||
{
|
||||
let name = new_theme_name.trim().to_string();
|
||||
let new_ct = CustomTheme {
|
||||
name: name.clone(),
|
||||
preset: PRESETS[*preset_idx].name.to_string(),
|
||||
overrides: overrides.clone(),
|
||||
};
|
||||
// Replace existing custom theme with same name, or push new.
|
||||
if let Some(existing) = custom_themes.iter_mut().find(|ct| ct.name == name) {
|
||||
*existing = new_ct;
|
||||
} else {
|
||||
custom_themes.push(new_ct);
|
||||
}
|
||||
// Select the newly saved theme in the dropdown.
|
||||
let ci = custom_themes.iter().position(|ct| ct.name == name).unwrap_or(0);
|
||||
*combo_idx = PRESETS.len() + ci;
|
||||
new_theme_name.clear();
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
});
|
||||
|
||||
*open = keep_open;
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn section_label(ui: &mut egui::Ui, text: &str, color: egui::Color32) {
|
||||
ui.label(egui::RichText::new(text).strong().size(13.0).color(color));
|
||||
}
|
||||
|
||||
fn combo_label(combo_idx: usize, custom_themes: &[CustomTheme]) -> &str {
|
||||
if combo_idx < PRESETS.len() {
|
||||
PRESETS[combo_idx].name
|
||||
} else {
|
||||
custom_themes
|
||||
.get(combo_idx - PRESETS.len())
|
||||
.map(|ct| ct.name.as_str())
|
||||
.unwrap_or("Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
// ── per-colour row ────────────────────────────────────────────────────────────
|
||||
|
||||
/// One color override row. Uses fixed-width cells so every column snaps to the same X.
|
||||
fn color_row(
|
||||
ui: &mut egui::Ui,
|
||||
label: &str,
|
||||
label_color: egui::Color32,
|
||||
preset_color: egui::Color32,
|
||||
override_slot: &mut Option<[u8; 3]>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
|
||||
let mut current = override_slot
|
||||
.map(|[r, g, b]| egui::Color32::from_rgb(r, g, b))
|
||||
.unwrap_or(preset_color);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
|
||||
// ── Fixed-width label cell (painted directly, zero layout influence) ──
|
||||
let (label_rect, _) =
|
||||
ui.allocate_exact_size(egui::vec2(LABEL_W, ROW_H), egui::Sense::hover());
|
||||
ui.painter().text(
|
||||
egui::pos2(label_rect.left() + 2.0, label_rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
label,
|
||||
egui::FontId::proportional(12.5),
|
||||
label_color,
|
||||
);
|
||||
|
||||
// ── Color picker button (constrain to PICKER_W × ROW_H) ──────────────
|
||||
let orig_interact = ui.spacing().interact_size;
|
||||
ui.spacing_mut().interact_size = egui::vec2(PICKER_W, ROW_H);
|
||||
let pick_resp = color_edit_button_srgba(ui, &mut current, Alpha::Opaque);
|
||||
ui.spacing_mut().interact_size = orig_interact;
|
||||
|
||||
if pick_resp.changed() {
|
||||
let arr = color32_to_arr(current);
|
||||
*override_slot = if arr == color32_to_arr(preset_color) {
|
||||
None
|
||||
} else {
|
||||
Some(arr)
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// ── Dot indicator (shows when this colour has an override) ────────────
|
||||
let (dot_rect, _) =
|
||||
ui.allocate_exact_size(egui::vec2(8.0, ROW_H), egui::Sense::hover());
|
||||
if override_slot.is_some() {
|
||||
ui.painter().circle_filled(
|
||||
dot_rect.center(),
|
||||
3.0,
|
||||
egui::Color32::from_rgb(90, 155, 240),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reset button (fixed width) ─────────────────────────────────────────
|
||||
let (reset_rect, reset_resp) =
|
||||
ui.allocate_exact_size(egui::vec2(RESET_W, ROW_H), egui::Sense::click());
|
||||
let has_override = override_slot.is_some();
|
||||
let reset_alpha = if has_override { 200u8 } else { 60u8 };
|
||||
ui.painter().text(
|
||||
reset_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"↩",
|
||||
egui::FontId::proportional(13.0),
|
||||
egui::Color32::from_rgba_unmultiplied(180, 180, 180, reset_alpha),
|
||||
);
|
||||
if has_override
|
||||
&& reset_resp.on_hover_text("Reset to preset").clicked()
|
||||
{
|
||||
*override_slot = None;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
changed
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Bottom status row with diagnostics, language, encoding, and caret position.
|
||||
|
||||
use byte_draft::{
|
||||
detect_language, lint_document, read_text_file_capped_with_encoding, Diagnostic, LanguageId,
|
||||
NO_FORMATTER_PLAIN_MSG, TextEncoding, ViewKind,
|
||||
};
|
||||
use eframe::egui;
|
||||
|
||||
use super::{chrome, ByteDraftApp};
|
||||
|
||||
impl ByteDraftApp {
|
||||
/// Re-read the tab from disk using its current [`TextEncoding`] (after user changes encoding).
|
||||
fn reload_tab_text_for_encoding(&mut self, idx: usize) {
|
||||
let Some(tab) = self.session.tabs.get_mut(idx) else {
|
||||
return;
|
||||
};
|
||||
if matches!(tab.view_kind, ViewKind::Hex | ViewKind::Image) {
|
||||
return;
|
||||
}
|
||||
let Some(path) = tab.path.clone() else {
|
||||
return;
|
||||
};
|
||||
match read_text_file_capped_with_encoding(&path, tab.encoding) {
|
||||
Ok(s) => {
|
||||
tab.text = s;
|
||||
let ext = tab.extension_hint().map(|x| x.to_string());
|
||||
tab.detected_language = detect_language(&tab.text, ext.as_deref());
|
||||
tab.dirty = false;
|
||||
tab.diagnostics = lint_document(tab.effective_language(), &tab.text);
|
||||
}
|
||||
Err(e) => {
|
||||
tab.diagnostics = vec![Diagnostic::new(format!("Reload failed: {e}"))];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_status_bar(&mut self, ui: &mut egui::Ui) {
|
||||
self.session.normalize();
|
||||
let idx = self.session.active_tab;
|
||||
if idx >= self.session.tabs.len() {
|
||||
return;
|
||||
}
|
||||
let diags: Vec<Diagnostic> = self.session.tabs[idx]
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|d| d.message != NO_FORMATTER_PLAIN_MSG)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let status_fill = self.resolved.gutter_bg;
|
||||
const STATUS_ROW_H: f32 = 24.0;
|
||||
const STATUS_FONT: f32 = 12.0;
|
||||
let status_margin = egui::Margin {
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 0,
|
||||
bottom: chrome::STATUS_PAD_ABOVE_RESIZE_STRIP.round() as i8,
|
||||
};
|
||||
let status_panel_h = STATUS_ROW_H + status_margin.topf() + status_margin.bottomf();
|
||||
|
||||
let (line, col) = self.editor_cursor_line_col;
|
||||
|
||||
let result = egui::Panel::bottom("status")
|
||||
.resizable(false)
|
||||
.exact_size(status_panel_h)
|
||||
.frame(
|
||||
egui::Frame::NONE
|
||||
.fill(status_fill)
|
||||
.inner_margin(status_margin),
|
||||
)
|
||||
.show_inside(ui, |ui| {
|
||||
let text_color = self.resolved.text_muted;
|
||||
let mut lang_changed = false;
|
||||
let mut enc_changed = false;
|
||||
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 16.0;
|
||||
|
||||
if !diags.is_empty() {
|
||||
let error_msg = diags
|
||||
.iter()
|
||||
.take(1)
|
||||
.map(|d| {
|
||||
let loc = match (d.line, d.column) {
|
||||
(Some(l), Some(c)) => format!("{l}:{c}: "),
|
||||
(Some(l), None) => format!("{l}: "),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!("{loc}{}", d.message)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
ui.label(
|
||||
egui::RichText::new(&error_msg)
|
||||
.size(STATUS_FONT)
|
||||
.color(self.resolved.diagnostic_warn),
|
||||
);
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let tab = &mut self.session.tabs[idx];
|
||||
let syntax_on = matches!(
|
||||
tab.view_kind,
|
||||
ViewKind::Text
|
||||
| ViewKind::MarkdownPreview
|
||||
| ViewKind::MarkdownPreviewOnly
|
||||
);
|
||||
|
||||
let selected = tab.effective_language().as_str();
|
||||
ui.add_enabled_ui(syntax_on, |ui| {
|
||||
egui::ComboBox::from_id_salt("byte_draft_status_language")
|
||||
.width(80.0)
|
||||
.selected_text(
|
||||
egui::RichText::new(selected)
|
||||
.size(STATUS_FONT)
|
||||
.color(text_color),
|
||||
)
|
||||
.show_ui(ui, |ui| {
|
||||
for &lang in LanguageId::ALL {
|
||||
if ui
|
||||
.selectable_label(
|
||||
tab.language_override == Some(lang),
|
||||
lang.as_str(),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
tab.language_override = Some(lang);
|
||||
lang_changed = true;
|
||||
}
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
tab.language_override.is_none(),
|
||||
"Auto-detect",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
tab.language_override = None;
|
||||
lang_changed = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if tab.path.is_some() && syntax_on {
|
||||
let enc = tab.encoding;
|
||||
egui::ComboBox::from_id_salt("byte_draft_status_encoding")
|
||||
.width(108.0)
|
||||
.selected_text(
|
||||
egui::RichText::new(enc.status_label())
|
||||
.size(STATUS_FONT)
|
||||
.color(text_color),
|
||||
)
|
||||
.show_ui(ui, |ui| {
|
||||
for &e in TextEncoding::ALL {
|
||||
if ui
|
||||
.selectable_label(enc == e, e.status_label())
|
||||
.clicked()
|
||||
{
|
||||
tab.encoding = e;
|
||||
enc_changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Ln {}, Col {}", line, col))
|
||||
.size(STATUS_FONT)
|
||||
.color(text_color),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
(lang_changed, enc_changed)
|
||||
});
|
||||
|
||||
if result.inner.0 {
|
||||
self.refresh_lint_active_tab();
|
||||
}
|
||||
if result.inner.1 {
|
||||
self.reload_tab_text_for_encoding(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ use std::sync::{Arc, OnceLock};
|
||||
use byte_draft::LanguageId;
|
||||
use eframe::egui::text::{LayoutJob, TextFormat};
|
||||
|
||||
use super::theme;
|
||||
use eframe::egui::{Color32, FontId, TextStyle, Ui};
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::ThemeSet;
|
||||
@@ -79,6 +78,26 @@ fn push_slice_find(
|
||||
}
|
||||
|
||||
|
||||
// ── contrast helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn relative_luminance(c: Color32) -> f32 {
|
||||
let linearize = |v: u8| {
|
||||
let f = v as f32 / 255.0;
|
||||
if f <= 0.04045 {
|
||||
f / 12.92
|
||||
} else {
|
||||
((f + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
0.2126 * linearize(c.r()) + 0.7152 * linearize(c.g()) + 0.0722 * linearize(c.b())
|
||||
}
|
||||
|
||||
fn contrast_ratio(a: Color32, b: Color32) -> f32 {
|
||||
let l1 = relative_luminance(a).max(relative_luminance(b));
|
||||
let l2 = relative_luminance(a).min(relative_luminance(b));
|
||||
(l1 + 0.05) / (l2 + 0.05)
|
||||
}
|
||||
|
||||
// ── SyntaxEngine trait ────────────────────────────────────────────────────────
|
||||
|
||||
/// Provides syntax-highlighted byte-range spans for a document.
|
||||
@@ -95,6 +114,7 @@ pub trait SyntaxEngine: Send + Sync {
|
||||
&self,
|
||||
text: &str,
|
||||
lang: LanguageId,
|
||||
syntax_theme: &str,
|
||||
) -> Option<Vec<(Range<usize>, Color32)>>;
|
||||
}
|
||||
|
||||
@@ -145,6 +165,7 @@ impl SyntectHighlighter {
|
||||
LanguageId::C => "c",
|
||||
LanguageId::Cpp => "cpp",
|
||||
LanguageId::Java => "java",
|
||||
LanguageId::Sql => "sql",
|
||||
LanguageId::Plain | LanguageId::Unknown => "",
|
||||
}
|
||||
}
|
||||
@@ -155,6 +176,7 @@ impl SyntaxEngine for SyntectHighlighter {
|
||||
&self,
|
||||
text: &str,
|
||||
lang: LanguageId,
|
||||
syntax_theme: &str,
|
||||
) -> Option<Vec<(Range<usize>, Color32)>> {
|
||||
let ext = Self::syntax_extension(lang);
|
||||
if ext.is_empty() {
|
||||
@@ -169,7 +191,8 @@ impl SyntaxEngine for SyntectHighlighter {
|
||||
let theme = self
|
||||
.theme_set
|
||||
.themes
|
||||
.get(self.dark_theme_name)
|
||||
.get(syntax_theme)
|
||||
.or_else(|| self.theme_set.themes.get(self.dark_theme_name))
|
||||
.or_else(|| self.theme_set.themes.values().next())
|
||||
.expect("ThemeSet::load_defaults() always contains at least one theme");
|
||||
|
||||
@@ -218,6 +241,11 @@ impl SyntaxEngine for SyntectHighlighter {
|
||||
/// - Find-match ranges are overlaid as a post-process via [`push_slice_find`] so that
|
||||
/// the highlighted token colors and the find highlight coexist correctly.
|
||||
/// - The public signature is unchanged from the previous implementation.
|
||||
/// Minimum WCAG contrast ratio below which a syntax-token color is replaced with the
|
||||
/// default foreground. 2.5 catches near-invisible tokens (e.g. dark punctuation on a
|
||||
/// dark background) without affecting well-contrasted colors.
|
||||
const MIN_CONTRAST: f32 = 2.5;
|
||||
|
||||
#[must_use]
|
||||
pub fn layout_highlighted(
|
||||
ui: &Ui,
|
||||
@@ -225,6 +253,9 @@ pub fn layout_highlighted(
|
||||
lang: LanguageId,
|
||||
wrap_width: f32,
|
||||
find_ranges: &[(usize, usize)],
|
||||
syntax_theme: &str,
|
||||
find_color: Color32,
|
||||
editor_bg: Color32,
|
||||
) -> Arc<eframe::egui::Galley> {
|
||||
let font_id = mono(ui);
|
||||
let default_color = ui.visuals().widgets.inactive.text_color();
|
||||
@@ -232,17 +263,24 @@ pub fn layout_highlighted(
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = wrap_width;
|
||||
|
||||
match SyntectHighlighter::get().highlight_spans(text, lang) {
|
||||
match SyntectHighlighter::get().highlight_spans(text, lang, syntax_theme) {
|
||||
Some(spans) if !spans.is_empty() => {
|
||||
for (range, color) in spans {
|
||||
// Replace colors with insufficient contrast against the editor background
|
||||
// so that punctuation and other dark tokens stay legible.
|
||||
let visible_color = if contrast_ratio(color, editor_bg) < MIN_CONTRAST {
|
||||
default_color
|
||||
} else {
|
||||
color
|
||||
};
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
range,
|
||||
color,
|
||||
visible_color,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
theme::FIND_MATCH_COLOR,
|
||||
find_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -255,7 +293,7 @@ pub fn layout_highlighted(
|
||||
default_color,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
theme::FIND_MATCH_COLOR,
|
||||
find_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -334,11 +372,13 @@ mod tests {
|
||||
|
||||
// ── highlight_spans: plain / unknown ──────────────────────────────────────
|
||||
|
||||
const TEST_THEME: &str = "base16-eighties.dark";
|
||||
|
||||
#[test]
|
||||
fn plain_text_returns_none() {
|
||||
let h = highlighter();
|
||||
assert!(h.highlight_spans("hello world", LanguageId::Plain).is_none());
|
||||
assert!(h.highlight_spans("", LanguageId::Unknown).is_none());
|
||||
assert!(h.highlight_spans("hello world", LanguageId::Plain, TEST_THEME).is_none());
|
||||
assert!(h.highlight_spans("", LanguageId::Unknown, TEST_THEME).is_none());
|
||||
}
|
||||
|
||||
// ── highlight_spans: smoke tests for each language ────────────────────────
|
||||
@@ -348,7 +388,7 @@ mod tests {
|
||||
#[test]
|
||||
fn $name() {
|
||||
let h = highlighter();
|
||||
let spans = h.highlight_spans($text, $lang);
|
||||
let spans = h.highlight_spans($text, $lang, TEST_THEME);
|
||||
assert!(spans.is_some(), "{} should produce Some(spans)", stringify!($lang));
|
||||
let spans = spans.unwrap();
|
||||
assert!(!spans.is_empty(), "{} spans must be non-empty for non-empty text", stringify!($lang));
|
||||
@@ -370,12 +410,13 @@ mod tests {
|
||||
smoke_test!(smoke_c, LanguageId::C, "#include <stdio.h>\nint main() { return 0; }\n");
|
||||
smoke_test!(smoke_cpp, LanguageId::Cpp, "#include <iostream>\nint main() { std::cout << 1; }\n");
|
||||
smoke_test!(smoke_java, LanguageId::Java, "public class Foo {\n public static void main(String[] args) {}\n}\n");
|
||||
smoke_test!(smoke_sql, LanguageId::Sql, "SELECT * FROM thing WHERE condition = true AND (condition2 = true) ORDER BY column DESC;\n");
|
||||
|
||||
// ── span integrity ────────────────────────────────────────────────────────
|
||||
|
||||
fn assert_spans_tile_text(text: &str, lang: LanguageId) {
|
||||
let h = highlighter();
|
||||
let spans = match h.highlight_spans(text, lang) {
|
||||
let spans = match h.highlight_spans(text, lang, TEST_THEME) {
|
||||
Some(s) => s,
|
||||
None => return, // plain text — no spans to check
|
||||
};
|
||||
@@ -411,7 +452,7 @@ mod tests {
|
||||
|
||||
fn assert_spans_on_char_boundaries(text: &str, lang: LanguageId) {
|
||||
let h = highlighter();
|
||||
let spans = match h.highlight_spans(text, lang) {
|
||||
let spans = match h.highlight_spans(text, lang, TEST_THEME) {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
@@ -450,6 +491,7 @@ mod tests {
|
||||
(LanguageId::C, "int x = 0;\n"),
|
||||
(LanguageId::Cpp, "int x = 0;\n"),
|
||||
(LanguageId::Java, "class A {}\n"),
|
||||
(LanguageId::Sql, "SELECT id FROM users WHERE active = true;\n"),
|
||||
];
|
||||
for &(lang, text) in cases {
|
||||
assert_spans_tile_text(text, lang);
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Monospace text editor with line numbers, find highlights, wrap, inline EOL markers.
|
||||
|
||||
use byte_draft::Tab;
|
||||
use eframe::egui;
|
||||
|
||||
use super::{syntax, theme::ResolvedTheme};
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn line_count_text(text: &str) -> usize {
|
||||
if text.is_empty() {
|
||||
1
|
||||
} else {
|
||||
text.chars().filter(|&c| c == '\n').count() + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the text editor with line numbers (plain text / markdown source).
|
||||
/// `editor_scroll_id` must be unique per scroll region (tab index + context).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn draw_text_editor_inner(
|
||||
ui: &mut egui::Ui,
|
||||
tab: &mut Tab,
|
||||
find_ranges: &[(usize, usize)],
|
||||
width: f32,
|
||||
height: f32,
|
||||
word_wrap: bool,
|
||||
show_line_endings: bool,
|
||||
editor_scroll_id: egui::Id,
|
||||
force_vertical_scroll: Option<f32>,
|
||||
theme: &ResolvedTheme,
|
||||
) -> (bool, Option<usize>, egui::Vec2, f32) {
|
||||
let lang = tab.effective_language();
|
||||
let prev = tab.text.clone();
|
||||
let w = width.max(1.0);
|
||||
let h = height.max(1.0);
|
||||
|
||||
let editor_bg = theme.editor_bg;
|
||||
let gutter_bg = theme.gutter_bg;
|
||||
let line_num_color = theme.line_num_color;
|
||||
|
||||
let line_count = line_count_text(&tab.text);
|
||||
let digit_count = ((line_count as f32).log10().floor() as usize + 1).max(2);
|
||||
let gutter_w = (digit_count as f32 * 9.0 + 24.0).max(48.0);
|
||||
|
||||
let font_id = egui::FontId::monospace(14.0);
|
||||
let eol_font = egui::FontId::monospace(12.0);
|
||||
let line_height = ui
|
||||
.painter()
|
||||
.layout_no_wrap("X".to_string(), font_id.clone(), egui::Color32::WHITE)
|
||||
.size()
|
||||
.y;
|
||||
let (outer_rect, _) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
|
||||
|
||||
let gutter_rect = egui::Rect::from_min_size(outer_rect.min, egui::vec2(gutter_w, h));
|
||||
let editor_left = gutter_rect.right();
|
||||
let editor_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(editor_left, outer_rect.top()),
|
||||
outer_rect.max,
|
||||
);
|
||||
|
||||
ui.painter().rect_filled(outer_rect, 0.0, editor_bg);
|
||||
|
||||
let text_w = editor_rect.width() - 16.0;
|
||||
let no_wrap_width: f32 = 1.0e9;
|
||||
|
||||
let mut scroll_offset = egui::Vec2::ZERO;
|
||||
let mut max_scroll_y = 0.0_f32;
|
||||
let mut caret_ccursor: Option<usize> = None;
|
||||
// (screen position at line-end, is_crlf): only visible rows are collected.
|
||||
// Drawn outside the scroll area with a clipped painter to stay within editor_rect.
|
||||
let mut eol_markers: Vec<(egui::Pos2, bool)> = Vec::new();
|
||||
// Pre-compute so we can reference it inside the closure without borrow issues.
|
||||
let is_crlf_list = if show_line_endings {
|
||||
classify_line_endings(&tab.text)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let syntax_theme = theme.syntax_theme;
|
||||
let find_color = theme.find_match;
|
||||
let mut layouter =
|
||||
|ui: &egui::Ui, string: &dyn egui::text_edit::TextBuffer, wrap_width: f32| {
|
||||
let wrap_lim = if word_wrap {
|
||||
wrap_width
|
||||
} else {
|
||||
no_wrap_width
|
||||
};
|
||||
syntax::layout_highlighted(
|
||||
ui, string.as_str(), lang, wrap_lim, find_ranges,
|
||||
syntax_theme, find_color, editor_bg,
|
||||
)
|
||||
};
|
||||
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(editor_rect), |ui| {
|
||||
let mut scroll = egui::ScrollArea::both()
|
||||
.id_salt(editor_scroll_id)
|
||||
.max_height(h)
|
||||
.auto_shrink([false, false]);
|
||||
if let Some(y) = force_vertical_scroll {
|
||||
scroll = scroll.vertical_scroll_offset(y.max(0.0));
|
||||
}
|
||||
let scroll_out = scroll.show(ui, |ui| {
|
||||
egui::Frame::NONE
|
||||
.inner_margin(egui::Margin::symmetric(8, 0))
|
||||
.show(ui, |ui| {
|
||||
let desired_w = if word_wrap { text_w } else { no_wrap_width };
|
||||
let out = egui::TextEdit::multiline(&mut tab.text)
|
||||
.font(font_id.clone())
|
||||
.desired_width(desired_w)
|
||||
.frame(egui::Frame::NONE)
|
||||
.margin(egui::vec2(0.0, 0.0))
|
||||
.layouter(&mut layouter)
|
||||
.show(ui);
|
||||
caret_ccursor = out
|
||||
.cursor_range
|
||||
.map(|r| r.primary.index)
|
||||
.or_else(|| out.state.cursor.char_range().map(|r| r.primary.index));
|
||||
if show_line_endings {
|
||||
let viewport_top = outer_rect.top() - line_height;
|
||||
let viewport_bottom = outer_rect.bottom() + line_height;
|
||||
let mut newline_idx = 0usize;
|
||||
for row in &out.galley.rows {
|
||||
if !row.ends_with_newline {
|
||||
continue;
|
||||
}
|
||||
let is_crlf = is_crlf_list.get(newline_idx).copied().unwrap_or(false);
|
||||
newline_idx += 1;
|
||||
let row_rect = row.rect().translate(out.galley_pos.to_vec2());
|
||||
let y = row_rect.center().y;
|
||||
// Only collect markers for rows within the visible viewport.
|
||||
if y < viewport_top || y > viewport_bottom {
|
||||
continue;
|
||||
}
|
||||
eol_markers.push((egui::pos2(row_rect.right() + 4.0, y), is_crlf));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
scroll_offset = scroll_out.state.offset;
|
||||
max_scroll_y = (scroll_out.content_size.y - scroll_out.inner_rect.height()).max(0.0);
|
||||
});
|
||||
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(gutter_rect, 0.0, gutter_bg);
|
||||
let first_visible_line = (scroll_offset.y / line_height).floor() as usize;
|
||||
let visible_lines = (h / line_height).ceil() as usize + 2;
|
||||
|
||||
for i in 0..visible_lines {
|
||||
let line_num = first_visible_line + i + 1;
|
||||
if line_num > line_count {
|
||||
break;
|
||||
}
|
||||
|
||||
let y = gutter_rect.top() + (i as f32 * line_height) - (scroll_offset.y % line_height);
|
||||
|
||||
if y >= gutter_rect.top() - line_height && y <= gutter_rect.bottom() {
|
||||
let text = format!("{:>width$}", line_num, width = digit_count);
|
||||
painter.text(
|
||||
egui::pos2(gutter_rect.right() - 12.0, y + line_height * 0.5),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
text,
|
||||
font_id.clone(),
|
||||
line_num_color,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
if show_line_endings {
|
||||
// Restrict to the editor area so badges can't bleed into the tab bar, gutter, or status bar.
|
||||
// Only visible rows are in eol_markers so this loop is O(visible rows), not O(total).
|
||||
let eol_painter = painter.with_clip_rect(editor_rect);
|
||||
let text_color = egui::Color32::from_rgb(160, 160, 160);
|
||||
let border_color = egui::Color32::from_rgb(100, 100, 100);
|
||||
let bg_color = egui::Color32::from_rgba_unmultiplied(60, 60, 60, 180);
|
||||
|
||||
// Measure badge size once — all labels ("LF", "CR") are the same width.
|
||||
let label_size = eol_painter
|
||||
.layout_no_wrap("LF".to_string(), eol_font.clone(), text_color)
|
||||
.size();
|
||||
let badge_w = label_size.x + 6.0;
|
||||
let badge_h = (line_height - 4.0).max(label_size.y + 4.0);
|
||||
let gap = 2.0;
|
||||
|
||||
for (pos, is_crlf) in eol_markers {
|
||||
let labels: &[&str] = if is_crlf { &["CR", "LF"] } else { &["LF"] };
|
||||
let mut x = pos.x;
|
||||
for label in labels {
|
||||
let badge_rect = egui::Rect::from_min_size(
|
||||
egui::pos2(x, pos.y - badge_h * 0.5),
|
||||
egui::vec2(badge_w, badge_h),
|
||||
);
|
||||
eol_painter.rect_filled(badge_rect, 2.0, bg_color);
|
||||
eol_painter.rect_stroke(
|
||||
badge_rect,
|
||||
2.0,
|
||||
egui::Stroke::new(1.0, border_color),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
eol_painter.text(
|
||||
badge_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
*label,
|
||||
eol_font.clone(),
|
||||
text_color,
|
||||
);
|
||||
x += badge_w + gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut edited = false;
|
||||
if tab.text != prev {
|
||||
tab.dirty = true;
|
||||
edited = true;
|
||||
}
|
||||
(edited, caret_ccursor, scroll_offset, max_scroll_y)
|
||||
}
|
||||
|
||||
/// Returns one `bool` per newline in `text`: `true` if the newline is a CRLF pair (`\r\n`),
|
||||
/// `false` if it is a bare LF. Standalone `\r` (no following `\n`) is ignored.
|
||||
fn classify_line_endings(text: &str) -> Vec<bool> {
|
||||
let bytes = text.as_bytes();
|
||||
bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, b)| {
|
||||
if *b == b'\n' {
|
||||
Some(i > 0 && bytes[i - 1] == b'\r')
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_line_endings, line_count_text};
|
||||
|
||||
// ── line_count_text ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn line_count_empty_is_one() {
|
||||
assert_eq!(line_count_text(""), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_no_newlines_is_one() {
|
||||
assert_eq!(line_count_text("hello world"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_single_newline_is_two() {
|
||||
assert_eq!(line_count_text("a\nb"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_trailing_newline_counts() {
|
||||
// "a\nb\n" has two \n → 3 lines (last one is empty)
|
||||
assert_eq!(line_count_text("a\nb\n"), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_only_newline_is_two() {
|
||||
assert_eq!(line_count_text("\n"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_crlf_counted_by_lf_only() {
|
||||
// CRLF: each \n is one line break; \r is irrelevant to counting
|
||||
assert_eq!(line_count_text("a\r\nb\r\n"), 3);
|
||||
}
|
||||
|
||||
// ── classify_line_endings ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn classify_empty_is_empty() {
|
||||
assert!(classify_line_endings("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_no_newlines_is_empty() {
|
||||
assert!(classify_line_endings("hello").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_lf_only() {
|
||||
assert_eq!(classify_line_endings("a\nb\n"), vec![false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_crlf_only() {
|
||||
assert_eq!(classify_line_endings("a\r\nb\r\n"), vec![true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_mixed_endings() {
|
||||
// First line LF, second CRLF
|
||||
assert_eq!(classify_line_endings("a\nb\r\n"), vec![false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_leading_lf() {
|
||||
// First byte is \n — index 0, so i > 0 is false → LF not CRLF
|
||||
assert_eq!(classify_line_endings("\nfoo"), vec![false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_cr_without_lf_is_ignored() {
|
||||
// Standalone \r produces no entry (only \n triggers classification)
|
||||
assert_eq!(classify_line_endings("a\rb"), vec![] as Vec<bool>);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_single_crlf() {
|
||||
assert_eq!(classify_line_endings("\r\n"), vec![true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Centralized UI theme system: built-in presets, per-color overrides, and resolution.
|
||||
|
||||
use eframe::egui;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const fn c(r: u8, g: u8, b: u8) -> egui::Color32 {
|
||||
egui::Color32::from_rgb(r, g, b)
|
||||
}
|
||||
|
||||
fn apply_ov(base: egui::Color32, ov: Option<[u8; 3]>) -> egui::Color32 {
|
||||
match ov {
|
||||
Some([r, g, b]) => c(r, g, b),
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color32_to_arr(col: egui::Color32) -> [u8; 3] {
|
||||
let [r, g, b, _] = col.to_srgba_unmultiplied();
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
// ── ThemeOverrides ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Optional per-colour overrides applied on top of a preset.
|
||||
/// Only `Some` fields override the preset value; `None` means "use preset default".
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ThemeOverrides {
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub bg_base: Option<[u8; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub bg_raised: Option<[u8; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub bg_inactive: Option<[u8; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub bg_hover: Option<[u8; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub bg_active: Option<[u8; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub selection: Option<[u8; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub text_primary: Option<[u8; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub text_muted: Option<[u8; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub editor_bg: Option<[u8; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub gutter_bg: Option<[u8; 3]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub find_match: Option<[u8; 3]>,
|
||||
}
|
||||
|
||||
impl ThemeOverrides {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bg_base.is_none()
|
||||
&& self.bg_raised.is_none()
|
||||
&& self.bg_inactive.is_none()
|
||||
&& self.bg_hover.is_none()
|
||||
&& self.bg_active.is_none()
|
||||
&& self.selection.is_none()
|
||||
&& self.text_primary.is_none()
|
||||
&& self.text_muted.is_none()
|
||||
&& self.editor_bg.is_none()
|
||||
&& self.gutter_bg.is_none()
|
||||
&& self.find_match.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
// ── AppTheme (preset) ─────────────────────────────────────────────────────────
|
||||
|
||||
/// A built-in theme preset. Contains all colour values and the syntect theme name.
|
||||
/// Representative swatch colours (`swatch_*`) are used in the preferences picker UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppTheme {
|
||||
pub name: &'static str,
|
||||
pub is_dark: bool,
|
||||
// Shell colours
|
||||
pub bg_base: egui::Color32,
|
||||
pub bg_raised: egui::Color32,
|
||||
pub bg_inactive: egui::Color32,
|
||||
pub bg_hover: egui::Color32,
|
||||
pub bg_active: egui::Color32,
|
||||
pub selection: egui::Color32,
|
||||
pub text_primary: egui::Color32,
|
||||
pub text_muted: egui::Color32,
|
||||
// Editor-specific
|
||||
pub editor_bg: egui::Color32,
|
||||
pub gutter_bg: egui::Color32,
|
||||
pub line_num_color: egui::Color32,
|
||||
pub find_match: egui::Color32,
|
||||
pub diagnostic_warn: egui::Color32,
|
||||
// Syntax engine
|
||||
pub syntax_theme: &'static str,
|
||||
// Representative syntax token colours (available for future UI use)
|
||||
#[allow(dead_code)] pub swatch_keyword: egui::Color32,
|
||||
#[allow(dead_code)] pub swatch_string: egui::Color32,
|
||||
#[allow(dead_code)] pub swatch_comment: egui::Color32,
|
||||
}
|
||||
|
||||
impl AppTheme {
|
||||
/// Merge `overrides` into this preset and return a [`ResolvedTheme`] ready for rendering.
|
||||
pub fn resolve(&self, ov: &ThemeOverrides) -> ResolvedTheme {
|
||||
ResolvedTheme {
|
||||
is_dark: self.is_dark,
|
||||
bg_base: apply_ov(self.bg_base, ov.bg_base),
|
||||
bg_raised: apply_ov(self.bg_raised, ov.bg_raised),
|
||||
bg_inactive: apply_ov(self.bg_inactive, ov.bg_inactive),
|
||||
bg_hover: apply_ov(self.bg_hover, ov.bg_hover),
|
||||
bg_active: apply_ov(self.bg_active, ov.bg_active),
|
||||
selection: apply_ov(self.selection, ov.selection),
|
||||
text_primary: apply_ov(self.text_primary, ov.text_primary),
|
||||
text_muted: apply_ov(self.text_muted, ov.text_muted),
|
||||
editor_bg: apply_ov(self.editor_bg, ov.editor_bg),
|
||||
gutter_bg: apply_ov(self.gutter_bg, ov.gutter_bg),
|
||||
find_match: apply_ov(self.find_match, ov.find_match),
|
||||
line_num_color: self.line_num_color,
|
||||
diagnostic_warn: self.diagnostic_warn,
|
||||
syntax_theme: self.syntax_theme,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ResolvedTheme ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Preset + overrides merged together — this is what the rest of the app reads each frame.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedTheme {
|
||||
pub is_dark: bool,
|
||||
pub bg_base: egui::Color32,
|
||||
pub bg_raised: egui::Color32,
|
||||
pub bg_inactive: egui::Color32,
|
||||
pub bg_hover: egui::Color32,
|
||||
pub bg_active: egui::Color32,
|
||||
pub selection: egui::Color32,
|
||||
pub text_primary: egui::Color32,
|
||||
pub text_muted: egui::Color32,
|
||||
pub editor_bg: egui::Color32,
|
||||
pub gutter_bg: egui::Color32,
|
||||
pub line_num_color: egui::Color32,
|
||||
pub find_match: egui::Color32,
|
||||
pub diagnostic_warn: egui::Color32,
|
||||
pub syntax_theme: &'static str,
|
||||
}
|
||||
|
||||
impl ResolvedTheme {
|
||||
/// Push all resolved colours into the egui context's visuals.
|
||||
pub fn apply(&self, ctx: &egui::Context) {
|
||||
let mut v = if self.is_dark {
|
||||
egui::Visuals::dark()
|
||||
} else {
|
||||
egui::Visuals::light()
|
||||
};
|
||||
v.window_corner_radius = egui::CornerRadius::ZERO;
|
||||
v.panel_fill = self.bg_base;
|
||||
v.extreme_bg_color = self.bg_base;
|
||||
v.widgets.noninteractive.bg_fill = self.bg_raised;
|
||||
v.widgets.inactive.bg_fill = self.bg_inactive;
|
||||
v.widgets.hovered.bg_fill = self.bg_hover;
|
||||
v.widgets.active.bg_fill = self.bg_active;
|
||||
v.selection.bg_fill = self.selection;
|
||||
if self.is_dark {
|
||||
v.widgets.noninteractive.fg_stroke.color = self.text_primary;
|
||||
v.widgets.inactive.fg_stroke.color = self.text_primary;
|
||||
}
|
||||
ctx.set_visuals(v);
|
||||
|
||||
let mut s = (*ctx.global_style()).clone();
|
||||
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
|
||||
ctx.set_global_style(s);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Built-in presets ──────────────────────────────────────────────────────────
|
||||
|
||||
/// All built-in presets in display order. Index 0 is the default.
|
||||
pub static PRESETS: &[AppTheme] = &[
|
||||
// ── Slate (default) ──────────────────────────────────────────────────────
|
||||
// Cool blue-slate shell paired with base16-eighties.dark syntax.
|
||||
// Uses muted purple/green/grey tokens so keywords never scream.
|
||||
// editor_bg is intentionally lighter than bg_base so the canvas stands out.
|
||||
AppTheme {
|
||||
name: "Slate Dark",
|
||||
is_dark: true,
|
||||
bg_base: c(20, 22, 28), // darker chrome/panels
|
||||
bg_raised: c(27, 29, 36),
|
||||
bg_inactive: c(34, 36, 44),
|
||||
bg_hover: c(40, 43, 52),
|
||||
bg_active: c(46, 50, 60),
|
||||
selection: c(44, 82, 130),
|
||||
text_primary: c(212, 216, 224),
|
||||
text_muted: c(110, 116, 130),
|
||||
editor_bg: c(30, 32, 40), // visibly lighter than bg_base
|
||||
gutter_bg: c(24, 26, 33),
|
||||
line_num_color: c( 80, 88, 105),
|
||||
find_match: c(255, 215, 55),
|
||||
diagnostic_warn: c(229, 192, 123),
|
||||
syntax_theme: "base16-eighties.dark",
|
||||
swatch_keyword: c(204, 153, 204),
|
||||
swatch_string: c(153, 199, 141),
|
||||
swatch_comment: c( 99, 107, 103),
|
||||
},
|
||||
// ── Ocean Dark ───────────────────────────────────────────────────────────
|
||||
// Deeper blue shell tuned to match the ocean.dark syntax palette.
|
||||
// The bg shares the theme's cool undertone so keyword coral reads naturally.
|
||||
AppTheme {
|
||||
name: "Ocean Dark",
|
||||
is_dark: true,
|
||||
bg_base: c(26, 32, 48),
|
||||
bg_raised: c(32, 39, 56),
|
||||
bg_inactive: c(38, 46, 64),
|
||||
bg_hover: c(44, 53, 72),
|
||||
bg_active: c(50, 60, 80),
|
||||
selection: c(48, 88, 140),
|
||||
text_primary: c(192, 197, 206),
|
||||
text_muted: c( 96, 108, 128),
|
||||
editor_bg: c(24, 30, 46),
|
||||
gutter_bg: c(30, 36, 52),
|
||||
line_num_color: c( 80, 96, 120),
|
||||
find_match: c(235, 203, 80),
|
||||
diagnostic_warn: c(235, 180, 100),
|
||||
syntax_theme: "base16-ocean.dark",
|
||||
swatch_keyword: c(191, 97, 106),
|
||||
swatch_string: c(163, 190, 140),
|
||||
swatch_comment: c( 96, 110, 128),
|
||||
},
|
||||
// ── Mocha Dark ───────────────────────────────────────────────────────────
|
||||
// Warm brown shell with mocha.dark syntax — earthy oranges and soft yellows.
|
||||
AppTheme {
|
||||
name: "Mocha Dark",
|
||||
is_dark: true,
|
||||
bg_base: c(28, 22, 18),
|
||||
bg_raised: c(35, 28, 23),
|
||||
bg_inactive: c(42, 34, 28),
|
||||
bg_hover: c(49, 40, 33),
|
||||
bg_active: c(56, 46, 38),
|
||||
selection: c( 96, 60, 38),
|
||||
text_primary: c(215, 205, 192),
|
||||
text_muted: c(130, 116, 102),
|
||||
editor_bg: c(26, 20, 16),
|
||||
gutter_bg: c(32, 25, 20),
|
||||
line_num_color: c(100, 84, 68),
|
||||
find_match: c(255, 210, 70),
|
||||
diagnostic_warn: c(230, 175, 90),
|
||||
syntax_theme: "base16-mocha.dark",
|
||||
swatch_keyword: c(210, 119, 70),
|
||||
swatch_string: c(152, 190, 101),
|
||||
swatch_comment: c(105, 90, 75),
|
||||
},
|
||||
// ── Solarized Dark ───────────────────────────────────────────────────────
|
||||
AppTheme {
|
||||
name: "Solarized Dark",
|
||||
is_dark: true,
|
||||
bg_base: c( 0, 43, 54),
|
||||
bg_raised: c( 7, 54, 66),
|
||||
bg_inactive: c( 13, 62, 74),
|
||||
bg_hover: c( 22, 72, 84),
|
||||
bg_active: c( 31, 82, 94),
|
||||
selection: c( 0, 74, 86),
|
||||
text_primary: c(131, 148, 150),
|
||||
text_muted: c( 88, 110, 117),
|
||||
editor_bg: c( 0, 43, 54),
|
||||
gutter_bg: c( 7, 54, 66),
|
||||
line_num_color: c( 58, 88, 100),
|
||||
find_match: c(181, 137, 0),
|
||||
diagnostic_warn: c(203, 75, 22),
|
||||
syntax_theme: "Solarized (dark)",
|
||||
swatch_keyword: c(133, 153, 0),
|
||||
swatch_string: c( 42, 161, 152),
|
||||
swatch_comment: c( 88, 110, 117),
|
||||
},
|
||||
// ── Solarized Light ──────────────────────────────────────────────────────
|
||||
AppTheme {
|
||||
name: "Solarized Light",
|
||||
is_dark: false,
|
||||
bg_base: c(253, 246, 227),
|
||||
bg_raised: c(238, 232, 213),
|
||||
bg_inactive: c(228, 222, 203),
|
||||
bg_hover: c(218, 212, 193),
|
||||
bg_active: c(208, 202, 183),
|
||||
selection: c(147, 193, 212),
|
||||
text_primary: c( 88, 110, 117),
|
||||
text_muted: c(147, 161, 161),
|
||||
editor_bg: c(253, 246, 227),
|
||||
gutter_bg: c(238, 232, 213),
|
||||
line_num_color: c(147, 161, 161),
|
||||
find_match: c(181, 137, 0),
|
||||
diagnostic_warn: c(203, 75, 22),
|
||||
syntax_theme: "Solarized (light)",
|
||||
swatch_keyword: c(133, 153, 0),
|
||||
swatch_string: c( 42, 161, 152),
|
||||
swatch_comment: c(147, 161, 161),
|
||||
},
|
||||
];
|
||||
|
||||
pub const DEFAULT_PRESET_IDX: usize = 0;
|
||||
|
||||
/// Find the preset index for a given name, falling back to the default.
|
||||
pub fn preset_idx_for_name(name: &str) -> usize {
|
||||
PRESETS
|
||||
.iter()
|
||||
.position(|p| p.name == name)
|
||||
.unwrap_or(DEFAULT_PRESET_IDX)
|
||||
}
|
||||
|
||||
// ── Custom (named) themes ─────────────────────────────────────────────────────
|
||||
|
||||
/// A user-saved named theme: a base preset plus optional color overrides.
|
||||
/// Persisted in `custom_themes.json` alongside `session.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomTheme {
|
||||
pub name: String,
|
||||
/// Name of the base [`AppTheme`] preset (matched via [`preset_idx_for_name`]).
|
||||
pub preset: String,
|
||||
#[serde(default)]
|
||||
pub overrides: ThemeOverrides,
|
||||
}
|
||||
|
||||
// ── Clipboard share format ────────────────────────────────────────────────────
|
||||
|
||||
/// Compact, shareable representation of a theme (preset name + any overrides).
|
||||
/// Omits `null` fields so the string stays short enough to paste in chat.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ThemeExport {
|
||||
pub preset: String,
|
||||
#[serde(default)]
|
||||
pub overrides: ThemeOverrides,
|
||||
}
|
||||
|
||||
impl ThemeExport {
|
||||
pub fn from_state(preset_idx: usize, overrides: &ThemeOverrides) -> Self {
|
||||
Self {
|
||||
preset: PRESETS[preset_idx].name.to_string(),
|
||||
overrides: overrides.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to a compact JSON string suitable for clipboard copy/paste.
|
||||
pub fn to_clipboard_string(&self) -> String {
|
||||
serde_json::to_string(self).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parse from a clipboard string. Returns `None` on invalid JSON.
|
||||
pub fn from_clipboard_string(s: &str) -> Option<Self> {
|
||||
serde_json::from_str(s.trim()).ok()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
---
|
||||
version: v1.2
|
||||
tools:
|
||||
## Access Groups
|
||||
## An access group is the equivalent of an Endpoint Group in Portainer.
|
||||
## ------------------------------------------------------------
|
||||
- name: listAccessGroups
|
||||
description: List all available access groups
|
||||
annotations:
|
||||
title: List Access Groups
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: createAccessGroup
|
||||
description: Create a new access group. Use access groups when you want to define
|
||||
accesses on more than one environment. Otherwise, define the accesses on
|
||||
the environment level.
|
||||
parameters:
|
||||
- name: name
|
||||
description: The name of the access group
|
||||
type: string
|
||||
required: true
|
||||
- name: environmentIds
|
||||
description: "The IDs of the environments that are part of the access group.
|
||||
Must include all the environment IDs that are part of the group - this
|
||||
includes new environments and the existing environments that are
|
||||
already associated with the group. Example: [1, 2, 3]"
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
annotations:
|
||||
title: Create Access Group
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: false
|
||||
openWorldHint: false
|
||||
- name: updateAccessGroupName
|
||||
description: Update the name of an existing access group.
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the access group to update
|
||||
type: number
|
||||
required: true
|
||||
- name: name
|
||||
description: The name of the access group
|
||||
type: string
|
||||
required: true
|
||||
annotations:
|
||||
title: Update Access Group Name
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateAccessGroupUserAccesses
|
||||
description: Update the user accesses of an existing access group.
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the access group to update
|
||||
type: number
|
||||
required: true
|
||||
- name: userAccesses
|
||||
description: "The user accesses that are associated with all the environments in
|
||||
the access group. The ID is the user ID of the user in Portainer.
|
||||
Example: [{id: 1, access: 'environment_administrator'}, {id: 2,
|
||||
access: 'standard_user'}]"
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
description: The ID of the user
|
||||
type: number
|
||||
access:
|
||||
description: The access level of the user. Can be environment_administrator,
|
||||
helpdesk_user, standard_user, readonly_user or operator_user
|
||||
type: string
|
||||
enum:
|
||||
- environment_administrator
|
||||
- helpdesk_user
|
||||
- standard_user
|
||||
- readonly_user
|
||||
- operator_user
|
||||
annotations:
|
||||
title: Update Access Group User Accesses
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateAccessGroupTeamAccesses
|
||||
description: Update the team accesses of an existing access group.
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the access group to update
|
||||
type: number
|
||||
required: true
|
||||
- name: teamAccesses
|
||||
description: "The team accesses that are associated with all the environments in
|
||||
the access group. The ID is the team ID of the team in Portainer.
|
||||
Example: [{id: 1, access: 'environment_administrator'}, {id: 2,
|
||||
access: 'standard_user'}]"
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
description: The ID of the team
|
||||
type: number
|
||||
access:
|
||||
description: The access level of the team. Can be environment_administrator,
|
||||
helpdesk_user, standard_user, readonly_user or operator_user
|
||||
type: string
|
||||
enum:
|
||||
- environment_administrator
|
||||
- helpdesk_user
|
||||
- standard_user
|
||||
- readonly_user
|
||||
- operator_user
|
||||
annotations:
|
||||
title: Update Access Group Team Accesses
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: addEnvironmentToAccessGroup
|
||||
description: Add an environment to an access group.
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the access group to update
|
||||
type: number
|
||||
required: true
|
||||
- name: environmentId
|
||||
description: The ID of the environment to add to the access group
|
||||
type: number
|
||||
required: true
|
||||
annotations:
|
||||
title: Add Environment To Access Group
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: removeEnvironmentFromAccessGroup
|
||||
description: Remove an environment from an access group.
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the access group to update
|
||||
type: number
|
||||
required: true
|
||||
- name: environmentId
|
||||
description: The ID of the environment to remove from the access group
|
||||
type: number
|
||||
required: true
|
||||
annotations:
|
||||
title: Remove Environment From Access Group
|
||||
readOnlyHint: false
|
||||
destructiveHint: true
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
## Environment
|
||||
## ------------------------------------------------------------
|
||||
- name: listEnvironments
|
||||
description: List all available environments
|
||||
annotations:
|
||||
title: List Environments
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateEnvironmentTags
|
||||
description: Update the tags associated with an environment
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the environment to update
|
||||
type: number
|
||||
required: true
|
||||
- name: tagIds
|
||||
description: >-
|
||||
The IDs of the tags that are associated with the environment.
|
||||
Must include all the tag IDs that should be associated with the environment - this includes new tags and existing tags.
|
||||
Providing an empty array will remove all tags.
|
||||
Example: [1, 2, 3]
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: number
|
||||
annotations:
|
||||
title: Update Environment Tags
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateEnvironmentUserAccesses
|
||||
description: Update the user access policies of an environment
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the environment to update
|
||||
type: number
|
||||
required: true
|
||||
- name: userAccesses
|
||||
description: >-
|
||||
The user accesses that are associated with the environment.
|
||||
The ID is the user ID of the user in Portainer.
|
||||
Must include all the access policies for all users that should be associated with the environment.
|
||||
Providing an empty array will remove all user accesses.
|
||||
Example: [{id: 1, access: 'environment_administrator'}, {id: 2, access: 'standard_user'}]
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
description: The ID of the user
|
||||
type: number
|
||||
access:
|
||||
description: The access level of the user
|
||||
type: string
|
||||
enum:
|
||||
- environment_administrator
|
||||
- helpdesk_user
|
||||
- standard_user
|
||||
- readonly_user
|
||||
- operator_user
|
||||
annotations:
|
||||
title: Update Environment User Accesses
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateEnvironmentTeamAccesses
|
||||
description: Update the team access policies of an environment
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the environment to update
|
||||
type: number
|
||||
required: true
|
||||
- name: teamAccesses
|
||||
description: >-
|
||||
The team accesses that are associated with the environment.
|
||||
The ID is the team ID of the team in Portainer.
|
||||
Must include all the access policies for all teams that should be associated with the environment.
|
||||
Providing an empty array will remove all team accesses.
|
||||
Example: [{id: 1, access: 'environment_administrator'}, {id: 2, access: 'standard_user'}]
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
description: The ID of the team
|
||||
type: number
|
||||
access:
|
||||
description: The access level of the team
|
||||
type: string
|
||||
enum:
|
||||
- environment_administrator
|
||||
- helpdesk_user
|
||||
- standard_user
|
||||
- readonly_user
|
||||
- operator_user
|
||||
annotations:
|
||||
title: Update Environment Team Accesses
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
## Environment Groups
|
||||
## An environment group is the equivalent of an Edge Group in Portainer.
|
||||
## ------------------------------------------------------------
|
||||
- name: createEnvironmentGroup
|
||||
description: Create a new environment group. Environment groups are the equivalent of Edge Groups in Portainer.
|
||||
parameters:
|
||||
- name: name
|
||||
description: The name of the environment group
|
||||
type: string
|
||||
required: true
|
||||
- name: environmentIds
|
||||
description: The IDs of the environments to add to the group
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: number
|
||||
annotations:
|
||||
title: Create Environment Group
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: false
|
||||
openWorldHint: false
|
||||
- name: listEnvironmentGroups
|
||||
description: List all available environment groups. Environment groups are the equivalent of Edge Groups in Portainer.
|
||||
annotations:
|
||||
title: List Environment Groups
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateEnvironmentGroupName
|
||||
description: Update the name of an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the environment group to update
|
||||
type: number
|
||||
required: true
|
||||
- name: name
|
||||
description: The new name for the environment group
|
||||
type: string
|
||||
required: true
|
||||
annotations:
|
||||
title: Update Environment Group Name
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateEnvironmentGroupEnvironments
|
||||
description: Update the environments associated with an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the environment group to update
|
||||
type: number
|
||||
required: true
|
||||
- name: environmentIds
|
||||
description: >-
|
||||
The IDs of the environments that should be part of the group.
|
||||
Must include all environment IDs that should be associated with the group.
|
||||
Providing an empty array will remove all environments from the group.
|
||||
Example: [1, 2, 3]
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: number
|
||||
annotations:
|
||||
title: Update Environment Group Environments
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateEnvironmentGroupTags
|
||||
description: Update the tags associated with an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the environment group to update
|
||||
type: number
|
||||
required: true
|
||||
- name: tagIds
|
||||
description: >-
|
||||
The IDs of the tags that should be associated with the group.
|
||||
Must include all tag IDs that should be associated with the group.
|
||||
Providing an empty array will remove all tags from the group.
|
||||
Example: [1, 2, 3]
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: number
|
||||
annotations:
|
||||
title: Update Environment Group Tags
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
## Settings
|
||||
## ------------------------------------------------------------
|
||||
- name: getSettings
|
||||
description: Get the settings of the Portainer instance
|
||||
annotations:
|
||||
title: Get Settings
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
## Stacks
|
||||
## ------------------------------------------------------------
|
||||
- name: listStacks
|
||||
description: List all available stacks
|
||||
annotations:
|
||||
title: List Stacks
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: getStackFile
|
||||
description: Get the compose file for a specific stack ID
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the stack to get the compose file for
|
||||
type: number
|
||||
required: true
|
||||
annotations:
|
||||
title: Get Stack File
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: createStack
|
||||
description: Create a new stack
|
||||
parameters:
|
||||
- name: name
|
||||
description: Name of the stack. Stack name must only consist of lowercase alpha
|
||||
characters, numbers, hyphens, or underscores as well as start with a
|
||||
lowercase character or number
|
||||
type: string
|
||||
required: true
|
||||
- name: file
|
||||
description: >-
|
||||
Content of the stack file. The file must be a valid
|
||||
docker-compose.yml file. example: services:
|
||||
web:
|
||||
image:nginx
|
||||
type: string
|
||||
required: true
|
||||
- name: environmentGroupIds
|
||||
description: "The IDs of the environment groups that the stack belongs to. Must
|
||||
include at least one environment group ID. Example: [1, 2, 3]"
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: number
|
||||
annotations:
|
||||
title: Create Stack
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: false
|
||||
openWorldHint: false
|
||||
- name: updateStack
|
||||
description: Update an existing stack
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the stack to update
|
||||
type: number
|
||||
required: true
|
||||
- name: file
|
||||
description: >-
|
||||
Content of the stack file. The file must be a valid
|
||||
docker-compose.yml file. example: version: 3
|
||||
services:
|
||||
web:
|
||||
image:nginx
|
||||
type: string
|
||||
required: true
|
||||
- name: environmentGroupIds
|
||||
description: "The IDs of the environment groups that the stack belongs to. Must
|
||||
include at least one environment group ID. Example: [1, 2, 3]"
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: number
|
||||
annotations:
|
||||
title: Update Stack
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
## Tags
|
||||
## ------------------------------------------------------------
|
||||
- name: createEnvironmentTag
|
||||
description: Create a new environment tag
|
||||
parameters:
|
||||
- name: name
|
||||
description: The name of the tag
|
||||
type: string
|
||||
required: true
|
||||
annotations:
|
||||
title: Create Environment Tag
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: false
|
||||
openWorldHint: false
|
||||
- name: listEnvironmentTags
|
||||
description: List all available environment tags
|
||||
annotations:
|
||||
title: List Environment Tags
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
## Teams
|
||||
## ------------------------------------------------------------
|
||||
- name: createTeam
|
||||
description: Create a new team
|
||||
parameters:
|
||||
- name: name
|
||||
description: The name of the team
|
||||
type: string
|
||||
required: true
|
||||
annotations:
|
||||
title: Create Team
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: false
|
||||
openWorldHint: false
|
||||
- name: listTeams
|
||||
description: List all available teams
|
||||
annotations:
|
||||
title: List Teams
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateTeamName
|
||||
description: Update the name of an existing team
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the team to update
|
||||
type: number
|
||||
required: true
|
||||
- name: name
|
||||
description: The new name of the team
|
||||
type: string
|
||||
required: true
|
||||
annotations:
|
||||
title: Update Team Name
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateTeamMembers
|
||||
description: Update the members of an existing team
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the team to update
|
||||
type: number
|
||||
required: true
|
||||
- name: userIds
|
||||
description: "The IDs of the users that are part of the team. Must include all
|
||||
the user IDs that are part of the team - this includes new users and
|
||||
the existing users that are already associated with the team. Example:
|
||||
[1, 2, 3]"
|
||||
type: array
|
||||
required: true
|
||||
items:
|
||||
type: number
|
||||
annotations:
|
||||
title: Update Team Members
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
|
||||
## Users
|
||||
## ------------------------------------------------------------
|
||||
- name: listUsers
|
||||
description: List all available users
|
||||
annotations:
|
||||
title: List Users
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: updateUserRole
|
||||
description: Update an existing user
|
||||
parameters:
|
||||
- name: id
|
||||
description: The ID of the user to update
|
||||
type: number
|
||||
required: true
|
||||
- name: role
|
||||
description: The role of the user. Can be admin, user or edge_admin
|
||||
type: string
|
||||
required: true
|
||||
enum:
|
||||
- admin
|
||||
- user
|
||||
- edge_admin
|
||||
annotations:
|
||||
title: Update User Role
|
||||
readOnlyHint: false
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
|
||||
## Docker Proxy
|
||||
## ------------------------------------------------------------
|
||||
- name: dockerProxy
|
||||
description: Proxy Docker requests to a specific Portainer environment.
|
||||
This tool can be used with any Docker API operation as documented in the Docker Engine API specification (https://docs.docker.com/reference/api/engine/version/v1.48/).
|
||||
parameters:
|
||||
- name: environmentId
|
||||
description: The ID of the environment to proxy Docker requests to
|
||||
type: number
|
||||
required: true
|
||||
- name: method
|
||||
description: The HTTP method to use to proxy the Docker API operation
|
||||
type: string
|
||||
required: true
|
||||
enum:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- HEAD
|
||||
- name: dockerAPIPath
|
||||
description: "The route of the Docker API operation to proxy. Must include the leading slash. Example: /containers/json"
|
||||
type: string
|
||||
required: true
|
||||
- name: queryParams
|
||||
description: "The query parameters to include in the Docker API operation. Must be an array of key-value pairs.
|
||||
Example: [{key: 'all', value: 'true'}, {key: 'filter', value: 'dangling'}]"
|
||||
type: array
|
||||
required: false
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The key of the query parameter
|
||||
value:
|
||||
type: string
|
||||
description: The value of the query parameter
|
||||
- name: headers
|
||||
description: "The headers to include in the Docker API operation. Must be an array of key-value pairs.
|
||||
Example: [{key: 'Content-Type', value: 'application/json'}]"
|
||||
type: array
|
||||
required: false
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The key of the header
|
||||
value:
|
||||
type: string
|
||||
description: The value of the header
|
||||
- name: body
|
||||
description: "The body of the Docker API operation to proxy. Must be a JSON string.
|
||||
Example: {'Image': 'nginx:latest', 'Name': 'my-container'}"
|
||||
type: string
|
||||
required: false
|
||||
annotations:
|
||||
title: Docker Proxy
|
||||
readOnlyHint: true
|
||||
destructiveHint: true
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
|
||||
## Kubernetes Proxy
|
||||
## ------------------------------------------------------------
|
||||
- name: kubernetesProxy
|
||||
description: Proxy Kubernetes requests to a specific Portainer environment.
|
||||
This tool can be used with any Kubernetes API operation as documented in the Kubernetes API specification (https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/).
|
||||
parameters:
|
||||
- name: environmentId
|
||||
description: The ID of the environment to proxy Kubernetes requests to
|
||||
type: number
|
||||
required: true
|
||||
- name: method
|
||||
description: The HTTP method to use to proxy the Kubernetes API operation
|
||||
type: string
|
||||
required: true
|
||||
enum:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- HEAD
|
||||
- name: kubernetesAPIPath
|
||||
description: "The route of the Kubernetes API operation to proxy. Must include the leading slash. Example: /api/v1/namespaces/default/pods"
|
||||
type: string
|
||||
required: true
|
||||
- name: queryParams
|
||||
description: "The query parameters to include in the Kubernetes API operation. Must be an array of key-value pairs.
|
||||
Example: [{key: 'watch', value: 'true'}, {key: 'fieldSelector', value: 'metadata.name=my-pod'}]"
|
||||
type: array
|
||||
required: false
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The key of the query parameter
|
||||
value:
|
||||
type: string
|
||||
description: The value of the query parameter
|
||||
- name: headers
|
||||
description: "The headers to include in the Kubernetes API operation. Must be an array of key-value pairs.
|
||||
Example: [{key: 'Content-Type', value: 'application/json'}]"
|
||||
type: array
|
||||
required: false
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The key of the header
|
||||
value:
|
||||
type: string
|
||||
description: The value of the header
|
||||
- name: body
|
||||
description: "The body of the Kubernetes API operation to proxy. Must be a JSON string.
|
||||
Example: {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'my-pod'}}"
|
||||
type: string
|
||||
required: false
|
||||
annotations:
|
||||
title: Kubernetes Proxy
|
||||
readOnlyHint: true
|
||||
destructiveHint: true
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
- name: getKubernetesResourceStripped
|
||||
description: >-
|
||||
Proxy GET requests to a specific Portainer environment for Kubernetes resources,
|
||||
and automatically strips verbose metadata fields (such as 'managedFields') from the API response
|
||||
to reduce its size. This tool is intended for retrieving Kubernetes resource
|
||||
information where a leaner payload is desired.
|
||||
This tool can be used with any GET Kubernetes API operation as documented
|
||||
in the Kubernetes API specification (https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/).
|
||||
For other methods (POST, PUT, DELETE, HEAD), use the 'kubernetesProxy' tool.
|
||||
parameters:
|
||||
- name: environmentId
|
||||
description: The ID of the environment to proxy Kubernetes GET requests to
|
||||
type: number
|
||||
required: true
|
||||
- name: kubernetesAPIPath
|
||||
description: "The route of the Kubernetes API GET operation to proxy. Must include the leading slash. Example: /api/v1/namespaces/default/pods"
|
||||
type: string
|
||||
required: true
|
||||
- name: queryParams
|
||||
description: "The query parameters to include in the Kubernetes API operation. Must be an array of key-value pairs.
|
||||
Example: [{key: 'watch', value: 'true'}, {key: 'fieldSelector', value: 'metadata.name=my-pod'}]"
|
||||
type: array
|
||||
required: false
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The key of the query parameter
|
||||
value:
|
||||
type: string
|
||||
description: The value of the query parameter
|
||||
- name: headers
|
||||
description: "The headers to include in the Kubernetes API operation. Must be an array of key-value pairs.
|
||||
Example: [{key: 'Accept', value: 'application/json'}]"
|
||||
type: array
|
||||
required: false
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The key of the header
|
||||
value:
|
||||
type: string
|
||||
description: The value of the header
|
||||
annotations:
|
||||
title: Get Kubernetes Resource (Stripped)
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||