8 Commits

Author SHA1 Message Date
Will Temple
ccc3984b01 Merge origin/main into macos
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-16 16:38:10 -04:00
Will Temple
23d9f02e3a review pass the last 2026-05-16 16:18:08 -04:00
Will Temple
d9ac6bfeb8 review pass 4 2026-05-16 15:23:43 -04:00
Will Temple
056f356065 Review pass 3 2026-05-16 15:16:49 -04:00
Will Temple
1a083ee12c More cleanup 2026-05-15 16:55:07 -07:00
Will Temple
bfda24ad0a Cleanup 2026-05-15 16:28:06 -07:00
Will Temple
67400f1499 Functional parity with linux 2026-05-15 13:31:59 -07:00
Will Temple
861bf63621 initial macos porting work 2026-03-23 15:16:20 -04:00
64 changed files with 7710 additions and 568 deletions

63
Cargo.lock generated
View File

@@ -1359,10 +1359,38 @@ name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"block2",
"dispatch2",
"libc",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
@@ -1376,6 +1404,19 @@ name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags",
"objc2",
@@ -1401,8 +1442,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-video",
"objc2-foundation",
"objc2-metal",
]
@@ -1883,6 +1928,7 @@ dependencies = [
"ruin-runtime",
"ruin_reactivity",
"ruin_ui",
"ruin_ui_platform_macos",
"ruin_ui_platform_wayland",
"tracing",
"tracing-subscriber",
@@ -1912,6 +1958,23 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "ruin_ui_platform_macos"
version = "0.1.0"
dependencies = [
"libc",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"raw-window-metal",
"ruin-runtime",
"ruin_ui",
"ruin_ui_renderer_wgpu",
"tracing",
]
[[package]]
name = "ruin_ui_platform_wayland"
version = "0.1.0"

View File

@@ -28,7 +28,11 @@ pub fn Display(state: CalcState) -> impl IntoView {
let result = state.result.clone();
move || {
let r = result.get();
if r.is_empty() { " ".to_string() } else { format!("= {r}") }
if r.is_empty() {
" ".to_string()
} else {
format!("= {r}")
}
}
});

View File

@@ -4,7 +4,7 @@ mod state;
use ruin_app::prelude::*;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, fmt};
use tracing_subscriber::{fmt, EnvFilter};
use components::{Display, Keypad, ModeToggle, ScientificPanel};
use state::{CalcMode, CalcState};
@@ -59,32 +59,56 @@ fn CalculatorApp() -> impl IntoView {
} else {
display.to_string()
};
use_shortcut(Shortcut::new(Key::Character(ch)), ShortcutScope::Application, {
use_shortcut(
Shortcut::new(Key::Character(ch)),
ShortcutScope::Application,
{
let text = text.clone();
move || s.input(&text)
});
},
);
}
// = key also evaluates
{
let s = state.clone();
use_shortcut(Shortcut::new(Key::Character('=')), ShortcutScope::Application, move || s.evaluate());
use_shortcut(
Shortcut::new(Key::Character('=')),
ShortcutScope::Application,
move || s.evaluate(),
);
}
{
let s = state.clone();
use_shortcut(Shortcut::new(Key::Enter), ShortcutScope::Application, move || s.evaluate());
use_shortcut(
Shortcut::new(Key::Enter),
ShortcutScope::Application,
move || s.evaluate(),
);
}
{
let s = state.clone();
use_shortcut(Shortcut::new(Key::Backspace), ShortcutScope::Application, move || s.backspace());
use_shortcut(
Shortcut::new(Key::Backspace),
ShortcutScope::Application,
move || s.backspace(),
);
}
{
let s = state.clone();
use_shortcut(Shortcut::new(Key::Escape), ShortcutScope::Application, move || s.clear());
use_shortcut(
Shortcut::new(Key::Escape),
ShortcutScope::Application,
move || s.clear(),
);
}
{
let s = state.clone();
use_shortcut(Shortcut::new(Key::Delete), ShortcutScope::Application, move || s.clear());
use_shortcut(
Shortcut::new(Key::Delete),
ShortcutScope::Application,
move || s.clear(),
);
}
// ---- Layout ----

View File

