Login
Artifact [93f158a00e]
Login

Artifact 93f158a00efa80b2dbf8bd66e5b20ebe554f74405042daa59ea33365c723450b:


#### libremiliacr
#### Copyright(C) 2020-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify
#### it under the terms of the GNU General Public License as published
#### the Free Software Foundation, either version 3 of the License, or
#### (at your option) any later version.
####
#### This program 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 program.If not, see<http:####www.gnu.org/licenses/.>

module RemiLib::Console
  # The `RemiLib::Console::Terminology` module provides additional escape codes
  # for the Terminology terminal emulator.
  #
  # [https://www.enlightenment.org/about-terminology](https://www.enlightenment.org/about-terminology)
  module Terminology
    extend self

    # :nodoc:
    QUERY = "\u{1B}[=c".to_slice

    # :nodoc:
    TERMINOLOGY_EXPECTED = "\u{1B}P!|7E7E5459\u{1B}\\".to_slice

    # How `#showMedia` displays things.
    enum DisplayMode
      Centered
      Filled
      Stretched
    end

    private def withEscape(output, *, noEOS = false, &) : Nil
      output.write_byte(0x1B) # #\Escape
      output.write_byte(0x7D) # #\}
      yield
      unless noEOS
        output.write_byte(0)
      end
    end

    # Returns true if the program is connected running in a Terminology
    # terminal, or false if it is not or it cannot be determined.
    def runningTerminology?(*, output : IO::FileDescriptor = STDOUT, input : IO::FileDescriptor = STDIN) : Bool
      return false unless LibC.tcgetattr(output.fd, out origAttrs) == 0
      return false unless LibC.tcgetattr(output.fd, out newAttrs) == 0

      result : String = ""
      begin
        # Turn off canonical (buffered) mode and echo
        newAttrs.c_lflag &= ~(newAttrs.c_lflag | LibC::ICANON.value | LibC::ECHO.value)

        # Minimum of number input read: 1 byte
        newAttrs.c_cc[LibC::VMIN] = 1

        {% begin %}
          {% if LibC.has_constant?(:VTIME) %}
            newAttrs.c_cc[LibC::VTIME] = 0
          {% else %}
            # Value retrieved from SBCL, which stores this as sb-posix:vtime.
            # Probably not too portable.
            newAttrs.c_cc[5] = 0
          {% end %}
        {% end %}

        # Set the new terminal attributes
        LibC.tcsetattr(output.fd, LibC::TCSANOW, pointerof(newAttrs))

        # Send the query about the device attributes
        output.write(QUERY)
        output.flush

        # Read input
        result = String.build do |str|
          TERMINOLOGY_EXPECTED.size.times do |_|
            byte = input.read_byte || break
            output.write_byte(byte)
            str.write_byte(byte)
          end
        end

        result.size == TERMINOLOGY_EXPECTED.size && result.to_slice == TERMINOLOGY_EXPECTED
      ensure
        # Restore old terminal attributes.
        LibC.tcsetattr(output.fd, LibC::TCSANOW, pointerof(origAttrs))
      end
    end

    private macro checkMediaDimensions(width, height)
      raise "Maximum {{width}} is 512" if {{width}} > 512
      raise "Maximum {{height}} is 512" if {{height}} > 512
      raise "Minimum {{width}} is 1" if {{width}} <= 0
      raise "Minimum {{height}} is 1" if {{height}} <= 0
    end

    # Displays a media file (or a URL pointing to media) in the terminal.
    # `width` and `height` are in cells, not pixels.
    def showMedia(filename : String|Path, width : UInt16, height : UInt16,
                  *, output : IO::FileDescriptor = STDOUT, dispMode : DisplayMode = DisplayMode::Centered) : Nil

      raise File::NotFoundError.new("Cannot find #{filename}", file: filename) unless File.exists?(filename)
      checkMediaDimensions(width, height)
      lineStr = "#" * width
      cmd = case dispMode
            in DisplayMode::Centered then "ic"
            in DisplayMode::Filled then "if"
            in DisplayMode::Stretched then "is"
            end

      withEscape(output, noEOS: true) do
        outstr = String.build do |str|
          str << cmd << '#' << width << ';' << height << ';' << File.expand_path(filename) << '\0'
          height.times do |i|
            str << "\u{1B}}ib\0" << lineStr << "\u{1B}}ie\0\n"
          end
        end

        output << outstr
      end
    end

    # Displays a media file (or a URL pointing to media) thumbnail in the
    # terminal.  `width` and `height` are in cells, not pixels.
    def showThumb(filename : String|Path, width : UInt16, height : UInt16,
                  *, output : IO::FileDescriptor = STDOUT, link : Bool|String = false) : Nil
      checkMediaDimensions(width, height)
      lineStr = "#" * width

      withEscape(output, noEOS: true) do
        outstr = String.build do |str|
          str << "it#" << width << ';' << height << ';'

          if link.is_a?(Bool) && link
            str << File.expand_path(filename) << "\0\n" << File.expand_path(filename) << '\0'
          elsif link.is_a?(String)
            str << link << "\n" << File.expand_path(filename) << '\0'
          else
            str << File.expand_path(filename) << '\0'
          end

          height.times do |i|
            str << "\u{1B}}ib\0" << lineStr << "\u{1B}}ie\0\n"
          end
        end

        output << outstr
      end
    end

    # Queries the grid and font size, then returns a tuple with four values:
    #
    # 1. The width of the terminal in characters
    # 2. The height of the terminal in characters
    # 3. The width of one character in pixels
    # 4. The height of one character in pixels
    #
    # This returns `{0, 0, 0, 0}` if it cannot determine the sizes.
    def queryGrid(*, output : IO::FileDescriptor = STDOUT, input : IO::FileDescriptor = STDIN) : Tuple(Int32, Int32, Int32, Int32)
      withEscape(output) do
        input.flush
        output << "qs"
      end

      result : String = input.raw &.gets
      return {0,0,0,0} unless result
      nums = result.strip.split(';').map &.to_i32
      raise "Unexpected result from terminal" unless nums.size == 4

      {nums[0], nums[1], nums[2], nums[3]}
    end

    # Sets the background state on or off.  If `makePermanent` is false (the
    # default), then the change is a temporary one.
    def setAlphaState(enable : Bool, *, output : IO::FileDescriptor = STDOUT, makePermanent : Bool = false) : Nil
      withEscape(output) do
        if enable
          if makePermanent
            output << "apon"
          else
            output << "aton"
          end
        else
          if makePermanent
            output << "apoff"
          else
            output << "atoff"
          end
        end
      end
    end

    # Sets the background to the given media file.  If `makePermanent` is false
    # (the default), then the change is a temporary one.
    def setBackground(filename : String|Path, *, output : IO::FileDescriptor = STDOUT,
                      makePermanent : Bool = false) : Nil
      withEscape(output) do
        if makePermanent
          output << "bp" << File.expand_path(filename)
        else
          output << "bt" << File.expand_path(filename)
        end
      end
    end

    # Pops a media file (or a URL pointing to media) up in the terminal.  If
    # `enqueue` is true, then the popup is queued up by the terminal, otherwise
    # it is immediately shown.
    def popup(filename : String|Path, *, output : IO::FileDescriptor = STDOUT, enqueue : Bool = false) : Nil
      withEscape(output) do
        if enqueue
          output << "pq" << File.expand_path(filename)
        else
          output << "pn" << File.expand_path(filename)
        end
      end
    end
  end
end