blob: 38e92381838c47882b3bd807fd96092f402869b4 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Native editor protocol. Snapshots are observations, never writable buffers.
use crate::editor::*;
use crate::file::{FileCapture, FileChoose, FileObservation};
use maka_plugin_core::contract::{Capture, Contract, Interface, Transport};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
impl crate::editor::Props for TextareaProps {
fn command(&self) -> Option<&EditorCommand> {
self.command.as_ref()
}
fn set_command(&mut self, command: Option<EditorCommand>) {
self.command = command;
}
}
use std::collections::BTreeMap;
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextareaProps {
pub appearance: crate::appearance::TextareaAppearance,
/// Exact whitespace-delimited literals rendered and edited as atomic inline tokens.
#[serde(default)]
#[schemars(schema_with = "inline_tokens_schema")]
pub inline_tokens: Vec<String>,
#[serde(default)]
pub menu: Option<TextareaMenu>,
#[schemars(length(min = 1))]
pub accessibility_label: String,
pub read_only: bool,
pub disabled: bool,
pub soft_wrap: bool,
/// Preferred terminal text rows, excluding native padding. A constrained
/// viewport may shrink further.
#[serde(default)]
pub terminal_rows: TerminalTextareaRows,
/// None preserves native multiline editing without a submit shortcut.
#[serde(default)]
pub submit: Option<SubmitKey>,
/// Yield suggestion navigation only for the original observed editor state.
#[serde(default)]
pub navigation: Option<EditorNavigation>,
#[schemars(range(min = -1, max = 32767))]
pub tab_index: i16,
/// Offer native focus once when this original input becomes visible and
/// enabled. Stable updates and viewport scrolling do not repeat the offer.
#[serde(default)]
pub auto_focus: bool,
#[serde(default)]
pub initial_text: String,
#[serde(default)]
pub placeholder: String,
#[serde(default)]
/// None issues no new command; an accepted command remains pending until
/// its matching receipt commits. Clearing props is not cancellation.
pub command: Option<EditorCommand>,
/// Opt-in local file-path paste. The context is returned unchanged; it is
/// correlation, never a filesystem path or an authorization grant.
#[serde(default)]
pub file_paste: Option<FilePastePolicy>,
#[serde(default)]
pub file_command: Option<FilePasteCommand>,
#[serde(default)]
pub persistence: Option<crate::checkpoint::EditorPersistence>,
}
fn inline_tokens_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
serde_json::json!({"type":"array","items":{
"type":"string","minLength":1,"pattern":r"^\S+$"
}})
.try_into()
.expect("inline token schema")
}
/// Source byte spans for exact whole-word literals; no prefix matching.
pub fn inline_token_ranges(text: &str, literals: &[String]) -> Vec<std::ops::Range<usize>> {
if literals.is_empty() {
return Vec::new();
}
let literals: std::collections::BTreeSet<&str> = literals.iter().map(String::as_str).collect();
let mut ranges = Vec::new();
let mut start = 0;
for word in text.split_whitespace() {
let offset = text[start..].find(word).expect("word in source") + start;
if literals.contains(word) {
ranges.push(offset..offset + word.len());
}
start = offset + word.len();
}
if !ranges.is_empty() {
use unicode_segmentation::UnicodeSegmentation;
let mut boundaries = text
.grapheme_indices(true)
.map(|(offset, _)| offset)
.chain(std::iter::once(text.len()))
.peekable();
ranges.retain(|range| {
[range.start, range.end].into_iter().all(|offset| {
while boundaries.peek().is_some_and(|boundary| *boundary < offset) {
boundaries.next();
}
boundaries.peek() == Some(&offset)
})
});
}
ranges
}
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct FilePastePolicy {
#[schemars(length(min = 1))]
pub context: String,
/// Clipboard text remains available when file attachments are disabled.
pub allow_files: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FilePasteObservation {
#[schemars(length(min = 1))]
pub context: String,
pub selection: FileObservation,
/// Includes the currently reading, ready or failed item.
pub remaining: usize,
/// A failed original path can be reread. Clipboard reads require a new gesture.
pub retryable: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FilePasteCommand {
pub selection: FileCapture,
pub action: FilePasteAction,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum FilePasteAction {
/// Release this ready/failed item and continue the original physical paste.
Next,
/// Retry the original failed path, without a new path from the guest.
Retry,
/// Discard this batch; already captured resources retain their own custody.
Cancel,
}
#[derive(Clone)]
pub struct TextareaView {
pub props: TextareaProps,
pub file_request: Option<String>,
}
pub fn file_paste_schema() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(FilePasteObservation))
.expect("file paste observation")
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TerminalTextareaRows {
#[schemars(range(min = 1))]
pub min: u16,
#[schemars(range(min = 1))]
pub max: u16,
/// Allow dragging the lower-right grip to override preferred rows, up to
/// the containing viewport height. The minimum still applies.
#[serde(default)]
pub resizable: bool,
/// Optional cap relative to the nearest declared layout viewport's height,
/// or the surface when none is declared. The minimum still applies.
#[serde(default)]
#[schemars(range(min = 0, max = 1))]
pub max_viewport_fraction: Option<f64>,
}
impl Default for TerminalTextareaRows {
fn default() -> Self {
Self {
min: 3,
max: 8,
resizable: false,
max_viewport_fraction: None,
}
}
}
/// Local presentation for user-initiated native editing actions. This is not
/// a command interface: clipboard access still requires native activation.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TextareaMenu {
pub appearance: crate::appearance::MenuAppearance,
pub labels: TextareaMenuLabels,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextareaMenuLabels {
#[schemars(length(min = 1))]
pub title: String,
#[schemars(length(min = 1))]
pub undo: String,
#[schemars(length(min = 1))]
pub redo: String,
#[schemars(length(min = 1))]
pub cut: String,
#[schemars(length(min = 1))]
pub copy: String,
#[schemars(length(min = 1))]
pub paste: String,
#[schemars(length(min = 1))]
pub select_all: String,
}
/// Product-selected native submit gesture. PrimaryEnter uses the platform
/// Control+Enter gesture in a terminal.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SubmitKey {
Enter,
PrimaryEnter,
ControlEnter,
}
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EditorNavigation {
#[schemars(length(min = 1))]
pub owner: String,
#[schemars(range(max = 9007199254740991u64))]
pub based_on: u64,
}
impl EditorNavigation {
pub fn matches(&self, observation: &EditorObservation) -> bool {
self.owner == observation.snapshot.text.owner
&& self.based_on == observation.revision
&& observation.snapshot.marked.is_none()
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum EditorNavigationKey {
Up,
Down,
Enter,
Tab,
Escape,
}
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EditorNavigated {
pub key: EditorNavigationKey,
pub observation: EditorObservation,
}
pub fn navigation_schema() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(EditorNavigated)).expect("editor navigation schema")
}
/// Submit carries the same original editor observation as a change; it grants
/// no text read and adds no second buffer or command owner.
pub fn reduce(
call: crate::ComponentCall<'_>,
) -> crate::Result<crate::ComponentOutput<TextareaView>> {
let previous_persistence = match &call {
crate::ComponentCall::Mount { .. } => None,
crate::ComponentCall::Update { state, .. } | crate::ComponentCall::Event { state, .. } => {
Some(state["persistence"].clone())
}
};
let submitted = matches!(&call, crate::ComponentCall::Event { key, .. } if *key == "submit");
let mut file_request: Option<String> = match &call {
crate::ComponentCall::Mount { .. } => None,
crate::ComponentCall::Update { state, .. } | crate::ComponentCall::Event { state, .. } => {
serde_json::from_value(state["fileRequest"].clone()).expect("native file paste request")
}
};
let mut files = None;
let mut checkpoint = None;
let mut navigation = None;
let call = match call {
crate::ComponentCall::Event {
state,
props,
key: "navigate",
input,
origin,
} => {
if !matches!(origin, crate::EventOrigin::Raw) {
return Err(crate::Error::Invalid(
"editor navigation requires native origin".into(),
));
}
navigation = Some(input.clone());
crate::ComponentCall::Update { state, props }
}
crate::ComponentCall::Event {
state,
props,
key,
input,
origin,
} if matches!(key, "file_choose" | "file_observe") => {
if !matches!(origin, crate::EventOrigin::Raw) {
return Err(crate::Error::Invalid(
"file paste requires native origin".into(),
));
}
if key == "file_choose" {
let typed: TextareaProps = serde_json::from_value(props.clone())
.map_err(|error| crate::Error::Invalid(error.to_string()))?;
if typed.disabled || typed.read_only || typed.file_paste.is_none() {
return Err(crate::Error::Invalid("file paste disabled".into()));
}
let choose: FileChoose = serde_json::from_value(input.clone())
.map_err(|error| crate::Error::Invalid(error.to_string()))?;
file_request = Some(choose.request);
} else {
files = Some(input.clone());
}
crate::ComponentCall::Update { state, props }
}
crate::ComponentCall::Event {
state,
props,
key: "checkpoint",
input,
origin,
} => {
if !matches!(origin, crate::EventOrigin::Raw) {
return Err(crate::Error::Invalid(
"checkpoint observation requires native origin".into(),
));
}
checkpoint = Some(input.clone());
crate::ComponentCall::Update { state, props }
}
other => other,
};
let mut output = crate::editor::reduce::<TextareaProps>(call)?;
if let Some(policy) = &output.frame.view.persistence {
policy.validate()?;
}
let persistence = output
.frame
.view
.persistence
.as_ref()
.map(|policy| serde_json::json!([policy.key, policy.format, policy.data_source]));
let identity = serde_json::json!(persistence);
if previous_persistence
.as_ref()
.is_some_and(|previous| *previous != identity)
{
return Err(crate::Error::Invalid(
"editor persistence identity requires a new mount".into(),
));
}
output.frame.state["persistence"] = identity;
if output.frame.view.inline_tokens.iter().any(|literal| {
literal.is_empty() || literal.chars().any(|c| c.is_whitespace() || c.is_control())
}) {
return Err(crate::Error::Invalid(
"invalid inline token literals".into(),
));
}
let rows = output.frame.view.terminal_rows;
if rows.min == 0
|| rows.min > rows.max
|| rows
.max_viewport_fraction
.is_some_and(|fraction| !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0)
{
return Err(crate::Error::Invalid(
"invalid terminal textarea row range".into(),
));
}
if submitted {
for event in &mut output.events {
event.name = "submitted".into();
}
}
output.frame.state["fileRequest"] = serde_json::json!(file_request);
if let Some(value) = files {
output.events.push(crate::Emission {
name: "files".into(),
value,
});
}
if let Some(value) = checkpoint {
output.events.push(crate::Emission {
name: "checkpoint".into(),
value,
});
}
if let Some(value) = navigation {
output.events.push(crate::Emission {
name: "navigated".into(),
value,
});
}
Ok(output.map_view(|props| TextareaView {
props,
file_request,
}))
}
pub fn textarea_contract() -> Contract {
Contract {
name: "maka.ui.Textarea".into(),
version: 1,
transport: Transport::Local,
interface: Interface::Component {
props: serde_json::to_value(schemars::schema_for!(TextareaProps))
.expect("editor props schema")
.into(),
events: BTreeMap::from([
("changed".into(), observation_schema().into()),
("submitted".into(), observation_schema().into()),
("navigated".into(), navigation_schema().into()),
("files".into(), file_paste_schema().into()),
(
"checkpoint".into(),
crate::checkpoint::observation_schema().into(),
),
]),
captures: BTreeMap::from([
(
"snapshot".into(),
Capture {
input: serde_json::to_value(schemars::schema_for!(EditorCapture))
.expect("capture schema")
.into(),
output: maka_plugin_core::contract::CaptureOutput::Resource {
resource: text_resource_type(),
},
},
),
(
"file".into(),
Capture {
input: serde_json::to_value(schemars::schema_for!(FileCapture))
.expect("file paste capture")
.into(),
output: maka_plugin_core::contract::CaptureOutput::Resource {
resource: maka_plugin_core::contract::binary_resource_type(),
},
},
),
]),
},
}
}
pub const IMPLEMENTATION: &str = "maka.ui.textarea";
pub fn native() -> maka_plugin_core::package::NativeDefinition {
maka_plugin_core::package::NativeDefinition::new(
IMPLEMENTATION,
serde_json::json!({"type":"object","properties":{},"required":[],"additionalProperties":false}),
[("Textarea", textarea_contract())],
)
}
#[cfg(test)]
mod inline_token_tests {
use super::*;
#[test]
fn complete_inline_catalog_matches_the_exact_long_tail_literal() {
let mut literals: Vec<_> = (0..4096)
.map(|index| format!("/skill:item-{index}"))
.collect();
let tail = format!("/skill:{}", "技能".repeat(100));
assert!(tail.len() > 512);
literals.push(tail.clone());
let prefix = "普通 /skill:missing ";
let text = format!("{prefix}{tail} {tail}-extra");
assert_eq!(
inline_token_ranges(&text, &literals),
vec![prefix.len()..prefix.len() + tail.len()]
);
assert_eq!(
&text[inline_token_ranges(&text, &literals)[0].clone()],
tail
);
}
#[test]
fn inline_literals_match_whole_unicode_words_only() {
let text = "🙂 /skill:foo /skill:foo-extra\n/skill:技能 後";
let literals = vec!["/skill:foo".into(), "/skill:技能".into()];
let spans = inline_token_ranges(text, &literals);
assert_eq!(
spans
.iter()
.map(|range| &text[range.clone()])
.collect::<Vec<_>>(),
vec!["/skill:foo", "/skill:技能"]
);
assert_eq!(spans[0].start, "🙂 ".len());
assert_eq!(
inline_token_ranges("/skill:foo /skill:foo", &literals),
vec![0..10, 11..21]
);
assert!(inline_token_ranges(text, &[]).is_empty());
assert!(inline_token_ranges(" \u{301}x", &["\u{301}x".into()]).is_empty());
}
}