diff --git a/CHANGELOG.md b/CHANGELOG.md index 668b4a72..a04e0144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html ### Added - `mdcat` builds on Windows now (see [GH-34][]). +### Changed +- Refactor internal terminal representation, replacing the terminal enum with a + new `Terminal` trait and dynamic dispatch. +- Only include terminal backends supported on the platform. + [GH-34]: https://github.com/lunaryorn/mdcat/pull/34 ## [0.8.0] – 2018-02-15 diff --git a/Cargo.toml b/Cargo.toml index f431d933..28e48923 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,18 @@ version = "0.8.1-pre" categories = ["command-line-utilities", "text-processing"] license = "Apache-2.0" authors = ["Sebastian Wiesner "] +build = "build.rs" [badges] travis-ci = { repository = "lunaryorn/mdcat" } maintenance = { status = "actively-developed" } +[features] +default = [] + +iterm2 = ["mime", "base64"] +terminology = ["immeta"] + [dependencies] atty = "^0.2" failure = "^0.1" @@ -22,6 +29,10 @@ reqwest = "^0.8" term_size = "^0.3" url = "^1.6" +mime = {version = "^0.3", optional = true} +base64 = {version = "^0.9", optional = true} +immeta = {version = "^0.4", optional = true} + [dependencies.clap] version = "^2.29" default-features = false @@ -37,13 +48,6 @@ version = "^2" default-features = false features = ["parsing", "assets", "dump-load"] -[target.'cfg(target_os = "macos")'.dependencies] -mime = "^0.3" -base64 = "^0.9" - -[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] -immeta = "^0.4" - [package.metadata.release] sign-commit = true upload-doc = false diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..e60ff0f6 --- /dev/null +++ b/build.rs @@ -0,0 +1,28 @@ +// Copyright 2018 Sebastian Wiesner + +// Licensed 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. + +fn enable_feature(feature: &str) { + println!("rustc-cfg={}", feature); +} + +fn main() { + #[cfg(target_os = "macos")] + { + enable_feature("iterm2"); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + enable_feature("terminology"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 38e318d6..d2fe4e8e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,13 +19,13 @@ //! Write markdown to TTYs. // Used by iTerm support on macos -#[cfg(target_os = "macos")] +#[cfg(feature = "iterm2")] extern crate base64; -#[cfg(target_os = "macos")] +#[cfg(feature = "iterm2")] extern crate mime; // Used by Terminology support -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(feature = "terminology")] extern crate immeta; extern crate atty; @@ -50,25 +50,23 @@ use syntect::highlighting::{Theme, ThemeSet}; use syntect::parsing::SyntaxSet; // These modules support iterm2; we do not need them if iterm2 is off. -#[cfg(target_os = "macos")] +#[cfg(feature = "iterm2")] mod magic; -#[cfg(target_os = "macos")] +#[cfg(feature = "iterm2")] mod process; -#[cfg(target_os = "macos")] +#[cfg(feature = "iterm2")] mod svg; -mod highlighting; mod resources; mod terminal; -use highlighting::write_as_ansi; use resources::Resource; use terminal::*; // Expose some select things for use in main pub use resources::ResourceAccess; pub use terminal::Size as TerminalSize; -pub use terminal::{Terminal, TerminalWrite}; +pub use terminal::{detect_terminal, AnsiTerminal, DumbTerminal, Terminal}; /// Dump markdown events to a writer. pub fn dump_events<'a, W, I>(writer: &mut W, events: I) -> Result<(), Error> @@ -90,8 +88,7 @@ where /// `push_tty` tries to limit output to the given number of TTY `columns` but /// does not guarantee that output stays within the column limit. pub fn push_tty<'a, W, I>( - writer: &mut W, - terminal: Terminal, + terminal: Box>, size: TerminalSize, events: I, base_dir: &'a Path, @@ -100,18 +97,10 @@ pub fn push_tty<'a, W, I>( ) -> Result<(), Error> where I: Iterator>, - W: Write + TerminalWrite, + W: Write, { let theme = &ThemeSet::load_defaults().themes["Solarized (dark)"]; - let mut context = Context::new( - writer, - terminal, - size, - base_dir, - resource_access, - syntax_set, - theme, - ); + let mut context = Context::new(terminal, size, base_dir, resource_access, syntax_set, theme); for event in events { write_event(&mut context, event)?; } @@ -165,13 +154,11 @@ impl<'a> InputContext<'a> { } /// Context for TTY output. -struct OutputContext<'a, W: Write + 'a> { - /// The writer to write to. - writer: &'a mut W, +struct OutputContext { /// The terminal dimensions to limit output to. size: Size, /// The target terminal. - terminal: Terminal, + terminal: Box>, } #[derive(Debug)] @@ -239,7 +226,7 @@ struct Context<'a, W: Write + 'a> { /// Context for input. input: InputContext<'a>, /// Context for output. - output: OutputContext<'a, W>, + output: OutputContext, /// Context for styling style: StyleContext, /// Context for the current block. @@ -256,10 +243,9 @@ struct Context<'a, W: Write + 'a> { list_item_kind: Vec, } -impl<'a, W: Write + 'a> Context<'a, W> { +impl<'a, W: Write> Context<'a, W> { fn new( - writer: &'a mut W, - terminal: Terminal, + terminal: Box>, size: Size, base_dir: &'a Path, resource_access: ResourceAccess, @@ -271,11 +257,7 @@ impl<'a, W: Write + 'a> Context<'a, W> { base_dir, resource_access, }, - output: OutputContext { - writer, - size, - terminal, - }, + output: OutputContext { size, terminal }, style: StyleContext { active_styles: Vec::new(), emphasis_level: 0, @@ -334,7 +316,7 @@ impl<'a, W: Write + 'a> Context<'a, W> { for style in &self.style.active_styles { self.output .terminal - .set_style(self.output.writer, *style) + .set_style(*style) .ignore_not_supported()?; } Ok(()) @@ -346,9 +328,9 @@ impl<'a, W: Write + 'a> Context<'a, W> { fn newline(&mut self) -> Result<(), Error> { self.output .terminal - .set_style(self.output.writer, AnsiStyle::Reset) + .set_style(AnsiStyle::Reset) .ignore_not_supported()?; - writeln!(self.output.writer)?; + writeln!(self.output.terminal.write())?; self.flush_styles() } @@ -364,7 +346,7 @@ impl<'a, W: Write + 'a> Context<'a, W> { /// Indent according to the current indentation level. fn indent(&mut self) -> Result<(), Error> { write!( - self.output.writer, + self.output.terminal.write(), "{}", " ".repeat(self.block.indent_level) ).map_err(Into::into) @@ -378,10 +360,7 @@ impl<'a, W: Write + 'a> Context<'a, W> { /// or `newline()`. fn enable_style(&mut self, style: AnsiStyle) -> Result<(), Error> { self.style.active_styles.push(style); - self.output - .terminal - .set_style(self.output.writer, style) - .ignore_not_supported() + self.output.terminal.set_style(style).ignore_not_supported() } /// Remove the last style and flush styles on the TTY. @@ -389,7 +368,7 @@ impl<'a, W: Write + 'a> Context<'a, W> { self.style.active_styles.pop(); self.output .terminal - .set_style(self.output.writer, AnsiStyle::Reset) + .set_style(AnsiStyle::Reset) .ignore_not_supported()?; self.flush_styles() } @@ -429,9 +408,11 @@ impl<'a, W: Write + 'a> Context<'a, W> { self.enable_style(AnsiStyle::Foreground(AnsiColour::Blue))?; while let Some(link) = self.links.pending_links.pop_front() { write!( - self.output.writer, + self.output.terminal.write(), "[{}]: {} {}", - link.index, link.destination, link.title + link.index, + link.destination, + link.title )?; self.newline()?; } @@ -444,7 +425,7 @@ impl<'a, W: Write + 'a> Context<'a, W> { fn write_border(&mut self) -> Result<(), Error> { self.enable_style(AnsiStyle::Foreground(AnsiColour::Green))?; writeln!( - self.output.writer, + self.output.terminal.write(), "{}", "\u{2500}".repeat(self.output.size.width.min(20)) )?; @@ -459,10 +440,10 @@ impl<'a, W: Write + 'a> Context<'a, W> { match self.code.current_highlighter { Some(ref mut highlighter) => { let regions = highlighter.highlight(&text); - write_as_ansi(self.output.writer, ®ions)?; + write_as_ansi(&mut *self.output.terminal, ®ions)?; } None => { - write!(self.output.writer, "{}", text)?; + write!(self.output.terminal.write(), "{}", text)?; self.links.last_text = Some(text); } } @@ -488,14 +469,14 @@ fn write_event<'a, W: Write>(ctx: &mut Context<'a, W>, event: Event<'a>) -> Resu ctx.newline()?; ctx.enable_style(AnsiStyle::Foreground(AnsiColour::Green))?; for line in content.lines() { - write!(ctx.output.writer, "{}", line)?; + write!(ctx.output.terminal.write(), "{}", line)?; ctx.newline()?; } ctx.reset_last_style()?; } InlineHtml(tag) => { ctx.enable_style(AnsiStyle::Foreground(AnsiColour::Green))?; - write!(ctx.output.writer, "{}", tag)?; + write!(ctx.output.terminal.write(), "{}", tag)?; ctx.reset_last_style()?; } FootnoteReference(_) => panic!("mdcat does not support footnotes"), @@ -511,7 +492,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err ctx.start_inline_text()?; ctx.enable_style(AnsiStyle::Foreground(AnsiColour::Green))?; write!( - ctx.output.writer, + ctx.output.terminal.write(), "{}", "\u{2550}".repeat(ctx.output.size.width as usize) )? @@ -521,14 +502,11 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err // them close to the text where they appeared in ctx.write_pending_links()?; ctx.start_inline_text()?; - ctx.output - .terminal - .set_mark(ctx.output.writer) - .ignore_not_supported()?; + ctx.output.terminal.set_mark().ignore_not_supported()?; let level_indicator = "\u{2504}".repeat(level as usize); ctx.enable_style(AnsiStyle::Bold)?; ctx.enable_style(AnsiStyle::Foreground(AnsiColour::Blue))?; - write!(ctx.output.writer, "{}", level_indicator)? + write!(ctx.output.terminal.write(), "{}", level_indicator)? } BlockQuote => { ctx.block.indent_level += 4; @@ -539,7 +517,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err CodeBlock(name) => { ctx.start_inline_text()?; ctx.write_border()?; - if ctx.output.terminal.supports_colours() { + if ctx.output.terminal.supports_styles() { if name.is_empty() { ctx.enable_style(AnsiStyle::Foreground(AnsiColour::Yellow))? } else { @@ -548,9 +526,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err Some(HighlightLines::new(syntax, ctx.code.theme)); // Give the highlighter a clear terminal with no prior // styles. - ctx.output - .terminal - .set_style(ctx.output.writer, AnsiStyle::Reset)?; + ctx.output.terminal.set_style(AnsiStyle::Reset)?; } if ctx.code.current_highlighter.is_none() { // If we have no highlighter for the current block, fall @@ -572,12 +548,12 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err ctx.block.level = BlockLevel::Inline; match ctx.list_item_kind.pop() { Some(ListItemKind::Unordered) => { - write!(ctx.output.writer, "\u{2022} ")?; + write!(ctx.output.terminal.write(), "\u{2022} ")?; ctx.block.indent_level += 2; ctx.list_item_kind.push(ListItemKind::Unordered); } Some(ListItemKind::Ordered(number)) => { - write!(ctx.output.writer, "{:>2}. ", number)?; + write!(ctx.output.terminal.write(), "{:>2}. ", number)?; ctx.block.indent_level += 4; ctx.list_item_kind.push(ListItemKind::Ordered(number + 1)); } @@ -595,12 +571,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err // or if the format doesn't support inline links, don't do anything // here; we will write a reference link when closing the link tag. let url = ctx.input.resolve_reference(&destination).into_url(); - if ctx - .output - .terminal - .set_link(ctx.output.writer, url.as_str()) - .is_ok() - { + if ctx.output.terminal.set_link(url.as_str()).is_ok() { ctx.links.inside_inline_link = true; } } @@ -609,12 +580,7 @@ fn start_tag<'a, W: Write>(ctx: &mut Context, tag: Tag<'a>) -> Result<(), Err if ctx .output .terminal - .write_inline_image( - ctx.output.writer, - ctx.output.size, - &resource, - ctx.input.resource_access, - ) + .write_inline_image(ctx.output.size, &resource, ctx.input.resource_access) .is_ok() { // If we could write an inline image, disable text output to @@ -652,9 +618,7 @@ fn end_tag<'a, W: Write>(ctx: &mut Context<'a, W>, tag: Tag<'a>) -> Result<(), E Some(_) => { // Reset anything left over from the highlighter and // re-enable all current styles. - ctx.output - .terminal - .set_style(ctx.output.writer, AnsiStyle::Reset)?; + ctx.output.terminal.set_style(AnsiStyle::Reset)?; ctx.flush_styles()?; ctx.code.current_highlighter = None; } @@ -687,7 +651,7 @@ fn end_tag<'a, W: Write>(ctx: &mut Context<'a, W>, tag: Tag<'a>) -> Result<(), E } Strong | Code => ctx.reset_last_style()?, Link(destination, title) => if ctx.links.inside_inline_link { - ctx.output.terminal.set_link(ctx.output.writer, "")?; + ctx.output.terminal.set_link("")?; ctx.links.inside_inline_link = false; } else { // When we did not write an inline link, create a normal reference @@ -704,7 +668,7 @@ fn end_tag<'a, W: Write>(ctx: &mut Context<'a, W>, tag: Tag<'a>) -> Result<(), E let index = ctx.add_link(destination, title); // Reference link ctx.enable_style(AnsiStyle::Foreground(AnsiColour::Blue))?; - write!(ctx.output.writer, "[{}]", index)?; + write!(ctx.output.terminal.write(), "[{}]", index)?; ctx.reset_last_style()?; } } @@ -714,7 +678,7 @@ fn end_tag<'a, W: Write>(ctx: &mut Context<'a, W>, tag: Tag<'a>) -> Result<(), E // If we could not write an inline image, write the image link // after the image title. ctx.enable_style(AnsiStyle::Foreground(AnsiColour::Blue))?; - write!(ctx.output.writer, " ({})", link)?; + write!(ctx.output.terminal.write(), " ({})", link)?; ctx.reset_last_style()?; } ctx.image.inline_image = false; diff --git a/src/main.rs b/src/main.rs index ea9850a4..86e3ac20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,15 +27,15 @@ use pulldown_cmark::Parser; use std::error::Error; use std::fs::File; use std::io::prelude::*; -use std::io::stdin; +use std::io::{stdin, stdout, Stdout}; use std::path::PathBuf; use std::str::FromStr; use syntect::parsing::SyntaxSet; -use mdcat::{ResourceAccess, Terminal, TerminalSize}; +use mdcat::{detect_terminal, AnsiTerminal, DumbTerminal, ResourceAccess, Terminal, TerminalSize}; /// Colour options, for the --colour option. -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] enum Colour { Yes, No, @@ -88,53 +88,61 @@ fn read_input>(filename: T) -> std::io::Result<(PathBuf, String)> } fn process_arguments(size: TerminalSize, args: Arguments) -> Result<(), Box> { - let (base_dir, input) = read_input(args.filename)?; - let parser = Parser::new(&input); - - if args.dump_events { - mdcat::dump_events(&mut std::io::stdout(), parser)?; + if args.detect_only { + println!("Terminal: {}", args.terminal.name()); Ok(()) } else { - let syntax_set = SyntaxSet::load_defaults_newlines(); - mdcat::push_tty( - &mut std::io::stdout(), - args.terminal, - TerminalSize { - width: args.columns, - ..size - }, - parser, - &base_dir, - args.resource_access, - syntax_set, - )?; - Ok(()) + let (base_dir, input) = read_input(args.filename)?; + let parser = Parser::new(&input); + + if args.dump_events { + mdcat::dump_events(&mut std::io::stdout(), parser)?; + Ok(()) + } else { + let syntax_set = SyntaxSet::load_defaults_newlines(); + mdcat::push_tty( + args.terminal, + TerminalSize { + width: args.columns, + ..size + }, + parser, + &base_dir, + args.resource_access, + syntax_set, + )?; + Ok(()) + } } } /// Represent command line arguments. -#[derive(Debug)] struct Arguments { filename: String, - terminal: Terminal, + terminal: Box>, resource_access: ResourceAccess, columns: usize, dump_events: bool, + detect_only: bool, } impl Arguments { /// Create command line arguments from matches. fn from_matches(matches: &clap::ArgMatches) -> clap::Result { - let terminal = match value_t!(matches, "colour", Colour)? { - Colour::No => Terminal::Dumb, - Colour::Yes => match Terminal::detect() { - Terminal::Dumb => Terminal::BasicAnsi, - other => other, - }, - Colour::Auto => Terminal::detect(), + let colour = value_t!(matches, "colour", Colour)?; + let terminal = if colour == Colour::No { + Box::new(DumbTerminal::new(stdout())) + } else { + let auto = detect_terminal(); + if !auto.supports_styles() && colour == Colour::Yes { + Box::new(AnsiTerminal::new(stdout())) + } else { + auto + } }; let filename = value_t!(matches, "filename", String)?; let dump_events = matches.is_present("dump_events"); + let detect_only = matches.is_present("detect_only"); let columns = value_t!(matches, "columns", usize)?; let resource_access = if matches.is_present("local_only") { ResourceAccess::LocalOnly @@ -147,6 +155,7 @@ impl Arguments { columns, resource_access, dump_events, + detect_only, terminal, }) } @@ -207,6 +216,12 @@ Report issues to .", .long("dump-events") .help("Dump Markdown parser events and exit") .hidden(true) + ) + .arg( + Arg::with_name("detect_only") + .long("detect-only") + .help("Only detect the terminal type and exit") + .hidden(true) ); let matches = app.get_matches(); diff --git a/src/terminal/ansi.rs b/src/terminal/ansi.rs new file mode 100644 index 00000000..390a96eb --- /dev/null +++ b/src/terminal/ansi.rs @@ -0,0 +1,122 @@ +// Copyright 2018 Sebastian Wiesner + +// Licensed 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. + +//! A standard Ansi terminal with no special features. + +use failure::Error; +use std::io; +use std::io::Write; + +use super::super::resources::{Resource, ResourceAccess}; +use super::error::NotSupportedError; +use super::types::{AnsiColour, AnsiStyle, Size}; +use super::write::Terminal; + +/// A simple ANSI terminal with support for basic ANSI styles. +/// +/// This represents most ordinary terminal emulators. +pub struct AnsiTerminal { + writer: W, +} + +impl AnsiTerminal { + /// Create a new ANSI terminal for th given writer. + pub fn new(writer: W) -> AnsiTerminal { + AnsiTerminal { writer } + } + + /// Write an OSC `command` to this terminal. + pub fn write_osc(&mut self, command: &str) -> io::Result<()> { + self.writer.write_all(&[0x1b, 0x5d])?; + self.writer.write_all(command.as_bytes())?; + self.writer.write_all(&[0x07])?; + Ok(()) + } + + /// Write a CSI SGR `command` to this terminal. + /// + /// See . + pub fn write_sgr(&mut self, command: &str) -> io::Result<()> { + self.writer.write_all(&[0x1b, 0x5b])?; + self.writer.write_all(command.as_bytes())?; + self.writer.write_all(&[0x6d])?; + Ok(()) + } + + /// Write an ANSI style to this terminal. + pub fn write_style(&mut self, style: AnsiStyle) -> io::Result<()> { + match style { + AnsiStyle::Reset => self.write_sgr(""), + AnsiStyle::Bold => self.write_sgr("1"), + AnsiStyle::Italic => self.write_sgr("3"), + AnsiStyle::Underline => self.write_sgr("4"), + AnsiStyle::NoItalic => self.write_sgr("23"), + AnsiStyle::Foreground(AnsiColour::Red) => self.write_sgr("31"), + AnsiStyle::Foreground(AnsiColour::Green) => self.write_sgr("32"), + AnsiStyle::Foreground(AnsiColour::Yellow) => self.write_sgr("33"), + AnsiStyle::Foreground(AnsiColour::Blue) => self.write_sgr("34"), + AnsiStyle::Foreground(AnsiColour::Magenta) => self.write_sgr("35"), + AnsiStyle::Foreground(AnsiColour::Cyan) => self.write_sgr("36"), + AnsiStyle::Foreground(AnsiColour::LightRed) => self.write_sgr("91"), + AnsiStyle::Foreground(AnsiColour::LightGreen) => self.write_sgr("92"), + AnsiStyle::Foreground(AnsiColour::LightYellow) => self.write_sgr("93"), + AnsiStyle::Foreground(AnsiColour::LightBlue) => self.write_sgr("94"), + AnsiStyle::Foreground(AnsiColour::LightMagenta) => self.write_sgr("95"), + AnsiStyle::Foreground(AnsiColour::LightCyan) => self.write_sgr("96"), + AnsiStyle::DefaultForeground => self.write_sgr("39"), + } + } +} + +impl Terminal for AnsiTerminal { + type TerminalWrite = W; + + fn name(&self) -> &'static str { + "ANSI" + } + + fn write(&mut self) -> &mut W { + &mut self.writer + } + + fn supports_styles(&self) -> bool { + true + } + + fn set_style(&mut self, style: AnsiStyle) -> Result<(), Error> { + self.write_style(style)?; + Ok(()) + } + + fn set_link(&mut self, _destination: &str) -> Result<(), Error> { + Err(NotSupportedError { + what: "inline links", + })? + } + + fn set_mark(&mut self) -> Result<(), Error> { + Err(NotSupportedError { what: "marks" })? + } + + fn write_inline_image( + &mut self, + _max_size: Size, + _resources: &Resource, + _access: ResourceAccess, + ) -> Result<(), Error> { + Err(NotSupportedError { + what: "inline images", + })? + } +} diff --git a/src/terminal/dumb.rs b/src/terminal/dumb.rs new file mode 100644 index 00000000..8e961640 --- /dev/null +++ b/src/terminal/dumb.rs @@ -0,0 +1,82 @@ +// Copyright 2018 Sebastian Wiesner + +// Licensed 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. + +//! A terminal with no features. + +use failure::Error; +use std::io::Write; + +use super::super::resources::{Resource, ResourceAccess}; +use super::error::NotSupportedError; +use super::types::{AnsiStyle, Size}; +use super::write::Terminal; + +/// A dumb terminal with no style support. +/// +/// With this terminal mdcat will render no special formatting at all. Use +/// when piping to other programs or when the terminal does not even support +/// ANSI codes. +pub struct DumbTerminal { + writer: W, +} + +impl DumbTerminal { + /// Create a new bump terminal for the given writer. + pub fn new(writer: W) -> DumbTerminal { + DumbTerminal { writer } + } +} + +impl Terminal for DumbTerminal { + type TerminalWrite = W; + + fn name(&self) -> &'static str { + "dumb" + } + + fn write(&mut self) -> &mut W { + &mut self.writer + } + + fn supports_styles(&self) -> bool { + false + } + + fn set_style(&mut self, _style: AnsiStyle) -> Result<(), Error> { + Err(NotSupportedError { + what: "ANSI styles", + })? + } + + fn set_link(&mut self, _destination: &str) -> Result<(), Error> { + Err(NotSupportedError { + what: "inline links", + })? + } + + fn set_mark(&mut self) -> Result<(), Error> { + Err(NotSupportedError { what: "marks" })? + } + + fn write_inline_image( + &mut self, + _max_size: Size, + _resources: &Resource, + _access: ResourceAccess, + ) -> Result<(), Error> { + Err(NotSupportedError { + what: "inline images", + })? + } +} diff --git a/src/terminal/error.rs b/src/terminal/error.rs new file mode 100644 index 00000000..a9feed4e --- /dev/null +++ b/src/terminal/error.rs @@ -0,0 +1,50 @@ +// Copyright 2018 Sebastian Wiesner + +// Licensed 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. + +//! Terminal errors. + +use failure::Error; + +/// The terminal does not support something. +#[derive(Debug, Fail)] +#[fail(display = "This terminal does not support {}.", what)] +pub struct NotSupportedError { + /// The operation which the terminal did not support. + pub what: &'static str, +} + +/// Ignore a `NotSupportedError`. +pub trait IgnoreNotSupported { + /// The type after ignoring `NotSupportedError`. + type R; + + /// Elide a `NotSupportedError` from this value. + fn ignore_not_supported(self) -> Self::R; +} + +impl IgnoreNotSupported for Error { + type R = Result<(), Error>; + + fn ignore_not_supported(self) -> Self::R { + self.downcast::().map(|_| ()) + } +} + +impl IgnoreNotSupported for Result<(), Error> { + type R = Result<(), Error>; + + fn ignore_not_supported(self) -> Self::R { + self.or_else(|err| err.ignore_not_supported()) + } +} diff --git a/src/highlighting.rs b/src/terminal/highlighting.rs similarity index 72% rename from src/highlighting.rs rename to src/terminal/highlighting.rs index 7a6dc1c6..44d293b7 100644 --- a/src/highlighting.rs +++ b/src/terminal/highlighting.rs @@ -14,8 +14,10 @@ //! Tools for syntax highlighting. -use super::terminal::{AnsiColour, AnsiStyle, TerminalWrite}; -use std::io::{Result, Write}; +use super::types::{AnsiColour, AnsiStyle}; +use super::write::Terminal; +use failure::Error; +use std::io::Write; use syntect::highlighting::{FontStyle, Style}; /// Write regions as ANSI 8-bit coloured text. @@ -33,10 +35,10 @@ use syntect::highlighting::{FontStyle, Style}; /// /// Furthermore we completely ignore any background colour settings, to avoid /// conflicts with the terminal colour themes. -pub fn write_as_ansi( - writer: &mut W, +pub fn write_as_ansi( + terminal: &mut Terminal, regions: &[(Style, &str)], -) -> Result<()> { +) -> Result<(), Error> { for &(style, text) in regions { let rgb = { let fg = style.foreground; @@ -51,31 +53,31 @@ pub fn write_as_ansi( | (0x83, 0x94, 0x96) | (0x93, 0xa1, 0xa1) | (0xee, 0xe8, 0xd5) - | (0xfd, 0xf6, 0xe3) => writer.write_style(AnsiStyle::DefaultForeground)?, - (0xb5, 0x89, 0x00) => writer.write_style(AnsiStyle::Foreground(AnsiColour::Yellow))?, // yellow - (0xcb, 0x4b, 0x16) => writer.write_style(AnsiStyle::Foreground(AnsiColour::LightRed))?, // orange - (0xdc, 0x32, 0x2f) => writer.write_style(AnsiStyle::Foreground(AnsiColour::Red))?, // red - (0xd3, 0x36, 0x82) => writer.write_style(AnsiStyle::Foreground(AnsiColour::Magenta))?, // magenta + | (0xfd, 0xf6, 0xe3) => terminal.set_style(AnsiStyle::DefaultForeground)?, + (0xb5, 0x89, 0x00) => terminal.set_style(AnsiStyle::Foreground(AnsiColour::Yellow))?, // yellow + (0xcb, 0x4b, 0x16) => terminal.set_style(AnsiStyle::Foreground(AnsiColour::LightRed))?, // orange + (0xdc, 0x32, 0x2f) => terminal.set_style(AnsiStyle::Foreground(AnsiColour::Red))?, // red + (0xd3, 0x36, 0x82) => terminal.set_style(AnsiStyle::Foreground(AnsiColour::Magenta))?, // magenta (0x6c, 0x71, 0xc4) => { - writer.write_style(AnsiStyle::Foreground(AnsiColour::LightMagenta))? + terminal.set_style(AnsiStyle::Foreground(AnsiColour::LightMagenta))? } // violet - (0x26, 0x8b, 0xd2) => writer.write_style(AnsiStyle::Foreground(AnsiColour::Blue))?, // blue - (0x2a, 0xa1, 0x98) => writer.write_style(AnsiStyle::Foreground(AnsiColour::Cyan))?, // cyan - (0x85, 0x99, 0x00) => writer.write_style(AnsiStyle::Foreground(AnsiColour::Green))?, // green + (0x26, 0x8b, 0xd2) => terminal.set_style(AnsiStyle::Foreground(AnsiColour::Blue))?, // blue + (0x2a, 0xa1, 0x98) => terminal.set_style(AnsiStyle::Foreground(AnsiColour::Cyan))?, // cyan + (0x85, 0x99, 0x00) => terminal.set_style(AnsiStyle::Foreground(AnsiColour::Green))?, // green (r, g, b) => panic!("Unexpected RGB colour: #{:2>0x}{:2>0x}{:2>0x}", r, g, b), }; let font = style.font_style; if font.contains(FontStyle::BOLD) { - writer.write_style(AnsiStyle::Bold)?; + terminal.set_style(AnsiStyle::Bold)?; }; if font.contains(FontStyle::ITALIC) { - writer.write_style(AnsiStyle::Italic)?; + terminal.set_style(AnsiStyle::Italic)?; }; if font.contains(FontStyle::UNDERLINE) { - writer.write_style(AnsiStyle::Underline)?; + terminal.set_style(AnsiStyle::Underline)?; }; - writer.write_all(text.as_bytes())?; - writer.write_style(AnsiStyle::Reset)?; + terminal.write().write_all(text.as_bytes())?; + terminal.set_style(AnsiStyle::Reset)?; } Ok(()) diff --git a/src/terminal/iterm2.rs b/src/terminal/iterm2.rs index b4b40114..babc6f71 100644 --- a/src/terminal/iterm2.rs +++ b/src/terminal/iterm2.rs @@ -14,56 +14,125 @@ //! Iterm2 specific functions -use super::super::magic; -use super::super::svg; -use super::{NotSupportedError, TerminalWrite}; use base64; use failure::Error; use mime; +use std; use std::ffi::OsStr; use std::io; use std::io::Write; use std::os::unix::ffi::OsStrExt; -/// Write an iterm2 mark; -pub fn write_mark(writer: &mut W) -> io::Result<()> { - writer.write_osc("1337;SetMark") +use super::super::magic; +use super::super::resources::{Resource, ResourceAccess}; +use super::super::svg; +use super::ansi::AnsiTerminal; +use super::error::NotSupportedError; +use super::types::{AnsiStyle, Size}; +use super::write::Terminal; + +/// The iTerm2 terminal. +/// +/// iTerm2 is a powerful macOS terminal emulator with many formatting +/// features, including images and inline links. +/// +/// See for more information. +pub struct ITerm2 { + ansi: AnsiTerminal, } -fn write_image_contents>( - writer: &mut W, - name: S, - contents: &[u8], -) -> io::Result<()> { - writer.write_osc(&format!( - "1337;File=name={};inline=1:{}", - base64::encode(name.as_ref().as_bytes()), - base64::encode(contents) - )) +/// Whether we run inside iTerm2 or not. +pub fn is_iterm2() -> bool { + std::env::var("TERM_PROGRAM") + .map(|value| value.contains("iTerm.app")) + .unwrap_or(false) } -/// Write an iterm2 inline image. -/// -/// `name` is the file name of the image, and `contents` holds the image contents. -pub fn write_inline_image>( - writer: &mut W, - name: S, - contents: &[u8], -) -> Result<(), Error> { - let mime = magic::detect_mime_type(contents)?; - match (mime.type_(), mime.subtype()) { - (mime::IMAGE, mime::PNG) - | (mime::IMAGE, mime::GIF) - | (mime::IMAGE, mime::JPEG) - | (mime::IMAGE, mime::BMP) => { - write_image_contents(writer, name, contents).map_err(Into::into) - } - (mime::IMAGE, subtype) if subtype.as_str() == "svg" => { - let png = svg::render_svg(contents)?; - write_image_contents(writer, name, &png).map_err(Into::into) +impl ITerm2 { + /// Create an iTerm2 terminal over an underlying ANSI terminal. + pub fn new(ansi: AnsiTerminal) -> ITerm2 { + ITerm2 { ansi } + } + + fn write_image_contents>( + &mut self, + name: S, + contents: &[u8], + ) -> io::Result<()> { + self.ansi.write_osc(&format!( + "1337;File=name={};inline=1:{}", + base64::encode(name.as_ref().as_bytes()), + base64::encode(contents) + )) + } + + /// Write an iterm2 inline image. + /// + /// `name` is the file name of the image, and `contents` holds the image + /// contents. + pub fn write_inline_image>( + &mut self, + name: S, + contents: &[u8], + ) -> Result<(), Error> { + let mime = magic::detect_mime_type(contents)?; + match (mime.type_(), mime.subtype()) { + (mime::IMAGE, mime::PNG) + | (mime::IMAGE, mime::GIF) + | (mime::IMAGE, mime::JPEG) + | (mime::IMAGE, mime::BMP) => self + .write_image_contents(name, contents) + .map_err(Into::into), + (mime::IMAGE, subtype) if subtype.as_str() == "svg" => { + let png = svg::render_svg(contents)?; + self.write_image_contents(name, &png).map_err(Into::into) + } + _ => Err(NotSupportedError { + what: "inline image with mimetype", + }.into()), } - _ => Err(NotSupportedError { - what: "inline image with mimetype", - }.into()), + } +} + +impl Terminal for ITerm2 { + type TerminalWrite = W; + + fn name(&self) -> &'static str { + "iTerm2" + } + + fn write(&mut self) -> &mut W { + self.ansi.write() + } + + fn supports_styles(&self) -> bool { + self.ansi.supports_styles() + } + + fn set_style(&mut self, style: AnsiStyle) -> Result<(), Error> { + self.ansi.set_style(style) + } + + fn set_link(&mut self, destination: &str) -> Result<(), Error> { + self.ansi.write_osc(&format!("8;;{}", destination))?; + Ok(()) + } + + fn set_mark(&mut self) -> Result<(), Error> { + self.ansi.write_osc("1337;SetMark")?; + Ok(()) + } + + fn write_inline_image( + &mut self, + _max_size: Size, + resource: &Resource, + access: ResourceAccess, + ) -> Result<(), Error> { + resource.read(access).and_then(|contents| { + self.write_inline_image(resource.as_str().as_ref(), &contents) + .map_err(Into::into) + })?; + Ok(()) } } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index f0ba72f8..cc962a92 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -14,379 +14,70 @@ //! Terminal utilities. -use super::resources::{Resource, ResourceAccess}; -use atty; -use failure::Error; -use std; -use std::io; -use std::io::prelude::*; -use term_size; - -#[cfg(target_os = "macos")] +// Support modules for terminal writing. +mod error; +mod highlighting; +mod types; +mod write; + +// Terminal implementations; +mod ansi; +mod dumb; +#[cfg(feature = "iterm2")] mod iterm2; -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(feature = "terminology")] mod terminology; +mod vte50; -/// The size of a text terminal. -#[derive(Debug, Copy, Clone)] -pub struct Size { - /// The width of the terminal, in characters aka columns. - pub width: usize, - /// The height of the terminal, in lines. - pub height: usize, -} - -impl Default for Size { - /// A good default size assumption for a terminal: 80x24. - fn default() -> Size { - Size { - width: 80, - height: 24, - } - } -} - -impl Size { - fn new(width: usize, height: usize) -> Size { - Size { width, height } - } - - /// Get terminal size from `$COLUMNS` and `$LINES`. - fn from_env() -> Option { - let columns = std::env::var("COLUMNS") - .ok() - .and_then(|value| value.parse::().ok()); - let rows = std::env::var("LINES") - .ok() - .and_then(|value| value.parse::().ok()); - - match (columns, rows) { - (Some(columns), Some(rows)) => Some(Size::new(columns, rows)), - _ => None, - } - } - - /// Detect the terminal size. - /// - /// Get the terminal size from the underlying TTY, and fallback to - /// `$COLUMNS` and `$LINES`. - pub fn detect() -> Option { - term_size::dimensions() - .map(|(w, h)| Size::new(w, h)) - .or_else(Size::from_env) - } -} - -/// An ANSI colour. -#[derive(Debug, Copy, Clone)] -#[allow(dead_code)] -pub enum AnsiColour { - Red, - Green, - Yellow, - Blue, - Magenta, - Cyan, - LightRed, - LightGreen, - LightYellow, - LightBlue, - LightMagenta, - LightCyan, -} - -/// An ANSI style to enable on a terminal. -#[derive(Debug, Copy, Clone)] -#[allow(dead_code)] -pub enum AnsiStyle { - Reset, - Bold, - Italic, - NoItalic, - Underline, - Foreground(AnsiColour), - DefaultForeground, -} - -/// A trait to provide terminal escape code for any `Write` implementation -pub trait TerminalWrite { - /// Write a OSC `command`. - fn write_osc(&mut self, command: &str) -> io::Result<()>; - - /// Write a CSI SGR `command`. - /// - /// See . - fn write_sgr(&mut self, command: &str) -> io::Result<()>; - - /// Write an ANSI style. - fn write_style(&mut self, style: AnsiStyle) -> io::Result<()> { - match style { - AnsiStyle::Reset => self.write_sgr(""), - AnsiStyle::Bold => self.write_sgr("1"), - AnsiStyle::Italic => self.write_sgr("3"), - AnsiStyle::Underline => self.write_sgr("4"), - AnsiStyle::NoItalic => self.write_sgr("23"), - AnsiStyle::Foreground(AnsiColour::Red) => self.write_sgr("31"), - AnsiStyle::Foreground(AnsiColour::Green) => self.write_sgr("32"), - AnsiStyle::Foreground(AnsiColour::Yellow) => self.write_sgr("33"), - AnsiStyle::Foreground(AnsiColour::Blue) => self.write_sgr("34"), - AnsiStyle::Foreground(AnsiColour::Magenta) => self.write_sgr("35"), - AnsiStyle::Foreground(AnsiColour::Cyan) => self.write_sgr("36"), - AnsiStyle::Foreground(AnsiColour::LightRed) => self.write_sgr("91"), - AnsiStyle::Foreground(AnsiColour::LightGreen) => self.write_sgr("92"), - AnsiStyle::Foreground(AnsiColour::LightYellow) => self.write_sgr("93"), - AnsiStyle::Foreground(AnsiColour::LightBlue) => self.write_sgr("94"), - AnsiStyle::Foreground(AnsiColour::LightMagenta) => self.write_sgr("95"), - AnsiStyle::Foreground(AnsiColour::LightCyan) => self.write_sgr("96"), - AnsiStyle::DefaultForeground => self.write_sgr("39"), - } - } -} - -impl TerminalWrite for T -where - T: Write, -{ - fn write_osc(&mut self, command: &str) -> io::Result<()> { - self.write_all(&[0x1b, 0x5d])?; - self.write_all(command.as_bytes())?; - self.write_all(&[0x07])?; - Ok(()) - } - - fn write_sgr(&mut self, command: &str) -> io::Result<()> { - self.write_all(&[0x1b, 0x5b])?; - self.write_all(command.as_bytes())?; - self.write_all(&[0x6d])?; - Ok(()) - } -} +use atty; +use std::io; -/// The terminal mdcat writes to. +#[cfg(feature = "iterm2")] +use self::iterm2::*; +#[cfg(feature = "terminology")] +use self::terminology::*; +use self::vte50::*; + +// Export types. +pub use self::ansi::AnsiTerminal; +pub use self::dumb::DumbTerminal; +pub use self::error::IgnoreNotSupported; +pub use self::highlighting::write_as_ansi; +pub use self::types::{AnsiColour, AnsiStyle, Size}; +pub use self::write::Terminal; + +/// Detect the terminal on stdout. /// -/// The terminal denotes what features mdcat can use when rendering markdown. -/// Features range from nothing at all on dumb terminals, to basic ANSI styling, -/// to inline links and inline images in some select terminal emulators. -#[derive(Debug, Copy, Clone)] -pub enum Terminal { - /// iTerm2. - /// - /// iTerm2 is a powerful macOS terminal emulator with many formatting - /// features, including images and inline links. - /// - /// See for more information. - ITerm2, - /// Terminology. - /// - /// Terminology is a terminal written for the Enlightenment window manager - /// using the powerful EFL libraries. It supports inline links and inline - /// images. - /// - /// See for more information. - Terminology, - /// A generic terminal based on a modern VTE version. - /// - /// VTE is Gnome library for terminal emulators. It powers some notable - /// terminal emulators like Gnome Terminal, and embedded terminals in - /// applications like GEdit. - /// - /// VTE 0.50 or newer support inline links. Older versions do not; we - /// recognize these as `BasicAnsi`. - GenericVTE50, - /// A terminal which supports basic ANSI sequences. - /// - /// Most terminal emulators fall into this category. - BasicAnsi, - /// A dumb terminal that supports no formatting. - /// - /// With this terminal mdcat will render no special formatting at all. Use - /// when piping to other programs or when the terminal does not even support - /// ANSI codes. - Dumb, -} - -/// The terminal does not support something. -#[derive(Debug, Fail)] -#[fail(display = "This terminal does not support {}.", what)] -pub struct NotSupportedError { - /// The operation which the terminal did not support. - pub what: &'static str, -} - -/// Ignore a `NotSupportedError`. -pub trait IgnoreNotSupported { - /// The type after ignoring `NotSupportedError`. - type R; - - /// Elide a `NotSupportedError` from this value. - fn ignore_not_supported(self) -> Self::R; -} - -impl IgnoreNotSupported for Error { - type R = Result<(), Error>; - - fn ignore_not_supported(self) -> Self::R { - self.downcast::().map(|_| ()) - } -} - -impl IgnoreNotSupported for Result<(), Error> { - type R = Result<(), Error>; - - fn ignore_not_supported(self) -> Self::R { - self.or_else(|err| err.ignore_not_supported()) - } -} - -/// Get the version of VTE underlying this terminal. +/// If stdout links to a TTY look at different pieces of information, in +/// particular environment variables set by terminal emulators, to figure +/// out what terminal emulator we run in. /// -/// Return `(minor, patch)` if this terminal uses VTE, otherwise return `None`. -fn get_vte_version() -> Option<(u8, u8)> { - std::env::var("VTE_VERSION").ok().and_then(|value| { - value[..2] - .parse::() - .into_iter() - .zip(value[2..4].parse::()) - .next() - }) -} - -impl Terminal { - /// Detect the underlying terminal application. - /// - /// If stdout links to a TTY look at various pieces of information, in - /// particular environment variables set by terminal emulators, to figure - /// out what terminal emulator we run in. - /// - /// If stdout does not link to a TTY assume a `Dumb` terminal which cannot - /// format anything. - pub fn detect() -> Terminal { - if atty::is(atty::Stream::Stdout) { - if cfg!(feature = "iterm") - && std::env::var("TERM_PROGRAM") - .map(|value| value.contains("iTerm.app")) - .unwrap_or(false) - { - Terminal::ITerm2 - } else if std::env::var("TERMINOLOGY") - .map(|value| value.trim() == "1") - .unwrap_or(false) +/// If stdout does not link to a TTY assume a `Dumb` terminal which cannot +/// format anything. +pub fn detect_terminal() -> Box> { + if atty::is(atty::Stream::Stdout) { + let ansi = AnsiTerminal::new(io::stdout()); + // Pattern matching lets use feature-switch branches, depending on + // enabled terminal support. In an if chain we can't do this, so that's + // why we have this weird match here. Note: Don't use true here because + // that makes clippy complain. + match 1 { + #[cfg(feature = "iterm2")] + _ if iterm2::is_iterm2() => { - Terminal::Terminology - } else { - match get_vte_version() { - Some(version) if version >= (50, 0) => Terminal::GenericVTE50, - _ => Terminal::BasicAnsi, - } - } - } else { - Terminal::Dumb - } - } - - /// Whether this terminal supports colours. - pub fn supports_colours(self) -> bool { - if let Terminal::Dumb = self { - false - } else { - true - } - } - - /// Set a style on this terminal. - pub fn set_style( - self, - writer: &mut W, - style: AnsiStyle, - ) -> Result<(), Error> { - if self.supports_colours() { - writer.write_style(style)?; - Ok(()) - } else { - Err(NotSupportedError { - what: "ANSI styles", - }.into()) - } - } - - /// Write an inline image. - /// - /// Only supported for some terminal emulators. - #[cfg(unix)] - #[allow(unused_variables)] - pub fn write_inline_image( - self, - writer: &mut W, - max_size: Size, - resource: &Resource, - resource_access: ResourceAccess, - ) -> Result<(), Error> { - match self { - #[cfg(target_os = "macos")] - Terminal::ITerm2 => resource.read(resource_access).and_then(|contents| { - iterm2::write_inline_image(writer, resource.as_str().as_ref(), &contents) - .map_err(Into::into) - })?, - #[cfg(all(unix, not(target_os = "macos")))] - Terminal::Terminology => { - terminology::write_inline_image(writer, max_size, resource, resource_access)? + Box::new(ITerm2::new(ansi)) } - _ => Err(NotSupportedError { - what: "inline images", - })?, - } - Ok(()) - } - - /// Write an inline image. - /// - /// Not supported on windows at all. - #[cfg(windows)] - pub fn write_inline_image( - self, - _writer: &mut W, - _max_size: Size, - _resource: &Resource, - _resource_access: ResourceAccess, - ) -> Result<(), Error> { - Err(NotSupportedError { - what: "inline images", - })? - } - - /// Set the link for the subsequent text. - /// - /// To stop a link write a link to an empty destination. - pub fn set_link(self, writer: &mut W, destination: &str) -> Result<(), Error> { - match self { - #[cfg(target_os = "macos")] - Terminal::ITerm2 => writer.write_osc(&format!("8;;{}", destination))?, - Terminal::Terminology | Terminal::GenericVTE50 => { - writer.write_osc(&format!("8;;{}", destination))? + #[cfg(feature = "terminology")] + _ if terminology::is_terminology() => + { + Box::new(Terminology::new(ansi)) } - _ => Err(NotSupportedError { - what: "inline links", - })?, + _ => match vte50::get_vte_version() { + Some(version) if version >= (50, 0) => Box::new(VTE50Terminal::new(ansi)), + _ => Box::new(ansi), + }, } - Ok(()) - } - - /// Set a mark in the current terminal. - /// - /// Only supported by iTerm2 currently. - #[cfg(target_os = "macos")] - pub fn set_mark(self, writer: &mut W) -> Result<(), Error> { - if let Terminal::ITerm2 = self { - iterm2::write_mark(writer)? - } else { - Err(NotSupportedError { what: "marks" })? - }; - Ok(()) - } - - /// Set a mark in the current terminal. - #[cfg(not(target_os = "macos"))] - pub fn set_mark(self, _writer: &mut W) -> Result<(), Error> { - Err(NotSupportedError { what: "marks" })? + } else { + Box::new(DumbTerminal::new(io::stdout())) } } diff --git a/src/terminal/terminology.rs b/src/terminal/terminology.rs index 8a4d9a89..fb2169fc 100644 --- a/src/terminal/terminology.rs +++ b/src/terminal/terminology.rs @@ -16,61 +16,114 @@ //! //! [Terminology]: http://terminolo.gy -use super::super::resources::{Resource, ResourceAccess}; -use super::*; use failure::Error; use immeta; +use std; use std::io::{ErrorKind, Write}; -/// Write an inline image denoted by `resource` for Terminology. +use super::super::resources::{Resource, ResourceAccess}; +use super::*; + +/// Whether we run in terminology or not. +pub fn is_terminology() -> bool { + std::env::var("TERMINOLOGY") + .map(|value| value.trim() == "1") + .unwrap_or(false) +} + +/// The Terminology terminal. /// -/// The image may extend at most `max_size`. +/// Terminology is a terminal written for the Enlightenment window manager +/// using the powerful EFL libraries. It supports inline links and inline +/// images. /// -/// If `resource` denotes a remote image fail with a `NotSupported` error. -pub fn write_inline_image( - writer: &mut W, - max_size: Size, - resource: &Resource, - resource_access: ResourceAccess, -) -> Result<(), Error> { - // Terminology escape sequence is like: set texture to path, then draw a - // rectangle of chosen character to be replaced by the given - // texture. Documentation gives the following C example: - // - // printf("\033}is#5;3;%s\000" - // "\033}ib\000#####\033}ie\000\n" - // "\033}ib\000#####\033}ie\000\n" - // "\033}ib\000#####\033}ie\000\n", "/tmp/icon.png"); - // - // We need to compute image proportion to draw the appropriate rectangle. - // If we can't compute the image proportion (e.g. it's an external URL), we - // fallback to a rectangle that is half of the screen. - - if resource.may_access(resource_access) { - let columns = max_size.width; - let lines = resource - .local_path() - .and_then(|path| immeta::load_from_file(path).ok()) - .map(|m| { - let d = m.dimensions(); - let (w, h) = (f64::from(d.width), f64::from(d.height)); - // We divide by 2 because terminal cursor/font most likely has a - // 1:2 proportion - (h * (columns / 2) as f64 / w) as usize - }) - .unwrap_or(max_size.height / 2); - - let mut command = format!("\x1b}}ic#{};{};{}\x00", columns, lines, resource.as_str()); - for _ in 0..lines { - command.push_str("\x1b}ib\x00"); - for _ in 0..columns { - command.push('#'); +/// See for more information. +pub struct Terminology { + ansi: AnsiTerminal, +} + +impl Terminology { + /// Create a Terminology terminal over an underlying ANSI terminal. + pub fn new(ansi: AnsiTerminal) -> Terminology { + Terminology { ansi } + } +} + +impl Terminal for Terminology { + type TerminalWrite = W; + + fn name(&self) -> &'static str { + "Terminology" + } + + fn write(&mut self) -> &mut W { + self.ansi.write() + } + + fn supports_styles(&self) -> bool { + self.ansi.supports_styles() + } + + fn set_style(&mut self, style: AnsiStyle) -> Result<(), Error> { + self.ansi.set_style(style) + } + + fn set_link(&mut self, destination: &str) -> Result<(), Error> { + self.ansi.write_osc(&format!("8;;{}", destination))?; + Ok(()) + } + + fn set_mark(&mut self) -> Result<(), Error> { + self.ansi.set_mark() + } + + fn write_inline_image( + &mut self, + max_size: Size, + resource: &Resource, + resource_access: ResourceAccess, + ) -> Result<(), Error> { + // Terminology escape sequence is like: set texture to path, then draw a + // rectangle of chosen character to be replaced by the given texture. + // Documentation gives the following C example: + // + // printf("\033}is#5;3;%s\000" + // "\033}ib\000#####\033}ie\000\n" + // "\033}ib\000#####\033}ie\000\n" + // "\033}ib\000#####\033}ie\000\n", "/tmp/icon.png"); + // + // We need to compute image proportion to draw the appropriate + // rectangle. If we can't compute the image proportion (e.g. it's an + // external URL), we fallback to a rectangle that is half of the screen. + if resource.may_access(resource_access) { + let columns = max_size.width; + let lines = resource + .local_path() + .and_then(|path| immeta::load_from_file(path).ok()) + .map(|m| { + let d = m.dimensions(); + let (w, h) = (f64::from(d.width), f64::from(d.height)); + // We divide by 2 because terminal cursor/font most likely has a + // 1:2 proportion + (h * (columns / 2) as f64 / w) as usize + }) + .unwrap_or(max_size.height / 2); + + let mut command = format!("\x1b}}ic#{};{};{}\x00", columns, lines, resource.as_str()); + for _ in 0..lines { + command.push_str("\x1b}ib\x00"); + for _ in 0..columns { + command.push('#'); + } + command.push_str("\x1b}ie\x00\n"); } - command.push_str("\x1b}ie\x00\n"); + self.ansi.write().write_all(command.as_bytes())?; + Ok(()) + } else { + Err( + std::io::Error::new(ErrorKind::PermissionDenied, "Remote resources not allowed") + .into(), + ) } - writer.write_all(command.as_bytes())?; - Ok(()) - } else { - Err(std::io::Error::new(ErrorKind::PermissionDenied, "Remote resources not allowed").into()) } } diff --git a/src/terminal/types.rs b/src/terminal/types.rs new file mode 100644 index 00000000..1fe90747 --- /dev/null +++ b/src/terminal/types.rs @@ -0,0 +1,99 @@ +// Copyright 2018 Sebastian Wiesner + +// Licensed 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. + +//! Basic terminal types + +use std; +use term_size; + +/// The size of a text terminal. +#[derive(Debug, Copy, Clone)] +pub struct Size { + /// The width of the terminal, in characters aka columns. + pub width: usize, + /// The height of the terminal, in lines. + pub height: usize, +} + +impl Default for Size { + /// A good default size assumption for a terminal: 80x24. + fn default() -> Size { + Size { + width: 80, + height: 24, + } + } +} + +impl Size { + fn new(width: usize, height: usize) -> Size { + Size { width, height } + } + + /// Get terminal size from `$COLUMNS` and `$LINES`. + pub fn from_env() -> Option { + let columns = std::env::var("COLUMNS") + .ok() + .and_then(|value| value.parse::().ok()); + let rows = std::env::var("LINES") + .ok() + .and_then(|value| value.parse::().ok()); + + match (columns, rows) { + (Some(columns), Some(rows)) => Some(Size::new(columns, rows)), + _ => None, + } + } + + /// Detect the terminal size. + /// + /// Get the terminal size from the underlying TTY, and fallback to + /// `$COLUMNS` and `$LINES`. + pub fn detect() -> Option { + term_size::dimensions() + .map(|(w, h)| Size::new(w, h)) + .or_else(Size::from_env) + } +} + +/// An ANSI colour. +#[derive(Debug, Copy, Clone)] +#[allow(dead_code)] +pub enum AnsiColour { + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + LightRed, + LightGreen, + LightYellow, + LightBlue, + LightMagenta, + LightCyan, +} + +/// An ANSI style to enable on a terminal. +#[derive(Debug, Copy, Clone)] +#[allow(dead_code)] +pub enum AnsiStyle { + Reset, + Bold, + Italic, + NoItalic, + Underline, + Foreground(AnsiColour), + DefaultForeground, +} diff --git a/src/terminal/vte50.rs b/src/terminal/vte50.rs new file mode 100644 index 00000000..efea5d1c --- /dev/null +++ b/src/terminal/vte50.rs @@ -0,0 +1,94 @@ +// Copyright 2018 Sebastian Wiesner + +// Licensed 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. + +//! VTE newer than 50. + +use failure::Error; +use std; +use std::io::Write; + +use super::super::resources::{Resource, ResourceAccess}; +use super::ansi::AnsiTerminal; +use super::types::{AnsiStyle, Size}; +use super::write::Terminal; + +/// Get the version of VTE underlying this terminal. +/// +/// Return `(minor, patch)` if this terminal uses VTE, otherwise return `None`. +pub fn get_vte_version() -> Option<(u8, u8)> { + std::env::var("VTE_VERSION").ok().and_then(|value| { + value[..2] + .parse::() + .into_iter() + .zip(value[2..4].parse::()) + .next() + }) +} + +/// A generic terminal based on a modern VTE (>= 50) version. +/// +/// VTE is Gnome library for terminal emulators. It powers some notable +/// terminal emulators like Gnome Terminal, and embedded terminals in +/// applications like GEdit. +/// +/// VTE 0.50 or newer support inline links. Older versions do not; we +/// recognize these as `BasicAnsi`. +pub struct VTE50Terminal { + ansi: AnsiTerminal, +} + +impl VTE50Terminal { + /// Create a VTE 50 terminal over an underlying ANSI terminal. + pub fn new(ansi: AnsiTerminal) -> VTE50Terminal { + VTE50Terminal { ansi } + } +} + +impl Terminal for VTE50Terminal { + type TerminalWrite = W; + + fn name(&self) -> &'static str { + "VTE 50" + } + + fn write(&mut self) -> &mut W { + self.ansi.write() + } + + fn supports_styles(&self) -> bool { + self.ansi.supports_styles() + } + + fn set_style(&mut self, style: AnsiStyle) -> Result<(), Error> { + self.ansi.set_style(style) + } + + fn set_link(&mut self, destination: &str) -> Result<(), Error> { + self.ansi.write_osc(&format!("8;;{}", destination))?; + Ok(()) + } + + fn set_mark(&mut self) -> Result<(), Error> { + self.ansi.set_mark() + } + + fn write_inline_image( + &mut self, + max_size: Size, + resources: &Resource, + access: ResourceAccess, + ) -> Result<(), Error> { + self.ansi.write_inline_image(max_size, resources, access) + } +} diff --git a/src/terminal/write.rs b/src/terminal/write.rs new file mode 100644 index 00000000..34aeedba --- /dev/null +++ b/src/terminal/write.rs @@ -0,0 +1,62 @@ +// Copyright 2018 Sebastian Wiesner + +// Licensed 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. + +//! Writer for terminals. + +use super::super::resources::{Resource, ResourceAccess}; +use super::types::{AnsiStyle, Size}; +use failure::Error; +use std::io::Write; + +/// Write to terminals. +pub trait Terminal { + /// The associated writer of this terminal. + type TerminalWrite: Write; + + /// Get a descriptive name for this terminal. + fn name(&self) -> &str; + + /// Get a writer for this terminal. + fn write(&mut self) -> &mut Self::TerminalWrite; + + /// Whether this terminal supports styles. + fn supports_styles(&self) -> bool; + + /// Active a style on the terminal. + /// + /// The default implementation errors with `NotSupportedError`. + fn set_style(&mut self, style: AnsiStyle) -> Result<(), Error>; + + /// Set a link to the given destination on the terminal. + /// + /// To stop a link write a link with an empty destination. + /// + /// The default implementation errors with `NotSupportedError`. + fn set_link(&mut self, destination: &str) -> Result<(), Error>; + + /// Set a jump mark on the terminal. + /// + /// The default implementation errors with `NotSupportedError`. + fn set_mark(&mut self) -> Result<(), Error>; + + /// Write an inline image from the given resource to the terminal. + /// + /// The default implementation errors with `NotSupportedError`. + fn write_inline_image( + &mut self, + max_size: Size, + resource: &Resource, + access: ResourceAccess, + ) -> Result<(), Error>; +}