Sparkly — Create multiline sparks ▁▂▆▄▃▇▅ (Emacs package)

Below you find the latest version of (1) the package's README and (2) its main source file.

For the git repository and issue tracker, see the project's page on sr.ht.

For more packages, see Software.


README.org

Fonts matter

The monospace font that you choose can make a big difference on how your sparks look. Try them out.

More details about fonts are available further below.

In browsers, the space between lines may look particularly large.

Overview

With Sparkly you can create multiline sparks such as these ones:

(sparkly-vs '(3 14 15 92 65 35 89 79 32 38 46 26 43
                38 32 79 50 28 84 19 71 69 39 93 75
                10 58 20 97 49 44 59 23 07 81 64 06))
=> "\

   ▄  ▁                ▅    █
   █  █           ▄    █    █     ▁
   █  █▇       ▇  █    █▃   █     █
   █▁ ██       █  █ ▇▅ ██   █     █
   ██ ██       █  █ ██ ██ ▂ █  ▃  ██
   ██ ██       █▂ █ ██ ██ █ █▁ █  ██
   ██ ██  ▆ ▃  ██ █ ██ ██ █ ██▄█  ██
   ██▃██ ▆█ █▆ ██ █ ██▇██ █ ████  ██
   ████████▂█████▄█ █████ █ ████  ██
   ████████████████▃█████ █▄████▇ ██
 ▆▇██████████████████████▂███████ ██
▃████████████████████████████████▇██▆"

which you could also fit in, say, exactly four lines:

(sparkly-vs '(3 14 15 92 65 35 89 79 32 38 46 26 43
                38 32 79 50 28 84 19 71 69 39 93 75
                10 58 20 97 49 44 59 23 07 81 64 06)
            :lns 4)
=> "\
   ▆  ▅▂       ▂  ▄    ▇▁   █     ▃
   █▅ ██       █  █ ▇▇ ██ ▃ █  ▃  █▅
   ██▄██▃▅▇▁▆▅▃██▁█ ██▅██ █ ██▇█  ██
▁▅▅████████████████▆█████▃█▇█████▂██▂"

or just one, making it a "traditional sparkline":

(sparkly-vs '(3 14 15 92 65 35 89 79 32 38 46 26 43
                38 32 79 50 28 84 19 71 69 39 93 75
                10 58 20 97 49 44 59 23 07 81 64 06)
            :lns 1)
=> "\
_▁▁█▅▃▇▇▃▃▄▂▄▃▃▇▄▂▇▂▆▆▃█▆▁▅▂█▄▄▅▂▁▇▅_"

Parameters can be adjusted. If, for example, we run the following:

