Skip to content
This repository was archived by the owner on Jun 15, 2024. It is now read-only.

Commit 10859b1

Browse files
committed
add basic support for ansi escape sequences in UI (#91)
1 parent eaf3766 commit 10859b1

File tree

9 files changed

+440
-29
lines changed

9 files changed

+440
-29
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The official release will have a defined and more stable API. If you are already
88
# 0.8.0
99
* Improvements:
1010
* UI: Trigger symbol is now visible before a manual trigger is reached (#97)
11+
* UI: Console output now supports basic ANSI escape sequences (#91)
1112
* Bug fixes:
1213
* UI did not display the second detail if it had the same label as the first (#98)
1314
* Breaking Changes:

ansiparse-externs.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
function ansiparse(str) {}
2+
ansiparse.foregroundColors = {
3+
"30": {},
4+
"31": {},
5+
"32": {},
6+
"33": {},
7+
"34": {},
8+
"35": {},
9+
"36": {},
10+
"37": {},
11+
"90": {}
12+
}
13+
ansiparse.backgroundColors= {
14+
"40": {},
15+
"41": {},
16+
"42": {},
17+
"43": {},
18+
"44": {},
19+
"45": {},
20+
"46": {},
21+
"47": {}
22+
}
23+
ansiparse.styles = {
24+
"1": {},
25+
"3": {},
26+
"4": {}
27+
}

ansiparse.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
ansiparse = function (str) {
2+
//
3+
// I'm terrible at writing parsers.
4+
//
5+
var matchingControl = null,
6+
matchingData = null,
7+
matchingText = '',
8+
ansiState = [],
9+
result = [],
10+
state = {},
11+
eraseChar;
12+
13+
//
14+
// General workflow for this thing is:
15+
// \033\[33mText
16+
// | | |
17+
// | | matchingText
18+
// | matchingData
19+
// matchingControl
20+
//
21+
// In further steps we hope it's all going to be fine. It usually is.
22+
//
23+
24+
//
25+
// Erases a char from the output
26+
//
27+
eraseChar = function () {
28+
var index, text;
29+
if (matchingText.length) {
30+
matchingText = matchingText.substr(0, matchingText.length - 1);
31+
}
32+
else if (result.length) {
33+
index = result.length - 1;
34+
text = result[index].text;
35+
if (text.length === 1) {
36+
//
37+
// A result bit was fully deleted, pop it out to simplify the final output
38+
//
39+
result.pop();
40+
}
41+
else {
42+
result[index].text = text.substr(0, text.length - 1);
43+
}
44+
}
45+
};
46+
47+
for (var i = 0; i < str.length; i++) {
48+
if (matchingControl != null) {
49+
if (matchingControl == '\033' && str[i] == '\[') {
50+
//
51+
// We've matched full control code. Lets start matching formating data.
52+
//
53+
54+
//
55+
// "emit" matched text with correct state
56+
//
57+
if (matchingText) {
58+
state.text = matchingText;
59+
result.push(state);
60+
state = {};
61+
matchingText = "";
62+
}
63+
64+
matchingControl = null;
65+
matchingData = '';
66+
}
67+
else {
68+
//
69+
// We failed to match anything - most likely a bad control code. We
70+
// go back to matching regular strings.
71+
//
72+
matchingText += matchingControl + str[i];
73+
matchingControl = null;
74+
}
75+
continue;
76+
}
77+
else if (matchingData != null) {
78+
if (str[i] == ';') {
79+
//
80+
// `;` separates many formatting codes, for example: `\033[33;43m`
81+
// means that both `33` and `43` should be applied.
82+
//
83+
// TODO: this can be simplified by modifying state here.
84+
//
85+
ansiState.push(matchingData);
86+
matchingData = '';
87+
}
88+
else if (str[i] == 'm') {
89+
//
90+
// `m` finished whole formatting code. We can proceed to matching
91+
// formatted text.
92+
//
93+
ansiState.push(matchingData);
94+
matchingData = null;
95+
matchingText = '';
96+
97+
//
98+
// Convert matched formatting data into user-friendly state object.
99+
//
100+
// TODO: DRY.
101+
//
102+
ansiState.forEach(function (ansiCode) {
103+
if (ansiparse.foregroundColors[ansiCode]) {
104+
state.foreground = ansiparse.foregroundColors[ansiCode];
105+
}
106+
else if (ansiparse.backgroundColors[ansiCode]) {
107+
state.background = ansiparse.backgroundColors[ansiCode];
108+
}
109+
else if (ansiCode == 39) {
110+
delete state.foreground;
111+
}
112+
else if (ansiCode == 49) {
113+
delete state.background;
114+
}
115+
else if (ansiparse.styles[ansiCode]) {
116+
state[ansiparse.styles[ansiCode]] = true;
117+
}
118+
else if (ansiCode == 22) {
119+
state.bold = false;
120+
}
121+
else if (ansiCode == 23) {
122+
state.italic = false;
123+
}
124+
else if (ansiCode == 24) {
125+
state.underline = false;
126+
}
127+
});
128+
ansiState = [];
129+
}
130+
else {
131+
matchingData += str[i];
132+
}
133+
continue;
134+
}
135+
136+
if (str[i] == '\033') {
137+
matchingControl = str[i];
138+
}
139+
else if (str[i] == '\u0008') {
140+
eraseChar();
141+
}
142+
else {
143+
matchingText += str[i];
144+
}
145+
}
146+
147+
if (matchingText) {
148+
state.text = matchingText + (matchingControl ? matchingControl : '');
149+
result.push(state);
150+
}
151+
return result;
152+
}
153+
154+
ansiparse.foregroundColors = {
155+
'30': 'black',
156+
'31': 'red',
157+
'32': 'green',
158+
'33': 'yellow',
159+
'34': 'blue',
160+
'35': 'magenta',
161+
'36': 'cyan',
162+
'37': 'white',
163+
'90': 'grey'
164+
};
165+
166+
ansiparse.backgroundColors = {
167+
'40': 'black',
168+
'41': 'red',
169+
'42': 'green',
170+
'43': 'yellow',
171+
'44': 'blue',
172+
'45': 'magenta',
173+
'46': 'cyan',
174+
'47': 'white'
175+
};
176+
177+
ansiparse.styles = {
178+
'1': 'bold',
179+
'3': 'italic',
180+
'4': 'underline'
181+
};
182+
183+
if (typeof module == "object" && typeof window == "undefined") {
184+
module.exports = ansiparse;
185+
}
186+

project.clj

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@
4444
:asset-path "js-gen/out"
4545
:jar true
4646
:optimizations :advanced
47-
:pretty-print false}}}}
47+
:pretty-print false
48+
:externs ["ansiparse-externs.js"]
49+
:foreign-libs [{:file "ansiparse.js"
50+
:provides ["ansiparse"]}]}}}}
4851
:profiles {:release {:hooks [leiningen.cljsbuild]}
4952
;; the namespace for all the clojurescript-dependencies,
5053
;; we don't want them as dependencies of the final library as cljs is already compiled then
@@ -88,8 +91,14 @@
8891
:cljsbuild {:builds {:app {:source-paths ["visual-styleguide/src/cljs" "env/dev/cljs" "src/cljs"]
8992
:compiler {:main "lambdacd.dev"
9093
:optimizations :none
91-
:source-map true}}
94+
:source-map true
95+
:externs ["ansiparse-externs.js"]
96+
:foreign-libs [{:file "ansiparse.js"
97+
:provides ["ansiparse"]}]}}
9298
:test {:source-paths ["src/cljs" "test/cljs"]
9399
:compiler {:optimizations :none
94100
:main "lambdacd.runner"
95-
:pretty-print true}}}}}})
101+
:pretty-print true
102+
:externs ["ansiparse-externs.js"]
103+
:foreign-libs [{:file "ansiparse.js"
104+
:provides ["ansiparse"]}]}}}}}})

