Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ jobs:
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: "1.18.2"
otp-version: "27.2"
elixir-version: "1.19"
otp-version: "28"
- name: Restore dependencies cache
uses: actions/cache@v3
with:
Expand All @@ -53,8 +53,8 @@ jobs:
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: "1.18.0"
otp-version: "27.0.1"
elixir-version: "1.19"
otp-version: "28"
- name: Restore dependencies cache
uses: actions/cache@v3
with:
Expand Down
4 changes: 2 additions & 2 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
elixir 1.18.2-otp-27
erlang 27.2
elixir 1.19.4-otp-28
erlang 28.2
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

**Added**

- Better documented typespecs. ([#11](https://github.com/codedge-llc/pane/pull/11))
- More tests for `Pane.Page` and `Pane.Viewer`. ([#11](https://github.com/codedge-llc/pane/pull/11))

**Fixed**

- Terminal size detection on Windows (replaced `tput` with `:io.rows/0`).
([#11](https://github.com/codedge-llc/pane/pull/11)

## v0.5.0 - 2024-08-31

**Changed**
Expand Down
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2017-2025 Codedge LLC (https://www.codedge.io/)
Copyright (c) 2017-2026 Codedge LLC (https://www.codedge.io/)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@ Git commit subjects use the [Karma style](http://karma-runner.github.io/5.0/dev/

## License

Copyright (c) 2017-2025 Codedge LLC (https://www.codedge.io/)
Copyright (c) 2017-2026 Codedge LLC (https://www.codedge.io/)

This library is MIT licensed. See the [LICENSE](https://github.com/codedge-llc/pane/blob/main/LICENSE.md) for details.
2 changes: 1 addition & 1 deletion lib/pane.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ defmodule Pane do
@doc ~S"""
Paginates data and starts a pseudo-interactive console.
"""
@spec console(any) :: no_return
@spec console(any()) :: :ok
def console(data) when is_binary(data) do
if IO.ANSI.enabled?() do
start_and_recv(data)
Expand Down
6 changes: 3 additions & 3 deletions lib/pane/page.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Pane.Page do

@type t :: %__MODULE__{
data: String.t(),
index: pos_integer
index: pos_integer()
}

@doc ~S"""
Expand All @@ -18,7 +18,7 @@ defmodule Pane.Page do
iex> Pane.Page.new("test", 1)
%Pane.Page{data: "test", index: 1}
"""
@spec new(String.t(), pos_integer) :: t
@spec new(String.t(), pos_integer()) :: t()
def new(data, index) do
%__MODULE__{
data: data,
Expand All @@ -37,7 +37,7 @@ defmodule Pane.Page do
iex> p1.data
"1\n2\n3\n4"
"""
@spec paginate(String.t(), pos_integer) :: [t]
@spec paginate(String.t(), pos_integer()) :: [t()]
def paginate(data, max_lines \\ @max_lines) do
data
|> String.split("\n")
Expand Down
28 changes: 26 additions & 2 deletions lib/pane/viewer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ defmodule Pane.Viewer do

use GenServer

@type t :: %__MODULE__{
pages: [Pane.Page.t()],
total_pages: non_neg_integer(),
index: non_neg_integer()
}

@default_max_lines 50

@doc ~S"""
Starts a `Pane.Viewer` with given opts.

Expand All @@ -15,10 +23,12 @@ defmodule Pane.Viewer do
iex> is_pid(pid)
true
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end

@spec stop :: :ok
def stop, do: GenServer.stop(__MODULE__)

@doc ~S"""
Expand Down Expand Up @@ -50,16 +60,22 @@ defmodule Pane.Viewer do
{:ok, state}
end

@spec first_page :: Pane.Page.t()
def first_page, do: GenServer.call(__MODULE__, :first_page)

@spec last_page :: Pane.Page.t()
def last_page, do: GenServer.call(__MODULE__, :last_page)

@spec next_page :: Pane.Page.t()
def next_page, do: GenServer.call(__MODULE__, :next_page)

@spec prev_page :: Pane.Page.t()
def prev_page, do: GenServer.call(__MODULE__, :prev_page)

@spec current_page :: Pane.Page.t()
def current_page, do: GenServer.call(__MODULE__, :current_page)

@spec prompt :: String.t()
def prompt, do: GenServer.call(__MODULE__, :prompt)

def handle_call(:first_page, _from, state) do
Expand Down Expand Up @@ -100,33 +116,41 @@ defmodule Pane.Viewer do

def handle_call(:prompt, _from, state), do: {:reply, prompt(state), state}

@spec current_page(t()) :: Pane.Page.t()
def current_page(state), do: Enum.at(state.pages, state.index)

@spec last_page_index(t()) :: non_neg_integer()
def last_page_index(state), do: Enum.count(state.pages) - 1

@spec inc_page(t()) :: t()
def inc_page(%{index: i, total_pages: total} = state) when i < total - 1 do
%{state | index: state.index + 1}
end

def inc_page(state), do: state

@spec dec_page(t()) :: t()
def dec_page(%{index: i} = state) when i > 0 do
%{state | index: i - 1}
end

def dec_page(state), do: state

@spec page_description(t()) :: String.t()
def page_description(state) do
"#{state.index + 1} of #{last_page_index(state) + 1}"
end

@spec prompt(t()) :: String.t()
def prompt(state) do
"[#{page_description(state)}] (j)next (k)prev (f)first (l)last (q)quit "
end

@spec max_lines :: pos_integer()
def max_lines do
case System.cmd("tput", ["lines"]) do
{count, 0} -> count |> String.trim() |> String.to_integer()
case :io.rows() do
{:ok, rows} -> rows
{:error, _} -> @default_max_lines
end
end
end
12 changes: 6 additions & 6 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
%{
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"certifi": {:hex, :certifi, "2.5.3", "70bdd7e7188c804f3a30ee0e7c99655bc35d8ac41c23e12325f36ab449b70651", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "ed516acb3929b101208a9d700062d520f3953da3b6b918d866106ffa980e1c10"},
"credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"},
"dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"},
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"dogma": {:hex, :dogma, "0.1.16", "3c1532e2f63ece4813fe900a16704b8e33264da35fdb0d8a1d05090a3022eef9", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "8533cb896ea527959923f9c3f08e7083e18ff681388ad7c9a599dd5d28e9085f"},
"earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm", "1b34655872366414f69dd987cb121c049f76984b6ac69f52fff6d8fd64d29cfd"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
"ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
"excoveralls": {:hex, :excoveralls, "0.18.2", "86efd87a0676a3198ff50b8c77620ea2f445e7d414afa9ec6c4ba84c9f8bdcc2", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "230262c418f0de64077626a498bd4fdf1126d5c2559bb0e6b43deac3005225a4"},
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"},
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"hackney": {:hex, :hackney, "1.17.0", "717ea195fd2f898d9fe9f1ce0afcc2621a41ecfe137fae57e7fe6e9484b9aa99", [:rebar3], [{:certifi, "~>2.5", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "64c22225f1ea8855f584720c0e5b3cd14095703af1c9fbc845ba042811dc671c"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
Expand Down
46 changes: 46 additions & 0 deletions test/pane/page_test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,50 @@
defmodule Pane.PageTest do
use ExUnit.Case
doctest Pane.Page

alias Pane.Page

describe "paginate/2" do
test "empty string returns single page" do
[page] = Page.paginate("")
assert page.data == ""
assert page.index == 0
end

test "data within max_lines returns single page" do
data = Enum.join(1..5, "\n")
[page] = Page.paginate(data, 10)
assert page.data == data
assert page.index == 0
end

test "data exceeding max_lines returns multiple pages" do
data = Enum.join(1..10, "\n")
pages = Page.paginate(data, 3)
assert length(pages) == 4
assert Enum.map(pages, & &1.index) == [0, 1, 2, 3]
end

test "data at exact max_lines boundary returns single page" do
data = Enum.join(1..5, "\n")
[page] = Page.paginate(data, 5)
assert page.data == data
assert page.index == 0
end

test "data one over max_lines boundary returns two pages" do
data = Enum.join(1..6, "\n")
pages = Page.paginate(data, 5)
assert length(pages) == 2
assert List.first(pages).data == Enum.join(1..5, "\n")
assert List.last(pages).data == "6"
end

test "pages contain correct data splits" do
data = Enum.join(1..6, "\n")
[p1, p2] = Page.paginate(data, 3)
assert p1.data == "1\n2\n3"
assert p2.data == "4\n5\n6"
end
end
end
91 changes: 91 additions & 0 deletions test/pane/viewer_test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,95 @@
defmodule Pane.ViewerTest do
use ExUnit.Case
doctest Pane.Viewer, import: true

alias Pane.{Page, Viewer}

defp build_state(num_pages, index \\ 0) do
pages = Enum.map(0..(num_pages - 1), &Page.new("page #{&1}", &1))

%Viewer{
pages: pages,
total_pages: num_pages,
index: index
}
end

describe "current_page/1" do
test "returns page at current index" do
state = build_state(3, 1)
page = Viewer.current_page(state)
assert page.data == "page 1"
assert page.index == 1
end

test "returns first page when index is 0" do
state = build_state(3)
page = Viewer.current_page(state)
assert page.data == "page 0"
end
end

describe "inc_page/1" do
test "increments index" do
state = build_state(3, 0)
assert Viewer.inc_page(state).index == 1
end

test "does not increment past last page" do
state = build_state(3, 2)
assert Viewer.inc_page(state).index == 2
end

test "does not increment on single page" do
state = build_state(1, 0)
assert Viewer.inc_page(state).index == 0
end
end

describe "dec_page/1" do
test "decrements index" do
state = build_state(3, 2)
assert Viewer.dec_page(state).index == 1
end

test "does not decrement below 0" do
state = build_state(3, 0)
assert Viewer.dec_page(state).index == 0
end
end

describe "last_page_index/1" do
test "returns last index" do
assert Viewer.last_page_index(build_state(5)) == 4
end

test "returns 0 for single page" do
assert Viewer.last_page_index(build_state(1)) == 0
end
end

describe "page_description/1" do
test "formats current position" do
state = build_state(5, 2)
assert Viewer.page_description(state) == "3 of 5"
end

test "formats first page" do
state = build_state(3, 0)
assert Viewer.page_description(state) == "1 of 3"
end

test "formats last page" do
state = build_state(3, 2)
assert Viewer.page_description(state) == "3 of 3"
end
end

describe "prompt/1" do
test "includes page description and commands" do
state = build_state(3, 1)
prompt = Viewer.prompt(state)
assert prompt == "[2 of 3] (j)next (k)prev (f)first (l)last (q)quit "
end
end
end