(sparkly-vs '(5 10 20))
(sparkly-vs '(5 10 20) :fit t)
(sparkly-vs '(5 10 20) :lns 2)
(sparkly-vs '(5 10 20) :lns 2 :lim 1)
(sparkly-vs '(2 2.5 3) :fac 1)

we get, respectively, these (shown side by side for comparison):

  ▄    █                ▄█
 ▂█   ▄█    █          ███
▅██  ▆██  ▄██  ▂▄█     ███

See sparkly-vs below for more details.

Installation

See my page Software for the most up-to-date instructions on how to download and install any of my Emacs packages.

Having downloaded and installed the package and its dependencies, adapt the configurations below to your init.el file.

(use-package sparkly :demand t)

Alternatively, if you don't have use-package:

(require 'sparkly)

Customization

You can globally customize the bars' glyphs with, for example:

(use-package sparkly
  :demand t
  :custom ; preset: "_▁▂▃▄▅▆▇█"    (default value)
  ;; could also be: ".▁▂▃▄▅▆▇█"    (dot for 0)
  ;;                " ▁▂▃▄▅▆▇█"    (empty for 0)
  (sparkly-bars-user "▁▂▃▄▅▆▇█")) ;(9→8 steps)
;;           shorter^

Besides that, you'll want to check the monospace font you're using.

Details about fonts

The monospace font that you choose can make a big difference on how your sparks look. Try them out.

Three issues are noteworthy here: space between lines, space between columns, and alignment at the base.

  1. Visual spacing between the lines of multiline sparks can vary from none to tiny to pronounced.
    • When none, you can't quite see where one line ends and the other begins.
    • When tiny, you see a very thin horizontal line of discontinuity separating lines.
    • When pronounced, you see a sizable gap between the lines.
  2. A division between columns might also be noticeable in some fonts.
  3. Finally, not every font aligns the low line with the base of the blocks, with the result that this:

                 ▄
       ▂         █
    ___█▁_█_▂_▇_██▄_▆__
    

    might not show up with a straight line at the base from beginning to end.

Multiline sparks look better when:

  • the space between lines is none or tiny (gaps look bad);
  • the space between columns is none; and
  • the low line (default glyph to represent zero) perfectly aligns with the base of blocks.

I sampled a few popular monospace fonts to see how they looked. In particular:

  • The only one that perfectly matched these three requirements was the DejaVu Sans Mono.
  • Liberation Mono and Noto Mono had no spacing issues, but base alignment is imperfect.
  • Noto Sans Mono had the worst result: it lacks base alignment, and the gap between lines is huge.

If your only issue is base alignment, and you don't want to change fonts, you can replace the
default glyph for zero (low line: "_") by customizing sparkly-bars-user.

Summary of callables

Here's an overview of this package's callables:

Function Summary
sparkly-vi Insert vertical sparks from LIST at line below point.
sparkly-vs Create vertical multiline sparks string from LIST.
sparkly-see-readme Open sparkly's README.org file.
sparkly-see-news See the News in sparkly's README.org file.

They're described in more detail below.

Functions

Vertical multiline sparks

Given a list, create its corresponding vertical multiline sparks.

sparkly-vi (list &optional key-1 value-1 key-2 value-2 ...)

Insert vertical sparks from LIST at line below point.

Return nil.

Optional key–value ARGS may be passed. See sparkly-vs for KEYS.

If aggressive-indent-mode is on, turn it off while inserting.
Autodetect where point is before inserting, and open lines
accordingly.

sparkly-vs (list &optional key-1 value-1 key-2 value-2 ...)

Create vertical multiline sparks string from LIST.

All elements of LIST must be numbers. Negative ones will be
flattened to zero.

Optional key–value ARGS may be passed.

The following KEYS are available:

KEY Explanation Default
:fac Stretching factor 0.125
:lns Number of lines nil
:fit Whether to round up nil
:lim Maximum number of lines nil
:wrp Wrap spark at this width nil (∞)
:wsp Empty lines between wrapped sparks nil (0)
:bar String to represent spark steps _▁▂▃▄▅▆▇█

Constraints:

  • :fac and :lns are mutually exclusive
  • values of :fac, :lns, and :lim must be positive numbers
  • values of :wrp and wsp must be non-negative integers
  • value of :fit must be a boolean

Details:

  • :fac is a stretching factor to apply to all numbers of the list.
  • :lns is the exact number of lines to be occupied by the maximum
    number of the list.
  • :fit is whether to round up :lns to the next integer.
  • :lim is the maximum number of lines allowed;
    this is particularly useful if the input is variable and you want
    to impose a ceiling on how many lines it might end up displaying.
  • :wrp is the maximum width of the spark block; if 0, or nil, or
    larger than the spark width, there won't be wrapping.
  • :wsp is the number of empty lines to add between each wrapped spark.
  • :bar is a string to represent spark steps.

Here's how these values are parsed from input:

  • First, if :lns is nil, then:
    • If :fac is nil, set it to ⅛ (= .125).
    • Set :lns as (* max :fac), where max is the LIST's maximum.
  • Then, if :fit is t, round up :lns to the next integer.
  • Then, if :lim is non-nil, set :lns to the minimum value between
    :lim and :lns.
  • Finally, set :fac to (/ :lns max 1.0)

This value of :fac is then applied to all the numbers.

Sparks will be represented by the first non-nil value in this list:
(sparkly-bars bar sparkly-bars-user sparkly-bars-preset)

The last has value "_▁▂▃▄▅▆▇█" and the others start as nil. So:

  • Customizable sparkly-bars-user can be set to override the preset.
  • Passing :bar as sparkly argument will override both.
  • And let-binding sparkly-bars will override all the three.

One advantage of this last one is that it can be wrapped around several
sparkly expressions. This is particularly useful when modifying it for
sparkly-stats-report (from sparkly-stats package), which see.

  ;;;; The 4 fills half of the line: 4 = ½*8
  (sparkly-vs '(2 3 4))         => "▂▃▄"

  ;;;; Stretched so that 4 reaches the top of the line
  (sparkly-vs '(2 3 4) :fit t)  => "▄▆█"

  ;;;; Same as above, because automatically numlines=1
  (sparkly-vs '(2 3 4) :lns 1)  => "▄▆█"

  ;;;; Stretched to fill three lines
  (sparkly-vs '(2 3 4) :lns 3)  => "\
 ▂█
▄██
███"

  ;;;; Stretched to max-value-of-seq lines
  (sparkly-vs '(2 3 4) :fac 1)  => "\

 ██
███
███"

  ;;;; The 9s automatically span to a second line
  (sparkly-vs '(3 1 4 1 5 9 2 6 5 3 5 8 9 7 9 3 2 3 8 4 6 2 6 4 3 3
                  8 3 2 7 9 5 0 2 8 8 4 1 9 7 1 6 9 3 9 9 3 7 5 1 0
                  5 8 2 0 9 7 4 9 4 4 5 9 2 3 0 7 8 1 6 4 0 6 2 8 6))
  => "\
     ▁      ▁ ▁               ▁       ▁   ▁ ▁▁         ▁  ▁   ▁
▃▁▄▁▅█▂▆▅▃▅██▇█▃▂▃█▄▆▂▆▄▃▃█▃▂▇█▅_▂██▄▁█▇▁▆█▃██▃▇▅▁_▅█▂_█▇▄█▄▄▅█▂▃_▇█▁▆▄_▆▂█▆"

  ;;;; Here it automatically uses 13 lines: 97 = (12+⅛)*8
  (sparkly-vs '(3 14 15 92 65 35 89 79 32 38 46 26 43
                  38 32 79 50 28 84 19 71 69 39 93 75
                  10 58 20 97 49 44 59 23 07 81 64 06))
  ;;;; 
  => "\

   ▄  ▁                ▅    █
   █  █           ▄    █    █     ▁
   █  █▇       ▇  █    █▃   █     █
   █▁ ██       █  █ ▇▅ ██   █     █
   ██ ██       █  █ ██ ██ ▂ █  ▃  ██
   ██ ██       █▂ █ ██ ██ █ █▁ █  ██
   ██ ██  ▆ ▃  ██ █ ██ ██ █ ██▄█  ██
   ██▃██ ▆█ █▆ ██ █ ██▇██ █ ████  ██
   ████████▂█████▄█ █████ █ ████  ██
   ████████████████▃█████ █▄████▇ ██
 ▆▇██████████████████████▂███████ ██
▃████████████████████████████████▇██▆"

  ;;;; We can stretch it so that 97 reaches the top of the 13th line
  (sparkly-vs '(3 14 15 92 65 35 89 79 32 38 46 26 43
                  38 32 79 50 28 84 19 71 69 39 93 75
                  10 58 20 97 49 44 59 23 07 81 64 06)
              :fit t) ;;;; 
  => "\
   ▃                   ▄    █
   █  ▇           ▂    █    █
   █  █▅       ▅  █    █    █     ▇
   █  ██       █  █ ▄▂ ██   █     █
   █▆ ██       █  █ ██ ██   █     █▅
   ██ ██       █  █ ██ ██ ▆ █  ▇  ██
   ██ ██  ▁    █▆ █ ██ ██ █ █▅ █  ██
   ██ ██ ▁█ ▆▁ ██ █ ██▂██ █ ██▇█  ██
   ██▆██▂██ ██▂██ █ █████ █ ████  ██
   ████████▄█████▆█ █████ █ ████▁ ██
   ████████████████▄█████ █▅█████ ██
 ▇███████████████████████▃███████ ██
▃███████████████████████████████████▆"

  ;;;; Here we fit the max value (97) to exactly four lines
  (sparkly-vs '(3 14 15 92 65 35 89 79 32 38 46 26 43
                  38 32 79 50 28 84 19 71 69 39 93 75
                  10 58 20 97 49 44 59 23 07 81 64 06)
              :lns 4) ;;;; 
  => "\
   ▆  ▅▂       ▂  ▄    ▇▁   █     ▃
   █▅ ██       █  █ ▇▇ ██ ▃ █  ▃  █▅
   ██▄██▃▅▇▁▆▅▃██▁█ ██▅██ █ ██▇█  ██
▁▅▅████████████████▆█████▃█▇█████▂██▂"

  ;;;; Here we wrap the previous spark at a maximum width of 10
  (sparkly-vs '(3 14 15 92 65 35 89 79 32 38 46 26 43
                  38 32 79 50 28 84 19 71 69 39 93 75
                  10 58 20 97 49 44 59 23 07 81 64 06)
              :lns 4 :wrp 10)
  => "\
   ▆  ▅▂
   █▅ ██
   ██▄██▃▅
▁▅▅███████
     ▂  ▄
     █  █
▇▁▆▅▃██▁█
█████████▆
   ▇▁   █
▇▇ ██ ▃ █
██▅██ █ ██
█████▃█▇██

 ▃  █▅
▇█  ██
███▂██▂"

  ;;;; Same, but wrapping at 20 and adding two lines between the pieces
  (sparkly-vs '(3 14 15 92 65 35 89 79 32 38 46 26 43
                  38 32 79 50 28 84 19 71 69 39 93 75
                  10 58 20 97 49 44 59 23 07 81 64 06)
              :lns 4 :wrp 20 :wsp 2)
  => "\
   ▆  ▅▂       ▂  ▄
   █▅ ██       █  █
   ██▄██▃▅▇▁▆▅▃██▁█
▁▅▅████████████████▆


   ▇▁   █     ▃
▇▇ ██ ▃ █  ▃  █▅
██▅██ █ ██▇█  ██
█████▃█▇█████▂██▂"

  ;;;; And here we fit it to a single line
  (sparkly-vs '(3 14 15 92 65 35 89 79 32 38 46 26 43
                  38 32 79 50 28 84 19 71 69 39 93 75
                  10 58 20 97 49 44 59 23 07 81 64 06)
              :lns 1) ;;;; 
  =>  "\
_▁▁█▅▃▇▇▃▃▄▂▄▃▃▇▄▂▇▂▆▆▃█▆▁▅▂█▄▄▅▂▁▇▅_"

  ;;;; You can have "natural" sizes with :fac 1
  (let* ((π float-pi)  ; 3.141592653589793
         (e float-e)   ; 2.718281828459045
         (m (* π e))   ; 8.539734222673566
         (s (+ π e))   ; 5.859874482048838
         (q (/ π e))   ; 1.155727349790922
         (d (- π e))   ; 0.423310825130748
         (l (list π e 0
                  m s 0
                  q d 0
                  4 2 0)))
    (sparkly-vs l :fac 1))
  => "\



   █▇
   ██
▁  ██    █
█▆ ██    █
██ ██ ▁  ██
██_██_█▃_██_"

  ;;;; Wrapping removes any empty lines at the top of pieces,
  ;;;; which makes the output tight. For example:
  ;;;;; Here, the edges have many empty lines at their top
  (sparkly-vs '(10 15 18 4 19 8 27 45 1 4 1 4))
  => "\


      ▃█
  ▂ ▃ ██
▂▇█ █ ██
███▄████▁▄▁▄"

  ;;;;; But when wrapping, each piece is tightly packed
  (sparkly-vs '(10 15 18 4 19 8 27 45 1 4 1 4) :wrp 4)
  => "\

▂▇█
███▄


  ▃█
▃ ██
█ ██
████
▁▄▁▄"

  ;;;;; Yet you can add empty lines between pieces, if you want
  (sparkly-vs '(10 15 18 4 19 8 27 45 1 4 1 4) :wrp 4 :wsp 1)
  => "\

▂▇█
███▄



  ▃█
▃ ██
█ ██
████

▁▄▁▄"

  ;;;; Additional simple examples
  ;;;;; Naturally uses 2.5 lines
  (sparkly-vs '(5 10 20)) => "\

 ▂█
▅██"

  ;;;;; Still 2.5 lines
  (sparkly-vs '(5 10 20) :lim 3) => "\

 ▂█
▅██"

  ;;;;; Stretched to exactly 3
  (sparkly-vs '(5 10 20) :fit t) => "\

 ▄█
▆██"
  (sparkly-vs '(5 10 20) :lns 3) => "\

 ▄█
▆██"

  ;;;;; Compressed to exactly 2
  (sparkly-vs '(5 10 20) :lim 2) => "\

▄██"
  (sparkly-vs '(5 10 20) :lns 2) => "\

▄██"

  ;;;;; Compressed to exactly 1
  (sparkly-vs '(5 10 20) :lns 1) => "▂▄█"

  ;;;; Special cases
  ;;;;; Arg :lns need not be an integer
  (sparkly-vs '(1 6 2) :lns 1)    => "▁█▃"
  (sparkly-vs '(1 6 2) :lns .6)   => "▁▅▂"
  (sparkly-vs '(1 6 2) :lns .001) => "___"
  (sparkly-vs '(1 6 2) :lns (sqrt 2)) => "\

▂█▄"  ;<-- max value 6 occupies ~√2 lines
  (sparkly-vs '(1 6 2) :lns float-pi) => "\



▄██"  ;<-- max value 6 occupies ~π lines

  ;;;;; Empty list returns empty string
  (sparkly-vs '())         => ""
  (sparkly-vs '() :lns 2)  => ""
  (sparkly-vs '() :lim 2)  => ""
  (sparkly-vs '() :fit t)  => ""

  ;;;;; Negative numbers are flattened to zero...
  (sparkly-vs '(-5  2  4)) => "_▂▄"
  (sparkly-vs '( 2 -5  4)) => "▂_▄"
  (sparkly-vs '( 2  4 -5)) => "▂▄_"

  ;;;;; ...and zero shows as low line
  (sparkly-vs '( 0  2  4)) => "_▂▄"
  (sparkly-vs '( 2  0  4)) => "▂_▄"
  (sparkly-vs '( 2  4  0)) => "▂▄_"
  (sparkly-vs '( 0  0  0)) => "___"

  ;;;;; But you can override that — the whole bars, actually
  ;;;;;; by customizing sparkly-bars-user               (overrides _▁▂▃▄▅▆▇█)
  ;;       (no examples)                                sparkly-bars-preset^

  ;;;;;; by passing a value to :bar                          (overrides both)
  ;;;;;;; Default
  (sparkly-vs '(0 4 0 0 2 7 0))                                   => "_▄__▂▇_"
  ;;;;;;; Replace the zero: from "_" to " "
  (sparkly-vs '(0 4 0 0 2 7 0) :bar " ▁▂▃▄▅▆▇█")                  => " ▄  ▂▇"
  ;;;;;;; Replace the zero: from "_" to "."
  (sparkly-vs '(0 4 0 0 2 7 0) :bar ".▁▂▃▄▅▆▇█")                  => ".▄..▂▇."
  ;;;;;;; Replace the zero: from "_" to "▁" by removing the first
  (sparkly-vs '(0 4 0 0 2 7 0) :bar "▁▂▃▄▅▆▇█")                   => "▁▅▁▁▃█▁"
  ;;;;;;; Build it with 5 instead of 9
  (sparkly-vs '(0 4 0 0 2 7 0) :bar "_▂▄▆█")
  => "\

_█__▄█_"

  (sparkly-vs '(0 4 0 0 2 7 0) :bar " ░▒▓█")
  => "\

 █  ▒█"

  (sparkly-vs '(0 4 0 0 2 7 0) :bar "○◔◑◕●")
  => "\

○●○○◑●○"

  ;;;;;; by let-binding sparkly-bars                          (overrides all)
  (let ((sparkly-bars " ▁▂▃▄▅▆▇█"))
    (sparkly-vs '(0 4 0 0 2 7 0)))
  => " ▄  ▂▇"

  (let ((sparkly-bars ".▁▂▃▄▅▆▇█"))
    (sparkly-vs '(0 4 0 0 2 7 0) :bar "_▂▄▆█"))
  => ".▄..▂▇."

  ;;;; Errors
  (sparkly-vs  [5 20] :lns 4 :fac 1) !!> error ; not a list
  (sparkly-vs '(5 20) :lns 4 :fac 1) !!> error ; mutually exclusive
  (sparkly-vs '(5 20) :fit 2)        !!> error ; not a boolean
  (sparkly-vs '(5 20) :lns 0)        !!> error ; ≤ 0
  (sparkly-vs '(5 20) :fac 0)        !!> error
  (sparkly-vs '(5 20) :lim 0)        !!> error
  (sparkly-vs '(5 20) :lns -1)       !!> error
  (sparkly-vs '(5 20) :fac -1)       !!> error
  (sparkly-vs '(5 20) :lim -1)       !!> error

Commands

See README

Commands to open sparkly's README.org. Optionally, find things in it.

sparkly-see-readme (&optional heading narrow)

Open sparkly's README.org file.

Search for the file in sparkly.el's directory.

If found, open it read-only.

If optional argument HEADING is passed, try to navigate to the
heading after opening it. HEADING should be a string.

If optional argument NARROW is non-nil, narrow to that heading.
This argument has no effect if HEADING is nil or not found.

sparkly-see-news ()

See the News in sparkly's README.org file.

Contributing

See my page Software for information about how to contribute to any of my Emacs packages.

News

0.3.2

Sparkly News

Pieces are now tightly packed when multiline sparks are wrapped.

Enhancements

Wrapped pieces are now tightly packed

Up to here, when wrapping a multiline spark, every piece would take the total number of lines.

For example, consider this 12-column specimen:

       ▅        ; 6
       █        ; 5
      ▃█        ; 4
  ▂ ▃ ██        ; 3
▂▇█ █ ██        ; 2
███▄████▁▄▁▄    ; 1

If we wrapped it in three pieces of four, each piece would use six lines.
This would take 3×6 = 18 lines of your screen.

Now the three pieces wrap tightly, taking only 3+6+1 = 10 lines:

(sparkly-vi '(10 15 18 4 19 8 27 45 1 4 1 4) :wrp 4); 10
▂▇█     ; 9
███▄    ; 8; 7; 6
  ▃█    ; 5
▃ ██    ; 4
█ ██    ; 3
████    ; 2
▁▄▁▄    ; 1

This is unrelated with the number of empty lines between pieces,
which still defaults to 0 and still can be changed with the :wsp key.

0.3.1

Sparkly News

This release brings improved documentation about customizing Sparkly and details about fonts.

0.3.0

Sparkly News

This release brings new features. You can now:

  • wrap sparks to the desired width, and
  • change the glyphs that compose the sparks

If you have Democratize, run M-x democratize-library to update the available examples.

New features

New keys for sparkly-vs

With the new key :wrp, you can now wrap long multiline sparks to a specified width.

And with :wsp you control the number of empty lines to add between each wrapped spark.

Wrapping is particularly useful for visualizing long sparks that don't fit your screen.

There's also :bar now, with which you can pass a string different than "_▁▂▃▄▅▆▇█" to represent your sparks.

New customizable variable sparkly-bars-user

If non-nil, it'll always override "_▁▂▃▄▅▆▇█".

New variable sparkly-bars

Not to be changed directly.

Rather, it can be let-bound around spark-generating code when modification is desired.

See sparkly-vs docstring for more information, as well as the new examples using it.

0.2.1

An input list can now have only zeros.

Bug fixes

List of 0s is now valid input

An arithmetic overflow error was happening when a list had only 0s:

(sparkly-vs '(0 0 0)) !!> arith-error

This has now been fixed:

(sparkly-vs '(0 0 0)) => "___"

0.2.0

Release

See also

Other packages

You can integrate sparkly-vs's examples into Helpful or Help buffers with another package of mine called Democratize, which can also integrate examples from xht, dash, s, f, and native Emacs libraries.

And with Sparkly Stats you can create statistics and sparks from daily data.

License

This project follows the REUSE Specification (FAQ), which in turn is built upon SPDX.

Therefore, license and copyright information can be found in:

  • each file's comment header, or
  • an adjacent file of the same name with the additional extension .license, or
  • the .reuse/dep5 file

The full text of the licenses can be found in the LICENSES subdirectory.


sparkly.el

Structure

;;; sparkly.el --- Create multiline sparks ▁▂▆▄▃▇▅  -*- lexical-binding: t -*-
;;; Commentary:
;;;; For all the details, please do see the README
;;; Code:
;;;; Libraries
;;;; Package metadata
;;;; Customizable variables
;;;; Other variables
;;;; Functions
;;;;; Description macro
;;;;; Vertical multiline sparks
;;;; Commands
;;;;; See README
;;;; Wrapping up
;;; sparkly.el ends here

Contents

;;; sparkly.el --- Create multiline sparks ▁▂▆▄▃▇▅  -*- lexical-binding: t -*-

;; SPDX-FileCopyrightText: © flandrew <https://flandrew.srht.site/listful>

;;---------------------------------------------------------------------------
;; Author:    flandrew
;; Created:   2023-11-12
;; Updated:   2025-11-02
;; Keywords:  extensions
;; Homepage:  <https://flandrew.srht.site/listful/software.html>
;;---------------------------------------------------------------------------
;; Package-Version:  0.3.2
;; Package-Requires: ((emacs "25.1") (dash "2.18") (s "1.4"))
;;---------------------------------------------------------------------------

;; SPDX-License-Identifier: GPL-3.0-or-later

;; This file is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published
;; by the Free Software Foundation, either version 3 of the License,
;; or (at your option) any later version.
;;
;; This file is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;;
;; You should have received a copy of the GNU General Public License
;; along with this file. If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:
;;
;; With Sparkly you can create multiline sparks:
;;
;;      ▆  ▅▂       ▂  ▄    ▇▁   █     ▃
;;      █▅ ██       █  █ ▇▇ ██ ▃ █  ▃  █▅
;;      ██▄██▃▅▇▁▆▅▃██▁█ ██▅██ █ ██▇█  ██
;;   ▁▅▅████████████████▆█████▃█▇█████▂██▂
;;
;;;; For all the details, please do see the README
;;
;; Open it easily with:
;;   (find-file-read-only "README.org")   <--- C-x C-e here¹
;;
;; or from any buffer:
;;   M-x sparkly-see-readme
;;
;; or read it online:
;;   <https://flandrew.srht.site/listful/sw-emacs-sparkly.html>
;;
;; ¹ or the key that ‘eval-last-sexp’ is bound to, if not C-x C-e.
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;



;;; Code:
;;;; Libraries

(require 's)
(require 'dash)      ; -iota’, therefore 2.18+
(require 'lisp-mnt)  ; lm-summary’, ‘lm-homepage’, ‘lm-version’, ‘lm-header


;;;; Package metadata

(defvar sparkly--name "Sparkly")

(defvar sparkly--dot-el
  (format "%s.el" (file-name-sans-extension (eval-and-compile
                                              (or load-file-name
                                                  buffer-file-name)))))
(defvar sparkly--readme-org
  (expand-file-name "README.org" (file-name-directory sparkly--dot-el)))

(defvar sparkly--summary  (lm-summary   sparkly--dot-el))
(defvar sparkly--homepage (lm-homepage  sparkly--dot-el))
(defvar sparkly--version  (lm-with-file sparkly--dot-el
                            (or (lm-header "package-version")
                                (lm-version))))


;;;; Customizable variables

(defgroup sparkly nil
  (format "%s." sparkly--summary)
  :group 'extensions
  :link  '(emacs-library-link :tag "Lisp file" "sparkly.el")
  :link  `(file-link :tag "README.org" ,sparkly--readme-org)
  :link  `(url-link  :tag "Homepage"   ,sparkly--homepage))

(defcustom sparkly-bars-user nil
  "User-defined default string for spark steps.
If non-nil, it'll always override ‘sparkly-bars-preset’."
  :package-version '(sparkly "0.3.0")
  :type 'string)


;;;; Other variables

(defvar sparkly-bars-preset "_▁▂▃▄▅▆▇█"
  "Preset default string to represent spark steps.")

(defvar sparkly-bars nil
  "Actual string that will be used to represent spark steps.
This variable should not be directly changed. Rather, it should be
let-bound around spark-generating code when modification is desired.")


;;;; Functions
;;;;; Description macro

(defmacro sparkly--describe (str)
  "Describe with string STR the contents under this heading.
Think of it as a docstring for the headings of your elisp files.
For the full docstring, look for ‘orgreadme-fy-describe’ in the
package ‘orgreadme-fy’."
  (declare (indent 0))
  (unless (stringp str)
    (user-error "‘sparkly--describe’ must receive a string")))


;;;;; Vertical multiline sparks

(sparkly--describe
  "Given a list, create its corresponding vertical multiline sparks.
- ‘sparkly-vi’ inserts the string
- ‘sparkly-vs’ returns the string")

(defun sparkly-vi (list &rest args)
  "Insert vertical sparks from LIST at line below point.
Return nil.

Optional key–value ARGS may be passed. See ‘sparkly-vs’ for KEYS.

If ‘aggressive-indent-mode’ is on, turn it off while inserting.
Autodetect where point is before inserting, and open lines
accordingly.

\(fn LIST &optional KEY-1 VALUE-1 KEY-2 VALUE-2 ...)"
  (sparkly--smart-insert (apply #'sparkly-vs list args)))

(defun sparkly-vs (list &rest args)
  "Create vertical multiline sparks string from LIST.
All elements of LIST must be numbers. Negative ones will be
flattened to zero.

Optional key–value ARGS may be passed.

The following KEYS are available:

  | KEY    | Explanation                        | Default   |
  |--------+------------------------------------+-----------|
  | `:fac' | Stretching factor                  | 0.125     |
  | `:lns' | Number of lines                    | nil       |
  | `:fit' | Whether to round up                | nil       |
  | `:lim' | Maximum number of lines            | nil       |
  |--------+------------------------------------+-----------|
  | `:wrp' | Wrap spark at this width           | nil (∞)   |
  | `:wsp' | Empty lines between wrapped sparks | nil (0)   |
  | `:bar' | String to represent spark steps    | _▁▂▃▄▅▆▇█ |


Constraints:
- `:fac' and `:lns' are mutually exclusive
- values of `:fac', `:lns', and `:lim' must be positive numbers
- values of `:wrp' and `wsp' must be non-negative integers
- value of `:fit' must be a boolean


Details:
- `:fac' is a stretching factor to apply to all numbers of the list.

- `:lns' is the exact number of lines to be occupied by the maximum
  number of the list.

- `:fit' is whether to round up `:lns' to the next integer.

- `:lim' is the maximum number of lines allowed;
  this is particularly useful if the input is variable and you want
  to impose a ceiling on how many lines it might end up displaying.

- `:wrp' is the maximum width of the spark block; if 0, or nil, or
  larger than the spark width, there won't be wrapping.

- `:wsp' is the number of empty lines to add between each wrapped spark.

- `:bar' is a string to represent spark steps.


Here's how these values are parsed from input:
- First, if `:lns' is nil, then:
  - If `:fac' is nil, set it to ⅛ (= .125).
  - Set `:lns' as (* max `:fac'), where max is the LIST's maximum.

- Then, if `:fit' is t, round up `:lns' to the next integer.

- Then, if `:lim' is non-nil, set `:lns' to the minimum value between
  `:lim' and `:lns'.

- Finally, set `:fac' to (/ `:lns' max 1.0)

This value of `:fac' is then applied to all the numbers.

Sparks will be represented by the first non-nil value in this list:
  (sparkly-bars  bar  sparkly-bars-user  sparkly-bars-preset)

The last has value \"_▁▂▃▄▅▆▇█\" and the others start as nil. So:
- Customizable ‘sparkly-bars-user’ can be set to override the preset.
- Passing `:bar' as sparkly argument will override both.
- And let-binding ‘sparkly-bars’ will override all the three.

One advantage of this last one is that it can be wrapped around several
sparkly expressions. This is particularly useful when modifying it for
sparkly-stats-report’ (from sparkly-stats package), which see.

\(fn LIST &optional KEY-1 VALUE-1 KEY-2 VALUE-2 ...)"
  (if (null list) ""
    (-let [(lns fac wrp wsp bar) (apply #'sparkly--parse list args)]
      (let* ((barwid (1- (length bar)))              ; 1- number of glyphs
             (barzer (substring bar 0 1))            ; first glyph: the zero
             (baremp (concat " " (substring bar 1))) ; first glyph made " "
             (num2ls (lambda (num)
                       (let ((norm (round (* barwid fac num))))
                         (--map (number-to-string
                                 (let ((Δ (- norm (* barwid it))))
                                   (if (>= Δ barwid) barwid (if (<= Δ 0) 0 Δ))))
                                (-iota (ceiling lns))))))
             (sparkl (-map num2ls list))
             (sparkn (apply #'-zip-lists sparkl))
             (sparka (-zip-pair (-map #'number-to-string (-iota (1+ barwid)))
                                (-map #'string baremp)))
             (sparks (--map (s-replace-all sparka (s-join "" it))
                            sparkn))
             (linlen (length (car sparks))))
        ;; The bottom line (and only it) will have the 0s replaced
        (setcar sparks (s-replace " " barzer (car sparks)))
        (setq sparks (nreverse sparks))
        ;; We can now possibly wrap, remove trailing spaces, and finish
        (when (or (null wrp) (= wrp 0) (> wrp linlen))
          (setq wrp linlen))
        (s-trim-right
         (with-output-to-string
           (dolist (sub (sparkly--partition-steps linlen wrp))
             (dolist (lin sparks)
               (--> (s-trim-right (apply #'substring lin sub))
                    (unless (s-blank? it) (princ it) (terpri))))
             (dotimes (_i (or wsp 0)) (terpri)))))))))

(defun sparkly--partition-steps (len wrp)
  "Create partition steps for string length LEN and wrapping at WRP."
  (let ((max (* wrp (/ (1- len) wrp))))
    (-partition-all-in-steps 2 1 (number-sequence 0 max wrp))))

(defun sparkly--parse (list &rest args)
  "Parse LIST. Return (LNS FAC WRP WSP BAR).
Optional key–value ARGS may be passed. See ‘sparkly-vs’ for KEYS.
\n(fn LIST &optional KEY-1 VALUE-1 KEY-2 VALUE-2 ...)"
  (-let [(&plist :fac :fit :lim :lns :wrp :wsp :bar) args]
    ;; Check for errors
    (or (listp    list) (error "%s must be a list"    list))
    (or (booleanp  fit) (error "%s must be a boolean" :fit))
    (and lns fac (error "%s and %s are mutually exclusive params" :lns :fac))
    (--each `((:lns ,lns) (:fac ,fac) (:lim ,lim))
      (-let [(k v) it]
        (or (null v)
            (and (numberp v) (> v 0))
            (error "%s, if given, must be a positive number" k))))
    (--each `((:wrp ,wrp) (:wsp ,wsp))
      (-let [(k v) it]
        (or (null v)
            (and (integerp v) (>= v 0))
            (error "%s, if given, must be a non-negative integer" k))))
    (or (null bar) (stringp bar)
        (error "%s, if given, must be a string" :bar))
    ;; Produce output
    (let ((max (-max list)))
      (setq bar (or sparkly-bars bar sparkly-bars-user sparkly-bars-preset))
      (or   fac (setq fac (/ 1.0 (1- (length bar)))))
      (or   lns (setq lns (if (= max 0) 1 (* max fac))))
      (when fit (setq lns (ceiling lns)))
      (when lim (setq lns (min lim lns)))
      (when (> max 0) (setq fac (/ lns max 1.0)))
      (list lns fac wrp wsp bar))))

(defun sparkly--smart-insert (string)
  "Insert STRING into line after point without indenting."
  (save-excursion
    (atomic-change-group
      (unless (bolp) (forward-line))
      (unless (eolp) (open-line 1))
      (if (and (fboundp 'aggressive-indent-mode)
               (bound-and-true-p aggressive-indent-mode))
          (prog2
              (aggressive-indent-mode -1)
              (insert string)
            (aggressive-indent-mode 1))
        (insert string)))))


;;;; Commands
;;;;; See README

(sparkly--describe
  "Commands to open sparkly's README.org. Optionally, find things in it.")

;;;###autoload
(defun sparkly-see-readme (&optional heading narrow)
  "Open sparkly's README.org file.
Search for the file in sparkly.el's directory.

If found, open it read-only.

If optional argument HEADING is passed, try to navigate to the
heading after opening it. HEADING should be a string.

If optional argument NARROW is non-nil, narrow to that heading.
This argument has no effect if HEADING is nil or not found."
  (interactive)
  (let ((readme sparkly--readme-org))
    (if (file-exists-p readme)
        (let ((pr (make-progress-reporter
                   (format "Opening %s ... "
                           (abbreviate-file-name readme)))))
          (find-file-read-only readme)
          (when heading
            (sparkly--goto-org-heading heading narrow))
          (progress-reporter-done pr))
      (message "Couldn't find %s's README.org" sparkly--name))))

;;;###autoload
(defun sparkly-see-news ()
  "See the News in sparkly's README.org file."
  (interactive)
  (sparkly-see-readme "News" 'narrow)
  (sparkly--display-org-subtree))

(defun sparkly--display-org-subtree ()
  "Selectively display org subtree."
  (let ((cmds '( outline-hide-subtree outline-show-children
                 outline-next-heading outline-show-branches)))
    (and (equal (mapcar #'fboundp cmds) '(t t t t))
         (mapc #'funcall cmds))))

(defun sparkly--goto-org-heading (heading &optional narrow)
  "Navigate to org HEADING and optionally NARROW to it."
  (let* ((hrx (format "^[*]+ %s" heading))
         (pos (save-match-data
                (save-excursion
                  (save-restriction
                    (widen)  (goto-char (point-max))
                    (re-search-backward hrx nil t 1))))))
    (when pos
      (widen)  (goto-char pos)
      (if (and narrow (fboundp 'org-narrow-to-subtree))
          (org-narrow-to-subtree)
        (recenter-top-bottom 1))
      (when (fboundp 'outline-show-subtree)
        (outline-show-subtree))
      (when (fboundp 'org-flag-drawer)
        (save-excursion
          (forward-line 1)
          (org-flag-drawer t))))))


;;;; Wrapping up

(provide 'sparkly)

;; Local Variables:
;; coding:                     utf-8
;; indent-tabs-mode:           nil
;; sentence-end-double-space:  nil
;; outline-regexp:             ";;;;* "
;; End:

;;; sparkly.el ends here
📆 2025-01-09