mirror of
https://github.com/Myriad-Dreamin/tinymist.git
synced 2025-08-03 01:42:14 +00:00
feat: add status bar to showing words count, also for compiling status (#158)
* feat: add status bar to showing words count, also for compilng status * dev: add configuration for compile status * fix: let focus state correct * dev: improve hint
This commit is contained in:
parent
454127e354
commit
6722b2501f
16 changed files with 633 additions and 47 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -3734,6 +3734,7 @@ dependencies = [
|
|||
"typst-ts-svg-exporter",
|
||||
"typstfmt_lib",
|
||||
"typstyle",
|
||||
"unicode-script",
|
||||
"vergen",
|
||||
"walkdir",
|
||||
]
|
||||
|
|
|
@ -128,6 +128,7 @@ mod polymorphic {
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum ExportKind {
|
||||
Pdf,
|
||||
WordCount,
|
||||
Svg { page: PageSelection },
|
||||
Png { page: PageSelection },
|
||||
}
|
||||
|
@ -136,6 +137,7 @@ mod polymorphic {
|
|||
pub fn extension(&self) -> &str {
|
||||
match self {
|
||||
Self::Pdf => "pdf",
|
||||
Self::WordCount => "txt",
|
||||
Self::Svg { .. } => "svg",
|
||||
Self::Png { .. } => "png",
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@ crossbeam-channel.workspace = true
|
|||
lsp-types.workspace = true
|
||||
dhat = { version = "0.3.3", optional = true }
|
||||
chrono = { version = "0.4" }
|
||||
unicode-script = "0.5"
|
||||
|
||||
[features]
|
||||
default = ["cli", "preview"]
|
||||
|
|
|
@ -6,6 +6,7 @@ pub mod render;
|
|||
pub mod typ_client;
|
||||
pub mod typ_server;
|
||||
|
||||
use tinymist_query::ExportKind;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use typst::util::Deferred;
|
||||
use typst_ts_compiler::{
|
||||
|
@ -23,7 +24,7 @@ use self::{
|
|||
use crate::{
|
||||
compiler::CompileServer,
|
||||
world::{ImmutDict, LspWorld, LspWorldBuilder},
|
||||
TypstLanguageServer,
|
||||
ExportMode, TypstLanguageServer,
|
||||
};
|
||||
|
||||
pub use formatting::{FormattingConfig, FormattingRequest};
|
||||
|
@ -33,26 +34,46 @@ type CompileDriverInner = CompileDriverImpl<LspWorld>;
|
|||
impl CompileServer {
|
||||
pub fn server(
|
||||
&self,
|
||||
diag_group: String,
|
||||
editor_group: String,
|
||||
entry: EntryState,
|
||||
inputs: ImmutDict,
|
||||
) -> CompileClientActor {
|
||||
let (doc_tx, doc_rx) = watch::channel(None);
|
||||
let (render_tx, _) = broadcast::channel(10);
|
||||
|
||||
// Run the Export actor before preparing cluster to avoid loss of events
|
||||
self.handle.spawn(
|
||||
ExportActor::new(
|
||||
doc_rx.clone(),
|
||||
render_tx.subscribe(),
|
||||
ExportConfig {
|
||||
let config = ExportConfig {
|
||||
substitute_pattern: self.config.output_path.clone(),
|
||||
entry: entry.clone(),
|
||||
mode: self.config.export_pdf,
|
||||
},
|
||||
};
|
||||
|
||||
// Run Export actors before preparing cluster to avoid loss of events
|
||||
self.handle.spawn(
|
||||
ExportActor::new(
|
||||
editor_group.clone(),
|
||||
doc_rx.clone(),
|
||||
self.diag_tx.clone(),
|
||||
render_tx.subscribe(),
|
||||
config.clone(),
|
||||
ExportKind::Pdf,
|
||||
)
|
||||
.run(),
|
||||
);
|
||||
if self.config.notify_compile_status {
|
||||
let mut config = config;
|
||||
config.mode = ExportMode::OnType;
|
||||
self.handle.spawn(
|
||||
ExportActor::new(
|
||||
editor_group.clone(),
|
||||
doc_rx.clone(),
|
||||
self.diag_tx.clone(),
|
||||
render_tx.subscribe(),
|
||||
config,
|
||||
ExportKind::WordCount,
|
||||
)
|
||||
.run(),
|
||||
);
|
||||
}
|
||||
|
||||
// Take all dirty files in memory as the initial snapshot
|
||||
let snapshot = FileChangeSet::default();
|
||||
|
@ -63,14 +84,14 @@ impl CompileServer {
|
|||
let handler = CompileHandler {
|
||||
#[cfg(feature = "preview")]
|
||||
inner: std::sync::Arc::new(parking_lot::Mutex::new(None)),
|
||||
diag_group: diag_group.clone(),
|
||||
diag_group: editor_group.clone(),
|
||||
doc_tx,
|
||||
render_tx: render_tx.clone(),
|
||||
diag_tx: self.diag_tx.clone(),
|
||||
editor_tx: self.diag_tx.clone(),
|
||||
};
|
||||
|
||||
let position_encoding = self.const_config().position_encoding;
|
||||
let diag_group = diag_group.clone();
|
||||
let diag_group = editor_group.clone();
|
||||
let entry = entry.clone();
|
||||
let font_resolver = self.font.clone();
|
||||
move || {
|
||||
|
@ -104,7 +125,7 @@ impl CompileServer {
|
|||
}
|
||||
});
|
||||
|
||||
CompileClientActor::new(diag_group, self.config.clone(), entry, inner, render_tx)
|
||||
CompileClientActor::new(editor_group, self.config.clone(), entry, inner, render_tx)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,25 +7,33 @@ use lsp_types::Url;
|
|||
use tinymist_query::{DiagnosticsMap, LspDiagnostic};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{LspHost, TypstLanguageServer};
|
||||
use crate::{tools::word_count::WordsCount, LspHost, TypstLanguageServer};
|
||||
|
||||
pub struct CompileClusterActor {
|
||||
pub enum CompileClusterRequest {
|
||||
Diag(String, Option<DiagnosticsMap>),
|
||||
Status(String, TinymistCompileStatusEnum),
|
||||
WordCount(String, Option<WordsCount>),
|
||||
}
|
||||
|
||||
pub struct EditorActor {
|
||||
pub host: LspHost<TypstLanguageServer>,
|
||||
pub diag_rx: mpsc::UnboundedReceiver<(String, Option<DiagnosticsMap>)>,
|
||||
pub diag_rx: mpsc::UnboundedReceiver<CompileClusterRequest>,
|
||||
|
||||
pub diagnostics: HashMap<Url, HashMap<String, Vec<LspDiagnostic>>>,
|
||||
pub affect_map: HashMap<String, Vec<Url>>,
|
||||
pub published_primary: bool,
|
||||
pub notify_compile_status: bool,
|
||||
}
|
||||
|
||||
impl CompileClusterActor {
|
||||
impl EditorActor {
|
||||
pub async fn run(mut self) {
|
||||
let mut compile_status = TinymistCompileStatusEnum::Compiling;
|
||||
let mut words_count = None;
|
||||
loop {
|
||||
tokio::select! {
|
||||
e = self.diag_rx.recv() => {
|
||||
let Some((group, diagnostics)) = e else {
|
||||
break;
|
||||
};
|
||||
match e {
|
||||
Some(CompileClusterRequest::Diag(group, diagnostics)) => {
|
||||
info!("received diagnostics from {}: diag({:?})", group, diagnostics.as_ref().map(|e| e.len()));
|
||||
|
||||
let with_primary = (self.affect_map.len() <= 1 && self.affect_map.contains_key("primary")) && group == "primary";
|
||||
|
@ -40,6 +48,37 @@ impl CompileClusterActor {
|
|||
self.published_primary = again_with_primary;
|
||||
}
|
||||
}
|
||||
Some(CompileClusterRequest::Status(group, status)) => {
|
||||
log::debug!("received status request");
|
||||
if self.notify_compile_status {
|
||||
if group != "primary" {
|
||||
continue;
|
||||
}
|
||||
compile_status = status;
|
||||
self.host.send_notification::<TinymistCompileStatus>(TinymistCompileStatus {
|
||||
status: compile_status.clone(),
|
||||
words_count: words_count.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(CompileClusterRequest::WordCount(group, wc)) => {
|
||||
log::debug!("received word count request");
|
||||
if self.notify_compile_status {
|
||||
if group != "primary" {
|
||||
continue;
|
||||
}
|
||||
words_count = wc;
|
||||
self.host.send_notification::<TinymistCompileStatus>(TinymistCompileStatus {
|
||||
status: compile_status.clone(),
|
||||
words_count: words_count.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -136,3 +175,24 @@ impl CompileClusterActor {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Notification
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TinymistCompileStatusEnum {
|
||||
Compiling,
|
||||
CompileSuccess,
|
||||
CompileError,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TinymistCompileStatus {
|
||||
pub status: TinymistCompileStatusEnum,
|
||||
#[serde(rename = "wordsCount")]
|
||||
pub words_count: Option<WordsCount>,
|
||||
}
|
||||
|
||||
impl lsp_types::notification::Notification for TinymistCompileStatus {
|
||||
type Params = TinymistCompileStatus;
|
||||
const METHOD: &'static str = "tinymist/compileStatus";
|
||||
}
|
||||
|
|
|
@ -12,12 +12,14 @@ use parking_lot::Mutex;
|
|||
use tinymist_query::{ExportKind, PageSelection};
|
||||
use tokio::sync::{
|
||||
broadcast::{self, error::RecvError},
|
||||
oneshot, watch,
|
||||
mpsc, oneshot, watch,
|
||||
};
|
||||
use typst::{foundations::Smart, layout::Frame};
|
||||
use typst_ts_core::{config::compiler::EntryState, path::PathClean, ImmutPath, TypstDocument};
|
||||
|
||||
use crate::ExportMode;
|
||||
use crate::{tools::word_count, ExportMode};
|
||||
|
||||
use super::cluster::CompileClusterRequest;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OneshotRendering {
|
||||
|
@ -48,6 +50,8 @@ pub struct ExportConfig {
|
|||
}
|
||||
|
||||
pub struct ExportActor {
|
||||
group: String,
|
||||
editor_tx: mpsc::UnboundedSender<CompileClusterRequest>,
|
||||
render_rx: broadcast::Receiver<RenderActorRequest>,
|
||||
document: watch::Receiver<Option<Arc<TypstDocument>>>,
|
||||
|
||||
|
@ -59,17 +63,22 @@ pub struct ExportActor {
|
|||
|
||||
impl ExportActor {
|
||||
pub fn new(
|
||||
group: String,
|
||||
document: watch::Receiver<Option<Arc<TypstDocument>>>,
|
||||
editor_tx: mpsc::UnboundedSender<CompileClusterRequest>,
|
||||
render_rx: broadcast::Receiver<RenderActorRequest>,
|
||||
config: ExportConfig,
|
||||
kind: ExportKind,
|
||||
) -> Self {
|
||||
Self {
|
||||
group,
|
||||
editor_tx,
|
||||
render_rx,
|
||||
document,
|
||||
substitute_pattern: config.substitute_pattern,
|
||||
entry: config.entry,
|
||||
mode: config.mode,
|
||||
kind: ExportKind::Pdf,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -282,6 +291,15 @@ impl ExportActor {
|
|||
.encode_png()
|
||||
.map_err(|err| anyhow::anyhow!("failed to encode PNG ({err})"))?
|
||||
}
|
||||
ExportKind::WordCount => {
|
||||
let wc = word_count::word_count(doc);
|
||||
log::debug!("word count: {wc:?}");
|
||||
let _ = self.editor_tx.send(CompileClusterRequest::WordCount(
|
||||
self.group.clone(),
|
||||
Some(wc),
|
||||
));
|
||||
return Ok(PathBuf::new());
|
||||
}
|
||||
};
|
||||
|
||||
std::fs::write(&to, data)
|
||||
|
|
|
@ -55,7 +55,10 @@ use typst_ts_core::{
|
|||
Error, ImmutPath, TypstFont,
|
||||
};
|
||||
|
||||
use super::typ_server::CompileClient as TsCompileClient;
|
||||
use super::{
|
||||
cluster::{CompileClusterRequest, TinymistCompileStatusEnum},
|
||||
typ_server::CompileClient as TsCompileClient,
|
||||
};
|
||||
use super::{render::ExportConfig, typ_server::CompileServerActor};
|
||||
use crate::world::LspWorld;
|
||||
use crate::{
|
||||
|
@ -72,7 +75,7 @@ type CompileDriverInner = CompileDriverImpl<LspWorld>;
|
|||
type CompileService = CompileServerActor<CompileDriver>;
|
||||
type CompileClient = TsCompileClient<CompileService>;
|
||||
|
||||
type DiagnosticsSender = mpsc::UnboundedSender<(String, Option<DiagnosticsMap>)>;
|
||||
type EditorSender = mpsc::UnboundedSender<CompileClusterRequest>;
|
||||
|
||||
pub struct CompileHandler {
|
||||
pub(super) diag_group: String,
|
||||
|
@ -82,7 +85,7 @@ pub struct CompileHandler {
|
|||
|
||||
pub(super) doc_tx: watch::Sender<Option<Arc<TypstDocument>>>,
|
||||
pub(super) render_tx: broadcast::Sender<RenderActorRequest>,
|
||||
pub(super) diag_tx: DiagnosticsSender,
|
||||
pub(super) editor_tx: EditorSender,
|
||||
}
|
||||
|
||||
impl CompilationHandle for CompileHandler {
|
||||
|
@ -103,6 +106,17 @@ impl CompilationHandle for CompileHandler {
|
|||
let _ = self.render_tx.send(RenderActorRequest::OnTyped);
|
||||
}
|
||||
|
||||
self.editor_tx
|
||||
.send(CompileClusterRequest::Status(
|
||||
self.diag_group.clone(),
|
||||
if res.is_ok() {
|
||||
TinymistCompileStatusEnum::CompileSuccess
|
||||
} else {
|
||||
TinymistCompileStatusEnum::CompileError
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
#[cfg(feature = "preview")]
|
||||
{
|
||||
let inner = self.inner.lock();
|
||||
|
@ -115,7 +129,10 @@ impl CompilationHandle for CompileHandler {
|
|||
|
||||
impl CompileHandler {
|
||||
fn push_diagnostics(&mut self, diagnostics: Option<DiagnosticsMap>) {
|
||||
let err = self.diag_tx.send((self.diag_group.clone(), diagnostics));
|
||||
let err = self.editor_tx.send(CompileClusterRequest::Diag(
|
||||
self.diag_group.clone(),
|
||||
diagnostics,
|
||||
));
|
||||
if let Err(err) = err {
|
||||
error!("failed to send diagnostics: {:#}", err);
|
||||
}
|
||||
|
@ -141,6 +158,13 @@ impl CompileMiddleware for CompileDriver {
|
|||
}
|
||||
|
||||
fn wrap_compile(&mut self, env: &mut CompileEnv) -> SourceResult<Arc<typst::model::Document>> {
|
||||
self.handler
|
||||
.editor_tx
|
||||
.send(CompileClusterRequest::Status(
|
||||
self.handler.diag_group.clone(),
|
||||
TinymistCompileStatusEnum::Compiling,
|
||||
))
|
||||
.unwrap();
|
||||
self.handler.status(CompileStatus::Compiling);
|
||||
match self.inner_mut().compile(env) {
|
||||
Ok(doc) => {
|
||||
|
|
|
@ -8,13 +8,13 @@ use lsp_types::{notification::Notification as _, ExecuteCommandParams};
|
|||
use paste::paste;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
use tinymist_query::{DiagnosticsMap, ExportKind, PageSelection};
|
||||
use tinymist_query::{ExportKind, PageSelection};
|
||||
use tokio::sync::mpsc;
|
||||
use typst::util::Deferred;
|
||||
use typst_ts_core::ImmutPath;
|
||||
|
||||
use crate::{
|
||||
actor::{render::ExportConfig, typ_client::CompileClientActor},
|
||||
actor::{cluster::CompileClusterRequest, render::ExportConfig, typ_client::CompileClientActor},
|
||||
compiler_init::{CompileConfig, CompilerConstConfig},
|
||||
harness::InitializedLspDriver,
|
||||
internal_error, invalid_params, method_not_found, run_query,
|
||||
|
@ -72,7 +72,7 @@ pub struct CompileServerArgs {
|
|||
pub client: LspHost<CompileServer>,
|
||||
pub compile_config: CompileConfig,
|
||||
pub const_config: CompilerConstConfig,
|
||||
pub diag_tx: mpsc::UnboundedSender<(String, Option<DiagnosticsMap>)>,
|
||||
pub diag_tx: mpsc::UnboundedSender<CompileClusterRequest>,
|
||||
pub font: Deferred<SharedFontResolver>,
|
||||
pub handle: tokio::runtime::Handle,
|
||||
}
|
||||
|
@ -109,7 +109,7 @@ pub struct CompileServer {
|
|||
/// Source synchronized with client
|
||||
pub memory_changes: HashMap<Arc<Path>, MemoryFileMeta>,
|
||||
/// The diagnostics sender to send diagnostics to `crate::actor::cluster`.
|
||||
pub diag_tx: mpsc::UnboundedSender<(String, Option<DiagnosticsMap>)>,
|
||||
pub diag_tx: mpsc::UnboundedSender<CompileClusterRequest>,
|
||||
/// The compiler actor.
|
||||
pub compiler: Option<CompileClientActor>,
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ use comemo::Prehashed;
|
|||
use once_cell::sync::Lazy;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
use tinymist_query::{DiagnosticsMap, PositionEncoding};
|
||||
use tinymist_query::PositionEncoding;
|
||||
use tokio::sync::mpsc;
|
||||
use typst::foundations::IntoValue;
|
||||
use typst::syntax::FileId;
|
||||
|
@ -17,6 +17,7 @@ use typst::util::Deferred;
|
|||
use typst_ts_core::config::compiler::EntryState;
|
||||
use typst_ts_core::{ImmutPath, TypstDict};
|
||||
|
||||
use crate::actor::cluster::CompileClusterRequest;
|
||||
use crate::compiler::{CompileServer, CompileServerArgs};
|
||||
use crate::harness::LspDriver;
|
||||
use crate::world::{ImmutDict, SharedFontResolver};
|
||||
|
@ -93,6 +94,8 @@ pub struct CompileConfig {
|
|||
pub export_pdf: ExportMode,
|
||||
/// Specifies the root path of the project manually.
|
||||
pub root_path: Option<PathBuf>,
|
||||
/// Specifies the root path of the project manually.
|
||||
pub notify_compile_status: bool,
|
||||
/// Typst extra arguments.
|
||||
pub typst_extra_args: Option<CompileExtraOpts>,
|
||||
pub has_default_entry_path: bool,
|
||||
|
@ -144,6 +147,14 @@ impl CompileConfig {
|
|||
self.root_path = None;
|
||||
}
|
||||
|
||||
let compile_status = update.get("compileStatus").and_then(|x| x.as_str());
|
||||
if let Some(word_count) = compile_status {
|
||||
if !matches!(word_count, "enable" | "disable") {
|
||||
bail!("compileStatus must be either 'enable' or 'disable'");
|
||||
}
|
||||
}
|
||||
self.notify_compile_status = compile_status.map_or(false, |e| e != "disable");
|
||||
|
||||
'parse_extra_args: {
|
||||
if let Some(typst_extra_args) = update.get("typstExtraArgs") {
|
||||
let typst_args: Vec<String> = match serde_json::from_value(typst_extra_args.clone())
|
||||
|
@ -330,7 +341,7 @@ impl Default for CompilerConstConfig {
|
|||
pub struct CompileInit {
|
||||
pub handle: tokio::runtime::Handle,
|
||||
pub font: CompileFontOpts,
|
||||
pub diag_tx: mpsc::UnboundedSender<(String, Option<DiagnosticsMap>)>,
|
||||
pub diag_tx: mpsc::UnboundedSender<CompileClusterRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
|
@ -21,8 +21,7 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{Map, Value as JsonValue};
|
||||
use tinymist_query::{
|
||||
get_semantic_tokens_options, get_semantic_tokens_registration,
|
||||
get_semantic_tokens_unregistration, DiagnosticsMap, ExportKind, PageSelection,
|
||||
SemanticTokenContext,
|
||||
get_semantic_tokens_unregistration, ExportKind, PageSelection, SemanticTokenContext,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use typst::diag::StrResult;
|
||||
|
@ -33,6 +32,7 @@ use typst_ts_core::path::PathClean;
|
|||
use typst_ts_core::{error::prelude::*, ImmutPath};
|
||||
|
||||
use super::lsp_init::*;
|
||||
use crate::actor::cluster::CompileClusterRequest;
|
||||
use crate::actor::typ_client::CompileClientActor;
|
||||
use crate::actor::{FormattingConfig, FormattingRequest};
|
||||
use crate::compiler::{CompileServer, CompileServerArgs};
|
||||
|
@ -152,7 +152,7 @@ fn as_path_pos(inp: TextDocumentPositionParams) -> (PathBuf, Position) {
|
|||
pub struct TypstLanguageServerArgs {
|
||||
pub client: LspHost<TypstLanguageServer>,
|
||||
pub const_config: ConstConfig,
|
||||
pub diag_tx: mpsc::UnboundedSender<(String, Option<DiagnosticsMap>)>,
|
||||
pub diag_tx: mpsc::UnboundedSender<CompileClusterRequest>,
|
||||
pub font: Deferred<SharedFontResolver>,
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ use typst::util::Deferred;
|
|||
use typst_ts_core::error::prelude::*;
|
||||
use typst_ts_core::ImmutPath;
|
||||
|
||||
use crate::actor::cluster::CompileClusterActor;
|
||||
use crate::actor::cluster::EditorActor;
|
||||
use crate::compiler_init::CompileConfig;
|
||||
use crate::harness::LspHost;
|
||||
use crate::world::{ImmutDict, SharedFontResolver};
|
||||
|
@ -88,11 +88,14 @@ const CONFIG_ITEMS: &[&str] = &[
|
|||
"semanticTokens",
|
||||
"formatterMode",
|
||||
"typstExtraArgs",
|
||||
"compileStatus",
|
||||
];
|
||||
|
||||
/// The user configuration read from the editor.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Config {
|
||||
/// Specifies the root path of the project manually.
|
||||
pub notify_compile_status: bool,
|
||||
/// The compile configurations
|
||||
pub compile: CompileConfig,
|
||||
/// Dynamic configuration for semantic tokens.
|
||||
|
@ -357,12 +360,13 @@ impl Init {
|
|||
|
||||
service.run_format_thread();
|
||||
|
||||
let cluster_actor = CompileClusterActor {
|
||||
let cluster_actor = EditorActor {
|
||||
host: self.host.clone(),
|
||||
diag_rx,
|
||||
diagnostics: HashMap::new(),
|
||||
affect_map: HashMap::new(),
|
||||
published_primary: false,
|
||||
notify_compile_status: service.config.compile.notify_compile_status,
|
||||
};
|
||||
|
||||
let fallback = service.config.compile.determine_default_entry_path();
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
pub mod package;
|
||||
pub mod preview;
|
||||
pub mod word_count;
|
||||
|
|
302
crates/tinymist/src/tools/word_count.rs
Normal file
302
crates/tinymist/src/tools/word_count.rs
Normal file
|
@ -0,0 +1,302 @@
|
|||
use std::io::{self, Write};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typst::{model::Document, syntax::Span, text::TextItem};
|
||||
use typst_ts_core::{debug_loc::SourceSpanOffset, exporter_utils::map_err};
|
||||
use unicode_script::{Script, UnicodeScript};
|
||||
|
||||
/// Words count for a document.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WordsCount {
|
||||
/// Number of words.
|
||||
pub words: usize,
|
||||
/// Number of characters.
|
||||
pub chars: usize,
|
||||
/// Number of spaces.
|
||||
/// Multiple consecutive spaces are counted as one.
|
||||
pub spaces: usize,
|
||||
/// Number of CJK characters.
|
||||
#[serde(rename = "cjkChars")]
|
||||
pub cjk_chars: usize,
|
||||
}
|
||||
|
||||
/// Count words in a document.
|
||||
pub fn word_count(doc: &Document) -> WordsCount {
|
||||
// the mapping is still not use, so we prevent the warning here
|
||||
let _ = TextContent::map_back_spans;
|
||||
|
||||
let mut words = 0;
|
||||
let mut chars = 0;
|
||||
let mut cjk_chars = 0;
|
||||
let mut spaces = 0;
|
||||
|
||||
// First, get text representation of the document.
|
||||
let w = TextExporter::default();
|
||||
let content = w.collect(doc).unwrap();
|
||||
|
||||
/// A automaton to count words.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CountState {
|
||||
/// Waiting for a word. (Default state)
|
||||
InSpace,
|
||||
/// At a word.
|
||||
InNonCJK,
|
||||
/// At a CJK character.
|
||||
InCJK,
|
||||
}
|
||||
|
||||
fn is_cjk(c: char) -> bool {
|
||||
matches!(
|
||||
c.script(),
|
||||
Script::Han | Script::Hiragana | Script::Katakana | Script::Hangul
|
||||
)
|
||||
}
|
||||
|
||||
let mut state = CountState::InSpace;
|
||||
for c in content.chars() {
|
||||
chars += 1;
|
||||
|
||||
if c.is_whitespace() {
|
||||
if state != CountState::InSpace {
|
||||
spaces += 1;
|
||||
}
|
||||
state = CountState::InSpace;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check unicode script to see if it's a CJK character.
|
||||
if is_cjk(c) {
|
||||
words += 1;
|
||||
cjk_chars += 1;
|
||||
|
||||
state = CountState::InCJK;
|
||||
} else {
|
||||
if state != CountState::InNonCJK {
|
||||
words += 1;
|
||||
}
|
||||
|
||||
state = CountState::InNonCJK;
|
||||
}
|
||||
}
|
||||
|
||||
WordsCount {
|
||||
words,
|
||||
chars,
|
||||
spaces,
|
||||
cjk_chars,
|
||||
}
|
||||
}
|
||||
|
||||
/// Export text content from a document.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TextExporter {}
|
||||
|
||||
impl TextExporter {
|
||||
pub fn collect(&self, output: &Document) -> typst::diag::SourceResult<String> {
|
||||
let w = std::io::BufWriter::new(Vec::new());
|
||||
|
||||
let mut d = TextExportWorker { w };
|
||||
d.doc(output).map_err(map_err)?;
|
||||
|
||||
d.w.flush().unwrap();
|
||||
Ok(String::from_utf8(d.w.into_inner().unwrap()).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
struct TextExportWorker {
|
||||
w: std::io::BufWriter<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl TextExportWorker {
|
||||
fn doc(&mut self, doc: &Document) -> io::Result<()> {
|
||||
for page in doc.pages.iter() {
|
||||
self.frame(&page.frame)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn frame(&mut self, doc: &typst::layout::Frame) -> io::Result<()> {
|
||||
for (_, item) in doc.items() {
|
||||
self.item(item)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn item(&mut self, item: &typst::layout::FrameItem) -> io::Result<()> {
|
||||
use typst::introspection::Meta::*;
|
||||
use typst::layout::FrameItem::*;
|
||||
match item {
|
||||
Group(g) => self.frame(&g.frame),
|
||||
Text(t) => {
|
||||
write!(self.w, "{}", t.text.as_str())
|
||||
}
|
||||
// Meta(ContentHint(c), _) => f.write_char(*c),
|
||||
Meta(Link(..), _) | Shape(..) | Image(..) => self.w.write_all(b"object"),
|
||||
Meta(Elem(..) | Hide, _) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a text range, map it back to the original document.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MappedSpan {
|
||||
/// The start span.
|
||||
pub span: SourceSpanOffset,
|
||||
/// The end span.
|
||||
pub span_end: Option<SourceSpanOffset>,
|
||||
/// Whether a text range is completely covered by [`Self::span`] and
|
||||
/// [`Self::span_end`].
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
/// Annotated content for a font.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextContent {
|
||||
/// A string of the content for slicing.
|
||||
pub content: String,
|
||||
/// annotating document.
|
||||
pub doc: Arc<Document>,
|
||||
}
|
||||
|
||||
impl TextContent {
|
||||
/// Map text ranges (with byte offsets) back to the original document in
|
||||
/// batch.
|
||||
pub fn map_back_spans(
|
||||
&self,
|
||||
mut spans: Vec<std::ops::Range<usize>>,
|
||||
) -> Vec<Option<MappedSpan>> {
|
||||
// Sort for scanning
|
||||
spans.sort_by_key(|r| r.start);
|
||||
|
||||
// Scan the document recursively to map back the spans.
|
||||
let mut mapper = SpanMapper::default();
|
||||
mapper.doc(&self.doc);
|
||||
|
||||
// Align result with the input to prevent bad scanning.
|
||||
let mut offsets = mapper.span_offset;
|
||||
while spans.len() < offsets.len() {
|
||||
offsets.pop();
|
||||
}
|
||||
while spans.len() > offsets.len() {
|
||||
offsets.push(None);
|
||||
}
|
||||
|
||||
offsets
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct SpanMapper {
|
||||
offset: usize,
|
||||
spans_to_map: Vec<std::ops::Range<usize>>,
|
||||
span_offset: Vec<Option<MappedSpan>>,
|
||||
}
|
||||
|
||||
impl SpanMapper {
|
||||
fn doc(&mut self, doc: &Document) {
|
||||
for page in doc.pages.iter() {
|
||||
self.frame(&page.frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(&mut self, doc: &typst::layout::Frame) {
|
||||
for (_, item) in doc.items() {
|
||||
self.item(item);
|
||||
}
|
||||
}
|
||||
|
||||
fn item(&mut self, item: &typst::layout::FrameItem) {
|
||||
use typst::introspection::Meta::*;
|
||||
use typst::layout::FrameItem::*;
|
||||
match item {
|
||||
Group(g) => self.frame(&g.frame),
|
||||
Text(t) => {
|
||||
self.check(t.text.as_str(), Some(t));
|
||||
}
|
||||
Meta(Link(..), _) | Shape(..) | Image(..) => {
|
||||
self.check("object", None);
|
||||
}
|
||||
Meta(Elem(..) | Hide, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn check(&mut self, text: &str, src: Option<&TextItem>) {
|
||||
if let Some(src) = src {
|
||||
self.do_check(src);
|
||||
}
|
||||
self.offset += text.len();
|
||||
}
|
||||
|
||||
fn do_check(&mut self, text: &TextItem) -> Option<()> {
|
||||
let beg = self.offset;
|
||||
let end = beg + text.text.len();
|
||||
loop {
|
||||
let so = self.span_offset.len();
|
||||
let to_check = self.spans_to_map.get(so)?;
|
||||
if to_check.start >= end {
|
||||
return Some(());
|
||||
}
|
||||
if to_check.end <= beg {
|
||||
self.span_offset.push(None);
|
||||
log::info!("span out of range {to_check:?}");
|
||||
continue;
|
||||
}
|
||||
// todo: don't swallow the span
|
||||
if to_check.start < beg {
|
||||
self.span_offset.push(None);
|
||||
log::info!("span skipped {to_check:?}");
|
||||
continue;
|
||||
}
|
||||
|
||||
log::info!("span checking {to_check:?} in {text:?}");
|
||||
let inner = to_check.start - beg;
|
||||
self.span_offset
|
||||
.push(self.check_text_inner(text, inner..inner + (to_check.len())));
|
||||
}
|
||||
}
|
||||
|
||||
fn check_text_inner(&self, text: &TextItem, rng: std::ops::Range<usize>) -> Option<MappedSpan> {
|
||||
let glyphs = text
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|g| rng.contains(&g.range().start));
|
||||
let mut min_span: Option<(Range<usize>, (Span, u16))> = None;
|
||||
let mut max_span: Option<(Range<usize>, (Span, u16))> = None;
|
||||
let mut found = vec![];
|
||||
for glyph in glyphs {
|
||||
found.push(glyph.range());
|
||||
if let Some((mii, s)) = min_span.as_ref() {
|
||||
if glyph.range().start < mii.start && !s.0.is_detached() {
|
||||
// min_span = Some(glyph.range());
|
||||
min_span = Some((glyph.range(), glyph.span));
|
||||
}
|
||||
} else {
|
||||
// min_span = Some(glyph.range());
|
||||
min_span = Some((glyph.range(), glyph.span));
|
||||
}
|
||||
if let Some((mai, s)) = max_span.as_ref() {
|
||||
if glyph.range().end > mai.end && !s.0.is_detached() {
|
||||
// max_span = Some(glyph.range());
|
||||
max_span = Some((glyph.range(), glyph.span));
|
||||
}
|
||||
} else {
|
||||
// max_span = Some(glyph.range());
|
||||
max_span = Some((glyph.range(), glyph.span));
|
||||
}
|
||||
}
|
||||
found.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| a.end.cmp(&b.end)));
|
||||
let completed = !found.is_empty()
|
||||
&& found[0].start <= rng.start
|
||||
&& found[found.len() - 1].end >= rng.end;
|
||||
let span = min_span?.1 .0;
|
||||
let span_end = max_span.map(|m| m.1 .0);
|
||||
Some(MappedSpan {
|
||||
span: SourceSpanOffset::from(span),
|
||||
span_end: span_end.map(SourceSpanOffset::from),
|
||||
completed,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -91,6 +91,16 @@
|
|||
],
|
||||
"default": null
|
||||
},
|
||||
"tinymist.compileStatus": {
|
||||
"title": "Show/Report compilation status",
|
||||
"description": "In VSCode, enable compile status meaning that the extension will show the compilation status in the status bar. Since neovim and helix don't have a such feature, it is disabled by default at the language server lebel.",
|
||||
"type": "string",
|
||||
"default": "enable",
|
||||
"enum": [
|
||||
"enable",
|
||||
"disable"
|
||||
]
|
||||
},
|
||||
"tinymist.typstExtraArgs": {
|
||||
"title": "Specifies the arguments for Typst as same as typst-cli",
|
||||
"description": "You can pass any arguments as you like, and we will try to follow behaviors of the **same version** of typst-cli. Note: the arguments may be overridden by other settings. For example, `--font-path` will be overridden by `tinymist.fontPaths`.",
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
commands,
|
||||
ViewColumn,
|
||||
Uri,
|
||||
WorkspaceConfiguration,
|
||||
TextEditor,
|
||||
ExtensionMode,
|
||||
} from "vscode";
|
||||
|
@ -20,6 +19,7 @@ import {
|
|||
} from "vscode-languageclient/node";
|
||||
import vscodeVariables from "vscode-variables";
|
||||
import { activateEditorTool, getUserPackageData } from "./editor-tools";
|
||||
import { triggerStatusBar, wordCountItemProcess } from "./ui-extends";
|
||||
|
||||
let client: LanguageClient | undefined = undefined;
|
||||
|
||||
|
@ -94,6 +94,10 @@ async function startClient(context: ExtensionContext): Promise<void> {
|
|||
clientOptions
|
||||
);
|
||||
|
||||
client.onNotification("tinymist/compileStatus", (params) => {
|
||||
wordCountItemProcess(params);
|
||||
});
|
||||
|
||||
window.onDidChangeActiveTextEditor((editor: TextEditor | undefined) => {
|
||||
if (editor?.document.isUntitled) {
|
||||
return;
|
||||
|
@ -171,8 +175,29 @@ async function startClient(context: ExtensionContext): Promise<void> {
|
|||
context.subscriptions.push(
|
||||
commands.registerCommand("tinymist.traceCurrentFile", () => commandShowTrace(context))
|
||||
);
|
||||
context.subscriptions.push(
|
||||
commands.registerCommand("tinymist.showLog", () => {
|
||||
if (client) {
|
||||
client.outputChannel.show();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return client.start();
|
||||
await client.start();
|
||||
|
||||
// Find first document to focus
|
||||
const editor = window.activeTextEditor;
|
||||
if (editor?.document.languageId === "typst" && editor.document.uri.fsPath) {
|
||||
commandActivateDoc(editor.document.uri.fsPath);
|
||||
} else {
|
||||
window.visibleTextEditors.forEach((editor) => {
|
||||
if (editor.document.languageId === "typst" && editor.document.uri.fsPath) {
|
||||
commandActivateDoc(editor.document.uri.fsPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
export function deactivate(): Promise<void> | undefined {
|
||||
|
@ -492,7 +517,17 @@ async function commandInitTemplate(
|
|||
}
|
||||
}
|
||||
|
||||
let focusingFile: string | undefined = undefined;
|
||||
export function getFocusingFile() {
|
||||
return focusingFile;
|
||||
}
|
||||
|
||||
async function commandActivateDoc(fsPath: string | undefined): Promise<void> {
|
||||
// console.log("focus main", fsPath, new Error().stack);
|
||||
focusingFile = fsPath;
|
||||
if (!fsPath) {
|
||||
triggerStatusBar();
|
||||
}
|
||||
await client?.sendRequest("workspace/executeCommand", {
|
||||
command: "tinymist.focusMain",
|
||||
arguments: [fsPath],
|
||||
|
|
96
editors/vscode/src/ui-extends.ts
Normal file
96
editors/vscode/src/ui-extends.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
import * as vscode from "vscode";
|
||||
import { getFocusingFile } from "./extension";
|
||||
|
||||
let statusBarItem: vscode.StatusBarItem;
|
||||
|
||||
function initWordCountItem() {
|
||||
statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 1);
|
||||
statusBarItem.name = "Tinymist Status";
|
||||
statusBarItem.command = "tinymist.showLog";
|
||||
return statusBarItem;
|
||||
}
|
||||
|
||||
let words = 0;
|
||||
let chars = 0;
|
||||
let spaces = 0;
|
||||
let cjkChars = 0;
|
||||
|
||||
interface WordsCount {
|
||||
words: number;
|
||||
chars: number;
|
||||
spaces: number;
|
||||
cjkChars: number;
|
||||
}
|
||||
|
||||
export interface TinymistStatus {
|
||||
status: "compiling" | "compileSuccess" | "compileError";
|
||||
wordsCount: WordsCount;
|
||||
}
|
||||
|
||||
export const triggerStatusBar = () => {
|
||||
if (getFocusingFile()) {
|
||||
statusBarItem.show();
|
||||
} else {
|
||||
statusBarItem.hide();
|
||||
}
|
||||
};
|
||||
|
||||
export function wordCountItemProcess(event: TinymistStatus) {
|
||||
statusBarItem = statusBarItem || initWordCountItem();
|
||||
|
||||
const updateTooltip = () => {
|
||||
statusBarItem.tooltip = `${words} ${plural("Word", words)}
|
||||
${chars} ${plural("Character", chars)}
|
||||
${spaces} ${plural("Space", spaces)}
|
||||
${cjkChars} CJK ${plural("Character", cjkChars)}
|
||||
[Click to show logs]`;
|
||||
};
|
||||
|
||||
words = event.wordsCount?.words || 0;
|
||||
chars = event.wordsCount?.chars || 0;
|
||||
spaces = event.wordsCount?.spaces || 0;
|
||||
cjkChars = event.wordsCount?.cjkChars || 0;
|
||||
|
||||
const style: string = "errorStatus";
|
||||
if (statusBarItem) {
|
||||
if (event.status === "compiling") {
|
||||
if (style === "compact") {
|
||||
statusBarItem.text = "$(sync~spin)";
|
||||
} else if (style === "errorStatus") {
|
||||
statusBarItem.text = `$(sync~spin) ${words} ${plural("Word", words)}`;
|
||||
}
|
||||
statusBarItem.backgroundColor = new vscode.ThemeColor(
|
||||
"statusBarItem.prominentBackground"
|
||||
);
|
||||
updateTooltip();
|
||||
triggerStatusBar();
|
||||
} else if (event.status === "compileSuccess") {
|
||||
if (style === "compact") {
|
||||
statusBarItem.text = "$(typst-guy)";
|
||||
} else if (style === "errorStatus") {
|
||||
statusBarItem.text = `$(sync) ${words} ${plural("Word", words)}`;
|
||||
}
|
||||
statusBarItem.backgroundColor = new vscode.ThemeColor(
|
||||
"statusBarItem.prominentBackground"
|
||||
);
|
||||
updateTooltip();
|
||||
triggerStatusBar();
|
||||
} else if (event.status === "compileError") {
|
||||
if (style === "compact") {
|
||||
statusBarItem.text = "$(typst-guy)";
|
||||
} else if (style === "errorStatus") {
|
||||
statusBarItem.text = `$(sync) ${words} ${plural("Word", words)}`;
|
||||
}
|
||||
statusBarItem.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground");
|
||||
updateTooltip();
|
||||
triggerStatusBar();
|
||||
}
|
||||
}
|
||||
}
|
||||
function plural(w: string, words: number): string {
|
||||
if (words <= 1) {
|
||||
return w;
|
||||
} else {
|
||||
return w + "s";
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue