livebook/lib/live_book/notebook.ex
Jonatan Kłosko d93ab41e7a
Implement reordering cells using keyboard (#63)
* Implement moving cells with keyboard shortcuts

* Add tests for cell movement operation

* Refactor

* Does not mark cells as stale if Elixir cells did not change order
2021-03-01 13:29:46 +01:00

217 lines
5.9 KiB
Elixir

defmodule LiveBook.Notebook do
@moduledoc false
# Data structure representing a notebook.
#
# A notebook is just the representation and roughly
# maps to a file that the user can edit.
# A notebook *session* is a living process that holds a specific
# notebook instance and allows users to collaboratively apply
# changes to this notebook.
#
# A notebook is divided into a set of isolated *sections*.
defstruct [:name, :version, :sections, :metadata]
alias LiveBook.Notebook.{Section, Cell}
@type t :: %__MODULE__{
name: String.t(),
version: String.t(),
sections: list(Section.t()),
metadata: %{String.t() => term()}
}
@version "1.0"
@doc """
Returns a blank notebook.
"""
@spec new() :: t()
def new() do
%__MODULE__{
name: "Untitled notebook",
version: @version,
sections: [],
metadata: %{}
}
end
@doc """
Finds notebook section by id.
"""
@spec fetch_section(t(), Section.id()) :: {:ok, Section.t()} | :error
def fetch_section(notebook, section_id) do
Enum.find_value(notebook.sections, :error, fn section ->
section.id == section_id && {:ok, section}
end)
end
@doc """
Finds notebook cell by `id` and the corresponding section.
"""
@spec fetch_cell_and_section(t(), Cell.id()) :: {:ok, Cell.t(), Section.t()} | :error
def fetch_cell_and_section(notebook, cell_id) do
for(
section <- notebook.sections,
cell <- section.cells,
cell.id == cell_id,
do: {cell, section}
)
|> case do
[{cell, section}] -> {:ok, cell, section}
[] -> :error
end
end
@doc """
Finds a cell being `offset` from the given cell within the same section.
"""
@spec fetch_cell_sibling(t(), Cell.id(), integer()) :: {:ok, Cell.t()} | :error
def fetch_cell_sibling(notebook, cell_id, offset) do
with {:ok, cell, section} <- fetch_cell_and_section(notebook, cell_id) do
idx = Enum.find_index(section.cells, &(&1 == cell))
sibling_idx = idx + offset
if sibling_idx >= 0 and sibling_idx < length(section.cells) do
{:ok, Enum.at(section.cells, sibling_idx)}
else
:error
end
end
end
@doc """
Inserts `section` at the given `index`.
"""
@spec insert_section(t(), integer(), Section.t()) :: t()
def insert_section(notebook, index, section) do
sections = List.insert_at(notebook.sections, index, section)
%{notebook | sections: sections}
end
@doc """
Inserts `cell` at the given `index` within section identified by `section_id`.
"""
@spec insert_cell(t(), Section.id(), integer(), Cell.t()) :: t()
def insert_cell(notebook, section_id, index, cell) do
sections =
Enum.map(notebook.sections, fn section ->
if section.id == section_id do
%{section | cells: List.insert_at(section.cells, index, cell)}
else
section
end
end)
%{notebook | sections: sections}
end
@doc """
Deletes section with the given id.
"""
@spec delete_section(t(), Section.id()) :: t()
def delete_section(notebook, section_id) do
sections = Enum.reject(notebook.sections, &(&1.id == section_id))
%{notebook | sections: sections}
end
@doc """
Deletes cell with the given id.
"""
@spec delete_cell(t(), Cell.id()) :: t()
def delete_cell(notebook, cell_id) do
sections =
Enum.map(notebook.sections, fn section ->
%{section | cells: Enum.reject(section.cells, &(&1.id == cell_id))}
end)
%{notebook | sections: sections}
end
@doc """
Updates cell with the given function.
"""
@spec update_cell(t(), Cell.id(), (Cell.t() -> Cell.t())) :: t()
def update_cell(notebook, cell_id, fun) do
sections =
Enum.map(notebook.sections, fn section ->
cells =
Enum.map(section.cells, fn cell ->
if cell.id == cell_id, do: fun.(cell), else: cell
end)
%{section | cells: cells}
end)
%{notebook | sections: sections}
end
@doc """
Updates section with the given function.
"""
@spec update_section(t(), Section.id(), (Section.t() -> Section.t())) :: t()
def update_section(notebook, section_id, fun) do
sections =
Enum.map(notebook.sections, fn section ->
if section.id == section_id, do: fun.(section), else: section
end)
%{notebook | sections: sections}
end
@doc """
Moves cell within the given section at the specified position to a new position.
"""
@spec move_cell(t(), Section.id(), non_neg_integer(), non_neg_integer()) :: t()
def move_cell(notebook, section_id, from_idx, to_idx) do
update_section(notebook, section_id, fn section ->
{cell, cells} = List.pop_at(section.cells, from_idx)
if cell do
cells = List.insert_at(cells, to_idx, cell)
%{section | cells: cells}
else
section
end
end)
end
@doc """
Returns a list of Elixir cells that the given cell depends on.
The cells are ordered starting from the most direct parent.
"""
@spec parent_cells(t(), Cell.id()) :: list(Cell.t())
def parent_cells(notebook, cell_id) do
with {:ok, _, section} <- fetch_cell_and_section(notebook, cell_id) do
# A cell depends on all previous cells within the same section.
section.cells
|> Enum.take_while(&(&1.id != cell_id))
|> Enum.reverse()
|> Enum.filter(&(&1.type == :elixir))
else
_ -> []
end
end
@doc """
Returns a list of Elixir cells that depend on the given cell.
The cells are ordered starting from the most direct child.
"""
@spec child_cells(t(), Cell.id()) :: list(Cell.t())
def child_cells(notebook, cell_id) do
with {:ok, _, section} <- fetch_cell_and_section(notebook, cell_id) do
# A cell affects all the cells below it within the same section.
section.cells
|> Enum.drop_while(&(&1.id != cell_id))
|> Enum.drop(1)
|> Enum.filter(&(&1.type == :elixir))
else
_ -> []
end
end
end