@@ -77,11 +77,7 @@ impl CalcState {
}
self.expression.update(|e| {
// Pop a full UTF-8 character
let new_len = e
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
let new_len = e.char_indices().next_back().map(|(i, _)| i).unwrap_or(0);
e.truncate(new_len);
});
}
@@ -186,7 +182,11 @@ fn format_result(value: f64) -> String {
return "Error".to_string();
}
if value.is_infinite() {
return if value > 0.0 { "".to_string() } else { "-∞".to_string() };
return if value > 0.0 {
"".to_string()
} else {
"-∞".to_string()
};
}
if value.fract() == 0.0 && value.abs() < 1e15 {
return format!("{}", value as i64);

View File

@@ -290,6 +290,14 @@ impl Reactor {
self.inner.ensure_flush_scheduled();
}
/// Flushes currently queued reactive jobs immediately on the calling thread.
///
/// This is useful when host integrations need synchronous propagation
/// (for example, during native resize loops).
pub fn flush_now(&self) {
Rc::clone(&self.inner).flush_jobs();
}
pub(crate) fn allocate_node(&self) -> NodeId {
let raw = self.inner.next_node.get();
self.inner.next_node.set(raw.wrapping_add(1));

View File

@@ -73,10 +73,22 @@ struct Cursor<'a> {
#[derive(Clone)]
enum Command {
Insert { offset: usize, text: String },
Replace { start: usize, end: usize, text: String },
ReplaceAll { text: String },
Delete { start: usize, end: usize },
Insert {
offset: usize,
text: String,
},
Replace {
start: usize,
end: usize,
text: String,
},
ReplaceAll {
text: String,
},
Delete {
start: usize,
end: usize,
},
Print,
Help,
Quit,
@@ -130,7 +142,9 @@ async fn main() {
let parse_state = parse_state.clone();
let last_delta = last_delta.clone();
move || {
parse_state.with(|snapshot| print_snapshot(snapshot, &rope, last_delta.with(Clone::clone).as_ref()));
parse_state.with(|snapshot| {
print_snapshot(snapshot, &rope, last_delta.with(Clone::clone).as_ref())
});
}
})
.leak();
@@ -199,7 +213,9 @@ fn parse_command(input: &str) -> Result<Command, String> {
.split_once(' ')
.ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?;
return Ok(Command::Insert {
offset: offset.parse().map_err(|_| "invalid insert offset".to_string())?,
offset: offset
.parse()
.map_err(|_| "invalid insert offset".to_string())?,
text: text.to_string(),
});
}
@@ -215,7 +231,9 @@ fn parse_command(input: &str) -> Result<Command, String> {
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
return Ok(Command::Replace {
start: start.parse().map_err(|_| "invalid replace start".to_string())?,
start: start
.parse()
.map_err(|_| "invalid replace start".to_string())?,
end: end.parse().map_err(|_| "invalid replace end".to_string())?,
text: text.to_string(),
});
@@ -230,7 +248,9 @@ fn parse_command(input: &str) -> Result<Command, String> {
.split_once(' ')
.ok_or_else(|| "delete expects: delete <start> <end>".to_string())?;
return Ok(Command::Delete {
start: start.parse().map_err(|_| "invalid delete start".to_string())?,
start: start
.parse()
.map_err(|_| "invalid delete start".to_string())?,
end: end.parse().map_err(|_| "invalid delete end".to_string())?,
});
}
@@ -311,9 +331,10 @@ impl JsonParser {
if let Some(old) = old
&& old.kind == parsed.kind
&& map_range(absolute_range(old, old_parent_start), delta) == parsed.range
&& old_text
.get(absolute_range(old, old_parent_start).start..absolute_range(old, old_parent_start).end)
== new_text.get(parsed.range.start..parsed.range.end)
&& old_text.get(
absolute_range(old, old_parent_start).start
..absolute_range(old, old_parent_start).end,
) == new_text.get(parsed.range.start..parsed.range.end)
{
*reused += count_nodes(old);
return AstNode {
@@ -403,7 +424,10 @@ fn parse_json(text: &str) -> Result<ParsedNode, String> {
let node = cursor.parse_value()?;
cursor.skip_ws();
if !cursor.is_eof() {
return Err(format!("unexpected trailing data at byte {}", cursor.offset));
return Err(format!(
"unexpected trailing data at byte {}",
cursor.offset
));
}
Ok(node)
}
@@ -496,7 +520,10 @@ impl<'a> Cursor<'a> {
break;
}
Some(other) => {
return Err(format!("expected `,` or `}}` at byte {}, found `{other}`", self.offset));
return Err(format!(
"expected `,` or `}}` at byte {}, found `{other}`",
self.offset
));
}
None => return Err("unterminated object".to_string()),
}
@@ -537,7 +564,10 @@ impl<'a> Cursor<'a> {
break;
}
Some(other) => {
return Err(format!("expected `,` or `]` at byte {}, found `{other}`", self.offset));
return Err(format!(
"expected `,` or `]` at byte {}, found `{other}`",
self.offset
));
}
None => return Err("unterminated array".to_string()),
}
@@ -569,14 +599,24 @@ impl<'a> Cursor<'a> {
for _ in 0..4 {
match self.bump_char() {
Some(ch) if ch.is_ascii_hexdigit() => {}
_ => return Err(format!("invalid unicode escape at byte {}", self.offset)),
_ => {
return Err(format!(
"invalid unicode escape at byte {}",
self.offset
));
}
}
}
}
_ => return Err(format!("invalid escape sequence at byte {}", self.offset)),
},
Some(ch) if ch >= '\u{20}' => {}
Some(_) => return Err(format!("control character in string at byte {}", self.offset)),
Some(_) => {
return Err(format!(
"control character in string at byte {}",
self.offset
));
}
None => return Err("unterminated string".to_string()),
}
}

View File

@@ -9,10 +9,22 @@ use ruin_reactivity_parsing::{
use ruin_runtime::{async_main, fs, stdio};
enum Command {
Insert { offset: usize, text: String },
Replace { start: usize, end: usize, text: String },
ReplaceAll { text: String },
Delete { start: usize, end: usize },
Insert {
offset: usize,
text: String,
},
Replace {
start: usize,
end: usize,
text: String,
},
ReplaceAll {
text: String,
},
Delete {
start: usize,
end: usize,
},
Print,
Help,
Quit,
@@ -108,7 +120,9 @@ fn parse_command(input: &str) -> Result<Command, String> {
.split_once(' ')
.ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?;
return Ok(Command::Insert {
offset: offset.parse().map_err(|_| "invalid insert offset".to_string())?,
offset: offset
.parse()
.map_err(|_| "invalid insert offset".to_string())?,
text: text.to_string(),
});
}
@@ -124,7 +138,9 @@ fn parse_command(input: &str) -> Result<Command, String> {
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
return Ok(Command::Replace {
start: start.parse().map_err(|_| "invalid replace start".to_string())?,
start: start
.parse()
.map_err(|_| "invalid replace start".to_string())?,
end: end.parse().map_err(|_| "invalid replace end".to_string())?,
text: text.to_string(),
});
@@ -139,35 +155,35 @@ fn parse_command(input: &str) -> Result<Command, String> {
.split_once(' ')
.ok_or_else(|| "delete expects: delete <start> <end>".to_string())?;
return Ok(Command::Delete {
start: start.parse().map_err(|_| "invalid delete start".to_string())?,
start: start
.parse()
.map_err(|_| "invalid delete start".to_string())?,
end: end.parse().map_err(|_| "invalid delete end".to_string())?,
});
}
Err("unknown command; type `help`".to_string())
}
fn print_snapshot(snapshot: &TokenTreeParse, rope: &ReactiveRope, update: Option<&TokenTreeUpdate>) {
fn print_snapshot(
snapshot: &TokenTreeParse,
rope: &ReactiveRope,
update: Option<&TokenTreeUpdate>,
) {
let metrics = rope.snapshot_metrics();
println!();
println!("Document revision {}", metrics.revision);
println!(
"Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}",
metrics.text.bytes,
metrics.leaves,
snapshot.stats.total_nodes,
snapshot.stats.reused_nodes
metrics.text.bytes, metrics.leaves, snapshot.stats.total_nodes, snapshot.stats.reused_nodes
);
if let Some(update) = update {
println!(
"Last edit: [{}..{}) -> {:?}",
update.delta.range.start,
update.delta.range.end,
update.delta.inserted_text
update.delta.range.start, update.delta.range.end, update.delta.inserted_text
);
println!(
"Diagnostics changed={}, trivia changed={}",
update.diagnostics_changed,
update.trivia_changed
update.diagnostics_changed, update.trivia_changed
);
}
println!("Parse tree:");

View File

@@ -9,7 +9,7 @@ mod tokentree;
pub use delta::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange};
pub use metrics::{TextMetrics, TextPoint};
pub use parse::{
IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParserCheckpoint, ParseTree,
IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParseTree, ParserCheckpoint,
SyntaxFragment,
};
pub use rope::{
@@ -17,8 +17,8 @@ pub use rope::{
RopeSnapshotMetrics,
};
pub use tokentree::{
DelimiterKind, NumberBase, NumberLiteral, NumberSuffix, StringDelimiter, StringFragment,
ReactiveTokenTree, TokenTreeDiagnostic, TokenTreeDiagnosticKind, TokenTreeNode,
TokenTreeNodeKind, TokenTreeParse, TokenTreeParser, TokenTreeStats, TokenTreeUpdate,
TriviaComment, TriviaKind, TriviaStore,
DelimiterKind, NumberBase, NumberLiteral, NumberSuffix, ReactiveTokenTree, StringDelimiter,
StringFragment, TokenTreeDiagnostic, TokenTreeDiagnosticKind, TokenTreeNode, TokenTreeNodeKind,
TokenTreeParse, TokenTreeParser, TokenTreeStats, TokenTreeUpdate, TriviaComment, TriviaKind,
TriviaStore,
};

View File

@@ -102,7 +102,11 @@ mod tests {
fn invalidate(
&self,
delta_range: TextRange,
_previous_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
_previous_fragments: &[SyntaxFragment<
Self::Checkpoint,
Self::Tree,
Self::Diagnostic,
>],
) -> ParseInvalidation<Self::Checkpoint> {
ParseInvalidation {
resume_at: delta_range.start.saturating_sub(1),
@@ -115,13 +119,17 @@ mod tests {
mut cursor: RopeCursor,
_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
invalidation: ParseInvalidation<Self::Checkpoint>,
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError> {
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError>
{
self.parse_calls.set(self.parse_calls.get() + 1);
let text = cursor.read_bytes(usize::MAX);
Ok(ParseResult {
tree: ParseTree { root: text.clone() },
fragments: vec![SyntaxFragment {
range: TextRange::new(invalidation.resume_at, invalidation.resume_at + text.len()),
range: TextRange::new(
invalidation.resume_at,
invalidation.resume_at + text.len(),
),
start_checkpoint: invalidation.restart_checkpoint,
end_checkpoint: 2,
tree: text,

View File

@@ -259,7 +259,10 @@ impl ReactiveRope {
fn apply_edit(&self, edit: RopeEdit) -> EditDelta {
assert!(edit.range.start <= edit.range.end, "invalid edit range");
assert!(edit.range.end <= self.len_bytes(), "edit range out of bounds");
assert!(
edit.range.end <= self.len_bytes(),
"edit range out of bounds"
);
self.assert_char_boundary(edit.range.start);
self.assert_char_boundary(edit.range.end);
assert!(
@@ -304,7 +307,8 @@ impl ReactiveRope {
replacement.push_str(&edit.insert);
replacement.push_str(&suffix);
let structure_changed = last_index > first_index || replacement.len() > DEFAULT_LEAF_MAX_BYTES;
let structure_changed =
last_index > first_index || replacement.len() > DEFAULT_LEAF_MAX_BYTES;
if !structure_changed {
if let Some(leaf) = leaves.get(first_index) {
leaf.set_text(replacement);
@@ -338,7 +342,9 @@ impl ReactiveRope {
self.inner.structure.trigger();
}
self.inner.revision.set(self.inner.revision.get().wrapping_add(1));
self.inner
.revision
.set(self.inner.revision.get().wrapping_add(1));
let new_end = old_start.advance(&edit.insert);
drop(leaves);
@@ -399,7 +405,10 @@ impl ReactiveRope {
let end = cursor + bytes;
if offset < end {
let text = leaf.read();
assert!(text.is_char_boundary(offset - cursor), "offset must be a char boundary");
assert!(
text.is_char_boundary(offset - cursor),
"offset must be a char boundary"
);
return;
}
cursor = end;
@@ -710,7 +719,10 @@ mod tests {
let seen = Rc::new(RefCell::new(Vec::new()));
let _sub = rope.edit_events().subscribe({
let seen = Rc::clone(&seen);
move |delta| seen.borrow_mut().push((delta.range, delta.inserted_text.clone()))
move |delta| {
seen.borrow_mut()
.push((delta.range, delta.inserted_text.clone()))
}
});
rope.edit(RopeEdit::insert(1, "Z"));

View File

@@ -7,9 +7,9 @@ use ruin_reactivity::{Cell, Event, Subscription, cell_in, event_in};
use crate::{Anchor, AnchorBias, EditDelta, ReactiveRope, TextPoint, TextRange};
const SIGILS: &[&str] = &[
"<<=", ">>=", "..=", "::", "->", "=>", "==", "!=", "<=", ">=", "&&", "||", "+=", "-=",
"*=", "/=", "%=", "&=", "|=", "^=", "<<", ">>", "..", "+", "-", "*", "/", "%", "&", "|",
"^", "!", "~", "=", "<", ">", ":", ";", "?", ".", ",", "@", "#", "$",
"<<=", ">>=", "..=", "::", "->", "=>", "==", "!=", "<=", ">=", "&&", "||", "+=", "-=", "*=",
"/=", "%=", "&=", "|=", "^=", "<<", ">>", "..", "+", "-", "*", "/", "%", "&", "|", "^", "!",
"~", "=", "<", ">", ":", ";", "?", ".", ",", "@", "#", "$",
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -28,8 +28,14 @@ pub enum StringDelimiter {
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum StringFragment {
Text { range: TextRange, text: String },
Interpolation { range: TextRange, child_index: usize },
Text {
range: TextRange,
text: String,
},
Interpolation {
range: TextRange,
child_index: usize,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -403,7 +409,9 @@ impl TokenTreeParser {
reused_nodes: &mut usize,
) -> TokenTreeNode {
let parsed_local = localize(parsed.range, new_parent_start);
if let Some(old) = old.filter(|old| can_reuse_node(old, old_parent_start, parsed, old_text, new_text, delta)) {
if let Some(old) = old
.filter(|old| can_reuse_node(old, old_parent_start, parsed, old_text, new_text, delta))
{
*reused_nodes += count_nodes(old);
return TokenTreeNode {
id: old.id,
@@ -423,16 +431,14 @@ impl TokenTreeParser {
let candidate = old
.children
.get(index)
.filter(|candidate| {
!used[index]
&& can_descend_into_node(candidate, child)
})
.filter(|candidate| !used[index] && can_descend_into_node(candidate, child))
.map(|candidate| {
used[index] = true;
candidate
})
.or_else(|| {
old.children.iter().enumerate().find_map(|(candidate_index, candidate)| {
old.children.iter().enumerate().find_map(
|(candidate_index, candidate)| {
if used[candidate_index]
|| !can_descend_into_node(candidate, child)
{
@@ -440,7 +446,8 @@ impl TokenTreeParser {
}
used[candidate_index] = true;
Some(candidate)
})
},
)
});
self.reuse_node(
@@ -602,8 +609,8 @@ impl<'a> Cursor<'a> {
let (_, close) = delimiter_chars(delimiter);
self.bump_char();
let children = self.parse_sequence(Some(close));
let terminated =
self.slice(self.offset.saturating_sub(close.len_utf8()), self.offset) == close.to_string();
let terminated = self.slice(self.offset.saturating_sub(close.len_utf8()), self.offset)
== close.to_string();
RawNode {
range: TextRange::new(start, self.offset),
kind: TokenTreeNodeKind::List {
@@ -667,7 +674,9 @@ impl<'a> Cursor<'a> {
fn parse_string(&mut self) -> RawNode {
let start = self.offset;
let delimiter_char = self.bump_char().expect("string should start with delimiter");
let delimiter_char = self
.bump_char()
.expect("string should start with delimiter");
let delimiter = match delimiter_char {
'\'' => StringDelimiter::SingleQuote,
'"' => StringDelimiter::DoubleQuote,
@@ -702,9 +711,8 @@ impl<'a> Cursor<'a> {
let interp_start = self.offset;
self.offset += 2;
let interpolation_children = self.parse_sequence(Some(')'));
let terminated_interp = self
.slice(self.offset.saturating_sub(1), self.offset)
== ")";
let terminated_interp =
self.slice(self.offset.saturating_sub(1), self.offset) == ")";
if !terminated_interp {
self.diagnostics.push(TokenTreeDiagnostic {
kind: TokenTreeDiagnosticKind::UnterminatedInterpolation,
@@ -972,7 +980,11 @@ impl<'a> Cursor<'a> {
fn scan_number(
&self,
start: usize,
) -> (NumberLiteral, usize, Option<(TokenTreeDiagnosticKind, String)>) {
) -> (
NumberLiteral,
usize,
Option<(TokenTreeDiagnosticKind, String)>,
) {
let mut cursor = start;
let mut base = NumberBase::Decimal;
if self.starts_with_at(start, "0b") {
@@ -1190,14 +1202,17 @@ fn can_reuse_node(
let old_abs = absolute_range(old, old_parent_start);
old.kind == parsed.kind
&& map_range(old_abs, delta) == parsed.range
&& old_text.get(old_abs.start..old_abs.end) == new_text.get(parsed.range.start..parsed.range.end)
&& old_text.get(old_abs.start..old_abs.end)
== new_text.get(parsed.range.start..parsed.range.end)
}
fn can_descend_into_node(old: &TokenTreeNode, parsed: &RawNode) -> bool {
match (&old.kind, &parsed.kind) {
(TokenTreeNodeKind::Body, TokenTreeNodeKind::Body) => true,
(
TokenTreeNodeKind::List { delimiter: left, .. },
TokenTreeNodeKind::List {
delimiter: left, ..
},
TokenTreeNodeKind::List {
delimiter: right, ..
},
@@ -1225,15 +1240,12 @@ fn count_nodes(node: &TokenTreeNode) -> usize {
1 + node.children.iter().map(count_nodes).sum::<usize>()
}
fn number_body_valid(
raw: &str,
base: NumberBase,
saw_radix: bool,
suffix_start: usize,
) -> bool {
fn number_body_valid(raw: &str, base: NumberBase, saw_radix: bool, suffix_start: usize) -> bool {
let mut body = &raw[..suffix_start];
if matches!(base, NumberBase::Binary | NumberBase::Octal | NumberBase::Decimal | NumberBase::Hexadecimal)
&& body.starts_with("0")
if matches!(
base,
NumberBase::Binary | NumberBase::Octal | NumberBase::Decimal | NumberBase::Hexadecimal
) && body.starts_with("0")
&& body.len() >= 2
&& matches!(body.as_bytes()[1], b'b' | b'o' | b'd' | b'x')
{
@@ -1334,12 +1346,15 @@ mod tests {
}
fn node_text<'a>(parse: &'a TokenTreeParse, node: &TokenTreeNode) -> &'a str {
let range = parse.absolute_range(node.id).expect("node should have absolute range");
let range = parse
.absolute_range(node.id)
.expect("node should have absolute range");
&parse.text()[range.start..range.end]
}
fn symbol_texts(parse: &TokenTreeParse) -> Vec<&str> {
parse.root
parse
.root
.children
.iter()
.filter_map(|node| match &node.kind {
@@ -1350,7 +1365,8 @@ mod tests {
}
fn sigil_texts(parse: &TokenTreeParse) -> Vec<&str> {
parse.root
parse
.root
.children
.iter()
.filter_map(|node| match &node.kind {
@@ -1376,7 +1392,11 @@ mod tests {
}
fn diagnostic_kinds(parse: &TokenTreeParse) -> Vec<TokenTreeDiagnosticKind> {
parse.diagnostics.iter().map(|diagnostic| diagnostic.kind).collect()
parse
.diagnostics
.iter()
.map(|diagnostic| diagnostic.kind)
.collect()
}
#[test]
@@ -1399,11 +1419,26 @@ mod tests {
fn parses_mixed_top_level_sequence_in_order() {
let parse = parse_text("alpha + 12 \"hi\" [beta]");
assert_eq!(parse.root.children.len(), 5);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Symbol { .. }));
assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Sigil { .. }));
assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Number(..)));
assert!(matches!(parse.root.children[3].kind, TokenTreeNodeKind::String { .. }));
assert!(matches!(parse.root.children[4].kind, TokenTreeNodeKind::List { .. }));
assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Sigil { .. }
));
assert!(matches!(
parse.root.children[2].kind,
TokenTreeNodeKind::Number(..)
));
assert!(matches!(
parse.root.children[3].kind,
TokenTreeNodeKind::String { .. }
));
assert!(matches!(
parse.root.children[4].kind,
TokenTreeNodeKind::List { .. }
));
}
#[test]
@@ -1418,12 +1453,24 @@ mod tests {
}
));
let outer = &parse.root.children[0];
assert!(matches!(outer.children[0].kind, TokenTreeNodeKind::Symbol { .. }));
assert!(matches!(
outer.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
let inner_paren = find_list_child(outer, DelimiterKind::Parenthesis);
assert!(matches!(inner_paren.children[0].kind, TokenTreeNodeKind::Symbol { .. }));
assert!(matches!(inner_paren.children[1].kind, TokenTreeNodeKind::Sigil { .. }));
assert!(matches!(
inner_paren.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
inner_paren.children[1].kind,
TokenTreeNodeKind::Sigil { .. }
));
let inner_bracket = find_list_child(inner_paren, DelimiterKind::Bracket);
assert!(matches!(inner_bracket.children[0].kind, TokenTreeNodeKind::Symbol { .. }));
assert!(matches!(
inner_bracket.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
}
#[test]
@@ -1491,9 +1538,18 @@ mod tests {
TokenTreeDiagnosticKind::UnmatchedClosingDelimiter,
]
);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. }));
assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Symbol { .. }));
assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Error { .. }));
assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Error { .. }
));
assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
parse.root.children[2].kind,
TokenTreeNodeKind::Error { .. }
));
}
#[test]
@@ -1514,8 +1570,14 @@ mod tests {
terminated: false
}
));
assert!(matches!(list.children[0].kind, TokenTreeNodeKind::Symbol { .. }));
assert!(matches!(list.children[1].kind, TokenTreeNodeKind::Error { .. }));
assert!(matches!(
list.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
list.children[1].kind,
TokenTreeNodeKind::Error { .. }
));
}
#[test]
@@ -1525,8 +1587,14 @@ mod tests {
diagnostic_kinds(&parse),
vec![TokenTreeDiagnosticKind::UnmatchedClosingDelimiter]
);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::List { .. }));
assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Error { .. }));
assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::List { .. }
));
assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Error { .. }
));
}
#[test]
@@ -1546,7 +1614,10 @@ mod tests {
let parse = parse_text("left+right(foo)");
assert_eq!(symbol_texts(&parse), vec!["left", "right"]);
assert_eq!(sigil_texts(&parse), vec!["+"]);
assert!(matches!(parse.root.children[3].kind, TokenTreeNodeKind::List { .. }));
assert!(matches!(
parse.root.children[3].kind,
TokenTreeNodeKind::List { .. }
));
}
#[test]
@@ -1556,14 +1627,21 @@ mod tests {
diagnostic_kinds(&parse),
vec![TokenTreeDiagnosticKind::InvalidNumber]
);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. }));
assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Error { .. }
));
}
#[test]
fn every_defined_sigil_parses_as_a_sigil_node() {
for sigil in SIGILS {
let parse = parse_text(sigil);
assert_eq!(parse.root.children.len(), 1, "sigil `{sigil}` should be a single token");
assert_eq!(
parse.root.children.len(),
1,
"sigil `{sigil}` should be a single token"
);
assert!(
matches!(
&parse.root.children[0].kind,
@@ -1592,8 +1670,14 @@ mod tests {
fn preserves_comments_as_trivia() {
let parse = parse_text("foo // hi\n/* block */ bar");
assert_eq!(parse.trivia.comments().len(), 2);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Symbol { .. }));
assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Symbol { .. }));
assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Symbol { .. }
));
}
#[test]
@@ -1602,7 +1686,10 @@ mod tests {
assert!(parse.diagnostics.is_empty());
assert_eq!(parse.trivia.comments().len(), 1);
assert_eq!(parse.trivia.comments()[0].kind, TriviaKind::BlockComment);
assert_eq!(parse.trivia.comments()[0].text, "/* outer /* inner */ done */");
assert_eq!(
parse.trivia.comments()[0].text,
"/* outer /* inner */ done */"
);
}
#[test]
@@ -1621,7 +1708,9 @@ mod tests {
let parse = parse_text("foo // hi\nbar /* block */ baz");
let line = &parse.trivia.comments()[0];
let block = &parse.trivia.comments()[1];
let overlapping = parse.trivia.comments_in_range(TextRange::new(line.range.start, block.range.end));
let overlapping = parse
.trivia
.comments_in_range(TextRange::new(line.range.start, block.range.end));
assert_eq!(overlapping.len(), 2);
let before_block = parse.trivia.comments_before(block.range.start);
assert_eq!(before_block.len(), 1);
@@ -1721,7 +1810,10 @@ mod tests {
);
assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::String { terminated: true, .. }
TokenTreeNodeKind::String {
terminated: true,
..
}
));
}
@@ -1754,9 +1846,15 @@ mod tests {
assert_eq!(fragments.len(), 4);
assert_eq!(string.children.len(), 2);
assert!(matches!(fragments[0], StringFragment::Text { .. }));
assert!(matches!(fragments[1], StringFragment::Interpolation { child_index: 0, .. }));
assert!(matches!(
fragments[1],
StringFragment::Interpolation { child_index: 0, .. }
));
assert!(matches!(fragments[2], StringFragment::Text { .. }));
assert!(matches!(fragments[3], StringFragment::Interpolation { child_index: 1, .. }));
assert!(matches!(
fragments[3],
StringFragment::Interpolation { child_index: 1, .. }
));
assert!(matches!(
string.children[0].kind,
TokenTreeNodeKind::List {
@@ -1784,7 +1882,10 @@ mod tests {
TokenTreeNodeKind::String { fragments, .. } => {
assert_eq!(fragments.len(), 2);
assert_eq!(string.children.len(), 1);
assert!(matches!(string.children[0].kind, TokenTreeNodeKind::List { .. }));
assert!(matches!(
string.children[0].kind,
TokenTreeNodeKind::List { .. }
));
}
other => panic!("expected string, got {other:?}"),
}
@@ -1818,19 +1919,52 @@ mod tests {
fn parses_numbers_with_supported_bases_and_suffixes() {
let cases = [
("0", NumberBase::Decimal, false, None),
("12.3f64", NumberBase::Decimal, true, Some(NumberSuffix::F64)),
(
"12.3f64",
NumberBase::Decimal,
true,
Some(NumberSuffix::F64),
),
("0d1_2_3", NumberBase::Decimal, false, None),
("0b1010u8", NumberBase::Binary, false, Some(NumberSuffix::U8)),
(
"0b1010u8",
NumberBase::Binary,
false,
Some(NumberSuffix::U8),
),
("0o77i16", NumberBase::Octal, false, Some(NumberSuffix::I16)),
("0xffu", NumberBase::Hexadecimal, false, Some(NumberSuffix::BigUint)),
("99d", NumberBase::Decimal, false, Some(NumberSuffix::BigDecimal)),
("42d128", NumberBase::Decimal, false, Some(NumberSuffix::Decimal128)),
("5f", NumberBase::Decimal, false, Some(NumberSuffix::BigFloat)),
(
"0xffu",
NumberBase::Hexadecimal,
false,
Some(NumberSuffix::BigUint),
),
(
"99d",
NumberBase::Decimal,
false,
Some(NumberSuffix::BigDecimal),
),
(
"42d128",
NumberBase::Decimal,
false,
Some(NumberSuffix::Decimal128),
),
(
"5f",
NumberBase::Decimal,
false,
Some(NumberSuffix::BigFloat),
),
];
for (text, base, has_radix_point, suffix) in cases {
let parse = parse_text(text);
assert!(parse.diagnostics.is_empty(), "unexpected diagnostics for `{text}`");
assert!(
parse.diagnostics.is_empty(),
"unexpected diagnostics for `{text}`"
);
match &parse.root.children[0].kind {
TokenTreeNodeKind::Number(number) => {
assert_eq!(number.raw, text);
@@ -1848,7 +1982,13 @@ mod tests {
let parse = parse_text("1_2_3 0x_ff 0d12_.3f64 1._5");
assert!(parse.diagnostics.is_empty());
assert_eq!(parse.root.children.len(), 4);
assert!(parse.root.children.iter().all(|node| matches!(node.kind, TokenTreeNodeKind::Number(..))));
assert!(
parse
.root
.children
.iter()
.all(|node| matches!(node.kind, TokenTreeNodeKind::Number(..)))
);
}
#[test]
@@ -1858,7 +1998,10 @@ mod tests {
diagnostic_kinds(&parse),
vec![TokenTreeDiagnosticKind::InvalidNumber]
);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. }));
assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Error { .. }
));
}
#[test]
@@ -1872,28 +2015,52 @@ mod tests {
TokenTreeDiagnosticKind::InvalidNumber,
]
);
assert!(parse.root.children.iter().all(|node| matches!(node.kind, TokenTreeNodeKind::Error { .. })));
assert!(
parse
.root
.children
.iter()
.all(|node| matches!(node.kind, TokenTreeNodeKind::Error { .. }))
);
}
#[test]
fn numbers_reject_unknown_or_underscored_suffixes() {
let parse = parse_text("12foo 9u_8");
assert_eq!(diagnostic_kinds(&parse), vec![TokenTreeDiagnosticKind::InvalidNumber]);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. }));
assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Number(..)));
assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Symbol { .. }));
assert_eq!(
diagnostic_kinds(&parse),
vec![TokenTreeDiagnosticKind::InvalidNumber]
);
assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Error { .. }
));
assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Number(..)
));
assert!(matches!(
parse.root.children[2].kind,
TokenTreeNodeKind::Symbol { .. }
));
}
#[test]
fn numbers_stop_before_non_numeric_sigils() {
let parse = parse_text("0x1..2");
assert_eq!(parse.root.children.len(), 3);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Number(..)));
assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Number(..)
));
assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Sigil { ref text } if text == ".."
));
assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Number(..)));
assert!(matches!(
parse.root.children[2].kind,
TokenTreeNodeKind::Number(..)
));
}
#[test]
@@ -1910,9 +2077,13 @@ mod tests {
let (rope, parse) = parse_with_rope("{\n foo\n [bar]\n}");
let outer = &parse.root.children[0];
let bracket = find_list_child(outer, DelimiterKind::Bracket);
let range = parse.absolute_range(bracket.id).expect("bracket should have range");
let range = parse
.absolute_range(bracket.id)
.expect("bracket should have range");
assert_eq!(&parse.text()[range.start..range.end], "[bar]");
let (start, end) = parse.absolute_span(&rope, bracket.id).expect("span should exist");
let (start, end) = parse
.absolute_span(&rope, bracket.id)
.expect("span should exist");
assert_eq!(start.line, 2);
assert_eq!(start.column, 2);
assert_eq!(end.line, 2);
@@ -1931,7 +2102,8 @@ mod tests {
#[test]
fn reparsing_reuses_shifted_subtrees() {
let (_rope, first, second, _delta) = reparse_after_edit("{ a b }", RopeEdit::insert(2, "long "));
let (_rope, first, second, _delta) =
reparse_after_edit("{ a b }", RopeEdit::insert(2, "long "));
let original_b = first.root.children[0]
.children
.iter()
@@ -1962,16 +2134,22 @@ mod tests {
.find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b"))
.expect("reparsed tree should contain symbol b");
assert_eq!(original_b.id, reparsed_b.id);
let first_span = first.absolute_span(&rope, original_b.id).expect("first span should exist");
let second_span = second.absolute_span(&rope, reparsed_b.id).expect("second span should exist");
let first_span = first
.absolute_span(&rope, original_b.id)
.expect("first span should exist");
let second_span = second
.absolute_span(&rope, reparsed_b.id)
.expect("second span should exist");
assert_ne!(first_span, second_span);
assert_eq!(second_span.0.line, 2);
}
#[test]
fn reparsing_reuses_unaffected_siblings_when_one_symbol_changes() {
let (_rope, first, second, _delta) =
reparse_after_edit("{ left right }", RopeEdit::replace(TextRange::new(2, 6), "long"));
let (_rope, first, second, _delta) = reparse_after_edit(
"{ left right }",
RopeEdit::replace(TextRange::new(2, 6), "long"),
);
let first_list = &first.root.children[0];
let second_list = &second.root.children[0];
let old_right = first_list.children[1].id;
@@ -1986,8 +2164,14 @@ mod tests {
fn reparsing_does_not_reuse_node_when_kind_changes() {
let (_rope, first, second, _delta) =
reparse_after_edit("foo", RopeEdit::replace(TextRange::new(0, 3), "123"));
assert!(matches!(first.root.children[0].kind, TokenTreeNodeKind::Symbol { .. }));
assert!(matches!(second.root.children[0].kind, TokenTreeNodeKind::Number(..)));
assert!(matches!(
first.root.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
second.root.children[0].kind,
TokenTreeNodeKind::Number(..)
));
assert_ne!(first.root.children[0].id, second.root.children[0].id);
}
@@ -2046,5 +2230,4 @@ mod tests {
assert_eq!(seen.len(), 1);
assert!(seen[0].diagnostics_changed || seen[0].stats.total_nodes > 0);
}
}

View File

@@ -8,9 +8,14 @@ ruin_reactivity = { path = "../reactivity" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
ruin_app_proc_macros = { package = "ruin-app-proc-macros", path = "../ruin_app_proc_macros" }
ruin_ui = { path = "../ui" }
ruin_ui_platform_wayland = { path = "../ui_platform_wayland" }
tracing = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
ruin_ui_platform_wayland = { path = "../ui_platform_wayland" }
[target.'cfg(target_os = "macos")'.dependencies]
ruin_ui_platform_macos = { path = "../ui_platform_macos" }
[dev-dependencies]
tracing-subscriber = { version = "0.3", default-features = false, features = [
"env-filter",

18
lib/ruin_app/build.rs Normal file
View File

@@ -0,0 +1,18 @@
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=macos/Info.plist");
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "macos" {
return;
}
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest dir"));
let plist = manifest_dir.join("macos/Info.plist");
let arg = format!("-Wl,-sectcreate,__TEXT,__info_plist,{}", plist.display());
// Ensure all ruin_app executables/examples opt into Retina rendering.
println!("cargo:rustc-link-arg-examples={arg}");
}

View File

@@ -1,8 +1,17 @@
use ruin_app::{PartialTextStyle, prelude::*};
use ruin_ui::{BoxShadow, BoxShadowKind, Point};
use tracing_subscriber::EnvFilter;
#[ruin_runtime::async_main]
async fn main() -> ruin_app::Result<()> {
let _ = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_target(true)
.without_time()
.try_init();
let title = "RUIN Counter";
App::new()

View File

@@ -90,21 +90,12 @@ fn TaskBoard() -> impl IntoView {
}
});
let all_label = if matches!(filter.get(), TaskFilter::All) {
"● All"
} else {
"○ All"
};
let open_label = if matches!(filter.get(), TaskFilter::OpenOnly) {
"● Open"
} else {
"○ Open"
};
let done_label = if matches!(filter.get(), TaskFilter::CompletedOnly) {
"● Completed"
} else {
"○ Completed"
};
let all_label = filter_label(matches!(filter.get(), TaskFilter::All), "All");
let open_label = filter_label(matches!(filter.get(), TaskFilter::OpenOnly), "Open");
let done_label = filter_label(
matches!(filter.get(), TaskFilter::CompletedOnly),
"Completed",
);
let task_rows = visible_ids.with(|ids| {
ids.iter()
.map(|task_id| {
@@ -466,6 +457,14 @@ fn move_task(tasks: &Signal<Vec<Task>>, task_id: u64, direction: MoveDirection)
});
}
fn filter_label(active: bool, label: &str) -> String {
if active {
format!("[x] {label}")
} else {
format!("[ ] {label}")
}
}
fn seed_tasks() -> Vec<Task> {
vec![
Task {

View File

@@ -259,7 +259,12 @@ fn CardFrame(title: View, toolbar: ChildViews, children: ChildViews) -> impl Int
}
#[component]
fn InlineDialog(open: bool, title: View, actions: ChildViews, children: ChildViews) -> impl IntoView {
fn InlineDialog(
open: bool,
title: View,
actions: ChildViews,
children: ChildViews,
) -> impl IntoView {
if open {
let actions_row = view! {
row(gap = 10.0) {

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>RUIN</string>
<key>CFBundleIdentifier</key>
<string>dev.ruin.example</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>

View File

@@ -4,13 +4,18 @@ use std::iter;
use std::rc::Rc;
use std::time::Instant;
use ruin_reactivity::effect;
use ruin_reactivity::{current as current_reactor, effect};
#[cfg(target_os = "macos")]
use ruin_runtime::{queue_future, spawn_worker};
use ruin_ui::{
Color, Element, InteractionTree, KeyboardEvent, LayoutCache, LayoutSnapshot, PlatformEvent,
PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent,
RoutedPointerEventKind, TextSystem, UiSize, WindowController, WindowSpec, WindowUpdate,
layout_snapshot_with_cache,
};
#[cfg(target_os = "macos")]
use ruin_ui_platform_macos::{run_macos_app_event_loop, start_macos_ui};
#[cfg(target_os = "linux")]
use ruin_ui_platform_wayland::start_wayland_ui;
use crate::Result;
@@ -24,6 +29,16 @@ use crate::input::{
};
use crate::view::View;
#[cfg(target_os = "linux")]
fn start_platform_ui() -> ruin_ui::UiRuntime {
start_wayland_ui()
}
#[cfg(target_os = "macos")]
fn start_platform_ui() -> ruin_ui::UiRuntime {
start_macos_ui()
}
#[derive(Clone, Debug)]
pub struct Window {
pub(crate) spec: WindowSpec,
@@ -92,8 +107,7 @@ impl App {
pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> {
MountedApp {
window: self.window,
root: Rc::new(root),
render_state: Rc::new(RenderState::default()),
root,
}
}
}
@@ -139,19 +153,17 @@ impl Mountable for Element {
pub struct MountedApp<M: Mountable> {
pub(crate) window: Window,
pub(crate) root: Rc<M>,
pub(crate) render_state: Rc<RenderState>,
pub(crate) root: M,
}
impl<M: Mountable> MountedApp<M> {
pub async fn run(self) -> Result<()> {
let MountedApp {
window: app_window,
root,
render_state,
} = self;
let mut ui = start_wayland_ui();
async fn run_with_ui_runtime(
app_window: Window,
root: M,
mut ui: ruin_ui::UiRuntime,
) -> Result<()> {
let root = Rc::new(root);
let render_state = Rc::new(RenderState::default());
let window = ui.create_window(app_window.spec.clone())?;
let initial_viewport = app_window
.spec
@@ -159,6 +171,7 @@ impl<M: Mountable> MountedApp<M> {
.unwrap_or(UiSize::new(960.0, 640.0));
let viewport = ruin_reactivity::cell(initial_viewport);
let configure_serial = ruin_reactivity::cell(0_u64);
let scene_version = StdCell::new(0_u64);
let text_system = Rc::new(RefCell::new(TextSystem::new()));
let layout_cache = Rc::new(RefCell::new(LayoutCache::new()));
@@ -177,6 +190,7 @@ impl<M: Mountable> MountedApp<M> {
let _scene_effect = effect({
let window = window.clone();
let viewport = viewport.clone();
let configure_serial = configure_serial.clone();
let text_system = Rc::clone(&text_system);
let layout_cache = Rc::clone(&layout_cache);
let interaction_tree = Rc::clone(&interaction_tree);
@@ -193,6 +207,7 @@ impl<M: Mountable> MountedApp<M> {
let interaction_version = input_state.interaction_version.clone();
move || {
let viewport = viewport.get();
let _ = configure_serial.get();
let version = scene_version.get().wrapping_add(1);
scene_version.set(version);
let _ = text_selection.version.get();
@@ -245,7 +260,8 @@ impl<M: Mountable> MountedApp<M> {
if window_resizable {
let current_min = *last_min_size.borrow();
if current_min != Some(desired_min) {
let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
let _ =
window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
*last_min_size.borrow_mut() = Some(desired_min);
}
} else {
@@ -297,6 +313,7 @@ impl<M: Mountable> MountedApp<M> {
break;
};
let mut should_flush_reactive_work = false;
for event in iter::once(event).chain(ui.take_pending_events()) {
match event {
PlatformEvent::Configured {
@@ -310,6 +327,8 @@ impl<M: Mountable> MountedApp<M> {
"app received Configured, queuing layout effect"
);
let _ = viewport.set(configuration.actual_inner_size);
configure_serial.update(|value| *value = value.wrapping_add(1));
should_flush_reactive_work = true;
}
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
Self::handle_pointer_event(
@@ -320,6 +339,7 @@ impl<M: Mountable> MountedApp<M> {
&mut input_state,
event,
)?;
should_flush_reactive_work = true;
}
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
Self::handle_keyboard_event(
@@ -329,22 +349,74 @@ impl<M: Mountable> MountedApp<M> {
&input_state,
event,
)?;
should_flush_reactive_work = true;
}
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::lifecycle",
?window_id,
"app received CloseRequested, issuing open(false)"
);
let _ = window.update(WindowUpdate::new().open(false));
}
PlatformEvent::Closed { window_id } if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::lifecycle",
?window_id,
"app received Closed, shutting down ui runtime"
);
ui.shutdown()?;
return Ok(());
}
_ => {}
}
}
if should_flush_reactive_work {
current_reactor().flush_now();
}
}
ui.shutdown()?;
Ok(())
}
}
impl<M: Mountable> MountedApp<M> {
#[cfg(not(target_os = "macos"))]
pub async fn run(self) -> Result<()> {
let ui = start_platform_ui();
Self::run_with_ui_runtime(self.window, self.root, ui).await
}
#[cfg(target_os = "macos")]
pub async fn run(self) -> Result<()>
where
M: Send + 'static,
{
let ui = start_platform_ui();
let (done_tx, done_rx) = std::sync::mpsc::channel::<std::result::Result<(), String>>();
let window = self.window;
let root = self.root;
let _worker = spawn_worker(
move || {
queue_future(async move {
let result = Self::run_with_ui_runtime(window, root, ui)
.await
.map_err(|err| err.to_string());
let _ = done_tx.send(result);
});
},
|| {},
);
run_macos_app_event_loop();
match done_rx.recv() {
Ok(Ok(())) => Ok(()),
Ok(Err(err)) => Err(err.into()),
Err(_) => Err("ui worker terminated before completion".into()),
}
}
pub(crate) fn handle_pointer_event(
window: &WindowController,
@@ -508,7 +580,10 @@ impl<M: Mountable> MountedApp<M> {
}
fn interaction_ids(targets: &[ruin_ui::HitTarget]) -> Vec<ruin_ui::ElementId> {
targets.iter().filter_map(|target| target.element_id).collect()
targets
.iter()
.filter_map(|target| target.element_id)
.collect()
}
fn update_interaction_state(
@@ -518,8 +593,14 @@ impl<M: Mountable> MountedApp<M> {
) -> bool {
let previous_hovered = input_state.hovered_elements.get();
let previous_pressed = input_state.pressed_elements.get();
let hovered_changed = input_state.hovered_elements.set(hovered_ids.clone()).is_some();
let pressed_changed = input_state.pressed_elements.set(pressed_ids.clone()).is_some();
let hovered_changed = input_state
.hovered_elements
.set(hovered_ids.clone())
.is_some();
let pressed_changed = input_state
.pressed_elements
.set(pressed_ids.clone())
.is_some();
if hovered_changed || pressed_changed {
tracing::trace!(
target: "ruin_app::interaction",

View File

@@ -99,10 +99,7 @@ pub(crate) fn render_with_context_and_interaction(
RenderOutput { view, side_effects }
}
pub(crate) fn with_render_context(
context: RenderContext,
render: impl FnOnce() -> View,
) -> View {
pub(crate) fn with_render_context(context: RenderContext, render: impl FnOnce() -> View) -> View {
CURRENT_RENDER_CONTEXT.with(|slot| {
let previous = slot.replace(Some(context.clone()));

View File

@@ -1,5 +1,5 @@
use std::any::type_name;
use std::any::TypeId;
use std::any::type_name;
use std::cell::Cell as StdCell;
use std::future::Future;
use std::marker::PhantomData;
@@ -9,8 +9,8 @@ use ruin_ui::{ElementId, InteractionTree, KeyboardEvent, KeyboardEventKind, Keyb
use crate::ContextKey;
use crate::context::{
ContextEntry, MemoSlot, ResourceSlot, with_hook_slot,
with_render_context, with_render_context_state,
ContextEntry, MemoSlot, ResourceSlot, with_hook_slot, with_render_context,
with_render_context_state,
};
use crate::input::ShortcutBinding;
@@ -114,7 +114,10 @@ pub fn use_context<C: ContextKey>() -> C::Value {
})
}
pub fn provide<C: ContextKey>(value: C::Value, render: impl FnOnce() -> crate::view::View) -> crate::view::View {
pub fn provide<C: ContextKey>(
value: C::Value,
render: impl FnOnce() -> crate::view::View,
) -> crate::view::View {
with_render_context_state(|context| {
let mut context_entries = (*context.context_entries).clone();
context_entries.push(ContextEntry::new::<C>(value));

View File

@@ -3,9 +3,8 @@ use std::collections::HashMap;
use std::rc::Rc;
use ruin_ui::{
CursorIcon, DisplayItem, ElementId, HitTarget, InteractionTree, KeyboardEvent,
PointerButton, PointerEvent, Quad, RoutedPointerEvent,
RoutedPointerEventKind, WindowController,
CursorIcon, DisplayItem, ElementId, HitTarget, InteractionTree, KeyboardEvent, PointerButton,
PointerEvent, Quad, RoutedPointerEvent, RoutedPointerEventKind, WindowController,
};
use crate::hooks::{Shortcut, ShortcutScope, Signal};
@@ -76,8 +75,7 @@ impl EventBindings {
event: &KeyboardEvent,
interaction_tree: &InteractionTree,
) {
let Some(handler) =
key_handler_for_focus(&self.on_key, focused_element, interaction_tree)
let Some(handler) = key_handler_for_focus(&self.on_key, focused_element, interaction_tree)
else {
return;
};
@@ -200,6 +198,14 @@ pub(crate) fn sync_primary_selection(
interaction_tree: &InteractionTree,
selection: Option<TextSelection>,
) -> crate::Result<()> {
#[cfg(not(target_os = "linux"))]
{
let _ = (window, interaction_tree, selection);
return Ok(());
}
#[cfg(target_os = "linux")]
{
let Some(selection) = selection else {
window.set_primary_selection_text(String::new())?;
return Ok(());
@@ -215,6 +221,7 @@ pub(crate) fn sync_primary_selection(
window.set_primary_selection_text(copied)?;
Ok(())
}
}
pub(crate) fn scroll_handler_for_event<'a>(
handlers: &'a HashMap<ElementId, ScrollHandler>,
@@ -325,7 +332,11 @@ pub(crate) fn build_focused_ancestor_chain(
// View helper methods for attaching handlers — used by primitives
impl View {
pub(crate) fn with_press_handler(mut self, element_id: ElementId, handler: PressHandler) -> Self {
pub(crate) fn with_press_handler(
mut self,
element_id: ElementId,
handler: PressHandler,
) -> Self {
self.bindings.on_press.insert(element_id, handler);
self
}
@@ -339,11 +350,7 @@ impl View {
self
}
pub(crate) fn with_key_handler(
mut self,
element_id: ElementId,
handler: KeyHandler,
) -> Self {
pub(crate) fn with_key_handler(mut self, element_id: ElementId, handler: KeyHandler) -> Self {
self.bindings.on_key.insert(element_id, handler);
self
}

View File

@@ -9,8 +9,8 @@ use std::error::Error;
pub mod app;
pub mod colors;
pub mod converters;
mod context;
pub mod converters;
mod hooks;
mod input;
pub mod primitives;
@@ -20,22 +20,22 @@ pub mod view;
// Re-export public items from each module.
pub use app::{App, Component, ContextKey, MountedApp, Mountable, Window, __render_mountable_for_test};
pub use app::{
__render_mountable_for_test, App, Component, ContextKey, Mountable, MountedApp, Window,
};
pub use converters::{IntoBorder, IntoEdges, IntoShadow};
pub use hooks::{
BlockWidget, FocusScope, InteractionState, Key, Memo, Resource, ResourceState,
ScrollBoxWidget, Shortcut, ShortcutScope, Signal, WidgetRef, provide, use_context,
use_effect, use_interaction_state, use_memo, use_resource, use_shortcut,
use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
BlockWidget, FocusScope, InteractionState, Key, Memo, Resource, ResourceState, ScrollBoxWidget,
Shortcut, ShortcutScope, Signal, WidgetRef, provide, use_context, use_effect,
use_interaction_state, use_memo, use_resource, use_shortcut, use_shortcut_with_context,
use_signal, use_widget_ref, use_window_title,
};
pub use primitives::{
ButtonBuilder, ContainerBuilder, ContainerProps, FontWeight, ScrollBoxBuilder, TextBuilder,
TextRole, block, button, column, row, scroll_box, text,
};
pub use text_style::PartialTextStyle;
pub use view::{
ChildViews, Children, IntoView, LazyChildren, TextChildren, TextValue, View,
};
pub use view::{ChildViews, Children, IntoView, LazyChildren, TextChildren, TextValue, View};
// Bare string literals as container children produce a text element with context-aware styling.
// These impls live here (not in view.rs) so they can call primitives::text().
@@ -58,10 +58,11 @@ pub use ruin_app_proc_macros::{component, context_provider, view};
pub type Result<T> = std::result::Result<T, Box<dyn Error>>;
pub mod prelude {
pub use crate::PartialTextStyle;
pub use crate::{
App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder,
ContextKey, FocusScope, FontWeight, InteractionState, IntoBorder, IntoEdges, IntoView,
Key, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder,
ContextKey, FocusScope, FontWeight, InteractionState, IntoBorder, IntoEdges, IntoView, Key,
Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder,
ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole,
TextValue, View, WidgetRef, Window, block, button, colors, column, component,
context_provider, provide, row, scroll_box, surfaces, text, use_context, use_effect,
@@ -73,7 +74,6 @@ pub mod prelude {
PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind,
ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize,
};
pub use crate::PartialTextStyle;
}
#[cfg(test)]
@@ -201,7 +201,10 @@ mod tests {
}
});
let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should be set");
let widget_ref = widget_ref_slot
.borrow()
.clone()
.expect("widget ref should be set");
let element_id = widget_ref
.element_id()
.expect("widget ref should have an element id after render");
@@ -216,7 +219,10 @@ mod tests {
let pressed = Rc::clone(&pressed);
let widget_ref_slot = Rc::clone(&widget_ref_slot);
move || {
let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should persist");
let widget_ref = widget_ref_slot
.borrow()
.clone()
.expect("widget ref should persist");
*hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered);
*pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed);
block().widget_ref(widget_ref).children(())
@@ -292,7 +298,10 @@ mod tests {
#[test]
fn scroll_box_arrow_keys_work_after_clicking_text_content() {
use ruin_ui::{KeyboardEvent, KeyboardEventKind, KeyboardKey, PointerEvent, PointerEventKind, PointerButton};
use ruin_ui::{
KeyboardEvent, KeyboardEventKind, KeyboardKey, PointerButton, PointerEvent,
PointerEventKind,
};
let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>));
let render = render_with_context(Rc::new(RenderState::default()), {
@@ -428,7 +437,7 @@ mod tests {
#[test]
fn live_input_path_scrolls_a_scroll_box_rendered_inside_a_branch() {
use ruin_ui::{PointerEvent, PointerEventKind, PointerButton};
use ruin_ui::{PointerButton, PointerEvent, PointerEventKind};
let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>));
let render = render_with_context(Rc::new(RenderState::default()), {
@@ -466,8 +475,11 @@ mod tests {
.borrow()
.clone()
.expect("scroll signal should have been captured");
let snapshot =
ruin_ui::layout_snapshot(1, ruin_ui::UiSize::new(1080.0, 760.0), render.view.element());
let snapshot = ruin_ui::layout_snapshot(
1,
ruin_ui::UiSize::new(1080.0, 760.0),
render.view.element(),
);
let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone()));
let bindings = RefCell::new(render.view.bindings.clone());
let mut pointer_router = ruin_ui::PointerRouter::new();
@@ -511,7 +523,7 @@ mod tests {
#[test]
fn scroll_box_stays_interactive_when_it_appears_on_a_later_render() {
use ruin_ui::{PointerEvent, PointerEventKind, PointerButton};
use ruin_ui::{PointerButton, PointerEvent, PointerEventKind};
let state = Rc::new(RenderState::default());
let ready_slot = Rc::new(RefCell::new(None::<Signal<bool>>));
@@ -571,8 +583,11 @@ mod tests {
let _ = ready.set(true);
let render = render_once(state, ready_slot, Rc::clone(&offset_slot));
let snapshot =
ruin_ui::layout_snapshot(1, ruin_ui::UiSize::new(1080.0, 760.0), render.view.element());
let snapshot = ruin_ui::layout_snapshot(
1,
ruin_ui::UiSize::new(1080.0, 760.0),
render.view.element(),
);
let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone()));
let bindings = RefCell::new(render.view.bindings.clone());
let mut pointer_router = ruin_ui::PointerRouter::new();
@@ -646,8 +661,11 @@ mod tests {
.borrow()
.clone()
.expect("scroll signal should have been captured");
let snapshot =
ruin_ui::layout_snapshot(1, ruin_ui::UiSize::new(1080.0, 760.0), render.view.element());
let snapshot = ruin_ui::layout_snapshot(
1,
ruin_ui::UiSize::new(1080.0, 760.0),
render.view.element(),
);
let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone()));
let bindings = RefCell::new(render.view.bindings.clone());
let mut pointer_router = ruin_ui::PointerRouter::new();

View File

@@ -5,9 +5,9 @@ use ruin_ui::{CursorIcon, Edges, Element, RoutedPointerEvent};
use crate::context::allocate_element_id;
use crate::input::PressHandler;
use crate::primitives::ContainerProps;
use crate::surfaces;
use crate::text_style::{pop_text_style, push_text_style};
use crate::view::{Children, View};
use crate::surfaces;
pub struct ButtonBuilder {
pub(crate) props: ContainerProps,

View File

@@ -56,6 +56,8 @@ pub(crate) fn current_text_style() -> PartialTextStyle {
stack
.borrow()
.iter()
.fold(PartialTextStyle::default(), |acc, layer| acc.merge_over(layer))
.fold(PartialTextStyle::default(), |acc, layer| {
acc.merge_over(layer)
})
})
}

View File

@@ -86,7 +86,6 @@ impl Children for () {
}
}
impl<T: IntoView> Children for T {
fn into_views(self) -> Vec<View> {
vec![self.into_view()]

View File

@@ -60,10 +60,7 @@ fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
Some(prop) if prop.ident == "children" => {
let prop = inputs.pop().expect("last input should exist");
let kind = parse_children_contract_kind(&prop.ty)?;
Some(ChildContract {
ty: prop.ty,
kind,
})
Some(ChildContract { ty: prop.ty, kind })
}
_ => None,
};
@@ -87,7 +84,8 @@ fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
}
Some(contract) => {
let child_arg_ty = child_builder_arg_type_tokens(contract.kind);
let child_value = wrap_special_value_tokens(contract.kind, &format_ident!("children"));
let child_value =
wrap_special_value_tokens(contract.kind, &format_ident!("children"));
let child_field_ident = child_field_ident
.as_ref()
.expect("child field ident should exist");
@@ -574,10 +572,7 @@ fn child_builder_arg_type_tokens(kind: SpecialValueKind) -> proc_macro2::TokenSt
}
}
fn wrap_special_value_tokens(
kind: SpecialValueKind,
ident: &Ident,
) -> proc_macro2::TokenStream {
fn wrap_special_value_tokens(kind: SpecialValueKind, ident: &Ident) -> proc_macro2::TokenStream {
match kind {
SpecialValueKind::Plain => quote! { #ident },
SpecialValueKind::ChildViews => quote! { ::ruin_app::ChildViews::from_children(#ident) },

View File

@@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use crate::op::completion::{CompletionFuture, CompletionHandle};
use crate::sys::linux::channel::runtime_waiter;
use crate::sys::current::channel::runtime_waiter;
/// Creates a bounded channel with room for at most `capacity` queued messages.
///

View File

@@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use crate::op::completion::{CompletionFuture, CompletionHandle};
use crate::sys::linux::channel::runtime_waiter;
use crate::sys::current::channel::runtime_waiter;
/// Creates a single-use channel for transferring one value from a [`Sender`] to a [`Receiver`].
///

View File

@@ -3,44 +3,9 @@
use std::io;
use std::os::fd::RawFd;
use crate::op::completion::completion_for_current_thread;
use crate::platform::linux_x86_64::runtime::with_current_driver;
use crate::platform::linux_x86_64::uring::{IORING_OP_POLL_ADD, IoUringCqe};
/// Waits until `fd` becomes readable or reports an error/hangup condition.
pub async fn wait_readable(fd: RawFd) -> io::Result<()> {
submit_poll(fd, libc::POLLIN | libc::POLLERR | libc::POLLHUP).await
}
async fn submit_poll(fd: RawFd, mask: i16) -> io::Result<()> {
let (future, handle) = completion_for_current_thread::<io::Result<()>>();
let callback_handle = handle.clone();
let token = with_current_driver(|driver| {
driver.submit_operation(
move |sqe| {
sqe.opcode = IORING_OP_POLL_ADD;
sqe.fd = fd;
sqe.len = 0;
sqe.op_flags = mask as u32;
},
move |cqe| {
callback_handle.complete(cqe_to_result(cqe));
},
)
})?;
handle.set_cancel(move || {
let _ = with_current_driver(|driver| driver.cancel_operation(token));
});
future.await
}
fn cqe_to_result(cqe: IoUringCqe) -> io::Result<()> {
if cqe.res < 0 {
return Err(io::Error::from_raw_os_error(-cqe.res));
}
Ok(())
crate::sys::current::fd::wait_readable(fd).await
}
#[cfg(test)]
@@ -53,8 +18,8 @@ mod tests {
#[test]
fn wait_readable_resolves_for_pipe() {
let mut fds = [0; 2];
let result = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) };
assert_eq!(result, 0, "pipe2 should succeed");
let result = unsafe { libc::pipe(fds.as_mut_ptr()) };
assert_eq!(result, 0, "pipe should succeed");
let read_fd = fds[0];
let write_fd = fds[1];

View File

@@ -19,7 +19,7 @@ use crate::op::fs::{
FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions,
RawDirEntry as OpDirEntry, RawMetadata,
};
use crate::sys::linux::fs as sys_fs;
use crate::sys::current::fs as sys_fs;
struct FileInner {
fd: OwnedFd,

View File

@@ -1,7 +1,7 @@
//! Runtime, driver, async I/O, and channel primitives for RUIN.
//!
//! The crate is centered around a single-threaded event loop with explicit worker threads,
//! JavaScript-style microtask/macrotask scheduling, and Linux `io_uring`-backed I/O.
//! JavaScript-style microtask/macrotask scheduling and platform-specific async I/O backends.
//!
//! Most users will start with:
//!
@@ -11,17 +11,22 @@
//!
//! # Platform support
//!
//! `ruin-runtime` currently targets Linux on `x86_64`.
//! `ruin-runtime` currently targets:
//! - Linux `x86_64`
//! - macOS `aarch64`
//!
//! RUIN runtime foundations.
//!
//! This crate provides a Linux x86_64 runtime substrate: the mesh allocator, the driver, and a
//! single-threaded runtime loop with worker-thread task forwarding.
//! This crate provides a platform runtime substrate with a single-threaded runtime loop and
//! worker-thread task forwarding.
#![feature(thread_local)]
#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
compile_error!("ruin-runtime currently supports only Linux x86_64.");
#[cfg(not(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64")
)))]
compile_error!("ruin-runtime currently supports Linux x86_64 and macOS aarch64.");
extern crate alloc;
@@ -60,11 +65,24 @@ pub use ruin_runtime_proc_macros::async_main;
/// thread before calling [`run`].
pub use ruin_runtime_proc_macros::main;
/// Driver primitives re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use platform::linux_x86_64::driver::{
/// Driver primitives re-exported from the active backend.
#[cfg(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64")
))]
pub use platform::current::driver::{
Driver, ReadyEvents, ThreadNotifier, create, create_driver, monotonic_now,
};
/// Runtime/event-loop primitives re-exported from the active backend.
#[cfg(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64")
))]
pub use platform::current::runtime::{
IntervalHandle, JoinHandle, ThreadHandle, TimeoutHandle, WorkerHandle, clear_interval,
clear_timeout, current_thread_handle, queue_future, queue_microtask, queue_task, run,
run_ready_tasks, run_until_stalled, set_interval, set_timeout, spawn_worker, yield_now,
};
/// Public mesh-allocator surface re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use platform::linux_x86_64::mesh_alloc::{
@@ -81,22 +99,17 @@ pub use platform::linux_x86_64::mesh_alloc::{
/// Additional allocator helpers re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use platform::linux_x86_64::mesh_alloc::{FreelistId, bitmaps_meshable};
/// Runtime/event-loop primitives re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use platform::linux_x86_64::runtime::{
IntervalHandle, JoinHandle, ThreadHandle, TimeoutHandle, WorkerHandle, clear_interval,
clear_timeout, current_thread_handle, queue_future, queue_microtask, queue_task, run,
set_interval, set_timeout, spawn_worker, yield_now,
};
/// Returns the default global mesh allocator configuration for this crate.
///
/// This is useful when embedding the allocator in a `#[global_allocator]` static.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub const fn default_global_allocator() -> GlobalMeshAllocator {
GlobalMeshAllocator::with_default_config()
}
#[cfg(test)]
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
mod tests {
use super::{MeshAllocator, page_size};

View File

@@ -73,10 +73,10 @@ impl TcpStream {
where
A: ToSocketAddrs + Send + 'static,
{
let addrs = crate::sys::linux::net::resolve_addrs(addr).await?;
let addrs = crate::sys::current::net::resolve_addrs(addr).await?;
let mut last_error = None;
for addr in addrs {
match crate::sys::linux::net::connect_stream(addr).await {
match crate::sys::current::net::connect_stream(addr).await {
Ok(fd) => return Ok(Self::from_owned_fd(fd)),
Err(error) => last_error = Some(error),
}
@@ -93,7 +93,7 @@ impl TcpStream {
/// Connects to `addr`, failing if the deadline elapses first.
pub async fn connect_timeout(addr: &SocketAddr, timeout: Duration) -> io::Result<Self> {
validate_timeout(timeout)?;
crate::sys::linux::net::connect_stream_timeout(*addr, timeout)
crate::sys::current::net::connect_stream_timeout(*addr, timeout)
.await
.map(Self::from_owned_fd)
}
@@ -102,10 +102,10 @@ impl TcpStream {
pub async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let data = match self.read_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await?
crate::sys::current::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await?
}
None => {
crate::sys::linux::net::recv(NetOp::Recv {
crate::sys::current::net::recv(NetOp::Recv {
fd: self.raw_fd(),
len: buf.len(),
flags: 0,
@@ -137,10 +137,11 @@ impl TcpStream {
pub async fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.write_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout).await
crate::sys::current::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout)
.await
}
None => {
crate::sys::linux::net::send(NetOp::Send {
crate::sys::current::net::send(NetOp::Send {
fd: self.raw_fd(),
data: buf.to_vec(),
flags: 0,
@@ -167,7 +168,7 @@ impl TcpStream {
/// Shuts down the read, write, or both halves of the connection.
pub async fn shutdown(&self, how: Shutdown) -> io::Result<()> {
crate::sys::linux::net::shutdown(NetOp::Shutdown {
crate::sys::current::net::shutdown(NetOp::Shutdown {
fd: self.raw_fd(),
how,
})
@@ -176,39 +177,39 @@ impl TcpStream {
/// Duplicates the underlying stream socket.
pub async fn try_clone(&self) -> io::Result<Self> {
crate::sys::linux::net::duplicate(self.raw_fd())
crate::sys::current::net::duplicate(self.raw_fd())
.await
.map(Self::from_owned_fd)
}
/// Returns the local socket address of this stream.
pub fn local_addr(&self) -> io::Result<SocketAddr> {
crate::sys::linux::net::local_addr(self.raw_fd())
crate::sys::current::net::local_addr(self.raw_fd())
}
/// Returns the remote peer address of this stream.
pub fn peer_addr(&self) -> io::Result<SocketAddr> {
crate::sys::linux::net::peer_addr(self.raw_fd())
crate::sys::current::net::peer_addr(self.raw_fd())
}
/// Reads the current `TCP_NODELAY` setting.
pub fn nodelay(&self) -> io::Result<bool> {
crate::sys::linux::net::nodelay(self.raw_fd())
crate::sys::current::net::nodelay(self.raw_fd())
}
/// Enables or disables `TCP_NODELAY`.
pub fn set_nodelay(&self, enabled: bool) -> io::Result<()> {
crate::sys::linux::net::set_nodelay(self.raw_fd(), enabled)
crate::sys::current::net::set_nodelay(self.raw_fd(), enabled)
}
/// Reads the socket's IP time-to-live value.
pub fn ttl(&self) -> io::Result<u32> {
crate::sys::linux::net::ttl(self.raw_fd())
crate::sys::current::net::ttl(self.raw_fd())
}
/// Sets the socket's IP time-to-live value.
pub fn set_ttl(&self, ttl: u32) -> io::Result<()> {
crate::sys::linux::net::set_ttl(self.raw_fd(), ttl)
crate::sys::current::net::set_ttl(self.raw_fd(), ttl)
}
/// Returns the read timeout used by async read operations on this handle.
@@ -270,10 +271,10 @@ impl TcpListener {
where
A: ToSocketAddrs + Send + 'static,
{
let addrs = crate::sys::linux::net::resolve_addrs(addr).await?;
let addrs = crate::sys::current::net::resolve_addrs(addr).await?;
let mut last_error = None;
for addr in addrs {
match crate::sys::linux::net::bind_listener(addr, None).await {
match crate::sys::current::net::bind_listener(addr, None).await {
Ok(fd) => return Ok(Self::from_owned_fd(fd)),
Err(error) => last_error = Some(error),
}
@@ -289,7 +290,8 @@ impl TcpListener {
/// Accepts an incoming connection.
pub async fn accept(&self) -> io::Result<(TcpStream, SocketAddr)> {
let accepted = crate::sys::linux::net::accept(NetOp::Accept { fd: self.raw_fd() }).await?;
let accepted =
crate::sys::current::net::accept(NetOp::Accept { fd: self.raw_fd() }).await?;
let stream = TcpStream::from_owned_fd(unsafe { OwnedFd::from_raw_fd(accepted.fd) });
Ok((stream, accepted.peer_addr))
@@ -297,17 +299,17 @@ impl TcpListener {
/// Returns the local socket address of this listener.
pub fn local_addr(&self) -> io::Result<SocketAddr> {
crate::sys::linux::net::local_addr(self.raw_fd())
crate::sys::current::net::local_addr(self.raw_fd())
}
/// Reads the listener socket's IP time-to-live value.
pub fn ttl(&self) -> io::Result<u32> {
crate::sys::linux::net::ttl(self.raw_fd())
crate::sys::current::net::ttl(self.raw_fd())
}
/// Sets the listener socket's IP time-to-live value.
pub fn set_ttl(&self, ttl: u32) -> io::Result<()> {
crate::sys::linux::net::set_ttl(self.raw_fd(), ttl)
crate::sys::current::net::set_ttl(self.raw_fd(), ttl)
}
fn from_owned_fd(fd: OwnedFd) -> Self {
@@ -327,10 +329,10 @@ impl UdpSocket {
where
A: ToSocketAddrs + Send + 'static,
{
let addrs = crate::sys::linux::net::resolve_addrs(addr).await?;
let addrs = crate::sys::current::net::resolve_addrs(addr).await?;
let mut last_error = None;
for addr in addrs {
match crate::sys::linux::net::bind_datagram(addr).await {
match crate::sys::current::net::bind_datagram(addr).await {
Ok(fd) => return Ok(Self::from_owned_fd(fd)),
Err(error) => last_error = Some(error),
}
@@ -352,10 +354,10 @@ impl UdpSocket {
where
A: ToSocketAddrs + Send + 'static,
{
let addrs = crate::sys::linux::net::resolve_addrs(addr).await?;
let addrs = crate::sys::current::net::resolve_addrs(addr).await?;
let mut last_error = None;
for addr in addrs {
match crate::sys::linux::net::connect(NetOp::Connect {
match crate::sys::current::net::connect(NetOp::Connect {
fd: self.raw_fd(),
addr,
})
@@ -378,10 +380,11 @@ impl UdpSocket {
pub async fn send(&self, buf: &[u8]) -> io::Result<usize> {
match self.write_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout).await
crate::sys::current::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout)
.await
}
None => {
crate::sys::linux::net::send(NetOp::Send {
crate::sys::current::net::send(NetOp::Send {
fd: self.raw_fd(),
data: buf.to_vec(),
flags: 0,
@@ -395,10 +398,10 @@ impl UdpSocket {
pub async fn recv(&self, buf: &mut [u8]) -> io::Result<usize> {
let data = match self.read_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await?
crate::sys::current::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await?
}
None => {
crate::sys::linux::net::recv(NetOp::Recv {
crate::sys::current::net::recv(NetOp::Recv {
fd: self.raw_fd(),
len: buf.len(),
flags: 0,
@@ -415,7 +418,7 @@ impl UdpSocket {
pub async fn peek(&self, buf: &mut [u8]) -> io::Result<usize> {
let data = match self.read_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::recv_timeout(
crate::sys::current::net::recv_timeout(
self.raw_fd(),
buf.len(),
libc::MSG_PEEK,
@@ -424,7 +427,7 @@ impl UdpSocket {
.await?
}
None => {
crate::sys::linux::net::recv(NetOp::Recv {
crate::sys::current::net::recv(NetOp::Recv {
fd: self.raw_fd(),
len: buf.len(),
flags: libc::MSG_PEEK,
@@ -442,13 +445,13 @@ impl UdpSocket {
where
A: ToSocketAddrs + Send + 'static,
{
let addrs = crate::sys::linux::net::resolve_addrs(addr).await?;
let addrs = crate::sys::current::net::resolve_addrs(addr).await?;
let mut last_error = None;
let timeout = self.write_timeout_value();
for addr in addrs {
let result = match timeout {
Some(timeout) => {
crate::sys::linux::net::send_to_timeout(
crate::sys::current::net::send_to_timeout(
self.raw_fd(),
buf.to_vec(),
addr,
@@ -458,7 +461,7 @@ impl UdpSocket {
.await
}
None => {
crate::sys::linux::net::send_to(NetOp::SendTo {
crate::sys::current::net::send_to(NetOp::SendTo {
fd: self.raw_fd(),
target: addr,
data: buf.to_vec(),
@@ -485,11 +488,11 @@ impl UdpSocket {
pub async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
let datagram = match self.read_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::recv_from_timeout(self.raw_fd(), buf.len(), 0, timeout)
crate::sys::current::net::recv_from_timeout(self.raw_fd(), buf.len(), 0, timeout)
.await?
}
None => {
crate::sys::linux::net::recv_from(NetOp::RecvFrom {
crate::sys::current::net::recv_from(NetOp::RecvFrom {
fd: self.raw_fd(),
len: buf.len(),
flags: 0,
@@ -506,7 +509,7 @@ impl UdpSocket {
pub async fn peek_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
let datagram = match self.read_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::recv_from_timeout(
crate::sys::current::net::recv_from_timeout(
self.raw_fd(),
buf.len(),
libc::MSG_PEEK,
@@ -515,7 +518,7 @@ impl UdpSocket {
.await?
}
None => {
crate::sys::linux::net::recv_from(NetOp::RecvFrom {
crate::sys::current::net::recv_from(NetOp::RecvFrom {
fd: self.raw_fd(),
len: buf.len(),
flags: libc::MSG_PEEK,
@@ -530,39 +533,39 @@ impl UdpSocket {
/// Duplicates the underlying UDP socket.
pub async fn try_clone(&self) -> io::Result<Self> {
crate::sys::linux::net::duplicate(self.raw_fd())
crate::sys::current::net::duplicate(self.raw_fd())
.await
.map(Self::from_owned_fd)
}
/// Returns the local socket address of this socket.
pub fn local_addr(&self) -> io::Result<SocketAddr> {
crate::sys::linux::net::local_addr(self.raw_fd())
crate::sys::current::net::local_addr(self.raw_fd())
}
/// Returns the connected peer address, if the socket has been connected.
pub fn peer_addr(&self) -> io::Result<SocketAddr> {
crate::sys::linux::net::peer_addr(self.raw_fd())
crate::sys::current::net::peer_addr(self.raw_fd())
}
/// Reads the `SO_BROADCAST` setting.
pub fn broadcast(&self) -> io::Result<bool> {
crate::sys::linux::net::broadcast(self.raw_fd())
crate::sys::current::net::broadcast(self.raw_fd())
}
/// Enables or disables `SO_BROADCAST`.
pub fn set_broadcast(&self, enabled: bool) -> io::Result<()> {
crate::sys::linux::net::set_broadcast(self.raw_fd(), enabled)
crate::sys::current::net::set_broadcast(self.raw_fd(), enabled)
}
/// Reads the socket's IP time-to-live value.
pub fn ttl(&self) -> io::Result<u32> {
crate::sys::linux::net::ttl(self.raw_fd())
crate::sys::current::net::ttl(self.raw_fd())
}
/// Sets the socket's IP time-to-live value.
pub fn set_ttl(&self, ttl: u32) -> io::Result<()> {
crate::sys::linux::net::set_ttl(self.raw_fd(), ttl)
crate::sys::current::net::set_ttl(self.raw_fd(), ttl)
}
/// Returns the read timeout used by async receive operations on this handle.
@@ -628,13 +631,13 @@ impl HyperRead for TcpStream {
if this.pending_read.is_none() {
this.pending_read = Some(match this.read_timeout_value() {
Some(timeout) => Box::pin(crate::sys::linux::net::recv_timeout(
Some(timeout) => Box::pin(crate::sys::current::net::recv_timeout(
this.raw_fd(),
buf.remaining(),
0,
timeout,
)),
None => crate::sys::linux::net::recv_future(this.raw_fd(), buf.remaining()),
None => crate::sys::current::net::recv_future(this.raw_fd(), buf.remaining()),
});
}
@@ -672,13 +675,13 @@ impl HyperWrite for TcpStream {
if this.pending_write.is_none() {
this.pending_write = Some(match this.write_timeout_value() {
Some(timeout) => Box::pin(crate::sys::linux::net::send_timeout(
Some(timeout) => Box::pin(crate::sys::current::net::send_timeout(
this.raw_fd(),
buf.to_vec(),
0,
timeout,
)),
None => crate::sys::linux::net::send_future(this.raw_fd(), buf.to_vec()),
None => crate::sys::current::net::send_future(this.raw_fd(), buf.to_vec()),
});
}
@@ -708,7 +711,7 @@ impl HyperWrite for TcpStream {
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
let this = self.get_mut();
if this.pending_shutdown.is_none() {
this.pending_shutdown = Some(crate::sys::linux::net::shutdown_future(
this.pending_shutdown = Some(crate::sys::current::net::shutdown_future(
this.raw_fd(),
Shutdown::Write,
));

View File

@@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use crate::platform::linux_x86_64::runtime::{ThreadHandle, current_thread_handle};
use crate::platform::current::runtime::{ThreadHandle, current_thread_handle};
type CancelCallback = Box<dyn FnOnce() + Send + 'static>;

View File

@@ -13,16 +13,12 @@ use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use std::time::Duration;
use super::driver::{Driver, ThreadNotifier, create_driver, monotonic_now};
use crate::platform::runtime_shared::{
IntervalCallback, LocalBoxFuture, LocalTask, LocalTaskQueue, MICROTASK_STARVATION_THRESHOLD,
MacroTaskQueue, SendTask,
};
use crate::trace_targets;
type LocalTask = Box<dyn FnOnce() + 'static>;
type SendTask = Box<dyn FnOnce() + Send + 'static>;
type LocalBoxFuture = Pin<Box<dyn Future<Output = ()> + 'static>>;
/// If the microtask queue runs more than this many tasks in a single turn
/// without yielding to the macrotask queue, a warning is emitted.
const MICROTASK_STARVATION_THRESHOLD: u64 = 1000;
struct MacroTask {
task: LocalTask,
/// Wall time at which this task entered the local queue. Populated only
@@ -493,6 +489,99 @@ pub fn run() {
}
}
/// Drains ready work on the current runtime thread without blocking for future work.
///
/// Unlike [`run`], this returns as soon as there are no immediately runnable
/// microtasks or macrotasks left. It is intended for host integrations that
/// need to re-enter the scheduler while an outer platform loop remains active.
pub fn run_until_stalled() {
let _ = current_thread();
loop {
drain_all();
let mut microtasks_run: u64 = 0;
while let Some(task) = pop_microtask() {
task();
microtasks_run += 1;
drain_all();
}
if microtasks_run >= MICROTASK_STARVATION_THRESHOLD {
tracing::warn!(
target: trace_targets::SCHEDULER,
event = "microtask_starvation",
count = microtasks_run,
"microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved",
);
}
if let Some(task) = pop_macrotask() {
task();
continue;
}
drain_all();
if has_ready_work() {
continue;
}
current_thread()
.shared
.closing
.store(false, Ordering::Release);
return;
}
}
/// Drains already-queued work on the current runtime thread without polling the
/// driver for timers or I/O readiness.
///
/// This is intended for host integrations that need to flush application work
/// from inside a host callback without re-entering timer callbacks.
pub fn run_ready_tasks() {
let _ = current_thread();
loop {
drain_remote_tasks();
drain_completed_workers();
let mut microtasks_run: u64 = 0;
while let Some(task) = pop_microtask() {
task();
microtasks_run += 1;
drain_remote_tasks();
drain_completed_workers();
}
if microtasks_run >= MICROTASK_STARVATION_THRESHOLD {
tracing::warn!(
target: trace_targets::SCHEDULER,
event = "microtask_starvation",
count = microtasks_run,
"microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved",
);
}
if let Some(task) = pop_macrotask() {
task();
continue;
}
drain_remote_tasks();
drain_completed_workers();
if has_ready_work() {
continue;
}
current_thread()
.shared
.closing
.store(false, Ordering::Release);
return;
}
}
fn drain_all() {
drain_driver_events();
drain_remote_tasks();
@@ -592,8 +681,8 @@ struct ThreadState {
driver: Driver,
shared: Arc<ThreadShared>,
worker_completion: Option<Arc<WorkerCompletion>>,
local_microtasks: RefCell<VecDeque<LocalTask>>,
local_macrotasks: RefCell<VecDeque<MacroTask>>,
local_microtasks: RefCell<LocalTaskQueue>,
local_macrotasks: RefCell<MacroTaskQueue<MacroTask>>,
timers: RefCell<TimerHeap>,
/// Zero-delay intervals bypasses the timer heap entirely. Each entry
/// re-enqueues itself as a macrotask on every turn.

View File

@@ -0,0 +1,504 @@
//! Public runtime driver primitives for macOS.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::io;
use std::os::fd::RawFd;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::op::completion::CompletionHandle;
type FdCompletion = CompletionHandle<io::Result<()>>;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub(crate) struct FdReadinessToken(u64);
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub(crate) enum FdInterest {
Readable,
Writable,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
struct FdKey {
fd: RawFd,
interest: FdInterest,
}
struct FdWaiter {
token: FdReadinessToken,
completion: FdCompletion,
}
#[derive(Clone)]
struct NotifierInner {
write_fd: RawFd,
closed: Arc<AtomicBool>,
}
impl NotifierInner {
fn notify(&self) -> io::Result<()> {
if self.closed.load(Ordering::Acquire) {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"target runtime driver is closed",
));
}
let byte = 1u8;
let written = unsafe {
libc::write(
self.write_fd,
&byte as *const u8 as *const libc::c_void,
std::mem::size_of::<u8>(),
)
};
if written < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::WouldBlock {
return Ok(());
}
return Err(error);
}
Ok(())
}
}
#[derive(Clone)]
/// Cross-thread notifier for a runtime thread's driver.
pub struct ThreadNotifier {
inner: NotifierInner,
}
impl ThreadNotifier {
/// Sends a wake notification to the target runtime thread.
pub fn notify(&self) -> io::Result<()> {
self.inner.notify()
}
}
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
/// Readiness information returned by [`Driver::poll`].
pub struct ReadyEvents {
/// One or more timer expirations are pending.
pub timer: bool,
/// One or more cross-thread wake notifications are pending.
pub wake: bool,
}
/// Low-level macOS runtime driver backed by `kqueue` and a wake pipe.
pub struct Driver {
kqueue_fd: RawFd,
wake_read_fd: RawFd,
wake_write_fd: RawFd,
closed: Arc<AtomicBool>,
timer_deadline: Cell<Option<Duration>>,
pending_wakes: Cell<u64>,
pending_timers: Cell<u64>,
next_fd_token: Cell<u64>,
fd_waiters: RefCell<HashMap<FdKey, FdWaiter>>,
}
/// Creates a new driver and its paired [`ThreadNotifier`].
pub fn create() -> io::Result<(Driver, ThreadNotifier)> {
create_driver()
}
/// Creates a new driver and its paired [`ThreadNotifier`].
pub fn create_driver() -> io::Result<(Driver, ThreadNotifier)> {
let kqueue_fd = cvt(unsafe { libc::kqueue() })?;
let mut pipe_fds = [0; 2];
cvt(unsafe { libc::pipe(pipe_fds.as_mut_ptr()) })?;
let wake_read_fd = pipe_fds[0];
let wake_write_fd = pipe_fds[1];
set_nonblocking(wake_read_fd)?;
set_nonblocking(wake_write_fd)?;
let event = libc::kevent {
ident: wake_read_fd as usize,
filter: libc::EVFILT_READ,
flags: libc::EV_ADD | libc::EV_ENABLE,
fflags: 0,
data: 0,
udata: std::ptr::null_mut(),
};
let submitted = unsafe {
libc::kevent(
kqueue_fd,
&event,
1,
std::ptr::null_mut(),
0,
std::ptr::null(),
)
};
if submitted < 0 {
let error = io::Error::last_os_error();
unsafe {
libc::close(wake_read_fd);
libc::close(wake_write_fd);
libc::close(kqueue_fd);
}
return Err(error);
}
let closed = Arc::new(AtomicBool::new(false));
let driver = Driver {
kqueue_fd,
wake_read_fd,
wake_write_fd,
closed: Arc::clone(&closed),
timer_deadline: Cell::new(None),
pending_wakes: Cell::new(0),
pending_timers: Cell::new(0),
next_fd_token: Cell::new(1),
fd_waiters: RefCell::new(HashMap::new()),
};
let notifier = ThreadNotifier {
inner: NotifierInner {
write_fd: wake_write_fd,
closed,
},
};
Ok((driver, notifier))
}
impl Driver {
pub(crate) fn bind_current_thread(&self) {}
pub(crate) fn unbind_current_thread(&self) {}
/// Polls the driver without blocking.
pub fn poll(&self) -> io::Result<Option<ReadyEvents>> {
let mut pending = ReadyEvents::default();
if self.pending_wakes.get() > 0 {
pending.wake = true;
}
if self.pending_timers.get() > 0 {
pending.timer = true;
}
if pending.wake || pending.timer {
return Ok(Some(pending));
}
self.process(Some(Duration::ZERO))
}
/// Blocks until at least one event is available.
pub fn wait(&self) -> io::Result<()> {
let now = monotonic_now()?;
let timeout = self
.timer_deadline
.get()
.map(|deadline| deadline.saturating_sub(now));
let _ = self.process(timeout)?;
Ok(())
}
/// Updates the currently armed timer deadline.
pub fn rearm_timer(&self, deadline: Option<Duration>) -> io::Result<()> {
self.timer_deadline.set(deadline);
Ok(())
}
/// Drains the accumulated wake notification count.
pub fn drain_wake(&self) -> io::Result<u64> {
let wakes = self.pending_wakes.replace(0);
if wakes == 0 {
Err(io::Error::new(
io::ErrorKind::WouldBlock,
"no wake events are pending",
))
} else {
Ok(wakes)
}
}
/// Drains the accumulated timer-expiration count.
pub fn drain_timer(&self) -> io::Result<u64> {
let timers = self.pending_timers.replace(0);
if timers == 0 {
Err(io::Error::new(
io::ErrorKind::WouldBlock,
"no timer events are pending",
))
} else {
Ok(timers)
}
}
pub(crate) fn register_fd_readiness(
&self,
fd: RawFd,
interest: FdInterest,
completion: FdCompletion,
) -> io::Result<FdReadinessToken> {
let key = FdKey { fd, interest };
let removed_stale_waiter = {
let mut waiters = self.fd_waiters.borrow_mut();
match waiters.get(&key) {
Some(waiter) if !waiter.completion.is_interested() => {
waiters.remove(&key);
true
}
Some(_) => {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"fd readiness already has a waiter for this interest",
));
}
None => false,
}
};
if removed_stale_waiter {
let _ = self.update_fd_interest(key, libc::EV_DELETE);
}
let token = self.allocate_fd_token();
self.update_fd_interest(key, libc::EV_ADD | libc::EV_ENABLE | libc::EV_ONESHOT)?;
self.fd_waiters
.borrow_mut()
.insert(key, FdWaiter { token, completion });
Ok(token)
}
pub(crate) fn cancel_fd_readiness(&self, token: FdReadinessToken) {
let mut empty_key = None;
{
let mut waiters = self.fd_waiters.borrow_mut();
for (key, entry) in waiters.iter() {
if entry.token == token {
empty_key = Some(*key);
break;
}
}
if let Some(key) = empty_key {
waiters.remove(&key);
}
}
if let Some(key) = empty_key {
let _ = self.update_fd_interest(key, libc::EV_DELETE);
}
}
fn process(&self, timeout: Option<Duration>) -> io::Result<Option<ReadyEvents>> {
let mut ready = ReadyEvents::default();
let mut events = [unsafe { std::mem::zeroed::<libc::kevent>() }; 16];
let timeout_spec = timeout_to_timespec(timeout);
let timeout_ptr = timeout_spec
.as_ref()
.map_or(std::ptr::null(), |value| value as *const libc::timespec);
let result = unsafe {
libc::kevent(
self.kqueue_fd,
std::ptr::null(),
0,
events.as_mut_ptr(),
events.len() as i32,
timeout_ptr,
)
};
if result < 0 {
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::Interrupted {
return Err(error);
}
}
let mut saw_any = false;
let count = result.max(0) as usize;
if count > 0 {
saw_any = true;
for event in events.iter().take(count) {
if event.ident as RawFd == self.wake_read_fd {
ready.wake = true;
let wakes = drain_wake_pipe(self.wake_read_fd)?;
self.pending_wakes
.set(self.pending_wakes.get().saturating_add(wakes));
} else if let Some(interest) = interest_from_filter(event.filter) {
self.complete_fd_waiters(event.ident as RawFd, interest, event);
}
}
}
if let Some(deadline) = self.timer_deadline.get()
&& monotonic_now()? >= deadline
{
ready.timer = true;
saw_any = true;
self.timer_deadline.set(None);
self.pending_timers
.set(self.pending_timers.get().saturating_add(1));
}
if saw_any { Ok(Some(ready)) } else { Ok(None) }
}
fn allocate_fd_token(&self) -> FdReadinessToken {
let token = self.next_fd_token.get();
self.next_fd_token.set(
token
.checked_add(1)
.expect("fd readiness token space exhausted"),
);
FdReadinessToken(token)
}
fn update_fd_interest(&self, key: FdKey, flags: u16) -> io::Result<()> {
let event = libc::kevent {
ident: key.fd as usize,
filter: filter_for_interest(key.interest),
flags,
fflags: 0,
data: 0,
udata: std::ptr::null_mut(),
};
let submitted = unsafe {
libc::kevent(
self.kqueue_fd,
&event,
1,
std::ptr::null_mut(),
0,
std::ptr::null(),
)
};
if submitted < 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
fn complete_fd_waiters(&self, fd: RawFd, interest: FdInterest, event: &libc::kevent) {
let key = FdKey { fd, interest };
let waiter = self.fd_waiters.borrow_mut().remove(&key);
let Some(waiter) = waiter else {
return;
};
let result = fd_event_result(event, interest);
waiter.completion.complete(result);
}
}
impl Drop for Driver {
fn drop(&mut self) {
self.closed.store(true, Ordering::Release);
unsafe {
libc::close(self.wake_read_fd);
libc::close(self.wake_write_fd);
libc::close(self.kqueue_fd);
}
}
}
/// Returns the current monotonic clock reading.
pub fn monotonic_now() -> io::Result<Duration> {
let mut now = std::mem::MaybeUninit::<libc::timespec>::uninit();
let result = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, now.as_mut_ptr()) };
if result < 0 {
return Err(io::Error::last_os_error());
}
let now = unsafe { now.assume_init() };
Ok(Duration::new(now.tv_sec as u64, now.tv_nsec as u32))
}
fn cvt(value: libc::c_int) -> io::Result<libc::c_int> {
if value < 0 {
Err(io::Error::last_os_error())
} else {
Ok(value)
}
}
fn filter_for_interest(interest: FdInterest) -> i16 {
match interest {
FdInterest::Readable => libc::EVFILT_READ,
FdInterest::Writable => libc::EVFILT_WRITE,
}
}
fn interest_from_filter(filter: i16) -> Option<FdInterest> {
if filter == libc::EVFILT_READ {
Some(FdInterest::Readable)
} else if filter == libc::EVFILT_WRITE {
Some(FdInterest::Writable)
} else {
None
}
}
fn fd_event_result(event: &libc::kevent, interest: FdInterest) -> io::Result<()> {
if event.flags & libc::EV_ERROR != 0 && event.data != 0 {
Err(io::Error::from_raw_os_error(event.data as i32))
} else if event.flags & libc::EV_EOF != 0 && interest == FdInterest::Writable {
Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"fd write side reached EOF",
))
} else {
Ok(())
}
}
fn set_nonblocking(fd: RawFd) -> io::Result<()> {
let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFL) })?;
cvt(unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) })?;
Ok(())
}
fn timeout_to_timespec(timeout: Option<Duration>) -> Option<libc::timespec> {
timeout.map(|value| libc::timespec {
tv_sec: value.as_secs() as libc::time_t,
tv_nsec: value.subsec_nanos() as libc::c_long,
})
}
fn drain_wake_pipe(fd: RawFd) -> io::Result<u64> {
let mut wakes = 0u64;
let mut buf = [0u8; 256];
loop {
let read = unsafe {
libc::read(
fd,
buf.as_mut_ptr() as *mut libc::c_void,
buf.len() as libc::size_t,
)
};
if read > 0 {
wakes = wakes.saturating_add(read as u64);
continue;
}
if read == 0 {
break;
}
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::WouldBlock {
break;
}
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(error);
}
Ok(wakes.max(1))
}

View File

@@ -0,0 +1,2 @@
pub mod driver;
pub mod runtime;

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,11 @@
pub(crate) mod runtime_shared;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub mod linux_x86_64;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub mod macos_aarch64;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use linux_x86_64 as current;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub use macos_aarch64 as current;

View File

@@ -0,0 +1,17 @@
use std::cell::RefCell;
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
pub(crate) type LocalTask = Box<dyn FnOnce() + 'static>;
pub(crate) type SendTask = Box<dyn FnOnce() + Send + 'static>;
pub(crate) type LocalBoxFuture = Pin<Box<dyn Future<Output = ()> + 'static>>;
pub(crate) type IntervalCallback = Rc<RefCell<Box<dyn FnMut()>>>;
/// If the microtask queue runs more than this many tasks in a single turn
/// without yielding to the macrotask queue, a warning is emitted.
pub(crate) const MICROTASK_STARVATION_THRESHOLD: u64 = 1000;
pub(crate) type LocalTaskQueue = VecDeque<LocalTask>;
pub(crate) type MacroTaskQueue<T> = VecDeque<T>;

View File

@@ -1,18 +1,23 @@
//! Async standard-input helpers.
use std::cell::Cell;
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::thread;
use crate::op::completion::completion_for_current_thread;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
use crate::platform::linux_x86_64::runtime::with_current_driver;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
use crate::platform::linux_x86_64::uring::{IORING_OP_READ, IoUringCqe, IoUringSqe};
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
use std::cell::Cell;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
thread_local! {
static STDIN_URING_SUPPORTED: Cell<Option<bool>> = const { Cell::new(None) };
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
const FILE_CURSOR: u64 = u64::MAX;
const READ_CHUNK_BYTES: usize = 1024;
@@ -60,6 +65,8 @@ impl Stdin {
async fn read_chunk(&self) -> io::Result<Vec<u8>> {
let fd = self.fd.as_raw_fd();
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
let support = STDIN_URING_SUPPORTED.with(Cell::get);
if support != Some(false) {
match submit_uring_read(fd, READ_CHUNK_BYTES).await {
@@ -73,6 +80,7 @@ impl Stdin {
Err(error) => return Err(error),
}
}
}
offload(move || {
let mut buffer = vec![0; READ_CHUNK_BYTES];
@@ -99,6 +107,7 @@ async fn offload<T: Send + 'static>(
future.await
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
async fn submit_uring_read(fd: RawFd, len: usize) -> io::Result<Vec<u8>> {
let mut buffer = vec![0; len];
let ptr = buffer.as_mut_ptr();
@@ -120,6 +129,7 @@ async fn submit_uring_read(fd: RawFd, len: usize) -> io::Result<Vec<u8>> {
.await
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
async fn submit_uring<T: Send + 'static, M>(
fill: impl FnOnce(&mut IoUringSqe),
map: M,
@@ -144,13 +154,8 @@ where
fn blocking_read(fd: RawFd, buffer: &mut [u8]) -> io::Result<usize> {
loop {
let read = unsafe {
libc::read(
fd,
buffer.as_mut_ptr().cast::<libc::c_void>(),
buffer.len(),
)
};
let read =
unsafe { libc::read(fd, buffer.as_mut_ptr().cast::<libc::c_void>(), buffer.len()) };
if read >= 0 {
return Ok(read as usize);
}
@@ -167,6 +172,7 @@ fn decode_line(bytes: Vec<u8>) -> io::Result<String> {
String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn cqe_to_result(cqe: IoUringCqe) -> io::Result<i32> {
if cqe.res < 0 {
Err(io::Error::from_raw_os_error(-cqe.res))
@@ -175,6 +181,7 @@ fn cqe_to_result(cqe: IoUringCqe) -> io::Result<i32> {
}
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn should_fallback_to_offload(error: &io::Error) -> bool {
matches!(
error.raw_os_error(),

View File

@@ -1,7 +1,7 @@
//! Linux channel wake helpers.
use crate::op::completion::{CompletionFuture, CompletionHandle, completion};
use crate::platform::linux_x86_64::runtime::try_current_thread_handle;
use crate::platform::current::runtime::try_current_thread_handle;
pub(crate) fn runtime_waiter<T: Send + 'static>() -> (CompletionFuture<T>, CompletionHandle<T>) {
let owner = try_current_thread_handle()

View File

@@ -0,0 +1,44 @@
//! Linux fd readiness backend.
use std::io;
use std::os::fd::RawFd;
use crate::op::completion::completion_for_current_thread;
use crate::platform::current::runtime::with_current_driver;
use crate::platform::linux_x86_64::uring::{IORING_OP_POLL_ADD, IoUringCqe};
/// Waits until `fd` becomes readable or reports an error/hangup condition.
pub async fn wait_readable(fd: RawFd) -> io::Result<()> {
submit_poll(fd, libc::POLLIN | libc::POLLERR | libc::POLLHUP).await
}
async fn submit_poll(fd: RawFd, mask: i16) -> io::Result<()> {
let (future, handle) = completion_for_current_thread::<io::Result<()>>();
let callback_handle = handle.clone();
let token = with_current_driver(|driver| {
driver.submit_operation(
move |sqe| {
sqe.opcode = IORING_OP_POLL_ADD;
sqe.fd = fd;
sqe.len = 0;
sqe.op_flags = mask as u32;
},
move |cqe| {
callback_handle.complete(cqe_to_result(cqe));
},
)
})?;
handle.set_cancel(move || {
let _ = with_current_driver(|driver| driver.cancel_operation(token));
});
future.await
}
fn cqe_to_result(cqe: IoUringCqe) -> io::Result<()> {
if cqe.res < 0 {
return Err(io::Error::from_raw_os_error(-cqe.res));
}
Ok(())
}

View File

@@ -1,5 +1,6 @@
//! Linux backend modules.
pub mod channel;
pub mod fd;
pub mod fs;
pub mod net;

View File

@@ -803,13 +803,9 @@ where
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
let callback_handle = handle.clone();
let token = with_current_driver(|driver| {
driver.submit_operation_with_linked_timeout(
fill,
timeout,
move |cqe| {
driver.submit_operation_with_linked_timeout(fill, timeout, move |cqe| {
callback_handle.complete(map(cqe));
},
)
})
})?;
handle.set_cancel(move || {
@@ -1079,7 +1075,6 @@ fn send_sync(fd: RawFd, data: Vec<u8>, flags: i32) -> io::Result<usize> {
cvt_long(written).map(|written| written as usize)
}
fn recv_sync(fd: RawFd, len: usize, flags: i32) -> io::Result<Vec<u8>> {
let mut buffer = vec![0; len];
let read = unsafe {
@@ -1127,7 +1122,6 @@ fn close_sync(fd: RawFd) -> io::Result<()> {
cvt(unsafe { libc::close(fd) }).map(|_| ())
}
/// Wrapper making `Box<libc::iovec>` sendable across the async CQE boundary.
///
/// Safety: `iov_base` points into a `Vec<u8>` that is owned by the same

View File

@@ -0,0 +1,10 @@
//! macOS channel wake helpers.
use crate::op::completion::{CompletionFuture, CompletionHandle, completion};
use crate::platform::current::runtime::try_current_thread_handle;
pub(crate) fn runtime_waiter<T: Send + 'static>() -> (CompletionFuture<T>, CompletionHandle<T>) {
let owner = try_current_thread_handle()
.expect("async channel operations must be polled on a runtime thread");
completion(owner)
}

View File

@@ -0,0 +1,49 @@
//! macOS fd readiness backend.
use std::io;
use std::os::fd::RawFd;
use crate::op::completion::completion_for_current_thread;
use crate::platform::current::driver::FdInterest;
use crate::platform::current::runtime::{
cancel_fd_readiness, current_thread_handle, with_current_driver,
};
/// Waits until `fd` becomes readable or reports an error/hangup condition.
pub async fn wait_readable(fd: RawFd) -> io::Result<()> {
wait_fd_readiness(fd, FdInterest::Readable).await
}
/// Waits until `fd` becomes writable or reports an error/hangup condition.
pub async fn wait_writable(fd: RawFd) -> io::Result<()> {
wait_fd_readiness(fd, FdInterest::Writable).await
}
async fn wait_fd_readiness(fd: RawFd, interest: FdInterest) -> io::Result<()> {
let (future, handle) = completion_for_current_thread::<io::Result<()>>();
let owner = current_thread_handle();
let token =
with_current_driver(|driver| driver.register_fd_readiness(fd, interest, handle.clone()));
match token {
Ok(token) => {
handle.set_cancel({
let handle = handle.clone();
move || {
let queued_handle = handle.clone();
let queued = owner.queue_task(move || {
cancel_fd_readiness(token);
queued_handle.finish(None);
});
if !queued {
handle.finish(None);
}
}
});
}
Err(error) => {
handle.complete(Err(error));
}
}
future.await
}

View File

@@ -0,0 +1,498 @@
//! macOS filesystem backend.
use std::collections::VecDeque;
use std::ffi::CString;
use std::future::poll_fn;
use std::io;
use std::os::fd::{FromRawFd, OwnedFd};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
use std::path::PathBuf;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::task::{Context, Poll, Waker};
use std::thread;
use crate::op::completion::completion_for_current_thread;
use crate::op::fs::{FileType, FsOp, MetadataTarget, RawDirEntry, RawMetadata};
use crate::platform::current::runtime::{ThreadHandle, current_thread_handle};
type BlockingTask = Box<dyn FnOnce() + Send + 'static>;
static BLOCKING_POOL: OnceLock<io::Result<BlockingPool>> = OnceLock::new();
const BLOCKING_QUEUE_CAPACITY: usize = 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExecutionPath {
Offload,
}
pub fn execution_path(_op: &FsOp) -> ExecutionPath {
ExecutionPath::Offload
}
pub async fn open(op: FsOp) -> io::Result<OwnedFd> {
let FsOp::Open { path, options } = op else {
unreachable!("open backend called with non-open op");
};
offload(move || {
let mut open = std::fs::OpenOptions::new();
open.read(options.read)
.write(options.write)
.append(options.append)
.truncate(options.truncate)
.create(options.create)
.create_new(options.create_new)
.mode(0o666);
let file = open.open(path)?;
Ok(unsafe { OwnedFd::from_raw_fd(std::os::fd::IntoRawFd::into_raw_fd(file)) })
})
.await
}
pub async fn read(op: FsOp) -> io::Result<Vec<u8>> {
let FsOp::Read { fd, offset, len } = op else {
unreachable!("read backend called with non-read op");
};
offload(move || {
let mut buffer = vec![0; len];
let read = match offset {
Some(offset) => unsafe {
libc::pread(
fd,
buffer.as_mut_ptr().cast::<libc::c_void>(),
len,
offset as libc::off_t,
)
},
None => unsafe { libc::read(fd, buffer.as_mut_ptr().cast::<libc::c_void>(), len) },
};
if read < 0 {
return Err(io::Error::last_os_error());
}
buffer.truncate(read as usize);
Ok(buffer)
})
.await
}
pub async fn write(op: FsOp) -> io::Result<usize> {
let FsOp::Write { fd, offset, data } = op else {
unreachable!("write backend called with non-write op");
};
offload(move || {
let written = match offset {
Some(offset) => unsafe {
libc::pwrite(
fd,
data.as_ptr().cast::<libc::c_void>(),
data.len(),
offset as libc::off_t,
)
},
None => unsafe { libc::write(fd, data.as_ptr().cast::<libc::c_void>(), data.len()) },
};
if written < 0 {
return Err(io::Error::last_os_error());
}
Ok(written as usize)
})
.await
}
pub async fn metadata(op: FsOp) -> io::Result<RawMetadata> {
let FsOp::Metadata {
target,
follow_symlinks,
} = op
else {
unreachable!("metadata backend called with non-metadata op");
};
offload(move || {
let metadata = match target {
MetadataTarget::Path(path) => {
if follow_symlinks {
std::fs::metadata(path)
} else {
std::fs::symlink_metadata(path)
}
}
MetadataTarget::File(fd) => {
let mut stat = unsafe { std::mem::zeroed::<libc::stat>() };
let result = unsafe { libc::fstat(fd, &mut stat) };
if result < 0 {
return Err(io::Error::last_os_error());
}
return Ok(raw_metadata_from_stat(&stat));
}
}?;
Ok(raw_metadata_from_std(&metadata))
})
.await
}
pub async fn sync_all(op: FsOp) -> io::Result<()> {
let FsOp::SyncAll { fd } = op else {
unreachable!("sync_all backend called with non-sync_all op");
};
offload(move || cvt(unsafe { libc::fsync(fd) }).map(|_| ())).await
}
pub async fn sync_data(op: FsOp) -> io::Result<()> {
let FsOp::SyncData { fd } = op else {
unreachable!("sync_data backend called with non-sync_data op");
};
offload(move || cvt(unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) }).map(|_| ())).await
}
pub async fn set_len(op: FsOp) -> io::Result<()> {
let FsOp::SetLen { fd, len } = op else {
unreachable!("set_len backend called with non-set_len op");
};
offload(move || cvt(unsafe { libc::ftruncate(fd, len as libc::off_t) }).map(|_| ())).await
}
pub async fn try_clone(op: FsOp) -> io::Result<OwnedFd> {
let FsOp::Duplicate { fd } = op else {
unreachable!("try_clone backend called with non-duplicate op");
};
offload(move || {
let duplicated = cvt(unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) })?;
Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
})
.await
}
pub async fn create_dir(op: FsOp) -> io::Result<()> {
let FsOp::CreateDir { path, mode } = op else {
unreachable!("create_dir backend called with non-create_dir op");
};
offload(move || {
let c_path = path_to_c_string(path)?;
cvt(unsafe { libc::mkdir(c_path.as_ptr(), mode as libc::mode_t) }).map(|_| ())
})
.await
}
pub async fn remove_file(op: FsOp) -> io::Result<()> {
let FsOp::RemoveFile { path } = op else {
unreachable!("remove_file backend called with non-remove_file op");
};
offload(move || std::fs::remove_file(path)).await
}
pub async fn remove_dir(op: FsOp) -> io::Result<()> {
let FsOp::RemoveDir { path } = op else {
unreachable!("remove_dir backend called with non-remove_dir op");
};
offload(move || std::fs::remove_dir(path)).await
}
pub async fn rename(op: FsOp) -> io::Result<()> {
let FsOp::Rename { from, to } = op else {
unreachable!("rename backend called with non-rename op");
};
offload(move || std::fs::rename(from, to)).await
}
pub async fn close(op: FsOp) -> io::Result<()> {
let FsOp::Close { fd } = op else {
unreachable!("close backend called with non-close op");
};
offload(move || cvt(unsafe { libc::close(fd) }).map(|_| ())).await
}
pub fn read_dir(op: FsOp) -> io::Result<ReadDirStream> {
let FsOp::ReadDir { path } = op else {
unreachable!("read_dir backend called with non-read_dir op");
};
ReadDirStream::new(path)
}
pub struct ReadDirStream {
state: Arc<ReadDirState>,
}
impl ReadDirStream {
fn new(path: PathBuf) -> io::Result<Self> {
let state = Arc::new(ReadDirState::new(current_thread_handle()));
let producer = Arc::clone(&state);
if let Err(error) = spawn_blocking(Box::new(move || produce_dir_entries(path, producer))) {
state.release_pending();
return Err(error);
}
Ok(Self { state })
}
pub async fn next_entry(&mut self) -> io::Result<Option<RawDirEntry>> {
poll_fn(|cx| self.state.poll_next(cx)).await
}
}
struct ReadDirState {
owner: ThreadHandle,
queue: Mutex<VecDeque<io::Result<RawDirEntry>>>,
done: AtomicBool,
pending: AtomicBool,
wake_queued: AtomicBool,
waker: Mutex<Option<Waker>>,
}
impl ReadDirState {
fn new(owner: ThreadHandle) -> Self {
owner.begin_async_operation();
Self {
owner,
queue: Mutex::new(VecDeque::new()),
done: AtomicBool::new(false),
pending: AtomicBool::new(true),
wake_queued: AtomicBool::new(false),
waker: Mutex::new(None),
}
}
fn push(self: &Arc<Self>, entry: io::Result<RawDirEntry>) {
self.queue.lock().unwrap().push_back(entry);
self.notify();
}
fn finish(self: &Arc<Self>) {
self.done.store(true, Ordering::Release);
self.release_pending();
self.notify();
}
fn release_pending(&self) {
if self.pending.swap(false, Ordering::AcqRel) {
self.owner.finish_async_operation();
}
}
fn notify(self: &Arc<Self>) {
if self.wake_queued.swap(true, Ordering::AcqRel) {
return;
}
let state = Arc::clone(self);
if !self.owner.queue_task(move || {
state.wake_queued.store(false, Ordering::Release);
if let Some(waker) = state.waker.lock().unwrap().take() {
waker.wake();
}
}) {
self.wake_queued.store(false, Ordering::Release);
}
}
fn poll_next(&self, cx: &mut Context<'_>) -> Poll<io::Result<Option<RawDirEntry>>> {
if let Some(entry) = self.queue.lock().unwrap().pop_front() {
return Poll::Ready(entry.map(Some));
}
if self.done.load(Ordering::Acquire) {
return Poll::Ready(Ok(None));
}
*self.waker.lock().unwrap() = Some(cx.waker().clone());
if let Some(entry) = self.queue.lock().unwrap().pop_front() {
let _ = self.waker.lock().unwrap().take();
return Poll::Ready(entry.map(Some));
}
if self.done.load(Ordering::Acquire) {
let _ = self.waker.lock().unwrap().take();
return Poll::Ready(Ok(None));
}
Poll::Pending
}
}
impl Drop for ReadDirStream {
fn drop(&mut self) {
self.state.release_pending();
}
}
fn produce_dir_entries(path: PathBuf, state: Arc<ReadDirState>) {
match std::fs::read_dir(path) {
Ok(entries) => {
for entry in entries {
match entry {
Ok(entry) => {
let file_name = entry.file_name();
state.push(Ok(RawDirEntry {
path: entry.path(),
file_name,
}));
}
Err(error) => state.push(Err(error)),
}
}
state.finish();
}
Err(error) => {
state.push(Err(error));
state.finish();
}
}
}
async fn offload<T: Send + 'static>(
work: impl FnOnce() -> io::Result<T> + Send + 'static,
) -> io::Result<T> {
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
let handle_for_task = handle.clone();
if let Err(error) = spawn_blocking(Box::new(move || handle_for_task.complete(work()))) {
handle.complete(Err(error));
}
future.await
}
struct BlockingPool {
sender: mpsc::SyncSender<BlockingTask>,
}
impl BlockingPool {
fn spawn(&self, task: BlockingTask) -> io::Result<()> {
self.sender.try_send(task).map_err(|error| match error {
mpsc::TrySendError::Full(_) => io::Error::new(
io::ErrorKind::WouldBlock,
"filesystem blocking worker queue is full",
),
mpsc::TrySendError::Disconnected(_) => io::Error::new(
io::ErrorKind::BrokenPipe,
"filesystem blocking worker pool has stopped",
),
})
}
}
fn spawn_blocking(task: BlockingTask) -> io::Result<()> {
blocking_pool()?.spawn(task)
}
fn blocking_pool() -> io::Result<&'static BlockingPool> {
match BLOCKING_POOL.get_or_init(create_blocking_pool) {
Ok(pool) => Ok(pool),
Err(error) => Err(io::Error::new(error.kind(), error.to_string())),
}
}
fn create_blocking_pool() -> io::Result<BlockingPool> {
let (sender, receiver) = mpsc::sync_channel::<BlockingTask>(BLOCKING_QUEUE_CAPACITY);
let receiver = Arc::new(Mutex::new(receiver));
let worker_count = std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(2)
.clamp(2, 4);
let mut spawned = 0usize;
let mut last_error = None;
for index in 0..worker_count {
let receiver = Arc::clone(&receiver);
match thread::Builder::new()
.name(format!("ruin-runtime-fs-offload-{index}"))
.spawn(move || {
loop {
let task = receiver.lock().unwrap().recv();
match task {
Ok(task) => task(),
Err(_) => break,
}
}
}) {
Ok(_) => spawned += 1,
Err(error) => last_error = Some(error),
}
}
if spawned == 0 {
return Err(io::Error::other(
last_error.expect("at least one worker spawn should have been attempted"),
));
}
Ok(BlockingPool { sender })
}
fn path_to_c_string(path: PathBuf) -> io::Result<CString> {
CString::new(path.as_os_str().as_bytes()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"path contains interior NUL bytes",
)
})
}
fn raw_metadata_from_std(metadata: &std::fs::Metadata) -> RawMetadata {
let file_type = metadata.file_type();
let kind = if file_type.is_file() {
FileType::File
} else if file_type.is_dir() {
FileType::Directory
} else if file_type.is_symlink() {
FileType::Symlink
} else if file_type.is_block_device() {
FileType::BlockDevice
} else if file_type.is_char_device() {
FileType::CharacterDevice
} else if file_type.is_fifo() {
FileType::Fifo
} else if file_type.is_socket() {
FileType::Socket
} else {
FileType::Unknown
};
RawMetadata {
file_type: kind,
mode: (metadata.mode() & 0o7777) as u16,
len: metadata.len(),
}
}
fn raw_metadata_from_stat(stat: &libc::stat) -> RawMetadata {
let kind = match stat.st_mode & libc::S_IFMT {
libc::S_IFREG => FileType::File,
libc::S_IFDIR => FileType::Directory,
libc::S_IFLNK => FileType::Symlink,
libc::S_IFBLK => FileType::BlockDevice,
libc::S_IFCHR => FileType::CharacterDevice,
libc::S_IFIFO => FileType::Fifo,
libc::S_IFSOCK => FileType::Socket,
_ => FileType::Unknown,
};
RawMetadata {
file_type: kind,
mode: stat.st_mode & 0o7777,
len: stat.st_size as u64,
}
}
fn cvt(value: libc::c_int) -> io::Result<libc::c_int> {
if value < 0 {
Err(io::Error::last_os_error())
} else {
Ok(value)
}
}

View File

@@ -0,0 +1,6 @@
//! macOS backend modules.
pub mod channel;
pub mod fd;
pub mod fs;
pub mod net;

View File

@@ -0,0 +1,895 @@
//! macOS networking backend.
use std::ffi::c_void;
use std::future::Future;
use std::io;
use std::mem::MaybeUninit;
use std::net::{
Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, SocketAddrV4, SocketAddrV6, ToSocketAddrs,
};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock, mpsc};
use std::thread;
use std::time::Duration;
use crate::op::completion::completion_for_current_thread;
use crate::op::net::{AcceptedSocket, NetOp, ReceivedDatagram};
const DEFAULT_LISTENER_BACKLOG: i32 = 1024;
const DNS_QUEUE_CAPACITY: usize = 256;
type RecvFuture = Pin<Box<dyn Future<Output = io::Result<Vec<u8>>> + 'static>>;
type SendFuture = Pin<Box<dyn Future<Output = io::Result<usize>> + 'static>>;
type ShutdownFuture = Pin<Box<dyn Future<Output = io::Result<()>> + 'static>>;
type BlockingTask = Box<dyn FnOnce() + Send + 'static>;
static DNS_POOL: OnceLock<io::Result<BlockingPool>> = OnceLock::new();
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExecutionPath {
Kqueue,
Offload,
}
pub fn execution_path(op: &NetOp) -> ExecutionPath {
match op {
NetOp::Socket { .. }
| NetOp::Connect { .. }
| NetOp::Bind { .. }
| NetOp::Listen { .. }
| NetOp::Accept { .. }
| NetOp::Send { .. }
| NetOp::SendTo { .. }
| NetOp::Recv { .. }
| NetOp::RecvFrom { .. }
| NetOp::Shutdown { .. }
| NetOp::Close { .. } => ExecutionPath::Kqueue,
}
}
pub async fn resolve_addrs<A>(addr: A) -> io::Result<Vec<SocketAddr>>
where
A: ToSocketAddrs + Send + 'static,
{
offload(move || {
let addrs = addr.to_socket_addrs()?.collect::<Vec<_>>();
if addrs.is_empty() {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"address resolved to no socket addresses",
))
} else {
Ok(addrs)
}
})
.await
}
pub async fn socket(op: NetOp) -> io::Result<OwnedFd> {
let NetOp::Socket {
domain,
socket_type,
protocol,
flags,
} = op
else {
unreachable!("socket backend called with non-socket op");
};
socket_sync(domain, socket_type, protocol, flags)
}
pub async fn connect(op: NetOp) -> io::Result<()> {
let NetOp::Connect { fd, addr } = op else {
unreachable!("connect backend called with non-connect op");
};
connect_async(fd, RawSocketAddr::from_socket_addr(addr)).await
}
pub async fn bind(op: NetOp) -> io::Result<()> {
let NetOp::Bind { fd, addr } = op else {
unreachable!("bind backend called with non-bind op");
};
bind_sync(fd, RawSocketAddr::from_socket_addr(addr))
}
pub async fn listen(op: NetOp) -> io::Result<()> {
let NetOp::Listen { fd, backlog } = op else {
unreachable!("listen backend called with non-listen op");
};
listen_sync(fd, backlog)
}
pub async fn accept(op: NetOp) -> io::Result<AcceptedSocket> {
let NetOp::Accept { fd } = op else {
unreachable!("accept backend called with non-accept op");
};
accept_async(fd).await
}
pub async fn send(op: NetOp) -> io::Result<usize> {
let NetOp::Send { fd, data, flags } = op else {
unreachable!("send backend called with non-send op");
};
send_async(fd, data, flags).await
}
pub async fn send_to(op: NetOp) -> io::Result<usize> {
let NetOp::SendTo {
fd,
target,
data,
flags,
} = op
else {
unreachable!("send_to backend called with non-send_to op");
};
send_to_async(fd, target, data, flags).await
}
pub async fn recv(op: NetOp) -> io::Result<Vec<u8>> {
let NetOp::Recv { fd, len, flags } = op else {
unreachable!("recv backend called with non-recv op");
};
recv_async(fd, len, flags).await
}
pub async fn recv_from(op: NetOp) -> io::Result<ReceivedDatagram> {
let NetOp::RecvFrom { fd, len, flags } = op else {
unreachable!("recv_from backend called with non-recv_from op");
};
recv_from_async(fd, len, flags).await
}
pub async fn shutdown(op: NetOp) -> io::Result<()> {
let NetOp::Shutdown { fd, how } = op else {
unreachable!("shutdown backend called with non-shutdown op");
};
shutdown_sync(fd, how)
}
pub async fn close(op: NetOp) -> io::Result<()> {
let NetOp::Close { fd } = op else {
unreachable!("close backend called with non-close op");
};
close_sync(fd)
}
pub async fn connect_stream(addr: SocketAddr) -> io::Result<OwnedFd> {
match connect_stream_inner(addr).await {
Err(error) if should_try_ipv4_loopback(addr, &error) => {
connect_stream_inner(localhost_v4(addr)).await
}
result => result,
}
}
pub async fn bind_listener(addr: SocketAddr, backlog: Option<i32>) -> io::Result<OwnedFd> {
match bind_listener_inner(addr, backlog).await {
Err(error) if should_try_ipv4_loopback(addr, &error) => {
bind_listener_inner(localhost_v4(addr), backlog).await
}
result => result,
}
}
pub async fn bind_datagram(addr: SocketAddr) -> io::Result<OwnedFd> {
match bind_datagram_inner(addr).await {
Err(error) if should_try_ipv4_loopback(addr, &error) => {
bind_datagram_inner(localhost_v4(addr)).await
}
result => result,
}
}
async fn connect_stream_inner(addr: SocketAddr) -> io::Result<OwnedFd> {
let stream = socket(NetOp::Socket {
domain: socket_domain(addr),
socket_type: libc::SOCK_STREAM,
protocol: 0,
flags: 0,
})
.await?;
connect(NetOp::Connect {
fd: stream.as_raw_fd(),
addr,
})
.await?;
Ok(stream)
}
async fn bind_listener_inner(addr: SocketAddr, backlog: Option<i32>) -> io::Result<OwnedFd> {
let listener = socket(NetOp::Socket {
domain: socket_domain(addr),
socket_type: libc::SOCK_STREAM,
protocol: 0,
flags: 0,
})
.await?;
set_reuse_addr(listener.as_raw_fd(), true)?;
bind(NetOp::Bind {
fd: listener.as_raw_fd(),
addr,
})
.await?;
listen(NetOp::Listen {
fd: listener.as_raw_fd(),
backlog: backlog.unwrap_or(DEFAULT_LISTENER_BACKLOG),
})
.await?;
Ok(listener)
}
async fn bind_datagram_inner(addr: SocketAddr) -> io::Result<OwnedFd> {
let socket = socket(NetOp::Socket {
domain: socket_domain(addr),
socket_type: libc::SOCK_DGRAM,
protocol: 0,
flags: 0,
})
.await?;
bind(NetOp::Bind {
fd: socket.as_raw_fd(),
addr,
})
.await?;
Ok(socket)
}
pub async fn duplicate(fd: RawFd) -> io::Result<OwnedFd> {
let duplicated = cvt(unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) })?;
set_nonblocking(duplicated)?;
Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
}
pub async fn recv_timeout(
fd: RawFd,
len: usize,
flags: i32,
timeout: Duration,
) -> io::Result<Vec<u8>> {
io_timeout(timeout, recv_async(fd, len, flags)).await
}
pub async fn send_timeout(
fd: RawFd,
data: Vec<u8>,
flags: i32,
timeout: Duration,
) -> io::Result<usize> {
io_timeout(timeout, send_async(fd, data, flags)).await
}
pub async fn recv_from_timeout(
fd: RawFd,
len: usize,
flags: i32,
timeout: Duration,
) -> io::Result<ReceivedDatagram> {
io_timeout(timeout, recv_from_async(fd, len, flags)).await
}
pub async fn send_to_timeout(
fd: RawFd,
data: Vec<u8>,
target: SocketAddr,
flags: i32,
timeout: Duration,
) -> io::Result<usize> {
io_timeout(timeout, send_to_async(fd, target, data, flags)).await
}
pub async fn connect_stream_timeout(addr: SocketAddr, timeout: Duration) -> io::Result<OwnedFd> {
let fd = socket_sync(socket_domain(addr), libc::SOCK_STREAM, 0, 0)?;
if let Err(error) = io_timeout(
timeout,
connect_async(fd.as_raw_fd(), RawSocketAddr::from_socket_addr(addr)),
)
.await
{
drop(fd);
return Err(error);
}
Ok(fd)
}
pub fn local_addr(fd: RawFd) -> io::Result<SocketAddr> {
socket_addr_with(libc::getsockname, fd)
}
pub fn peer_addr(fd: RawFd) -> io::Result<SocketAddr> {
socket_addr_with(libc::getpeername, fd)
}
pub fn nodelay(fd: RawFd) -> io::Result<bool> {
let mut value = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
cvt(unsafe {
libc::getsockopt(
fd,
libc::IPPROTO_TCP,
libc::TCP_NODELAY,
&mut value as *mut libc::c_int as *mut c_void,
&mut len,
)
})?;
Ok(value != 0)
}
pub fn broadcast(fd: RawFd) -> io::Result<bool> {
getsockopt_int(fd, libc::SOL_SOCKET, libc::SO_BROADCAST).map(|value| value != 0)
}
pub fn set_broadcast(fd: RawFd, enabled: bool) -> io::Result<()> {
setsockopt_int(fd, libc::SOL_SOCKET, libc::SO_BROADCAST, enabled.into())
}
pub fn ttl(fd: RawFd) -> io::Result<u32> {
match socket_family(fd)? {
libc::AF_INET => {
getsockopt_int(fd, libc::IPPROTO_IP, libc::IP_TTL).map(|value| value as u32)
}
libc::AF_INET6 => getsockopt_int(fd, libc::IPPROTO_IPV6, libc::IPV6_UNICAST_HOPS)
.map(|value| value as u32),
family => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unsupported socket family {family} for TTL"),
)),
}
}
pub fn set_ttl(fd: RawFd, ttl: u32) -> io::Result<()> {
let ttl = i32::try_from(ttl)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "TTL exceeds i32 range"))?;
match socket_family(fd)? {
libc::AF_INET => setsockopt_int(fd, libc::IPPROTO_IP, libc::IP_TTL, ttl),
libc::AF_INET6 => setsockopt_int(fd, libc::IPPROTO_IPV6, libc::IPV6_UNICAST_HOPS, ttl),
family => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unsupported socket family {family} for TTL"),
)),
}
}
pub fn set_nodelay(fd: RawFd, enabled: bool) -> io::Result<()> {
let value: libc::c_int = enabled.into();
cvt(unsafe {
libc::setsockopt(
fd,
libc::IPPROTO_TCP,
libc::TCP_NODELAY,
&value as *const libc::c_int as *const c_void,
std::mem::size_of_val(&value) as libc::socklen_t,
)
})
.map(|_| ())
}
pub fn recv_future(fd: RawFd, len: usize) -> RecvFuture {
Box::pin(recv(NetOp::Recv { fd, len, flags: 0 }))
}
pub fn send_future(fd: RawFd, data: Vec<u8>) -> SendFuture {
Box::pin(send(NetOp::Send { fd, data, flags: 0 }))
}
pub fn shutdown_future(fd: RawFd, how: Shutdown) -> ShutdownFuture {
Box::pin(shutdown(NetOp::Shutdown { fd, how }))
}
async fn offload<T: Send + 'static>(
work: impl FnOnce() -> io::Result<T> + Send + 'static,
) -> io::Result<T> {
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
if let Err(error) = dns_pool().and_then(|pool| {
pool.spawn(Box::new({
let handle = handle.clone();
move || handle.complete(work())
}))
}) {
handle.complete(Err(error));
}
future.await
}
struct BlockingPool {
sender: mpsc::SyncSender<BlockingTask>,
}
impl BlockingPool {
fn spawn(&self, task: BlockingTask) -> io::Result<()> {
self.sender.try_send(task).map_err(|error| match error {
mpsc::TrySendError::Full(_) => io::Error::new(
io::ErrorKind::WouldBlock,
"DNS resolver blocking worker queue is full",
),
mpsc::TrySendError::Disconnected(_) => io::Error::new(
io::ErrorKind::BrokenPipe,
"DNS resolver blocking worker pool has stopped",
),
})
}
}
fn dns_pool() -> io::Result<&'static BlockingPool> {
match DNS_POOL.get_or_init(create_dns_pool) {
Ok(pool) => Ok(pool),
Err(error) => Err(io::Error::new(error.kind(), error.to_string())),
}
}
fn create_dns_pool() -> io::Result<BlockingPool> {
let (sender, receiver) = mpsc::sync_channel::<BlockingTask>(DNS_QUEUE_CAPACITY);
let receiver = Arc::new(Mutex::new(receiver));
let worker_count = std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(2)
.clamp(2, 4);
let mut spawned = 0usize;
let mut last_error = None;
for index in 0..worker_count {
let receiver = Arc::clone(&receiver);
match thread::Builder::new()
.name(format!("ruin-runtime-dns-{index}"))
.spawn(move || {
loop {
let task = {
let receiver = receiver.lock().expect("DNS worker queue mutex poisoned");
receiver.recv()
};
match task {
Ok(task) => task(),
Err(_) => break,
}
}
}) {
Ok(_) => spawned += 1,
Err(error) => last_error = Some(error),
}
}
if spawned == 0 {
return Err(io::Error::other(last_error.expect(
"at least one DNS worker spawn should have been attempted",
)));
}
Ok(BlockingPool { sender })
}
async fn io_timeout<T>(
timeout: Duration,
future: impl Future<Output = io::Result<T>>,
) -> io::Result<T> {
crate::time::timeout(timeout, future)
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "operation timed out"))?
}
fn socket_domain(addr: SocketAddr) -> i32 {
match addr {
SocketAddr::V4(_) => libc::AF_INET,
SocketAddr::V6(_) => libc::AF_INET6,
}
}
fn shutdown_how(how: Shutdown) -> i32 {
match how {
Shutdown::Read => libc::SHUT_RD,
Shutdown::Write => libc::SHUT_WR,
Shutdown::Both => libc::SHUT_RDWR,
}
}
fn socket_addr_with(
op: unsafe extern "C" fn(RawFd, *mut libc::sockaddr, *mut libc::socklen_t) -> libc::c_int,
fd: RawFd,
) -> io::Result<SocketAddr> {
let mut storage = MaybeUninit::<libc::sockaddr_storage>::zeroed();
let mut len = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
cvt(unsafe { op(fd, storage.as_mut_ptr().cast::<libc::sockaddr>(), &mut len) })?;
let storage = unsafe { storage.assume_init() };
socket_addr_from_storage(&storage, len)
}
fn set_reuse_addr(fd: RawFd, enabled: bool) -> io::Result<()> {
setsockopt_int(fd, libc::SOL_SOCKET, libc::SO_REUSEADDR, enabled.into())
}
fn socket_family(fd: RawFd) -> io::Result<i32> {
let mut storage = MaybeUninit::<libc::sockaddr_storage>::zeroed();
let mut len = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
cvt(unsafe { libc::getsockname(fd, storage.as_mut_ptr().cast::<libc::sockaddr>(), &mut len) })?;
let storage = unsafe { storage.assume_init() };
Ok(storage.ss_family as i32)
}
fn getsockopt_int(fd: RawFd, level: i32, name: i32) -> io::Result<i32> {
let mut value = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
cvt(unsafe {
libc::getsockopt(
fd,
level,
name,
&mut value as *mut libc::c_int as *mut c_void,
&mut len,
)
})?;
Ok(value)
}
fn setsockopt_int(fd: RawFd, level: i32, name: i32, value: i32) -> io::Result<()> {
cvt(unsafe {
libc::setsockopt(
fd,
level,
name,
&value as *const libc::c_int as *const c_void,
std::mem::size_of_val(&value) as libc::socklen_t,
)
})
.map(|_| ())
}
fn socket_addr_from_storage(
storage: &libc::sockaddr_storage,
len: libc::socklen_t,
) -> io::Result<SocketAddr> {
match storage.ss_family as i32 {
libc::AF_INET => {
if len < std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"sockaddr_in length is truncated",
));
}
let addr = unsafe { *(storage as *const _ as *const libc::sockaddr_in) };
Ok(SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr)),
u16::from_be(addr.sin_port),
)))
}
libc::AF_INET6 => {
if len < std::mem::size_of::<libc::sockaddr_in6>() as libc::socklen_t {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"sockaddr_in6 length is truncated",
));
}
let addr = unsafe { *(storage as *const _ as *const libc::sockaddr_in6) };
Ok(SocketAddr::V6(SocketAddrV6::new(
Ipv6Addr::from(addr.sin6_addr.s6_addr),
u16::from_be(addr.sin6_port),
addr.sin6_flowinfo,
addr.sin6_scope_id,
)))
}
family => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unsupported socket family {family}"),
)),
}
}
#[derive(Clone, Copy)]
struct RawSocketAddr {
storage: libc::sockaddr_storage,
len: libc::socklen_t,
}
impl RawSocketAddr {
fn from_socket_addr(addr: SocketAddr) -> Self {
match addr {
SocketAddr::V4(addr) => {
let sockaddr = libc::sockaddr_in {
sin_len: std::mem::size_of::<libc::sockaddr_in>() as u8,
sin_family: libc::AF_INET as libc::sa_family_t,
sin_port: addr.port().to_be(),
sin_addr: libc::in_addr {
s_addr: u32::from_be_bytes(addr.ip().octets()).to_be(),
},
sin_zero: [0; 8],
};
let mut storage =
unsafe { MaybeUninit::<libc::sockaddr_storage>::zeroed().assume_init() };
unsafe {
std::ptr::write(
&mut storage as *mut libc::sockaddr_storage as *mut libc::sockaddr_in,
sockaddr,
);
}
Self {
storage,
len: std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
}
}
SocketAddr::V6(addr) => {
let sockaddr = libc::sockaddr_in6 {
sin6_len: std::mem::size_of::<libc::sockaddr_in6>() as u8,
sin6_family: libc::AF_INET6 as libc::sa_family_t,
sin6_port: addr.port().to_be(),
sin6_flowinfo: addr.flowinfo(),
sin6_addr: libc::in6_addr {
s6_addr: addr.ip().octets(),
},
sin6_scope_id: addr.scope_id(),
};
let mut storage =
unsafe { MaybeUninit::<libc::sockaddr_storage>::zeroed().assume_init() };
unsafe {
std::ptr::write(
&mut storage as *mut libc::sockaddr_storage as *mut libc::sockaddr_in6,
sockaddr,
);
}
Self {
storage,
len: std::mem::size_of::<libc::sockaddr_in6>() as libc::socklen_t,
}
}
}
}
fn as_ptr(&self) -> *const libc::sockaddr {
&self.storage as *const libc::sockaddr_storage as *const libc::sockaddr
}
fn len(&self) -> libc::socklen_t {
self.len
}
}
fn socket_sync(domain: i32, socket_type: i32, protocol: i32, _flags: u32) -> io::Result<OwnedFd> {
let fd = cvt(unsafe { libc::socket(domain, socket_type, protocol) })?;
set_cloexec(fd)?;
set_nonblocking(fd)?;
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
async fn connect_async(fd: RawFd, addr: RawSocketAddr) -> io::Result<()> {
loop {
let result = unsafe { libc::connect(fd, addr.as_ptr(), addr.len()) };
if result == 0 {
return Ok(());
}
let error = io::Error::last_os_error();
match error.raw_os_error() {
Some(libc::EINTR) => continue,
Some(libc::EINPROGRESS) | Some(libc::EALREADY) => {
crate::sys::current::fd::wait_writable(fd).await?;
return socket_error(fd);
}
Some(libc::EISCONN) => return Ok(()),
_ => return Err(error),
}
}
}
fn bind_sync(fd: RawFd, addr: RawSocketAddr) -> io::Result<()> {
cvt(unsafe { libc::bind(fd, addr.as_ptr(), addr.len()) }).map(|_| ())
}
fn listen_sync(fd: RawFd, backlog: i32) -> io::Result<()> {
cvt(unsafe { libc::listen(fd, backlog) }).map(|_| ())
}
fn accept_sync(fd: RawFd) -> io::Result<AcceptedSocket> {
let mut storage = MaybeUninit::<libc::sockaddr_storage>::zeroed();
let mut len = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
let accepted_fd =
cvt(unsafe { libc::accept(fd, storage.as_mut_ptr().cast::<libc::sockaddr>(), &mut len) })?;
set_cloexec(accepted_fd)?;
let storage = unsafe { storage.assume_init() };
let peer_addr = socket_addr_from_storage(&storage, len)?;
Ok(AcceptedSocket {
fd: accepted_fd,
peer_addr,
})
}
async fn accept_async(fd: RawFd) -> io::Result<AcceptedSocket> {
loop {
match accept_sync(fd) {
Ok(socket) => {
set_nonblocking(socket.fd)?;
return Ok(socket);
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
crate::sys::current::fd::wait_readable(fd).await?;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
async fn send_async(fd: RawFd, data: Vec<u8>, flags: i32) -> io::Result<usize> {
loop {
match send_slice_sync(fd, &data, flags) {
Ok(written) => return Ok(written),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
crate::sys::current::fd::wait_writable(fd).await?;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
fn send_slice_sync(fd: RawFd, data: &[u8], flags: i32) -> io::Result<usize> {
let written = unsafe { libc::send(fd, data.as_ptr().cast::<c_void>(), data.len(), flags) };
cvt_long(written).map(|written| written as usize)
}
async fn send_to_async(
fd: RawFd,
target: SocketAddr,
data: Vec<u8>,
flags: i32,
) -> io::Result<usize> {
loop {
match send_to_slice_sync(fd, target, &data, flags) {
Ok(written) => return Ok(written),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
crate::sys::current::fd::wait_writable(fd).await?;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
fn send_to_slice_sync(fd: RawFd, target: SocketAddr, data: &[u8], flags: i32) -> io::Result<usize> {
let addr = RawSocketAddr::from_socket_addr(target);
let written = unsafe {
libc::sendto(
fd,
data.as_ptr().cast::<c_void>(),
data.len(),
flags,
addr.as_ptr(),
addr.len(),
)
};
cvt_long(written).map(|written| written as usize)
}
async fn recv_async(fd: RawFd, len: usize, flags: i32) -> io::Result<Vec<u8>> {
loop {
match recv_sync(fd, len, flags) {
Ok(data) => return Ok(data),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
crate::sys::current::fd::wait_readable(fd).await?;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
fn recv_sync(fd: RawFd, len: usize, flags: i32) -> io::Result<Vec<u8>> {
let mut data = vec![0u8; len];
let read = unsafe { libc::recv(fd, data.as_mut_ptr().cast::<c_void>(), len, flags) };
let read = cvt_long(read)? as usize;
data.truncate(read);
Ok(data)
}
async fn recv_from_async(fd: RawFd, len: usize, flags: i32) -> io::Result<ReceivedDatagram> {
loop {
match recv_from_sync(fd, len, flags) {
Ok(datagram) => return Ok(datagram),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
crate::sys::current::fd::wait_readable(fd).await?;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
fn recv_from_sync(fd: RawFd, len: usize, flags: i32) -> io::Result<ReceivedDatagram> {
let mut data = vec![0u8; len];
let mut storage = MaybeUninit::<libc::sockaddr_storage>::zeroed();
let mut addr_len = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
let read = unsafe {
libc::recvfrom(
fd,
data.as_mut_ptr().cast::<c_void>(),
len,
flags,
storage.as_mut_ptr().cast::<libc::sockaddr>(),
&mut addr_len,
)
};
let read = cvt_long(read)? as usize;
data.truncate(read);
let storage = unsafe { storage.assume_init() };
let peer_addr = socket_addr_from_storage(&storage, addr_len)?;
Ok(ReceivedDatagram { data, peer_addr })
}
fn shutdown_sync(fd: RawFd, how: Shutdown) -> io::Result<()> {
cvt(unsafe { libc::shutdown(fd, shutdown_how(how)) }).map(|_| ())
}
fn close_sync(fd: RawFd) -> io::Result<()> {
cvt(unsafe { libc::close(fd) }).map(|_| ())
}
fn set_cloexec(fd: RawFd) -> io::Result<()> {
let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFD) })?;
cvt(unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) })?;
Ok(())
}
fn set_nonblocking(fd: RawFd) -> io::Result<()> {
let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFL) })?;
cvt(unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) })?;
Ok(())
}
fn should_try_ipv4_loopback(addr: SocketAddr, error: &io::Error) -> bool {
matches!(addr, SocketAddr::V6(v6) if v6.ip().is_loopback())
&& matches!(
error.raw_os_error(),
Some(libc::EADDRNOTAVAIL | libc::EAFNOSUPPORT | libc::ENETUNREACH)
)
}
fn socket_error(fd: RawFd) -> io::Result<()> {
let mut so_error: libc::c_int = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
cvt(unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_ERROR,
&mut so_error as *mut libc::c_int as *mut c_void,
&mut len,
)
})?;
if so_error == 0 {
Ok(())
} else {
Err(io::Error::from_raw_os_error(so_error))
}
}
fn localhost_v4(addr: SocketAddr) -> SocketAddr {
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, addr.port()))
}
fn cvt(value: libc::c_int) -> io::Result<libc::c_int> {
if value < 0 {
Err(io::Error::last_os_error())
} else {
Ok(value)
}
}
fn cvt_long(value: libc::ssize_t) -> io::Result<libc::ssize_t> {
if value < 0 {
Err(io::Error::last_os_error())
} else {
Ok(value)
}
}

View File

@@ -2,3 +2,10 @@
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub mod linux;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub mod macos;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use linux as current;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub use macos as current;

View File

@@ -67,7 +67,9 @@ fn bench_scroll_list(c: &mut Criterion) {
let viewport_height = 640.0;
let n = 500;
let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height);
let mut scroll_box = Element::scroll_box(0.0)
.width(400.0)
.height(viewport_height);
for i in 0..n {
scroll_box = scroll_box.child(
Element::new()
@@ -90,5 +92,10 @@ fn bench_scroll_list(c: &mut Criterion) {
});
}
criterion_group!(benches, bench_static_tree, bench_single_change, bench_scroll_list);
criterion_group!(
benches,
bench_static_tree,
bench_single_change,
bench_scroll_list
);
criterion_main!(benches);

View File

@@ -107,6 +107,7 @@ fn log_platform_event(event: &PlatformEvent) {
"clipboard text received"
);
}
#[cfg(target_os = "linux")]
PlatformEvent::PrimarySelectionText { window_id, text } => {
tracing::debug!(
event = "primary_selection_text",

View File

@@ -7,7 +7,9 @@ use crate::scene::{
UiSize,
};
use crate::text::TextSystem;
use crate::tree::{AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style};
use crate::tree::{
AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style,
};
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
let mut text_system = TextSystem::new();
@@ -278,20 +280,14 @@ fn layout_element(
&& !rects_overlap(rect, clip)
{
perf_stats.viewport_culled += 1;
return LayoutNode {
path,
element_id: element.id,
return layout_interaction_skeleton(
element,
rect,
corner_radius: 0.0,
clip_rect: None,
pointer_events: element.style.pointer_events,
focusable: element.style.focusable,
cursor: element.style.cursor.unwrap_or(CursorIcon::Default),
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
path,
text_system,
perf_stats,
layout_cache,
);
}
// Incremental layout cache check.
@@ -303,6 +299,7 @@ fn layout_element(
subtree_hash,
avail_width_bits: rect.size.width.round() as u32,
avail_height_bits: rect.size.height.round() as u32,
clip_rect_bits: cache_clip_rect_bits(rect, clip_rect),
};
if let Some(cached) = layout_cache.results.get(&cache_key) {
let offset = rect.origin;
@@ -586,13 +583,7 @@ fn layout_element(
text_system,
perf_stats,
layout_cache,
// Do NOT propagate clip_rect into children: the scroll-box PushClip already clips
// them visually, and propagating the clip causes the layout cache to bake in
// culling results for a specific scroll position. When the scroll changes, the
// same cache entry would replay with stale "empty" children that were culled at
// the previous offset. Culling applies only at the direct-child level of the
// scroll box (which passes Some(viewport_rect) to layout_container_children).
None,
clip_rect,
);
if pushed_clip {
@@ -879,8 +870,7 @@ fn layout_container_children(
// children with no explicit cross-axis style, since flex main size is unknown here.
// align_self on the child overrides the parent's align_items for this child.
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
let natural_cross =
if effective_align != AlignItems::Stretch
let natural_cross = if effective_align != AlignItems::Stretch
&& !is_flex
&& child_cross_size(child, element.style.direction).is_none()
{
@@ -888,8 +878,7 @@ fn layout_container_children(
FlexDirection::Row => UiSize::new(measured_main, available_cross),
FlexDirection::Column => UiSize::new(available_cross, measured_main),
};
let natural =
intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
let natural = intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
Some(
cross_axis_size(natural, element.style.direction)
.max(min_cross_outer)
@@ -966,6 +955,256 @@ fn layout_container_children(
children
}
fn layout_interaction_skeleton(
element: &Element,
rect: Rect,
path: LayoutPath,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> LayoutNode {
let cursor = element.style.cursor.unwrap_or_else(|| {
if element.text_node().is_some() {
CursorIcon::Text
} else {
CursorIcon::Default
}
});
let mut interaction = LayoutNode {
path,
element_id: element.id,
rect,
corner_radius: uniform_corner_radius(&element.style, rect),
clip_rect: None,
pointer_events: element.style.pointer_events,
focusable: element.style.focusable,
cursor,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
return interaction;
}
if let Some(scroll_box) = element.scroll_box_node() {
let content = inset_rect(rect, content_insets(&element.style));
if content.size.width <= 0.0 || content.size.height <= 0.0 {
return interaction;
}
let gutter_width = scroll_box
.scrollbar
.gutter_width
.max(0.0)
.min(content.size.width);
let viewport_rect = Rect::new(
content.origin.x,
content.origin.y,
(content.size.width - gutter_width).max(0.0),
content.size.height,
);
interaction.clip_rect = Some(viewport_rect);
let content_size = intrinsic_container_content_size(
element,
UiSize::new(
viewport_rect.size.width.max(0.0),
viewport_rect.size.height.max(0.0),
),
text_system,
perf_stats,
layout_cache,
);
let content_height = content_size.height.max(viewport_rect.size.height);
let offset_y = scroll_box
.offset_y
.max(0.0)
.min((content_height - viewport_rect.size.height).max(0.0));
interaction.children = layout_interaction_skeleton_children(
element,
Rect::new(
viewport_rect.origin.x,
viewport_rect.origin.y - offset_y,
viewport_rect.size.width,
content_height,
),
&interaction.path,
text_system,
perf_stats,
layout_cache,
);
let (scrollbar_track, scrollbar_thumb) = scrollbar_geometry(
content,
scroll_box.scrollbar,
content_height,
viewport_rect.size.height,
offset_y,
);
interaction.scroll_metrics = Some(ScrollMetrics {
viewport_rect,
content_height,
offset_y,
max_offset_y: (content_height - viewport_rect.size.height).max(0.0),
scrollbar_track,
scrollbar_thumb,
});
return interaction;
}
if element.text_node().is_some()
|| element.image_node().is_some()
|| element.children.is_empty()
{
return interaction;
}
let content = inset_rect(rect, content_insets(&element.style));
interaction.children = layout_interaction_skeleton_children(
element,
content,
&interaction.path,
text_system,
perf_stats,
layout_cache,
);
interaction
}
fn layout_interaction_skeleton_children(
element: &Element,
content: Rect,
path: &LayoutPath,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> Vec<LayoutNode> {
if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 {
return Vec::new();
}
let gap_count = element.children.len().saturating_sub(1) as f32;
let total_gap = element.style.gap * gap_count;
let available_main =
(main_axis_size(content.size, element.style.direction).max(0.0) - total_gap).max(0.0);
let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0);
let mut measured_children = Vec::with_capacity(element.children.len());
let mut fixed_total = 0.0;
let mut flex_total = 0.0;
for child in &element.children {
let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction);
let cross_inset = cross_axis_padding(child_insets, element.style.direction);
let min_cross_outer = child_min_cross_size(child, element.style.direction)
.map(|c| (c + cross_inset).max(0.0))
.unwrap_or(0.0);
let min_main_outer = child_min_main_size(child, element.style.direction)
.map(|m| (m + main_inset).max(0.0))
.unwrap_or(0.0);
let cross = child_cross_size(child, element.style.direction)
.map(|c| (c + cross_inset).max(0.0).max(min_cross_outer))
.unwrap_or(available_cross.max(min_cross_outer))
.min(available_cross.max(min_cross_outer));
let explicit_main = child_main_size(child, element.style.direction)
.map(|main| (main + main_inset).max(0.0).max(min_main_outer));
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex {
min_main_outer
} else {
intrinsic_main_size(
child,
element.style.direction,
cross,
available_main,
text_system,
perf_stats,
layout_cache,
)
.max(min_main_outer)
}
});
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
let natural_cross = if effective_align != AlignItems::Stretch
&& !is_flex
&& child_cross_size(child, element.style.direction).is_none()
{
let offered = match element.style.direction {
FlexDirection::Row => UiSize::new(measured_main, available_cross),
FlexDirection::Column => UiSize::new(available_cross, measured_main),
};
let natural = intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
Some(
cross_axis_size(natural, element.style.direction)
.max(min_cross_outer)
.min(available_cross.max(min_cross_outer)),
)
} else {
None
};
if is_flex {
flex_total += child_flex_weight(child);
} else {
fixed_total += measured_main;
}
measured_children.push(MeasuredChild {
cross,
natural_cross,
main: measured_main,
is_flex,
});
}
let remaining_main = (available_main - fixed_total).max(0.0);
let mut cursor = main_axis_origin(content, element.style.direction);
let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex {
let allocated = if flex_total <= 0.0 {
0.0
} else {
remaining_main * (child_flex_weight(child) / flex_total)
};
let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction);
let min_main_outer = child_min_main_size(child, element.style.direction)
.map(|m| (m + main_inset).max(0.0))
.unwrap_or(0.0);
allocated.max(min_main_outer)
} else {
measured.main
};
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
let actual_cross = measured.natural_cross.unwrap_or(measured.cross);
let cross_origin = {
let base = cross_axis_origin(content, element.style.direction);
match effective_align {
AlignItems::Stretch | AlignItems::Start => base,
AlignItems::Center => base + (available_cross - actual_cross) * 0.5,
AlignItems::End => base + available_cross - actual_cross,
}
};
let child_rect = child_rect(
cross_origin,
element.style.direction,
cursor,
child_main.max(0.0),
actual_cross,
);
children.push(layout_interaction_skeleton(
child,
child_rect,
path.child(index),
text_system,
perf_stats,
layout_cache,
));
cursor += child_main.max(0.0) + element.style.gap;
}
children
}
fn intrinsic_main_size(
child: &Element,
direction: FlexDirection,
@@ -1436,6 +1675,7 @@ struct LayoutCacheKey {
subtree_hash: u64,
avail_width_bits: u32,
avail_height_bits: u32,
clip_rect_bits: Option<(u32, u32, u32, u32)>,
}
struct CachedLayout {
@@ -1497,6 +1737,16 @@ fn rects_overlap(a: Rect, b: Rect) -> bool {
&& a.origin.y + a.size.height > b.origin.y
}
fn cache_clip_rect_bits(rect: Rect, clip_rect: Option<Rect>) -> Option<(u32, u32, u32, u32)> {
let clip = clip_rect?;
Some((
(clip.origin.x - rect.origin.x).round().to_bits(),
(clip.origin.y - rect.origin.y).round().to_bits(),
clip.size.width.round().to_bits(),
clip.size.height.round().to_bits(),
))
}
fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage {
let source_size = image.resource.size();
let source_aspect = if source_size.height > 0.0 {
@@ -1929,7 +2179,9 @@ fn child_rect(
#[cfg(test)]
mod tests {
use super::{LayoutCache, layout_scene, layout_snapshot, layout_snapshot_with_cache};
use super::{
LayoutCache, LayoutSnapshot, layout_scene, layout_snapshot, layout_snapshot_with_cache,
};
use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
use crate::text::TextSystem;
use crate::text::{TextStyle, TextWrap};
@@ -2625,4 +2877,142 @@ mod tests {
"expected >= 4 text items visible, got {text_items}"
);
}
#[test]
fn cached_scroll_container_recomputes_visible_children_after_offset_changes() {
fn scroll_root(offset_y: f32) -> Element {
let mut list = Element::column().gap(0.0);
for i in 0..20 {
list = list.child(
Element::new()
.height(40.0)
.child(Element::text(format!("item {i:02}"), text_style())),
);
}
Element::new().child(
Element::scroll_box(offset_y)
.width(400.0)
.height(200.0)
.child(list),
)
}
fn visible_texts(snapshot: &LayoutSnapshot) -> Vec<&str> {
snapshot
.scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect()
}
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
let size = UiSize::new(400.0, 200.0);
let first = layout_snapshot_with_cache(
1,
size,
&scroll_root(0.0),
&mut text_system,
&mut layout_cache,
);
let second = layout_snapshot_with_cache(
2,
size,
&scroll_root(240.0),
&mut text_system,
&mut layout_cache,
);
let first_visible = visible_texts(&first);
let second_visible = visible_texts(&second);
assert!(first_visible.iter().any(|text| text.contains("item 00")));
assert!(
second_visible.iter().any(|text| text.contains("item 06")),
"expected scrolled snapshot to include newly visible rows, got {second_visible:?}"
);
assert!(
!second_visible.iter().any(|text| text.contains("item 00")),
"expected initial rows to be culled after scrolling, got {second_visible:?}"
);
}
#[test]
fn culled_container_keeps_descendant_geometry_for_scroll_into_view() {
let target_id = ElementId::new(42);
let root = Element::new().child(
Element::scroll_box(0.0).width(400.0).height(120.0).child(
Element::column()
.child(
Element::column()
.height(160.0)
.child(Element::text("visible group", text_style())),
)
.child(
Element::column().height(160.0).child(
Element::new()
.id(target_id)
.height(40.0)
.child(Element::text("offscreen target", text_style())),
),
),
),
);
let snapshot = layout_snapshot(1, UiSize::new(400.0, 120.0), &root);
assert!(
snapshot
.scene
.items
.iter()
.all(|item| !matches!(item, DisplayItem::Text(text) if text.text.contains("offscreen target"))),
"offscreen target text should still be scene-culled"
);
assert!(
snapshot
.interaction_tree
.rect_for_element(target_id)
.is_some(),
"offscreen descendants must keep geometry for widget-ref scrolling"
);
}
#[test]
fn scroll_culling_keeps_visible_text_selectable_inside_viewport_clip() {
let text_id = ElementId::new(77);
let root = Element::new().child(
Element::scroll_box(120.0).width(360.0).height(120.0).child(
Element::paragraph(
"line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09",
text_style().with_line_height(20.0),
)
.id(text_id),
),
);
let snapshot = layout_snapshot(1, UiSize::new(360.0, 120.0), &root);
let text = snapshot
.interaction_tree
.text_for_element(text_id)
.expect("selectable text should remain in the interaction tree");
let visible_hit = snapshot
.interaction_tree
.text_hit_test(Point::new(8.0, 8.0))
.expect("visible scrolled text should be selectable");
assert_eq!(text.element_id, Some(text_id));
assert_eq!(visible_hit.target.element_id, Some(text_id));
assert!(
snapshot
.interaction_tree
.text_hit_test(Point::new(8.0, 160.0))
.is_none(),
"text hit testing should still honor the scroll viewport clip"
);
}
}

View File

@@ -9,6 +9,7 @@ pub(crate) mod trace_targets {
#![allow(dead_code)]
pub const PLATFORM: &str = "ruin_ui::platform";
pub const SCENE: &str = "ruin_ui::scene";
#[allow(dead_code)]
pub const TEXT_PERF: &str = "ruin_ui::text_perf";
}
@@ -37,7 +38,7 @@ pub use layout::{
pub use layout::{layout_scene, layout_scene_with_text_system};
pub use platform::{
PlatformClosed, PlatformEndpoint, PlatformEvent, PlatformProxy, PlatformRequest,
PlatformRuntime, start_headless,
PlatformRuntime, set_command_wake_hook, start_headless,
};
pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
pub use scene::{

View File

@@ -4,8 +4,8 @@ use std::cell::RefCell;
use std::collections::BTreeMap;
use std::fmt;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use ruin_runtime::channel::mpsc;
use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker};
@@ -18,6 +18,13 @@ use crate::trace_targets;
use crate::tree::CursorIcon;
use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate};
type CommandWakeHook = Arc<dyn Fn() + Send + Sync + 'static>;
fn command_wake_hook() -> &'static Mutex<Option<CommandWakeHook>> {
static HOOK: OnceLock<Mutex<Option<CommandWakeHook>>> = OnceLock::new();
HOOK.get_or_init(|| Mutex::new(None))
}
#[derive(Clone)]
pub struct PlatformProxy {
command_tx: mpsc::UnboundedSender<PlatformRequest>,
@@ -32,7 +39,7 @@ pub struct PlatformProxy {
pub struct PlatformRuntime {
proxy: PlatformProxy,
events: mpsc::Receiver<PlatformEvent>,
_worker: WorkerHandle,
worker: Option<WorkerHandle>,
}
pub struct PlatformEndpoint {
@@ -73,6 +80,7 @@ pub enum PlatformEvent {
window_id: WindowId,
text: String,
},
#[cfg(target_os = "linux")]
PrimarySelectionText {
window_id: WindowId,
text: String,
@@ -118,10 +126,12 @@ pub enum PlatformRequest {
RequestClipboardText {
window_id: WindowId,
},
#[cfg(target_os = "linux")]
SetPrimarySelectionText {
window_id: WindowId,
text: String,
},
#[cfg(target_os = "linux")]
RequestPrimarySelectionText {
window_id: WindowId,
},
@@ -203,7 +213,30 @@ impl PlatformRuntime {
Self {
proxy,
events: event_rx,
_worker: worker,
worker: Some(worker),
}
}
/// Creates a platform runtime hosted by the current runtime thread.
///
/// This is useful for backends that must run on a specific host thread
/// (for example, AppKit on macOS main thread).
pub fn custom_local(start: impl FnOnce(PlatformEndpoint)) -> Self {
let (command_tx, command_rx) = mpsc::unbounded_channel::<PlatformRequest>();
let (event_tx, event_rx) = mpsc::unbounded_channel::<PlatformEvent>();
let proxy = PlatformProxy {
command_tx,
next_window_id: Arc::new(AtomicU64::new(1)),
};
start(PlatformEndpoint {
commands: command_rx,
events: event_tx,
});
Self {
proxy,
events: event_rx,
worker: None,
}
}
@@ -216,6 +249,7 @@ impl PlatformRuntime {
}
pub fn take_pending_events(&mut self) -> Vec<PlatformEvent> {
let _ = self.worker.as_ref();
let mut events = Vec::new();
while let Ok(event) = self.events.try_recv() {
events.push(event);
@@ -247,6 +281,7 @@ impl PlatformProxy {
self.send(PlatformRequest::ReplaceScene { window_id, scene })
}
#[cfg(target_os = "linux")]
pub fn set_primary_selection_text(
&self,
window_id: WindowId,
@@ -258,6 +293,7 @@ impl PlatformProxy {
})
}
#[cfg(target_os = "linux")]
pub fn request_primary_selection_text(
&self,
window_id: WindowId,
@@ -317,7 +353,20 @@ impl PlatformProxy {
}
fn send(&self, command: PlatformRequest) -> Result<(), PlatformClosed> {
self.command_tx.send(command).map_err(|_| PlatformClosed)
self.command_tx.send(command).map_err(|_| PlatformClosed)?;
if let Ok(hook) = command_wake_hook().lock()
&& let Some(hook) = hook.as_ref().cloned()
{
hook();
}
Ok(())
}
}
#[doc(hidden)]
pub fn set_command_wake_hook(hook: Option<CommandWakeHook>) {
if let Ok(mut slot) = command_wake_hook().lock() {
*slot = hook;
}
}
@@ -356,7 +405,9 @@ pub fn start_headless() -> PlatformRuntime {
}
PlatformRequest::SetClipboardText { .. } => {}
PlatformRequest::RequestClipboardText { .. } => {}
#[cfg(target_os = "linux")]
PlatformRequest::SetPrimarySelectionText { .. } => {}
#[cfg(target_os = "linux")]
PlatformRequest::RequestPrimarySelectionText { .. } => {}
PlatformRequest::SetCursorIcon { .. } => {}
PlatformRequest::EmitCloseRequested { window_id } => {

View File

@@ -110,6 +110,7 @@ impl WindowController {
}
/// Copies plain text to the platform primary-selection buffer for this window.
#[cfg(target_os = "linux")]
pub fn set_primary_selection_text(
&self,
text: impl Into<String>,
@@ -128,6 +129,7 @@ impl WindowController {
}
/// Requests the current plain-text primary selection contents from the platform.
#[cfg(target_os = "linux")]
pub fn request_primary_selection_text(&self) -> Result<(), PlatformClosed> {
self.proxy.request_primary_selection_text(self.id)
}

View File

@@ -10,8 +10,9 @@ use cosmic_text::{
};
use fontconfig::Fontconfig;
use crate::{Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, TextLayoutData,
UiSize};
use crate::{
Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, TextLayoutData, UiSize,
};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TextAlign {
@@ -443,7 +444,11 @@ impl TextSystem {
// For no-wrap + start-aligned text, width doesn't affect shaping.
// Pass None so cosmic-text doesn't truncate and the result is
// consistent with the width-independent cache key.
let effective_width = if width_affects_layout(style) { width } else { None };
let effective_width = if width_affects_layout(style) {
width
} else {
None
};
borrowed.set_size(effective_width, None);
let default_attrs = default_attrs_for_style(style, default_family.as_deref());
if uses_plain_text_fast_path {
@@ -495,9 +500,7 @@ impl TextSystem {
// is color-independent. Per-span colors (from glyph.color_opt)
// are stored directly since they are part of the shape key via
// the spans hash.
color: glyph
.color_opt
.map_or(Color::SENTINEL, color_from_cosmic),
color: glyph.color_opt.map_or(Color::SENTINEL, color_from_cosmic),
cache_key: Some(physical.cache_key),
text_start: line_offset + glyph.start,
text_end: line_offset + glyph.end,
@@ -930,7 +933,9 @@ impl std::hash::Hash for TextStyle {
self.line_height.to_bits().hash(state);
self.color.hash(state);
self.font_family.hash(state);
self.bounds.map(|b| (b.width.to_bits(), b.height.to_bits())).hash(state);
self.bounds
.map(|b| (b.width.to_bits(), b.height.to_bits()))
.hash(state);
self.wrap.hash(state);
self.align.hash(state);
self.max_lines.hash(state);
@@ -1038,16 +1043,32 @@ mod tests {
let blue = text_system.prepare("hello", origin, &style_blue);
// Both PreparedTexts must have identical glyph shapes and sentinel colors —
// the second call hits the shape cache and produces the same layout data.
assert_eq!(red.glyphs.len(), blue.glyphs.len(), "glyph count must match");
assert_eq!(
red.glyphs.len(),
blue.glyphs.len(),
"glyph count must match"
);
for (r, b) in red.glyphs.iter().zip(blue.glyphs.iter()) {
assert_eq!(r.position, b.position, "glyph positions must match");
assert_eq!(r.cache_key, b.cache_key, "glyph cache keys must match");
assert_eq!(r.color, Color::SENTINEL, "default-color glyphs must use SENTINEL");
assert_eq!(b.color, Color::SENTINEL, "default-color glyphs must use SENTINEL");
assert_eq!(
r.color,
Color::SENTINEL,
"default-color glyphs must use SENTINEL"
);
assert_eq!(
b.color,
Color::SENTINEL,
"default-color glyphs must use SENTINEL"
);
}
// PreparedText::clone() is an Arc clone — O(1).
let cloned = red.clone();
assert_eq!(cloned.layout_ptr(), red.layout_ptr(), "clone must share the same Arc");
assert_eq!(
cloned.layout_ptr(),
red.layout_ptr(),
"clone must share the same Arc"
);
// But they should carry different default colors.
assert_eq!(red.default_color, Color::rgb(0xFF, 0x00, 0x00));
assert_eq!(blue.default_color, Color::rgb(0x00, 0x00, 0xFF));

View File

@@ -34,6 +34,11 @@ pub enum WindowLifecycle {
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WindowConfigured {
/// Current logical content size and platform presentation scale.
///
/// Backends should emit a new configuration when either the logical size or
/// scale changes. This lets hosts relayout for size changes while renderers
/// can update drawable/text scale when a window moves between displays.
pub actual_inner_size: UiSize,
pub scale_factor: f32,
pub visible: bool,

View File

@@ -0,0 +1,17 @@
[package]
name = "ruin_ui_platform_macos"
version = "0.1.0"
edition = "2024"
[dependencies]
libc = "0.2"
objc2 = "0.6.4"
objc2-core-foundation = "0.3.2"
objc2-foundation = "0.3.2"
objc2-quartz-core = "0.3.2"
raw-window-handle = "0.6"
raw-window-metal = "1.1.0"
ruin_ui = { path = "../ui" }
ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
tracing = "0.1"

File diff suppressed because it is too large Load Diff

View File

@@ -40,13 +40,13 @@ use wayland_client::{
use wayland_protocols::wp::cursor_shape::v1::client::{
wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1,
};
use wayland_protocols::xdg::decoration::zv1::client::{
zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
};
use wayland_protocols::wp::primary_selection::zv1::client::{
zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1,
zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1,
};
use wayland_protocols::xdg::decoration::zv1::client::{
zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
};
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
use xkbcommon::xkb;
@@ -242,7 +242,10 @@ fn fixed_size_request_for_spec(spec: &WindowSpec) -> Option<(u32, u32)> {
.or(spec.min_inner_size)
.or(spec.max_inner_size)
.unwrap_or_else(|| UiSize::new(800.0, 500.0));
Some((size.width.max(1.0).round() as u32, size.height.max(1.0).round() as u32))
Some((
size.width.max(1.0).round() as u32,
size.height.max(1.0).round() as u32,
))
}
fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape {
@@ -1352,7 +1355,9 @@ fn spawn_window_worker(
"worker observed resized frame"
);
state_ref.pending_viewport = Some(current_viewport);
state_ref.pending_viewport_since.get_or_insert_with(Instant::now);
state_ref
.pending_viewport_since
.get_or_insert_with(Instant::now);
// Emit configure BEFORE touching the GPU so the app
// thread starts layout immediately in parallel.
// The actual swapchain recreation is deferred to just
@@ -1397,7 +1402,9 @@ fn spawn_window_worker(
"scene size does not match current viewport; holding last buffer"
);
state_ref.pending_viewport = Some(current_viewport);
state_ref.pending_viewport_since.get_or_insert_with(Instant::now);
state_ref
.pending_viewport_since
.get_or_insert_with(Instant::now);
// Deadlock prevention: if the app responded to the
// configure we sent (scene matches in_flight) but the
// viewport has since moved on, clear the in_flight
@@ -1421,7 +1428,9 @@ fn spawn_window_worker(
// before rendering. Deferring from frame.resized means
// rapid configures pay one recreation instead of one
// per event.
if let Some((w, h)) = state_ref.pending_swapchain_size.take() {
if let Some((w, h)) =
state_ref.pending_swapchain_size.take()
{
let t_resize = std::time::Instant::now();
state_ref.renderer.resize(w, h);
let resize_gpu_us = t_resize.elapsed().as_micros();
@@ -1438,7 +1447,8 @@ fn spawn_window_worker(
let t_render = std::time::Instant::now();
match state_ref.renderer.render(scene) {
Ok(()) => {
let render_gpu_us = t_render.elapsed().as_micros();
let render_gpu_us =
t_render.elapsed().as_micros();
debug!(
target: "ruin_ui_platform_wayland::perf",
window_id = state_ref.window_id.raw(),
@@ -1520,10 +1530,10 @@ fn spawn_window_worker(
{
let delay =
repeat.next_at.saturating_duration_since(Instant::now());
state_ref.keyboard_repeat_timer = Some(set_timeout(
delay,
move || write_wakeup(pipe_write_fd),
));
state_ref.keyboard_repeat_timer =
Some(set_timeout(delay, move || {
write_wakeup(pipe_write_fd)
}));
}
}
}

View File

@@ -91,7 +91,8 @@ struct PreparedGlyphBitmap {
struct RasterizedText {
origin_offset: Point,
size: UiSize,
draw_size: UiSize,
texture_size: UiSize,
pixels: Vec<u8>,
}
@@ -169,7 +170,8 @@ struct UploadedAtlasText {
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct TextTextureKey {
text: String,
bounds: Option<(u32, u32)>,
bounds: Option<(i32, i32)>,
clip: Option<(i32, i32, i32, i32)>,
font_size_bits: u32,
line_height_bits: u32,
color: (u8, u8, u8, u8),
@@ -330,6 +332,7 @@ pub struct WgpuSceneRenderer {
image_cache_order: VecDeque<u64>,
#[allow(dead_code)]
glyph_atlas: GlyphAtlas,
scale_factor: f32,
base_color: Color,
}
@@ -377,8 +380,11 @@ impl WgpuSceneRenderer {
format,
width: width.max(1),
height: height.max(1),
present_mode: wgpu::PresentMode::AutoVsync,
desired_maximum_frame_latency: 2,
// RUIN prioritizes immediate resize/input feedback over frame pacing
// here. Keep latency to one frame and let callers opt into a more
// power-conservative policy once presentation settings are exposed.
present_mode: wgpu::PresentMode::AutoNoVsync,
desired_maximum_frame_latency: 1,
alpha_mode,
view_formats: vec![],
};
@@ -721,6 +727,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
image_cache: HashMap::new(),
image_cache_order: VecDeque::new(),
glyph_atlas,
scale_factor: 1.0,
base_color,
})
}
@@ -735,9 +742,33 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
self.surface.configure(&self.device, &self.config);
}
pub fn set_scale_factor(&mut self, scale_factor: f32) {
let next = scale_factor.max(1.0);
if (self.scale_factor - next).abs() <= f32::EPSILON {
return;
}
self.scale_factor = next;
// Text cache is scale-dependent; flush on scale transitions (e.g. display move).
self.text_cache.clear();
self.text_cache_order.clear();
self.glyph_atlas = create_glyph_atlas(
&self.device,
&self.text_bind_group_layout,
&self.text_sampler,
);
}
pub fn render(&mut self, scene: &SceneSnapshot) -> Result<(), RenderError> {
self.render_with_logical_size(scene, scene.logical_size)
}
pub fn render_with_logical_size(
&mut self,
scene: &SceneSnapshot,
logical_size: UiSize,
) -> Result<(), RenderError> {
let render_start = std::time::Instant::now();
let vertices = build_vertices(scene);
let vertices = build_vertices(scene, logical_size);
let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(frame)
| wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
@@ -761,7 +792,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
let mut uploaded_images = Vec::new();
let mut uploaded_texts = Vec::new();
let mut atlas_perf = AtlasTextPerfStats::default();
let uploaded_atlas_text = self.prepare_uploaded_atlas_text(scene, &mut atlas_perf);
let uploaded_atlas_text =
self.prepare_uploaded_atlas_text(scene, logical_size, &mut atlas_perf);
let mut clip_stack = Vec::new();
let mut active_clip = ActiveClip::default();
for item in &scene.items {
@@ -772,7 +804,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
DisplayItem::PopClip => pop_clip_state(&mut clip_stack, &mut active_clip),
DisplayItem::Image(image) => {
if let Some(uploaded) =
self.prepare_uploaded_image(image, scene.logical_size, active_clip)
self.prepare_uploaded_image(image, logical_size, active_clip)
{
uploaded_images.push(uploaded);
}
@@ -782,7 +814,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
continue;
}
if let Some(uploaded) =
self.prepare_uploaded_text(text, scene.logical_size, active_clip)
self.prepare_uploaded_text(text, logical_size, active_clip)
{
uploaded_texts.push(uploaded);
}
@@ -871,21 +903,29 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
Ok(())
}
fn rasterize_text(&mut self, text: &PreparedText) -> Option<RasterizedText> {
if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) {
return self.rasterize_prepared_text(text);
fn rasterize_text(
&mut self,
text: &PreparedText,
raster_clip: Option<Rect>,
) -> Option<RasterizedText> {
if (self.scale_factor - 1.0).abs() <= f32::EPSILON
&& text.glyphs.iter().all(|glyph| glyph.cache_key.is_some())
{
return self.rasterize_prepared_text(text, raster_clip);
}
let glyphs = self.collect_glyph_bitmaps(text);
let clip = match text.bounds {
Some(bounds) => PixelRect {
let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, self.scale_factor));
let glyphs = self.collect_glyph_bitmaps(text, clip);
let clip = clip
.or_else(|| {
text.bounds.map(|bounds| PixelRect {
left: 0,
top: 0,
right: bounds.width.ceil() as i32,
bottom: bounds.height.ceil() as i32,
},
None => glyph_union(&glyphs)?,
};
right: (bounds.width * self.scale_factor).ceil() as i32,
bottom: (bounds.height * self.scale_factor).ceil() as i32,
})
})
.or_else(|| glyph_union(&glyphs))?;
if clip.width() == 0 || clip.height() == 0 {
return None;
}
@@ -896,23 +936,36 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
}
Some(RasterizedText {
origin_offset: Point::new(clip.left as f32, clip.top as f32),
size: UiSize::new(clip.width() as f32, clip.height() as f32),
origin_offset: Point::new(
clip.left as f32 / self.scale_factor,
clip.top as f32 / self.scale_factor,
),
draw_size: UiSize::new(
clip.width() as f32 / self.scale_factor,
clip.height() as f32 / self.scale_factor,
),
texture_size: UiSize::new(clip.width() as f32, clip.height() as f32),
pixels,
})
}
fn rasterize_prepared_text(&mut self, text: &PreparedText) -> Option<RasterizedText> {
fn rasterize_prepared_text(
&mut self,
text: &PreparedText,
raster_clip: Option<Rect>,
) -> Option<RasterizedText> {
let glyphs = self.collect_prepared_glyphs(text);
let clip = match text.bounds {
Some(bounds) => PixelRect {
let clip = raster_clip
.map(rect_to_pixel_rect)
.or_else(|| {
text.bounds.map(|bounds| PixelRect {
left: 0,
top: 0,
right: bounds.width.ceil() as i32,
bottom: bounds.height.ceil() as i32,
},
None => glyph_union_prepared(&glyphs)?,
};
})
})
.or_else(|| glyph_union_prepared(&glyphs))?;
if clip.width() == 0 || clip.height() == 0 {
return None;
}
@@ -931,7 +984,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
Some(RasterizedText {
origin_offset: Point::new(clip.left as f32, clip.top as f32),
size: UiSize::new(clip.width() as f32, clip.height() as f32),
draw_size: UiSize::new(clip.width() as f32, clip.height() as f32),
texture_size: UiSize::new(clip.width() as f32, clip.height() as f32),
pixels,
})
}
@@ -972,13 +1026,20 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
glyphs
}
fn collect_glyph_bitmaps(&mut self, text: &PreparedText) -> Vec<GlyphBitmap> {
let mut buffer = Buffer::new_empty(Metrics::new(text.font_size, text.line_height));
fn collect_glyph_bitmaps(
&mut self,
text: &PreparedText,
raster_clip: Option<PixelRect>,
) -> Vec<GlyphBitmap> {
let mut buffer = Buffer::new_empty(Metrics::new(
text.font_size * self.scale_factor,
text.line_height * self.scale_factor,
));
{
let mut borrowed = buffer.borrow_with(&mut self.font_system);
borrowed.set_size(
text.bounds.map(|bounds| bounds.width),
text.bounds.map(|bounds| bounds.height),
text.bounds.map(|bounds| bounds.width * self.scale_factor),
text.bounds.map(|bounds| bounds.height * self.scale_factor),
);
borrowed.set_text(&text.text, &Attrs::new(), Shaping::Advanced, None);
}
@@ -999,13 +1060,17 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
if width == 0 || height == 0 {
continue;
}
glyphs.push(GlyphBitmap {
rect: PixelRect {
let rect = PixelRect {
left: physical.x + image.placement.left,
top: physical.y - image.placement.top,
right: physical.x + image.placement.left + width,
bottom: physical.y - image.placement.top + height,
},
};
if raster_clip.is_some_and(|clip| !pixel_rects_intersect(rect, clip)) {
continue;
}
glyphs.push(GlyphBitmap {
rect,
content: image.content,
color: glyph
.color_opt
@@ -1020,6 +1085,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
fn prepare_uploaded_atlas_text(
&mut self,
scene: &SceneSnapshot,
logical_size: UiSize,
perf: &mut AtlasTextPerfStats,
) -> Option<UploadedAtlasText> {
let mut vertices = Vec::new();
@@ -1053,7 +1119,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
if active_clip.rect_active && logical_clip_rect.is_none() {
continue;
}
let clip_rect = logical_clip_rect.map(rect_to_pixel_rect);
let clip_rect = logical_clip_rect
.map(|rect| scale_rect_to_pixel_rect(rect, self.scale_factor));
// Only iterate glyphs whose lines fall within the clip rect.
// For large text nodes (e.g. full Cargo.lock) this reduces
@@ -1064,7 +1131,10 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
perf.glyphs_clip_culled += (all_count - visible.len()) as u32;
for glyph in visible {
let Some(cache_key) = glyph.cache_key else {
let Some(cache_key) = glyph
.cache_key
.map(|cache_key| scale_glyph_cache_key(cache_key, self.scale_factor))
else {
continue;
};
let resolved_color = resolve_glyph_color(glyph.color, text.default_color);
@@ -1073,9 +1143,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
continue;
};
// Glyph positions are LOCAL; add text.origin for absolute screen coords.
let abs_x = (text.origin.x + glyph.position.x).round() as i32;
let abs_y = (text.origin.y + glyph.position.y).round() as i32;
// Atlas glyphs are rasterized in device pixels while scene geometry remains
// logical. Build and clip in device space, then convert back for vertices.
let abs_x =
((text.origin.x + glyph.position.x) * self.scale_factor).round() as i32;
let abs_y =
((text.origin.y + glyph.position.y) * self.scale_factor).round() as i32;
let glyph_rect = PixelRect {
left: abs_x + atlas_glyph.placement_left,
top: abs_y - atlas_glyph.placement_top,
@@ -1090,7 +1163,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
glyph_rect,
atlas_glyph.atlas_rect,
clip_rect,
scene.logical_size,
logical_size,
self.scale_factor,
resolved_color,
active_clip,
);
@@ -1263,9 +1337,10 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
logical_size: UiSize,
clip: ActiveClip,
) -> Option<UploadedText> {
let key = text_texture_key(text);
let raster_clip = text_raster_clip(text, clip, logical_size)?;
let key = text_texture_key(text, raster_clip, self.scale_factor);
if !self.text_cache.contains_key(&key) {
let rasterized = self.rasterize_text(text)?;
let rasterized = self.rasterize_text(text, raster_clip)?;
let cached = self.create_cached_text_texture(&rasterized);
self.text_cache.insert(key.clone(), cached);
}
@@ -1333,8 +1408,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
let texture = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("ruin-ui-renderer-wgpu-texture"),
size: wgpu::Extent3d {
width: text.size.width as u32,
height: text.size.height as u32,
width: text.texture_size.width as u32,
height: text.texture_size.height as u32,
depth_or_array_layers: 1,
},
mip_level_count: 1,
@@ -1354,12 +1429,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
&text.pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(text.size.width as u32 * 4),
rows_per_image: Some(text.size.height as u32),
bytes_per_row: Some(text.texture_size.width as u32 * 4),
rows_per_image: Some(text.texture_size.height as u32),
},
wgpu::Extent3d {
width: text.size.width as u32,
height: text.size.height as u32,
width: text.texture_size.width as u32,
height: text.texture_size.height as u32,
depth_or_array_layers: 1,
},
);
@@ -1382,7 +1457,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
_texture: texture,
bind_group,
origin_offset: text.origin_offset,
size: text.size,
size: text.draw_size,
}
}
@@ -1820,6 +1895,7 @@ fn push_glyph_vertices(
atlas_rect: AtlasRect,
clip_rect: Option<PixelRect>,
logical_size: UiSize,
scale_factor: f32,
color: Color,
clip: ActiveClip,
) {
@@ -1827,10 +1903,15 @@ fn push_glyph_vertices(
return;
};
let left = to_ndc_x(dest_rect.left as f32, logical_size.width.max(1.0));
let right = to_ndc_x(dest_rect.right as f32, logical_size.width.max(1.0));
let top = to_ndc_y(dest_rect.top as f32, logical_size.height.max(1.0));
let bottom = to_ndc_y(dest_rect.bottom as f32, logical_size.height.max(1.0));
let scale_factor = scale_factor.max(1.0);
let logical_left = dest_rect.left as f32 / scale_factor;
let logical_top = dest_rect.top as f32 / scale_factor;
let logical_right = dest_rect.right as f32 / scale_factor;
let logical_bottom = dest_rect.bottom as f32 / scale_factor;
let left = to_ndc_x(logical_left, logical_size.width.max(1.0));
let right = to_ndc_x(logical_right, logical_size.width.max(1.0));
let top = to_ndc_y(logical_top, logical_size.height.max(1.0));
let bottom = to_ndc_y(logical_bottom, logical_size.height.max(1.0));
let color = color_to_f32(color);
let clip_rect = clip_rect_array(clip);
@@ -1838,7 +1919,7 @@ fn push_glyph_vertices(
vertices.extend_from_slice(&[
TextVertex {
position: [left, top],
world_position: [dest_rect.left as f32, dest_rect.top as f32],
world_position: [logical_left, logical_top],
uv: [uv_rect.0, uv_rect.1],
color,
clip_rect,
@@ -1847,7 +1928,7 @@ fn push_glyph_vertices(
},
TextVertex {
position: [left, bottom],
world_position: [dest_rect.left as f32, dest_rect.bottom as f32],
world_position: [logical_left, logical_bottom],
uv: [uv_rect.0, uv_rect.3],
color,
clip_rect,
@@ -1856,7 +1937,7 @@ fn push_glyph_vertices(
},
TextVertex {
position: [right, top],
world_position: [dest_rect.right as f32, dest_rect.top as f32],
world_position: [logical_right, logical_top],
uv: [uv_rect.2, uv_rect.1],
color,
clip_rect,
@@ -1865,7 +1946,7 @@ fn push_glyph_vertices(
},
TextVertex {
position: [right, top],
world_position: [dest_rect.right as f32, dest_rect.top as f32],
world_position: [logical_right, logical_top],
uv: [uv_rect.2, uv_rect.1],
color,
clip_rect,
@@ -1874,7 +1955,7 @@ fn push_glyph_vertices(
},
TextVertex {
position: [left, bottom],
world_position: [dest_rect.left as f32, dest_rect.bottom as f32],
world_position: [logical_left, logical_bottom],
uv: [uv_rect.0, uv_rect.3],
color,
clip_rect,
@@ -1883,7 +1964,7 @@ fn push_glyph_vertices(
},
TextVertex {
position: [right, bottom],
world_position: [dest_rect.right as f32, dest_rect.bottom as f32],
world_position: [logical_right, logical_bottom],
uv: [uv_rect.2, uv_rect.3],
color,
clip_rect,
@@ -1902,9 +1983,9 @@ fn color_to_f32(color: Color) -> [f32; 4] {
]
}
fn build_vertices(scene: &SceneSnapshot) -> Vec<Vertex> {
let width = scene.logical_size.width.max(1.0);
let height = scene.logical_size.height.max(1.0);
fn build_vertices(scene: &SceneSnapshot, logical_size: UiSize) -> Vec<Vertex> {
let width = logical_size.width.max(1.0);
let height = logical_size.height.max(1.0);
let mut vertices = Vec::new();
let mut clip_stack = Vec::new();
let mut active_clip = ActiveClip::default();
@@ -2166,6 +2247,22 @@ fn rect_to_pixel_rect(rect: Rect) -> PixelRect {
}
}
fn scale_rect_to_pixel_rect(rect: Rect, scale: f32) -> PixelRect {
PixelRect {
left: (rect.origin.x * scale).floor() as i32,
top: (rect.origin.y * scale).floor() as i32,
right: ((rect.origin.x + rect.size.width) * scale).ceil() as i32,
bottom: ((rect.origin.y + rect.size.height) * scale).ceil() as i32,
}
}
fn pixel_rects_intersect(first: PixelRect, second: PixelRect) -> bool {
first.left < second.right
&& first.right > second.left
&& first.top < second.bottom
&& first.bottom > second.top
}
fn shadow_blur_extent(blur: f32) -> f32 {
blur.max(0.0) * 2.0
}
@@ -2225,6 +2322,33 @@ fn intersect_rects(first: Option<Rect>, second: Option<Rect>) -> Option<Rect> {
}
}
fn text_raster_clip(
text: &PreparedText,
clip: ActiveClip,
logical_size: UiSize,
) -> Option<Option<Rect>> {
let mut absolute_clip = Some(Rect::new(0.0, 0.0, logical_size.width, logical_size.height));
if clip.rect_active {
absolute_clip = intersect_rects(absolute_clip, Some(clip.resolved_rect()?));
}
if let Some(bounds) = text.bounds {
let text_bounds = Rect::new(text.origin.x, text.origin.y, bounds.width, bounds.height);
absolute_clip = intersect_rects(absolute_clip, Some(text_bounds));
}
if absolute_clip.is_none() {
return None;
}
Some(absolute_clip.map(|rect| {
Rect::new(
rect.origin.x - text.origin.x,
rect.origin.y - text.origin.y,
rect.size.width,
rect.size.height,
)
}))
}
fn empty_clip_rect() -> Rect {
Rect::new(1.0, 1.0, -1.0, -1.0)
}
@@ -2347,12 +2471,21 @@ fn clip_visible_glyphs(text: &PreparedText, clip: Option<Rect>) -> &[GlyphInstan
&text.glyphs[glyph_start..glyph_end.min(text.glyphs.len())]
}
fn text_texture_key(text: &PreparedText) -> TextTextureKey {
fn text_texture_key(
text: &PreparedText,
raster_clip: Option<Rect>,
scale_factor: f32,
) -> TextTextureKey {
TextTextureKey {
text: text.text.clone(),
bounds: text
.bounds
.map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())),
bounds: text.bounds.map(|bounds| {
(
(bounds.width * scale_factor).ceil() as i32,
(bounds.height * scale_factor).ceil() as i32,
)
}),
clip: raster_clip
.map(|clip| pixel_rect_tuple(scale_rect_to_pixel_rect(clip, scale_factor))),
font_size_bits: text.font_size.to_bits(),
line_height_bits: text.line_height.to_bits(),
color: (
@@ -2376,12 +2509,26 @@ fn text_texture_key(text: &PreparedText) -> TextTextureKey {
}
}
fn pixel_rect_tuple(rect: PixelRect) -> (i32, i32, i32, i32) {
(rect.left, rect.top, rect.right, rect.bottom)
}
fn scale_glyph_cache_key(cache_key: CacheKey, scale_factor: f32) -> CacheKey {
CacheKey {
font_size_bits: (f32::from_bits(cache_key.font_size_bits) * scale_factor.max(1.0))
.to_bits(),
..cache_key
}
}
#[cfg(test)]
mod tests {
use super::{
ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array,
push_clip_state, rounded_clip_arrays, text_texture_key,
ActiveClip, AtlasRect, PixelRect, blend_rgba, build_text_vertices, build_vertices,
clip_rect_array, push_clip_state, push_glyph_vertices, rounded_clip_arrays,
scale_glyph_cache_key, text_texture_key,
};
use cosmic_text::{CacheKey, CacheKeyFlags, SubpixelBin, fontdb};
use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
#[test]
@@ -2403,7 +2550,7 @@ mod tests {
Color::rgb(0xFF, 0xFF, 0xFF),
));
let vertices = build_vertices(&scene);
let vertices = build_vertices(&scene, scene.logical_size);
assert_eq!(vertices.len(), 12);
}
@@ -2435,7 +2582,81 @@ mod tests {
Color::rgb(0xEE, 0xEE, 0xEE),
);
assert_eq!(text_texture_key(&first), text_texture_key(&second));
assert_eq!(
text_texture_key(&first, None, 1.0),
text_texture_key(&second, None, 1.0)
);
}
#[test]
fn text_texture_key_normalizes_clip_to_device_pixels() {
let text = PreparedText::monospace(
"scrolling text",
Point::new(20.0, 30.0),
16.0,
8.0,
Color::rgb(0xEE, 0xEE, 0xEE),
);
let first = text_texture_key(&text, Some(Rect::new(0.001, 4.001, 80.001, 20.001)), 2.0);
let second = text_texture_key(&text, Some(Rect::new(0.002, 4.002, 80.002, 20.002)), 2.0);
let different_pixel =
text_texture_key(&text, Some(Rect::new(0.6, 4.001, 80.001, 20.001)), 2.0);
assert_eq!(first, second);
assert_ne!(first, different_pixel);
}
#[test]
fn scale_glyph_cache_key_scales_font_size_only() {
let key = CacheKey {
font_id: fontdb::ID::dummy(),
glyph_id: 42,
font_size_bits: 13.0_f32.to_bits(),
x_bin: SubpixelBin::One,
y_bin: SubpixelBin::Two,
font_weight: fontdb::Weight::BOLD,
flags: CacheKeyFlags::FAKE_ITALIC,
};
let scaled = scale_glyph_cache_key(key, 2.0);
assert_eq!(f32::from_bits(scaled.font_size_bits), 26.0);
assert_eq!(scaled.font_id, key.font_id);
assert_eq!(scaled.glyph_id, key.glyph_id);
assert_eq!(scaled.x_bin, key.x_bin);
assert_eq!(scaled.y_bin, key.y_bin);
assert_eq!(scaled.font_weight, key.font_weight);
assert_eq!(scaled.flags, key.flags);
}
#[test]
fn scaled_atlas_vertices_convert_device_pixels_to_logical_space() {
let mut vertices = Vec::new();
push_glyph_vertices(
&mut vertices,
PixelRect {
left: 20,
top: 10,
right: 40,
bottom: 30,
},
AtlasRect {
x: 0,
y: 0,
width: 20,
height: 20,
},
None,
UiSize::new(100.0, 100.0),
2.0,
Color::rgb(0xFF, 0xFF, 0xFF),
ActiveClip::default(),
);
assert_eq!(vertices.len(), 6);
assert_eq!(vertices[0].world_position, [10.0, 5.0]);
assert_eq!(vertices[5].world_position, [20.0, 15.0]);
}
#[test]