src/cljs/lambdacd/console_output_processor.cljs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
(ns lambdacd.console-output-processor
2-
(:require [clojure.string :as s]))
2+
(:require [clojure.string :as s]
3+
[ansiparse]))
34

45
(defn- process-carriage-returns [line]
56
(->> (clojure.string/split line "\r")
@@ -17,7 +18,33 @@
1718
(= "\b" x) (recur xs (rest result))
1819
:else (recur xs (conj result x)))))))
1920

20-
(defn process-ascii-escape-characters [s]
21+
(defn clean-up-text [s]
2122
(->> (s/split-lines s)
2223
(map process-carriage-returns)
23-
(map process-backspaces)))
24+
(map process-backspaces)
25+
(s/join "\n")))
26+
27+
(defn- de-ansify [s]
28+
(if (= "" s)
29+
[{:text s}]
30+
(js->clj (js/ansiparse s) :keywordize-keys true)))
31+
32+
(defn split-fragments-on-newline [fragment]
33+
(cond
34+
(= "\n" (:text fragment)) [:newline]
35+
(= "" (:text fragment)) [fragment]
36+
:else (->> (s/split-lines (:text fragment))
37+
(map #(assoc fragment :text %))
38+
(interpose :newline))))
39+
40+
(defn- partition-by-newline [c]
41+
(let [by-k #(not= :newline %)]
42+
(->> (partition-by by-k c)
43+
(filter #(not= [:newline] %)))))
44+
45+
46+
(defn process-ascii-escape-characters [s]
47+
(->> (clean-up-text s)
48+
(de-ansify)
49+
(mapcat split-fragments-on-newline)
50+
(partition-by-newline)))

src/cljs/lambdacd/output.cljs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,39 @@
8888
(still-active? status) output
8989
:else (str output "\n\n" "Step is finished: " (status-to-string status))))
9090

91-
(defn- console-output-line [idx line]
92-
[:pre {:class "console-output__line" :key (str idx "-" (hash line))} line])
91+
92+
(defn- fragment-style->classes [fragment]
93+
(->> fragment
94+
(filter #(true? (second %)))
95+
(map first)
96+
(map {:bold "console-output__line--bold"
97+
:italic "console-output__line--italic"
98+
:underline "console-output__line--underline"})
99+
(filter #(not (nil? %)))))
100+
101+
(defn- background-color->classes [fragment]
102+
(if (:background fragment)
103+
[(str "console-output__line--bg-" (:background fragment))]
104+
[]))
105+
106+
(defn- foreground-color->classes [fragment]
107+
(if (:foreground fragment)
108+
[(str "console-output__line--fg-" (:foreground fragment))]
109+
[]))
110+
111+
(defn ansi-fragment->classes [fragment]
112+
(->> (concat
113+
(fragment-style->classes fragment)
114+
(background-color->classes fragment)
115+
(foreground-color->classes fragment))
116+
(s/join " ")))
117+
118+
(defn- console-output-line-fragment [idx fragment]
119+
[:span {:key (str idx "-" (hash fragment)) :class (ansi-fragment->classes fragment)} (:text fragment)])
120+
121+
(defn- console-output-line [idx fragments]
122+
[:pre {:class "console-output__line" :key (str idx "-" (hash fragments))}
123+
(map-indexed console-output-line-fragment fragments)])
93124

94125
(defn console-component []
95126
(let [current-step-result (re-frame/subscribe [::db/current-step-result])]

0 commit comments

Comments
 (0)